From 5725da19e249ce5a368a024a1eee24f88093ecec Mon Sep 17 00:00:00 2001 From: Brett Jephson Date: Tue, 15 Sep 2026 13:37:03 +0100 Subject: [PATCH] Honour an explicit select slug on a tab (#4601) Co-authored-by: Claude Opus 5 (1M context) --- .changeset/sweet-ends-occur.md | 5 +++ .../src/components/DocumentView/Tabs/Tabs.tsx | 19 ++++++--- .../src/lib/select/resolveSelectSlug.test.ts | 40 +++++++++++++++++++ packages/gitbook/src/lib/select/slug.ts | 18 +++++++++ 4 files changed, 76 insertions(+), 6 deletions(-) create mode 100644 .changeset/sweet-ends-occur.md create mode 100644 packages/gitbook/src/lib/select/resolveSelectSlug.test.ts diff --git a/.changeset/sweet-ends-occur.md b/.changeset/sweet-ends-occur.md new file mode 100644 index 000000000..4ccb6fcb0 --- /dev/null +++ b/.changeset/sweet-ends-occur.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Let a tab keep its content selection across a rename, by honouring an explicit slug set on the tab instead of always deriving one from its title. diff --git a/packages/gitbook/src/components/DocumentView/Tabs/Tabs.tsx b/packages/gitbook/src/components/DocumentView/Tabs/Tabs.tsx index bb15d3043..76daf3a30 100644 --- a/packages/gitbook/src/components/DocumentView/Tabs/Tabs.tsx +++ b/packages/gitbook/src/components/DocumentView/Tabs/Tabs.tsx @@ -5,7 +5,12 @@ import { validateIconName } from '@gitbook/icons/icons'; import type { BlockProps } from '../Block'; import { Blocks } from '../Blocks'; import { DynamicTabs } from './DynamicTabs'; -import { generateSelectCSS, selectSetClassName, slugifySelectValue } from '@/lib/select'; +import { + generateSelectCSS, + resolveSelectSlug, + selectSetClassName, + slugifySelectValue, +} from '@/lib/select'; import { tcls } from '@/lib/tailwind'; export function Tabs(props: BlockProps) { @@ -26,6 +31,7 @@ export function Tabs(props: BlockProps) { return { id: tab.meta?.id ?? tab.key, title: tab.data.title ?? '', + slug: tab.data.slug, icon, body: ( ( +function withSelectSlugs( items: T[] ): (T & { slug: string })[] { return items.map((item) => ({ ...item, - slug: slugifySelectValue(item.title) || slugifySelectValue(item.id) || item.id, + slug: resolveSelectSlug(item) || slugifySelectValue(item.id) || item.id, })); } diff --git a/packages/gitbook/src/lib/select/resolveSelectSlug.test.ts b/packages/gitbook/src/lib/select/resolveSelectSlug.test.ts new file mode 100644 index 000000000..1a357f998 --- /dev/null +++ b/packages/gitbook/src/lib/select/resolveSelectSlug.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'bun:test'; + +import { resolveSelectSlug } from './slug'; + +// Mirrors packages/doc-core/src/select/__tests__/resolveSelectSlug.test.ts in the gitbook-x repo, so +// a tab resolves to the same slug in the editor preview as it does on the published site. +describe('resolveSelectSlug', () => { + it('prefers an explicit slug over the title', () => { + expect(resolveSelectSlug({ slug: 'macos', title: 'macOS 15' })).toBe('macos'); + }); + + it('slugifies an explicit slug, since it reaches CSS selectors and stored state', () => { + expect(resolveSelectSlug({ slug: 'On Prem!', title: 'Self hosted' })).toBe('on-prem'); + }); + + it('derives from the title when no slug is set', () => { + expect(resolveSelectSlug({ title: 'macOS 15' })).toBe('macos-15'); + }); + + it('keeps titles syncing across a site: same title, same slug', () => { + expect(resolveSelectSlug({ title: 'macOS' })).toBe(resolveSelectSlug({ title: 'macOS' })); + }); + + it('falls back to the title when the slug is empty or unaddressable', () => { + expect(resolveSelectSlug({ slug: '', title: 'Windows' })).toBe('windows'); + // An emoji-only slug reduces to nothing, which means "not set", not "clear the slug". + expect(resolveSelectSlug({ slug: '🎉', title: 'Windows' })).toBe('windows'); + }); + + it('returns an empty slug when neither field is addressable', () => { + expect(resolveSelectSlug({})).toBe(''); + expect(resolveSelectSlug({ title: '🎉' })).toBe(''); + expect(resolveSelectSlug({ slug: '🎉', title: '🎉' })).toBe(''); + }); + + it('is idempotent, so a resolved slug can be fed back in', () => { + const once = resolveSelectSlug({ title: 'macOS 15' }); + expect(resolveSelectSlug({ slug: once })).toBe(once); + }); +}); diff --git a/packages/gitbook/src/lib/select/slug.ts b/packages/gitbook/src/lib/select/slug.ts index b4864b76e..ca8f528b9 100644 --- a/packages/gitbook/src/lib/select/slug.ts +++ b/packages/gitbook/src/lib/select/slug.ts @@ -49,3 +49,21 @@ export function slugifySelectValue(name: string): string { // trailing `-` the cut may have exposed. return [...slug].slice(0, SLUG_MAX_CODE_POINTS).join('').replace(/-+$/u, ''); } + +/** + * Resolve the `select` slug for one option — a tab, a variant, a picker entry. + * + * An explicit slug wins but is still slugified, since it reaches generated CSS attribute selectors + * and is compared against stored state. Falling back to the title is what lets same-named options + * across a site select together with no authoring, so an absent slug is the normal case. + * + * DUPLICATED, ON PURPOSE, alongside {@link slugifySelectValue}: the editor owns the same function at + * `packages/doc-core/src/select/slug.ts` in the gitbook-x repo, and the two must agree or a tab + * resolves differently in the editor preview than on the published site. + */ +export function resolveSelectSlug(option: { slug?: string; title?: string }): string { + return ( + (option.slug ? slugifySelectValue(option.slug) : '') || + (option.title ? slugifySelectValue(option.title) : '') + ); +}