Merge branch 'main' into nolann/relative-insights-visitor-urls

This commit is contained in:
Nolann B.
2026-09-15 16:40:05 +02:00
committed by GitHub
12 changed files with 154 additions and 51 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Hide heading and expandable anchor links on pages whose layout disables anchors (landing pages).
+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.
+2 -2
View File
@@ -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"],
+1 -1
View File
@@ -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",
-1
View File
@@ -1747,7 +1747,6 @@ const testCases: TestsCase[] = [
{
name: 'Without previewed ads',
url: 'text-page?ads_preview=1',
run: waitForCookiesDialog,
},
],
},
@@ -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<DocumentBlockExpandable>) {
? await getSpaceLanguage(context.contentContext)
: defaultLanguage;
const showHashLink = shouldShowHashLinks(context);
return (
<Details
id={id}
@@ -41,7 +44,8 @@ export async function Expandable(props: BlockProps<DocumentBlockExpandable>) {
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<DocumentBlockExpandable>) {
context={context}
ancestorInlines={[]}
/>
<a
href={`#${id}`}
aria-label={tString(language, 'direct_link_to_heading')}
className={tcls(
'absolute',
'top-2',
'bottom-2',
'right-4',
'flex',
'items-center',
'dark:shadow-none',
'dark:ring-0'
)}
>
<Icon
icon="hashtag"
{showHashLink ? (
<a
href={`#${id}`}
aria-label={tString(language, 'direct_link_to_heading')}
className={tcls(
'inline-block',
'size-3',
'transition-colors',
'text-transparent',
'group-hover/expandable:text-tint-subtle',
'contrast-more:group-hover/expandable:text-tint-strong'
'absolute',
'top-2',
'bottom-2',
'right-4',
'flex',
'items-center',
'dark:shadow-none',
'dark:ring-0'
)}
/>
</a>
>
<Icon
icon="hashtag"
className={tcls(
'inline-block',
'size-3',
'transition-colors',
'text-transparent',
'group-hover/expandable:text-tint-subtle',
'contrast-more:group-hover/expandable:text-tint-strong'
)}
/>
</a>
) : null}
</summary>
<Blocks
nodes={body.nodes}
@@ -2,9 +2,22 @@ import type { DocumentBlockHeading, DocumentBlockTabs } from '@gitbook/api';
import { Icon } from '@gitbook/icons';
import { Link } from '../primitives';
import type { DocumentContext } from './DocumentView';
import { getBlockTextStyle } from './spacing';
import { type ClassValue, tcls } from '@/lib/tailwind';
/**
* Whether blocks should render a visible anchor link icon.
* Only the page body carries a page; search answers, AI chat and PDF export keep anchors.
*/
export function shouldShowHashLinks(context: DocumentContext): boolean {
const contentContext = context.contentContext;
if (!contentContext || !('page' in contentContext)) {
return true;
}
return contentContext.page.layout.anchors !== false;
}
/**
* A hash icon which adds the block or active block item's ID in the URL hash.
* The button needs to be wrapped in a container with `hashLinkButtonWrapperStyles`.
@@ -1,7 +1,7 @@
import type { DocumentBlockHeading } from '@gitbook/api';
import type { BlockProps } from './Block';
import { HashLinkButton, hashLinkButtonWrapperStyles } from './HashLinkButton';
import { HashLinkButton, hashLinkButtonWrapperStyles, shouldShowHashLinks } from './HashLinkButton';
import { HeadingRevealWrapper } from './HeadingRevealWrapper';
import { Inlines } from './Inlines';
import { getBlockTextStyle } from './spacing';
@@ -24,6 +24,8 @@ export async function Heading(props: BlockProps<DocumentBlockHeading>) {
? await getSpaceLanguage(context.contentContext)
: defaultLanguage;
const showHashLink = shouldShowHashLinks(context);
return (
<HeadingRevealWrapper
as={Tag}
@@ -34,7 +36,8 @@ export async function Heading(props: BlockProps<DocumentBlockHeading>) {
'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<DocumentBlockHeading>) {
<Inlines {...rest} context={context} nodes={block.nodes} ancestorInlines={[]} />
</span>
<HashLinkButton
id={id}
block={block}
className={tcls(
'absolute',
block.type === 'heading-1'
? '[transform:translateY(0.125em)]'
: '[transform:translateY(0.17em)]',
'pointer-fine:-ml-6 pointer-fine:relative pointer-fine:order-first pointer-fine:self-center pointer-fine:pr-2 pointer-fine:[transform:none]',
'pointer-fine:[.flip-heading-hash_&]:order-last pointer-fine:[.flip-heading-hash_&]:ml-1 pointer-fine:[.flip-heading-hash_&]:pl-2'
)}
iconClassName={tcls('size-4')}
label={tString(language, 'direct_link_to_heading')}
/>
{showHashLink ? (
<HashLinkButton
id={id}
block={block}
className={tcls(
'absolute',
block.type === 'heading-1'
? '[transform:translateY(0.125em)]'
: '[transform:translateY(0.17em)]',
'pointer-fine:-ml-6 pointer-fine:relative pointer-fine:order-first pointer-fine:self-center pointer-fine:pr-2 pointer-fine:[transform:none]',
'pointer-fine:[.flip-heading-hash_&]:order-last pointer-fine:[.flip-heading-hash_&]:ml-1 pointer-fine:[.flip-heading-hash_&]:pl-2'
)}
iconClassName={tcls('size-4')}
label={tString(language, 'direct_link_to_heading')}
/>
) : null}
</HeadingRevealWrapper>
);
}
@@ -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,
}));
}
+5
View File
@@ -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,
},
};
}
@@ -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) : '')
);
}