From 05cff0a03bebe979929823b1d698288b2f5f7431 Mon Sep 17 00:00:00 2001 From: Peter White <1788320+peterwhite@users.noreply.github.com> Date: Tue, 15 Sep 2026 09:20:38 +0200 Subject: [PATCH 1/2] Hide anchor links on landing pages (#4609) --- .changeset/hide-anchor-links-landing.md | 5 ++ bun.lock | 4 +- package.json | 2 +- packages/gitbook/e2e/internal.spec.ts | 1 - .../DocumentView/Expandable/Expandable.tsx | 56 ++++++++++--------- .../DocumentView/HashLinkButton.tsx | 13 +++++ .../src/components/DocumentView/Heading.tsx | 37 ++++++------ packages/gitbook/src/lib/pages.test.ts | 5 ++ 8 files changed, 78 insertions(+), 45 deletions(-) create mode 100644 .changeset/hide-anchor-links-landing.md diff --git a/.changeset/hide-anchor-links-landing.md b/.changeset/hide-anchor-links-landing.md new file mode 100644 index 000000000..4628ca658 --- /dev/null +++ b/.changeset/hide-anchor-links-landing.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Hide heading and expandable anchor links on pages whose layout disables anchors (landing pages). diff --git a/bun.lock b/bun.lock index d524c74fb..49f34f685 100644 --- a/bun.lock +++ b/bun.lock @@ -355,7 +355,7 @@ }, "catalog": { "@base-ui/react": "^1.7.0", - "@gitbook/api": "0.201.0", + "@gitbook/api": "0.202.0", "@scalar/api-client-react": "^1.3.46", "@tsconfig/node20": "^20.1.6", "@tsconfig/strictest": "^2.0.6", @@ -727,7 +727,7 @@ "@fortawesome/fontawesome-svg-core": ["@fortawesome/fontawesome-svg-core@7.2.0", "", { "dependencies": { "@fortawesome/fontawesome-common-types": "7.2.0" } }, "sha512-6639htZMjEkwskf3J+e6/iar+4cTNM9qhoWuRfj9F3eJD6r7iCzV1SWnQr2Mdv0QT0suuqU8BoJCZUyCtP9R4Q=="], - "@gitbook/api": ["@gitbook/api@0.201.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-bI+FyExZrz9V8yaMBOo4k2QHb0nJvrHYKXE+Svb3Dr1/kYA1bdyL5swCDCH9MxaRQjjJlN3VilvPmblKOYLuVA=="], + "@gitbook/api": ["@gitbook/api@0.202.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-qhrjEQbNNmCljR0AgP79+BsVh9yelh9TIBg9X3TyYx5a3lJwDiT3sw1ND9DFQSxGHp8hH5w+20KJHn/IDpTbaw=="], "@gitbook/browser-types": ["@gitbook/browser-types@workspace:packages/browser-types"], diff --git a/package.json b/package.json index 43e720205..f0413b1e7 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "@tsconfig/strictest": "^2.0.6", "@tsconfig/node20": "^20.1.6", "@base-ui/react": "^1.7.0", - "@gitbook/api": "0.201.0", + "@gitbook/api": "0.202.0", "@scalar/api-client-react": "^1.3.46", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", diff --git a/packages/gitbook/e2e/internal.spec.ts b/packages/gitbook/e2e/internal.spec.ts index 22806f570..a3d6055c2 100644 --- a/packages/gitbook/e2e/internal.spec.ts +++ b/packages/gitbook/e2e/internal.spec.ts @@ -1747,7 +1747,6 @@ const testCases: TestsCase[] = [ { name: 'Without previewed ads', url: 'text-page?ads_preview=1', - run: waitForCookiesDialog, }, ], }, diff --git a/packages/gitbook/src/components/DocumentView/Expandable/Expandable.tsx b/packages/gitbook/src/components/DocumentView/Expandable/Expandable.tsx index 2c8e4d05c..2c3832930 100644 --- a/packages/gitbook/src/components/DocumentView/Expandable/Expandable.tsx +++ b/packages/gitbook/src/components/DocumentView/Expandable/Expandable.tsx @@ -3,6 +3,7 @@ import { Icon } from '@gitbook/icons'; import type { BlockProps } from '../Block'; import { Blocks } from '../Blocks'; +import { shouldShowHashLinks } from '../HashLinkButton'; import { Inlines } from '../Inlines'; import { Details } from './Details'; import { ToggleChevron } from '@/components/primitives'; @@ -31,6 +32,8 @@ export async function Expandable(props: BlockProps) { ? await getSpaceLanguage(context.contentContext) : defaultLanguage; + const showHashLink = shouldShowHashLinks(context); + return (
) { className={tcls( 'cursor-pointer', 'px-4', - 'pr-10', + // Reserve room for the anchor icon pinned to the right edge + showHashLink && 'pr-10', 'py-4', 'relative', 'list-none', @@ -71,32 +75,34 @@ export async function Expandable(props: BlockProps) { context={context} ancestorInlines={[]} /> - - - + > + + + ) : null} ) { ? await getSpaceLanguage(context.contentContext) : defaultLanguage; + const showHashLink = shouldShowHashLinks(context); + return ( ) { 'font-heading', 'pdf-heading', 'block', - 'pr-6', + // Reserve room for the absolutely positioned anchor icon on coarse pointers + showHashLink && 'pr-6', 'pointer-fine:flex', 'pointer-fine:items-baseline', 'pointer-fine:pr-0', @@ -63,20 +66,22 @@ export async function Heading(props: BlockProps) { - + {showHashLink ? ( + + ) : null} ); } diff --git a/packages/gitbook/src/lib/pages.test.ts b/packages/gitbook/src/lib/pages.test.ts index f74da9131..6b3306cea 100644 --- a/packages/gitbook/src/lib/pages.test.ts +++ b/packages/gitbook/src/lib/pages.test.ts @@ -89,6 +89,7 @@ describe('resolveFirstDocument', () => { metadata: true, tags: true, actions: true, + anchors: true, }, }, ], @@ -143,6 +144,7 @@ describe('resolveFirstDocument', () => { metadata: true, tags: true, actions: true, + anchors: true, }, }, ]; @@ -190,6 +192,7 @@ describe('resolvePagePath', () => { metadata: true, tags: true, actions: true, + anchors: true, }, }, ]; @@ -267,6 +270,7 @@ describe('resolvePagePath', () => { metadata: true, tags: true, actions: true, + anchors: true, }, }, ], @@ -386,6 +390,7 @@ function createDocumentPage(id: string, path: string, hidden = false): RevisionP metadata: true, tags: true, actions: true, + anchors: true, }, }; } From 5725da19e249ce5a368a024a1eee24f88093ecec Mon Sep 17 00:00:00 2001 From: Brett Jephson Date: Tue, 15 Sep 2026 13:37:03 +0100 Subject: [PATCH 2/2] 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) : '') + ); +}