mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-16 23:55:20 +00:00
Merge branch 'main' into nolann/relative-insights-visitor-urls
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Hide heading and expandable anchor links on pages whose layout disables anchors (landing pages).
|
||||
@@ -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.
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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) : '')
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user