Honour an explicit select slug on a tab (#4601)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Brett Jephson
2026-09-15 13:37:03 +01:00
committed by GitHub
parent 05cff0a03b
commit 5725da19e2
4 changed files with 76 additions and 6 deletions
+5
View File
@@ -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.
@@ -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<DocumentBlockTabs>) {
@@ -26,6 +31,7 @@ export function Tabs(props: BlockProps<DocumentBlockTabs>) {
return {
id: tab.meta?.id ?? tab.key,
title: tab.data.title ?? '',
slug: tab.data.slug,
icon,
body: (
<Blocks
@@ -94,19 +100,20 @@ function SelectGroupStyle({ slugs }: { slugs: string[] }) {
}
/**
* Derive a `select` slug for each tab from its title. Untitled tabs fall back to their (stable) id
* so they stay selectable.
* Resolve a `select` slug for each tab: its explicit slug when set, else one derived from its title.
* Untitled tabs fall back to their (stable) id so they stay selectable.
*
* Same-named tabs deliberately share a slug — selecting one syncs every tab of that name, here and
* on other pages, which is the whole point of name-based selection. We don't disambiguate duplicates
* with a positional suffix: that would desync the duplicate and make a stored selection retarget
* whenever tabs are renamed or reordered.
* whenever tabs are renamed or reordered. An explicit slug is the way out for a tab that needs to
* keep its identity across a rename.
*/
function withSelectSlugs<T extends { id: string; title: string }>(
function withSelectSlugs<T extends { id: string; title: string; slug?: string }>(
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,
}));
}
@@ -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);
});
});
+18
View File
@@ -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) : '')
);
}