Support external links (#4561)

Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
Tomek
2026-08-31 13:11:13 +02:00
committed by GitHub
parent af698a49f5
commit 523b7cd4b6
19 changed files with 725 additions and 231 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": minor
---
Support external links in published site navigation.
+2 -2
View File
@@ -354,7 +354,7 @@
},
"catalog": {
"@base-ui/react": "^1.7.0",
"@gitbook/api": "0.196.0",
"@gitbook/api": "0.197.0",
"@scalar/api-client-react": "^1.3.46",
"@tsconfig/node20": "^20.1.6",
"@tsconfig/strictest": "^2.0.6",
@@ -726,7 +726,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.196.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-14f3LXiZljPuxFtYbsdH9mkXN4jucRBnF3QpSACuACDdvJHpTqGsCe52chg3nj72XBbMjD57BsXGklY+IYz9gw=="],
"@gitbook/api": ["@gitbook/api@0.197.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-g+AQm2QbTPXc3aGV/HXPALK/GAu3INQBtEtZ+qOQ2VAjwn6sVGJvtFq+iWQOGplWO2xx9ucV0gLHuSXmCvf6Gg=="],
"@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.196.0",
"@gitbook/api": "0.197.0",
"@scalar/api-client-react": "^1.3.46",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
@@ -2,7 +2,11 @@ import type { SiteSpace } from '@gitbook/api';
import { SocialAccountButton } from '../Footer/SocialAccounts';
import { SearchContainer, getSearchBaseProps } from '../Search';
import { SiteSectionTabs, encodeClientSiteSections } from '../SiteSections';
import {
SiteSectionTabs,
encodeClientSiteSections,
shouldRenderSiteSectionNavigation,
} from '../SiteSections';
import { HeaderLink } from './HeaderLink';
import { HeaderLinkMore } from './HeaderLinkMore';
import { HeaderLinks } from './HeaderLinks';
@@ -30,11 +34,7 @@ export async function Header(props: {
const searchProps = getSearchBaseProps(context);
const language = await getSpaceLanguage(context);
const withSections = Boolean(
visibleSections &&
(visibleSections.list.length > 1 || // Show section tabs if there are at least 2 sections or at least 1 section group
visibleSections.list.some((s) => s.object === 'site-section-group'))
);
const withSections = shouldRenderSiteSectionNavigation(visibleSections);
const headerSocialAccounts = customization.socialAccounts.filter(
(account) => account.display.header === true
@@ -1,3 +1,4 @@
import assertNever from 'assert-never';
import urlJoin from 'url-join';
import {
@@ -23,7 +24,7 @@ import { findBestTargetURL } from '../SiteSections/encodeClientSiteSections';
import { categorizeVariants } from '../SpaceLayout/categorizeVariants';
import { BreadcrumbItemDropdown, type BreadcrumbSibling } from './BreadcrumbItemDropdown';
import { PageTags } from './PageTags';
import type { GitBookSiteContext } from '@/lib/context';
import type { GitBookSiteContext, SiteStructureNode } from '@/lib/context';
import { type AncestorRevisionPage, resolveFirstDocument } from '@/lib/pages';
import { getLocalizedTitle, getSiteSpaceURL } from '@/lib/sites';
import { tcls } from '@/lib/tailwind';
@@ -53,7 +54,9 @@ export async function PageHeader(props: {
// (mirroring the section tabs). A single-space site has one section named after the site, which
// is not a meaningful breadcrumb.
const hasMultipleSections = Boolean(
context.visibleSections && context.visibleSections.list.length > 1
context.visibleSections &&
context.visibleSections.list.filter((item) => item.object !== 'site-external-link').length >
1
);
const currentSection = hasMultipleSections ? (context.sections?.current ?? null) : null;
// Variants to offer as a crumb: only the "generic" variants (versions, etc.). Language variants
@@ -64,8 +67,9 @@ export async function PageHeader(props: {
const contextCrumbs: BreadcrumbContextCrumb[] = [];
if (currentSection) {
// Walk the full section tree so the current section (and its enclosing groups) always shows
// as a crumb, even when it's hidden in the site structure. Only *visible* sections/groups
// are offered as siblings to switch to, though.
// as a crumb, even when it's hidden in the site structure. Siblings are the other nodes at
// the same level that the breadcrumb dropdown offers as section/group switch targets;
// external links are intentionally excluded from those section-only targets.
const chain = context.sections
? findSectionChain(context.sections.list, currentSection.id)
: [];
@@ -82,6 +86,9 @@ export async function PageHeader(props: {
label: getLocalizedTitle(node, context.locale),
icon: node.icon,
siblings: siblings
.filter(
(sibling): sibling is SectionNode => sibling.object !== 'site-external-link'
)
// Don't offer hidden sections/groups as switch targets.
.filter((sibling) => !visibleSectionIds || visibleSectionIds.has(sibling.id))
.map((sibling) => {
@@ -353,19 +360,27 @@ type SectionNode = SiteSection | SiteSectionGroup;
* an empty array if the section isn't found.
*/
function findSectionChain(
list: SectionNode[],
list: SiteStructureNode[],
sectionId: string
): { node: SectionNode; siblings: SectionNode[] }[] {
): { node: SectionNode; siblings: SiteStructureNode[] }[] {
for (const item of list) {
if (item.object === 'site-section') {
if (item.id === sectionId) {
return [{ node: item, siblings: list }];
}
} else {
const nested = findSectionChain(item.children, sectionId);
if (nested.length > 0) {
return [{ node: item, siblings: list }, ...nested];
switch (item.object) {
case 'site-section':
if (item.id === sectionId) {
return [{ node: item, siblings: list }];
}
break;
case 'site-section-group': {
const nested = findSectionChain(item.children, sectionId);
if (nested.length > 0) {
return [{ node: item, siblings: list }, ...nested];
}
break;
}
case 'site-external-link':
break;
default:
return assertNever(item, 'Unknown site structure node object type');
}
}
return [];
@@ -376,16 +391,55 @@ function findSectionChain(
* section found in its (possibly nested) children.
*/
function findFirstSection(node: SectionNode): SiteSection | null {
if (node.object === 'site-section') {
return node;
switch (node.object) {
case 'site-section':
return node;
case 'site-section-group':
for (const child of node.children) {
switch (child.object) {
case 'site-section':
return child;
case 'site-section-group': {
const found = findFirstSection(child);
if (found) {
return found;
}
break;
}
case 'site-external-link':
break;
default:
return assertNever(child, 'Unknown site structure node object type');
}
}
return null;
default:
return assertNever(node, 'Unknown section node object type');
}
for (const child of node.children) {
const found = findFirstSection(child);
if (found) {
return found;
}
/** Collect the ids of every section and section group in a (visible) section tree. */
function collectSectionNodeIds(list: SiteStructureNode[]): Set<string> {
const ids = new Set<string>();
const walk = (nodes: SiteStructureNode[]) => {
for (const node of nodes) {
switch (node.object) {
case 'site-section':
ids.add(node.id);
break;
case 'site-section-group':
ids.add(node.id);
walk(node.children);
break;
case 'site-external-link':
break;
default:
assertNever(node, 'Unknown site structure node object type');
}
}
}
return null;
};
walk(list);
return ids;
}
/**
@@ -399,21 +453,6 @@ function getSectionNodeURL(context: GitBookSiteContext, node: SectionNode): stri
return section ? findBestTargetURL(context, section) : undefined;
}
/** Collect the ids of every section and section group in a (visible) section tree. */
function collectSectionNodeIds(list: SectionNode[]): Set<string> {
const ids = new Set<string>();
const walk = (nodes: SectionNode[]) => {
for (const node of nodes) {
ids.add(node.id);
if (node.object === 'site-section-group') {
walk(node.children);
}
}
};
walk(list);
return ids;
}
/**
* Build the sibling list for a page-level breadcrumb item, from the children of its parent.
* Only pages with a resolvable path (documents & groups) are navigable, so links and computed pages
@@ -1,6 +1,6 @@
import type { SiteSection, SiteSpace } from '@gitbook/api';
import { encodeClientSiteSections } from '../SiteSections';
import { encodeClientSiteSections, hasMultipleSiteSections } from '../SiteSections';
import type { GitBookSiteContext } from '@/lib/context';
export interface SearchBaseProps {
@@ -25,7 +25,7 @@ export function getSearchBaseProps(context: GitBookSiteContext): SearchBaseProps
: undefined,
siteSpace,
siteSpaces: visibleSiteSpaces,
withSections: Boolean(visibleSections && visibleSections.list.length > 1),
withSections: hasMultipleSiteSections(visibleSections),
withSiteVariants:
visibleSections?.list.some(
(section) => section.object === 'site-section' && section.siteSpaces.length > 1
@@ -1,5 +1,6 @@
'use client';
import assertNever from 'assert-never';
import { motion } from 'motion/react';
import React from 'react';
@@ -9,6 +10,7 @@ import { useToggleAnimation } from '../hooks';
import { Link, ToggleChevron } from '../primitives';
import { ScrollContainer } from '../primitives/ScrollContainer';
import type {
ClientSiteNavigationItem,
ClientSiteSection,
ClientSiteSectionGroup,
ClientSiteSections,
@@ -45,23 +47,30 @@ export function SiteSectionList(props: { sections: ClientSiteSections; className
>
<div className="flex w-full flex-col px-2">
{sectionsAndGroups.map((item) => {
if (item.object === 'site-section-group') {
return (
<SiteSectionGroupItem
key={item.id}
group={item}
currentSection={currentSection}
/>
);
switch (item.object) {
case 'site-section-group':
return (
<SiteSectionGroupItem
key={item.id}
group={item}
currentSection={currentSection}
/>
);
case 'site-section':
case 'site-external-link':
return (
<SiteSectionListItem
item={item}
isActive={
item.object === 'site-section' &&
item.id === currentSection.id
}
key={item.id}
/>
);
default:
return assertNever(item, 'Unknown client site structure node');
}
return (
<SiteSectionListItem
section={item}
isActive={item.id === currentSection.id}
key={item.id}
/>
);
})}
</div>
</ScrollContainer>
@@ -71,18 +80,18 @@ export function SiteSectionList(props: { sections: ClientSiteSections; className
}
export function SiteSectionListItem(props: {
section: ClientSiteSection;
item: ClientSiteNavigationItem;
isActive: boolean;
className?: string;
style?: React.CSSProperties;
}) {
const { section, isActive, className, style, ...otherProps } = props;
const { item, isActive, className, style, ...otherProps } = props;
return (
<Link
href={section.url}
aria-current={isActive && 'page'}
id={section.id}
href={item.url}
aria-current={isActive ? 'page' : undefined}
id={item.id}
className={tcls(
'group/section-link',
'flex',
@@ -115,15 +124,15 @@ export function SiteSectionListItem(props: {
: null
)}
>
{section.icon ? (
<SectionIcon icon={section.icon as IconName} isActive={isActive} />
{item.icon ? (
<SectionIcon icon={item.icon as IconName} isActive={isActive} />
) : (
<span className={`text-sm opacity-8 ${isActive && 'opacity-10'}`}>
{section.title.substring(0, 2)}
<span className={tcls('text-sm opacity-8', isActive && 'opacity-10')}>
{item.title.substring(0, 2)}
</span>
)}
</div>
{section.title}
{item.title}
</Link>
);
}
@@ -219,24 +228,31 @@ export function SiteSectionGroupItem(props: {
{hasDescendants ? (
<Descendants isVisible={isOpen}>
{group.children.map((child) => {
if (child.object === 'site-section') {
return (
<SiteSectionListItem
section={child}
isActive={child.id === currentSection.id}
key={child.id}
/>
);
switch (child.object) {
case 'site-section':
case 'site-external-link':
return (
<SiteSectionListItem
item={child}
isActive={
child.object === 'site-section' &&
child.id === currentSection.id
}
key={child.id}
/>
);
case 'site-section-group':
return (
<SiteSectionGroupItem
group={child}
currentSection={currentSection}
key={child.id}
level={level + 1}
/>
);
default:
return assertNever(child, 'Unknown client site structure node');
}
return (
<SiteSectionGroupItem
group={child}
currentSection={currentSection}
key={child.id}
level={level + 1}
/>
);
})}
</Descendants>
) : null}
@@ -9,8 +9,8 @@ import { CONTAINER_STYLE } from '../layout';
import { ScrollContainer } from '../primitives/ScrollContainer';
import type {
ClientSiteSection,
ClientSiteSectionGroup,
ClientSiteSections,
ClientSiteStructureNode,
} from './encodeClientSiteSections';
import { SectionIcon } from './SectionIcon';
import { Button, Link, ToggleChevron } from '@/components/primitives';
@@ -101,7 +101,9 @@ export function SiteSectionTabs(props: {
const isActiveGroup =
isGroup &&
Boolean(findSectionInGroup(structureItem, currentSection.id));
const isActive = isActiveGroup || id === currentSection.id;
const isActive =
isActiveGroup ||
(structureItem.object === 'site-section' && id === currentSection.id);
return (
<NavigationMenu.Item key={id} value={id} id={id}>
{isGroup && structureItem.children.length > 0 ? (
@@ -137,7 +139,7 @@ export function SiteSectionTabs(props: {
render={
<SectionTab
url={
structureItem.object === 'site-section'
structureItem.object !== 'site-section-group'
? structureItem.url
: undefined
}
@@ -224,58 +226,58 @@ const SectionTab = React.forwardRef(function SectionTab(
* A list of section tiles grouped in the dropdown for a section group
*/
function SectionGroupTileList(props: {
items: (ClientSiteSection | ClientSiteSectionGroup)[];
items: ClientSiteStructureNode[];
currentSection: ClientSiteSection;
}) {
const { items, currentSection } = props;
// Separate non-grouped sections from grouped sections
const sections = items.filter((item) => item.object === 'site-section');
// Separate navigable items (sections, external links) from grouped items.
const navigableItems = items.filter((item) => item.object !== 'site-section-group');
const groups = items.filter((item) => item.object === 'site-section-group');
const hasSections = sections.length > 0;
const hasNavigableItems = navigableItems.length > 0;
const hasGroups = groups.length > 0;
// Loose sections only lead when the structure opens with one, otherwise they read as secondary links and trail the groups.
const sectionsLead = items[0]?.object === 'site-section';
// Navigable items lead when the structure opens with one; otherwise they read as secondary links and trail the groups.
const navigableItemsLead = items[0]?.object !== 'site-section-group';
const isMasonryLayout = groups.length > GROUP_MASONRY_THRESHOLD;
const masonryRows = groups.reduce((total, group) => total + 1 + group.children.length, 0); // title + sections
const masonryRows = groups.reduce((total, group) => total + 1 + group.children.length, 0); // title + children
const masonryColumnCount = Math.min(
Math.max(Math.ceil(groups.length / 2), Math.ceil(masonryRows / MAX_ITEMS_PER_COLUMN)),
MAX_MASONRY_COLUMNS
);
// Whichever panel comes second is recessed: it carries the divider, the background and inverted tile icons.
const sectionsRecessed = hasGroups && !sectionsLead;
const groupsRecessed = hasSections && sectionsLead;
const navigableItemsRecessed = hasGroups && !navigableItemsLead;
const groupsRecessed = hasNavigableItems && navigableItemsLead;
const RECESSED_PANEL = 'border-tint-subtle bg-tint-subtle max-md:border-t md:border-l';
// Non-grouped sections. The wrapper spans the dropdown's height, so the list itself can stay content-sized.
const sectionsPanel = hasSections ? (
// Non-grouped navigation items. The wrapper spans the dropdown's height, so the list itself can stay content-sized.
const navigableItemsPanel = hasNavigableItems ? (
<div
className={tcls(
'w-full shrink-0 md:w-max',
hasGroups ? (sectionsRecessed ? RECESSED_PANEL : 'bg-tint-base') : ''
hasGroups ? (navigableItemsRecessed ? RECESSED_PANEL : 'bg-tint-base') : ''
)}
>
<ul
className="flex w-full grid-flow-row flex-col gap-x-2 gap-y-0.5 p-3 md:grid md:w-max"
style={{
gridTemplateColumns: `repeat(${Math.ceil(sections.length / MAX_ITEMS_PER_COLUMN)}, minmax(0, 1fr))`,
gridTemplateColumns: `repeat(${Math.ceil(navigableItems.length / MAX_ITEMS_PER_COLUMN)}, minmax(0, 1fr))`,
}}
>
{sections.map((section) => (
{navigableItems.map((item) => (
<SectionGroupTile
key={section.id}
child={section}
key={item.id}
child={item}
currentSection={currentSection}
invertIcon={sectionsRecessed}
invertIcon={navigableItemsRecessed}
/>
))}
</ul>
</div>
) : null;
// Grouped sections
// Grouped navigation items.
const groupsPanel = hasGroups ? (
<div
className={tcls(
@@ -318,15 +320,15 @@ function SectionGroupTileList(props: {
return (
<div className="flex w-full flex-col md:flex-row">
{sectionsLead ? (
{navigableItemsLead ? (
<>
{sectionsPanel}
{navigableItemsPanel}
{groupsPanel}
</>
) : (
<>
{groupsPanel}
{sectionsPanel}
{navigableItemsPanel}
</>
)}
</div>
@@ -337,7 +339,7 @@ function SectionGroupTileList(props: {
* A section tile shown in the dropdown for a section group
*/
function SectionGroupTile(props: {
child: ClientSiteSection | ClientSiteSectionGroup;
child: ClientSiteStructureNode;
currentSection: ClientSiteSection;
invertIcon?: boolean;
/** Whether the tile is a top-level group of the dropdown's masonry layout. */
@@ -345,9 +347,9 @@ function SectionGroupTile(props: {
}) {
const { child, currentSection, invertIcon, isMasonry } = props;
if (child.object === 'site-section') {
if (child.object !== 'site-section-group') {
const { url, icon, title, description } = child;
const isActive = child.id === currentSection.id;
const isActive = child.object === 'site-section' && child.id === currentSection.id;
return (
<li className="group/section-tile flex w-full min-w-0 shrink-0 grow md:max-w-[var(--site-section-tile-max-width)]">
<Link
@@ -0,0 +1,204 @@
import { describe, expect, it } from 'bun:test';
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import type { SiteExternalLink, SiteSection, SiteSectionGroup, SiteSpace } from '@gitbook/api';
import { TranslationLanguage } from '@gitbook/api';
import { LinkContext } from '../primitives';
import { encodeClientSiteSections } from './encodeClientSiteSections';
import {
hasMultipleSiteSections,
shouldRenderSiteSectionNavigation,
} from './shouldRenderSiteSectionNavigation';
import { SiteSectionListItem } from './SiteSectionList';
import type { GitBookSiteContext } from '@/lib/context';
import { createLinker } from '@/lib/links';
function makeExternalLink(id: string, overrides: Partial<SiteExternalLink> = {}): SiteExternalLink {
return {
object: 'site-external-link',
id,
title: 'GitBook',
localizedTitle: { fr: 'GitBook FR' } as SiteExternalLink['localizedTitle'],
description: 'Visit GitBook',
localizedDescription: {
fr: 'Visiter GitBook',
} as SiteExternalLink['localizedDescription'],
draft: false,
url: 'https://www.gitbook.com',
icon: 'link',
...overrides,
};
}
function makeSection(): SiteSection {
return {
object: 'site-section',
id: 'docs',
title: 'Docs',
draft: false,
path: 'docs',
siteSpaces: [{ id: 'space', default: true } as SiteSpace],
urls: { published: 'https://docs.example.com/docs' },
};
}
function makeContext(section: SiteSection): GitBookSiteContext {
return {
locale: TranslationLanguage.Fr,
siteSpace: section.siteSpaces[0],
linker: createLinker({
host: 'docs.example.com',
siteBasePath: '/',
spaceBasePath: '/docs',
}),
} as GitBookSiteContext;
}
describe('encodeClientSiteSections', () => {
it('encodes localized external links at the root and inside groups', () => {
const section = makeSection();
const rootLink = makeExternalLink('root');
const nestedLink = makeExternalLink('nested', {
title: 'Community',
localizedTitle: { es: 'Comunidad' } as SiteExternalLink['localizedTitle'],
description: undefined,
localizedDescription: undefined,
url: 'https://community.example.com',
});
const group = {
object: 'site-section-group',
id: 'resources',
title: 'Resources',
children: [nestedLink],
} as SiteSectionGroup;
expect(
encodeClientSiteSections(makeContext(section), {
list: [section, rootLink, group],
current: section,
}).list
).toEqual([
{
id: 'docs',
title: 'Docs',
description: undefined,
icon: undefined,
object: 'site-section',
url: '/docs',
},
{
id: 'root',
title: 'GitBook FR',
description: 'Visiter GitBook',
icon: 'link',
object: 'site-external-link',
url: 'https://www.gitbook.com',
},
{
id: 'resources',
title: 'Resources',
icon: undefined,
object: 'site-section-group',
children: [
{
id: 'nested',
title: 'Community',
description: undefined,
icon: 'link',
object: 'site-external-link',
url: 'https://community.example.com',
},
],
},
]);
});
});
describe('shouldRenderSiteSectionNavigation', () => {
const section = makeSection();
it('keeps an ordinary one-section site unchanged', () => {
expect(
shouldRenderSiteSectionNavigation({ list: [section], current: section })
).toBeFalse();
});
it('shows navigation for a section plus an external link', () => {
expect(
shouldRenderSiteSectionNavigation({
list: [section, makeExternalLink('external')],
current: section,
})
).toBeTrue();
});
it('shows navigation for a link-containing group', () => {
const group = {
object: 'site-section-group',
id: 'resources',
children: [makeExternalLink('external')],
} as SiteSectionGroup;
expect(shouldRenderSiteSectionNavigation({ list: [group], current: section })).toBeTrue();
});
});
describe('hasMultipleSiteSections', () => {
const section = makeSection();
it('ignores external links when determining the search scope', () => {
expect(
hasMultipleSiteSections({
list: [section, makeExternalLink('external')],
current: section,
})
).toBeFalse();
});
it('counts content sections nested inside groups', () => {
const secondSection = { ...makeSection(), id: 'guides' };
const group = {
object: 'site-section-group',
id: 'resources',
title: 'Resources',
children: [section, makeExternalLink('external'), secondSection],
} as SiteSectionGroup;
expect(hasMultipleSiteSections({ list: [group], current: section })).toBeTrue();
});
});
describe('external navigation anchors', () => {
const item = {
id: 'external',
title: 'GitBook',
description: 'Visit GitBook',
object: 'site-external-link',
url: 'https://www.gitbook.com',
} as const;
function renderItem(externalTarget: '_self' | '_blank') {
return renderToStaticMarkup(
<LinkContext.Provider value={{ externalTarget }}>
<SiteSectionListItem item={item} isActive={false} />
</LinkContext.Provider>
);
}
it('uses the external target context and security relation', () => {
expect(renderItem('_self')).not.toContain('target=');
expect(renderItem('_blank')).toContain('target="_blank"');
expect(renderItem('_blank')).toContain('rel="noopener noreferrer"');
});
it('stays external and never becomes active', () => {
const markup = renderItem('_self');
expect(markup).toContain('href="https://www.gitbook.com"');
expect(markup).not.toContain('aria-current');
expect(markup).not.toContain('data-active');
expect(markup).not.toContain('prefetch');
});
});
@@ -1,8 +1,8 @@
import assertNever from 'assert-never';
import type { SiteSection, SiteSectionGroup, SiteSpace } from '@gitbook/api';
import type { SiteExternalLink, SiteSection, SiteSectionGroup, SiteSpace } from '@gitbook/api';
import type { GitBookSiteContext, SiteSections } from '@/lib/context';
import type { GitBookSiteContext, SiteSections, SiteStructureNode } from '@/lib/context';
import { toEmbeddableLinkForPublishedContent } from '@/lib/embeddable-linker';
import {
getLocalizedDescription,
@@ -12,7 +12,7 @@ import {
} from '@/lib/sites';
export type ClientSiteSections = {
list: (ClientSiteSection | ClientSiteSectionGroup)[];
list: ClientSiteStructureNode[];
current: ClientSiteSection;
};
@@ -24,9 +24,17 @@ export type ClientSiteSection = Pick<
};
export type ClientSiteSectionGroup = Pick<SiteSectionGroup, 'id' | 'title' | 'icon' | 'object'> & {
children: (ClientSiteSection | ClientSiteSectionGroup)[];
children: ClientSiteStructureNode[];
};
export type ClientSiteExternalLink = Pick<
SiteExternalLink,
'id' | 'title' | 'description' | 'icon' | 'object' | 'url'
>;
export type ClientSiteNavigationItem = ClientSiteSection | ClientSiteExternalLink;
export type ClientSiteStructureNode = ClientSiteNavigationItem | ClientSiteSectionGroup;
/**
* Encode the list of site sections into the data to be rendered in the client.
*/
@@ -39,7 +47,7 @@ export function encodeClientSiteSections(
const currentLanguage = context.locale;
const asEmbeddable = Boolean(options?.asEmbeddable);
const clientSections: (ClientSiteSection | ClientSiteSectionGroup)[] = [];
const clientSections: ClientSiteStructureNode[] = [];
for (const item of list) {
switch (item.object) {
@@ -64,6 +72,10 @@ export function encodeClientSiteSections(
clientSections.push(encodeSection(context, item, asEmbeddable));
continue;
}
case 'site-external-link': {
clientSections.push(encodeExternalLink(context, item));
continue;
}
default:
assertNever(item, 'Unknown site section object type');
}
@@ -77,10 +89,10 @@ export function encodeClientSiteSections(
function encodeChildren(
context: GitBookSiteContext,
children: (SiteSection | SiteSectionGroup)[],
children: SiteStructureNode[],
asEmbeddable: boolean
): (ClientSiteSection | ClientSiteSectionGroup)[] {
const clientChildren: (ClientSiteSection | ClientSiteSectionGroup)[] = [];
): ClientSiteStructureNode[] {
const clientChildren: ClientSiteStructureNode[] = [];
const currentLanguage = context.locale;
for (const child of children) {
@@ -106,6 +118,10 @@ function encodeChildren(
});
break;
}
case 'site-external-link': {
clientChildren.push(encodeExternalLink(context, child));
break;
}
default:
assertNever(child, 'Unknown site section object type');
}
@@ -114,6 +130,18 @@ function encodeChildren(
return clientChildren;
}
function encodeExternalLink(context: GitBookSiteContext, link: SiteExternalLink) {
const currentLanguage = context.locale;
return {
id: link.id,
title: getLocalizedTitle(link, currentLanguage),
description: getLocalizedDescription(link, currentLanguage),
icon: link.icon,
object: link.object,
url: link.url,
};
}
function encodeSection(context: GitBookSiteContext, section: SiteSection, asEmbeddable: boolean) {
const currentLanguage = context.locale;
return {
@@ -1,3 +1,4 @@
export * from './encodeClientSiteSections';
export * from './SiteSectionList';
export * from './SiteSectionTabs';
export * from './shouldRenderSiteSectionNavigation';
@@ -0,0 +1,31 @@
import type { SiteSections } from '@/lib/context';
/** Whether the published layout should show section navigation. */
export function shouldRenderSiteSectionNavigation(sections: SiteSections | null): boolean {
return Boolean(
sections &&
(sections.list.length > 1 ||
sections.list.some((item) => item.object === 'site-section-group'))
);
}
/** Whether search should offer a scope across multiple content sections. */
export function hasMultipleSiteSections(sections: SiteSections | null): boolean {
if (!sections) {
return false;
}
const countSections = (items: SiteSections['list']): number =>
items.reduce((count, item) => {
switch (item.object) {
case 'site-section':
return count + 1;
case 'site-section-group':
return count + countSections(item.children);
case 'site-external-link':
return count;
}
}, 0);
return countSections(sections.list) > 1;
}
@@ -13,7 +13,11 @@ import { InsightsProvider, VisitorProvider } from '../Insights';
import { CONTAINER_STYLE } from '../layout';
import { NavigationLoader } from '../primitives/NavigationLoader';
import { SearchContainer, getSearchBaseProps } from '../Search';
import { SiteSectionList, encodeClientSiteSections } from '../SiteSections';
import {
SiteSectionList,
encodeClientSiteSections,
shouldRenderSiteSectionNavigation,
} from '../SiteSections';
import { categorizeVariants } from './categorizeVariants';
import { SpaceLayoutContextProvider } from './SpaceLayoutContext';
import { Footer } from '@/components/Footer';
@@ -113,7 +117,7 @@ export function SpaceLayout(props: SpaceLayoutProps) {
const withTopHeader = customization.header.preset !== CustomizationHeaderPreset.None;
const withSections = Boolean(visibleSections && visibleSections.list.length > 1);
const withSections = shouldRenderSiteSectionNavigation(visibleSections);
const variants = categorizeVariants(context);
const socialLinks = customization.socialAccounts.filter((account) => account.display?.footer);
+39
View File
@@ -0,0 +1,39 @@
import { describe, expect, it } from 'bun:test';
import type { SiteExternalLink, SiteSection, SiteSectionGroup } from '@gitbook/api';
import { filterSectionsAndGroupsWithHiddenSiteSpaces } from './context';
function makeExternalLink(id: string): SiteExternalLink {
return {
object: 'site-external-link',
id,
title: 'GitBook',
draft: false,
url: 'https://www.gitbook.com',
};
}
describe('filterSectionsAndGroupsWithHiddenSiteSpaces', () => {
it('retains groups containing only external links', () => {
const link = makeExternalLink('external');
const group = {
object: 'site-section-group',
id: 'resources',
children: [link],
} as SiteSectionGroup;
expect(filterSectionsAndGroupsWithHiddenSiteSpaces([group])).toEqual([group]);
});
it('filters hidden sections without mistaking links for sections', () => {
const hiddenSection = {
object: 'site-section',
id: 'hidden',
siteSpaces: [{ hidden: true }],
} as SiteSection;
const link = makeExternalLink('external');
expect(filterSectionsAndGroupsWithHiddenSiteSpaces([hiddenSection, link])).toEqual([link]);
});
});
+36 -31
View File
@@ -10,6 +10,7 @@ import type {
RevisionPageDocument,
Site,
SiteCustomizationSettings,
SiteExternalLink,
SiteIntegrationScript,
SiteSection,
SiteSectionGroup,
@@ -151,8 +152,10 @@ export type GitBookSpaceContext = GitBookBaseContext & {
shareKey: string | undefined;
};
export type SiteStructureNode = SiteSection | SiteSectionGroup | SiteExternalLink;
export type SiteSections = {
list: (SiteSectionGroup | SiteSection)[];
list: SiteStructureNode[];
current: SiteSection;
};
@@ -581,6 +584,38 @@ export function checkIsRootSiteContext(context: GitBookSiteContext): boolean {
}
}
/** Filter sections with hidden spaces while preserving external navigation links. */
export function filterSectionsAndGroupsWithHiddenSiteSpaces(
sectionsOrGroups: SiteStructureNode[]
): SiteStructureNode[] {
return sectionsOrGroups
.map((entry) => {
switch (entry.object) {
case 'site-section':
return sectionHasOnlyHiddenSiteSpaces(entry) ? null : entry;
case 'site-external-link':
return entry;
case 'site-section-group': {
const visibleChildren = filterSectionsAndGroupsWithHiddenSiteSpaces(
entry.children
);
if (visibleChildren.length === 0) {
return null;
}
return {
...entry,
children: visibleChildren,
};
}
default:
return assertNever(entry, 'Unknown site structure node object type');
}
})
.filter((entry): entry is SiteStructureNode => Boolean(entry));
}
/**
* Filter out hidden site spaces from a list of site spaces.
*/
@@ -609,36 +644,6 @@ function parseCurrentSection(structure: SiteStructure, siteSectionId: string) {
return sections.find((section) => section.id === siteSectionId);
}
type SectionOrGroup = SiteSection | SiteSectionGroup;
/**
* Filter out sections where all site spaces are hidden and groups that become empty after filtering.
*/
function filterSectionsAndGroupsWithHiddenSiteSpaces(
sectionsOrGroups: SectionOrGroup[]
): SectionOrGroup[] {
return sectionsOrGroups
.map((entry) => {
if (entry.object === 'site-section') {
return sectionHasOnlyHiddenSiteSpaces(entry) ? null : entry;
}
const visibleChildren: SectionOrGroup[] = filterSectionsAndGroupsWithHiddenSiteSpaces(
entry.children
);
if (visibleChildren.length === 0) {
return null;
}
return {
...entry,
children: visibleChildren,
};
})
.filter((entry): entry is SiteSection | SiteSectionGroup => Boolean(entry));
}
function sectionHasOnlyHiddenSiteSpaces(section: SiteSection) {
return section.siteSpaces.every((siteSpace) => siteSpace.hidden);
}
+9 -10
View File
@@ -1,14 +1,9 @@
import { describe, expect, it } from 'bun:test';
import type {
JSONDocument,
RevisionPage,
RevisionTag,
SiteSection,
SiteSectionGroup,
} from '@gitbook/api';
import type { JSONDocument, RevisionPage, RevisionTag } from '@gitbook/api';
import { IconStyle } from '@gitbook/icons/types';
import type { SiteStructureNode } from '../context';
import { getContentInlineIconSourceRequests, parseRawSVG } from './inline';
describe('parseRawSVG', () => {
@@ -96,9 +91,12 @@ describe('getContentInlineIconSourceRequests', () => {
{
object: 'site-section-group',
icon: 'magnifying-glass',
children: [{ object: 'site-section', icon: 'xmark' }],
children: [
{ object: 'site-section', icon: 'xmark' },
{ object: 'site-external-link', icon: 'link' },
],
},
] as unknown as (SiteSection | SiteSectionGroup)[];
] as unknown as SiteStructureNode[];
const document = {
object: 'document',
nodes: [
@@ -128,6 +126,7 @@ describe('getContentInlineIconSourceRequests', () => {
{ icon: 'gear', iconStyle: IconStyle.Solid },
{ icon: 'magnifying-glass', iconStyle: IconStyle.Solid },
{ icon: 'xmark', iconStyle: IconStyle.Solid },
{ icon: 'link', iconStyle: IconStyle.Solid },
{ icon: 'copy', iconStyle: IconStyle.Solid },
{ icon: 'download', iconStyle: IconStyle.Solid },
]);
@@ -143,7 +142,7 @@ describe('getContentInlineIconSourceRequests', () => {
const sections = [
{ object: 'site-section', icon: undefined },
{ object: 'site-section', icon: 'also-not-real' },
] as unknown as (SiteSection | SiteSectionGroup)[];
] as unknown as SiteStructureNode[];
const document = {
object: 'document',
nodes: [
+15 -8
View File
@@ -1,4 +1,5 @@
import 'server-only';
import assertNever from 'assert-never';
import pRetry from 'p-retry';
import {
@@ -7,8 +8,6 @@ import {
type RevisionPage,
type RevisionTag,
type SiteCustomizationSettings,
type SiteSection,
type SiteSectionGroup,
} from '@gitbook/api';
import { getIconStyle } from '@gitbook/icons/getIconStyle';
import { validateIconName } from '@gitbook/icons/icons';
@@ -17,6 +16,7 @@ import { type IconName, IconStyle } from '@gitbook/icons/types';
import { GITBOOK_ICONS_ASSET_VERSION } from '@gitbook/icons/version';
import { getAssetURL } from '@/lib/assets';
import type { SiteStructureNode } from '@/lib/context';
import { GITBOOK_ICONS_TOKEN, GITBOOK_ICONS_URL, GITBOOK_URL } from '@/lib/env';
import { joinPath, joinPathWithBaseURL } from '@/lib/paths';
@@ -175,7 +175,7 @@ export function getContentInlineIconSourceRequests(input: {
pages?: RevisionPage[];
document?: JSONDocument | null;
tags?: RevisionTag[];
sections?: (SiteSection | SiteSectionGroup)[] | null;
sections?: SiteStructureNode[] | null;
}): IconSourceRequest[] {
const { iconStyle, pages = [], document = null, tags = [], sections = null } = input;
const requests: IconSourceRequest[] = [];
@@ -342,15 +342,22 @@ function collectTagIconSourceRequests(
}
function collectSiteSectionIconSourceRequests(
sections: (SiteSection | SiteSectionGroup)[],
sections: SiteStructureNode[],
iconStyle: IconStyle,
requests: IconSourceRequest[]
) {
for (const section of sections) {
addIconSourceRequest(requests, section.icon, iconStyle);
if (section.object === 'site-section-group') {
collectSiteSectionIconSourceRequests(section.children, iconStyle, requests);
switch (section.object) {
case 'site-section':
case 'site-external-link':
addIconSourceRequest(requests, section.icon, iconStyle);
break;
case 'site-section-group':
addIconSourceRequest(requests, section.icon, iconStyle);
collectSiteSectionIconSourceRequests(section.children, iconStyle, requests);
break;
default:
assertNever(section, 'Unknown site structure node object type');
}
}
}
+65 -1
View File
@@ -1,6 +1,13 @@
import { describe, expect, it } from 'bun:test';
import type { RevisionPageDocument, SiteSection, SiteSpace, SiteStructure } from '@gitbook/api';
import type {
RevisionPageDocument,
SiteExternalLink,
SiteSection,
SiteSectionGroup,
SiteSpace,
SiteStructure,
} from '@gitbook/api';
import { TranslationLanguage } from '@gitbook/api';
import { createLinker } from './links';
@@ -8,7 +15,9 @@ import {
filterSiteSpacesByLocale,
getFallbackSiteSpacePath,
getLinkerForSiteSpace,
getSiteStructureSections,
getSiteSpacePagePaths,
listAllSiteSpaces,
resolveSiteSpaceCustomHomePage,
} from './sites';
import type { GitBookSiteContext } from '@/lib/context';
@@ -17,6 +26,61 @@ function makeSiteSpace(language: TranslationLanguage | undefined): SiteSpace {
return { space: { language } } as SiteSpace;
}
function makeExternalLink(id: string): SiteExternalLink {
return {
object: 'site-external-link',
id,
title: 'GitBook',
localizedTitle: { fr: 'GitBook FR' } as SiteExternalLink['localizedTitle'],
description: 'Visit GitBook',
localizedDescription: {
fr: 'Visiter GitBook',
} as SiteExternalLink['localizedDescription'],
draft: false,
url: 'https://www.gitbook.com',
icon: 'link',
};
}
describe('site structure traversal', () => {
const rootSpace = { id: 'root-space' } as SiteSpace;
const nestedSpace = { id: 'nested-space' } as SiteSpace;
const rootSection = {
object: 'site-section',
id: 'root-section',
siteSpaces: [rootSpace],
} as SiteSection;
const nestedSection = {
object: 'site-section',
id: 'nested-section',
siteSpaces: [nestedSpace],
} as SiteSection;
const nestedLink = makeExternalLink('nested-link');
const group = {
object: 'site-section-group',
id: 'group',
children: [nestedLink, nestedSection],
} as SiteSectionGroup;
const rootLink = makeExternalLink('root-link');
const structure = {
type: 'sections',
structure: [rootSection, rootLink, group],
} satisfies SiteStructure;
it('preserves external links in navigation order', () => {
expect(getSiteStructureSections(structure)).toEqual([rootSection, rootLink, group]);
expect(group.children).toEqual([nestedLink, nestedSection]);
});
it('excludes external links from section and space results', () => {
expect(getSiteStructureSections(structure, { ignoreGroups: true })).toEqual([
rootSection,
nestedSection,
]);
expect(listAllSiteSpaces(structure)).toEqual([rootSpace, nestedSpace]);
});
});
describe('filterSiteSpacesByLocale', () => {
it('returns all spaces on a single-language site', () => {
const spaces = [makeSiteSpace(undefined), makeSiteSpace(undefined)];
+103 -53
View File
@@ -1,3 +1,5 @@
import assertNever from 'assert-never';
import type {
LocalizedString,
Revision,
@@ -20,7 +22,7 @@ import {
import { joinPath } from './paths';
import { flattenSectionsFromGroup } from './utils';
import { languages } from '@/intl/translations';
import type { GitBookSiteContext } from '@/lib/context';
import type { GitBookSiteContext, SiteStructureNode } from '@/lib/context';
/**
* Get all sections from a site structure.
@@ -33,21 +35,37 @@ export function getSiteStructureSections(
export function getSiteStructureSections(
siteStructure: SiteStructure,
options?: { ignoreGroups: false }
): SiteSection[] | SiteSectionGroup[];
): SiteStructureNode[];
export function getSiteStructureSections(
siteStructure: SiteStructure,
options?: { ignoreGroups: boolean }
) {
const { ignoreGroups } = options ?? { ignoreGroups: false };
return siteStructure.type === 'sections'
? ignoreGroups
? siteStructure.structure.flatMap((item) =>
item.object === 'site-section-group'
? flattenSectionsFromGroup<SiteSection | SiteSectionGroup>(item.children)
: item
)
: siteStructure.structure
: [];
switch (siteStructure.type) {
case 'siteSpaces':
return [];
case 'sections':
if (!ignoreGroups) {
return siteStructure.structure;
}
return siteStructure.structure.flatMap((item) => {
switch (item.object) {
case 'site-section':
return [item];
case 'site-section-group':
return flattenSectionsFromGroup<SiteStructureNode>(item.children).filter(
(child): child is SiteSection => child.object === 'site-section'
);
case 'site-external-link':
return [];
default:
return assertNever(item, 'Unknown site structure node object type');
}
});
default:
return assertNever(siteStructure, 'Unknown site structure type');
}
}
/**
@@ -180,20 +198,31 @@ export function getLinkerForSiteSpace(
/*
* Gets all site spaces, in a site structure and overrides the title
*/
export function listAllSiteSpaces(siteStructure: SiteStructure) {
if (siteStructure.type === 'siteSpaces') {
return siteStructure.structure;
export function listAllSiteSpaces(siteStructure: SiteStructure): SiteSpace[] {
switch (siteStructure.type) {
case 'siteSpaces':
return siteStructure.structure;
case 'sections':
return siteStructure.structure.flatMap((section) => {
switch (section.object) {
case 'site-section':
return section.siteSpaces;
case 'site-section-group':
return flattenSectionsFromGroup<SiteStructureNode>(section.children)
.filter(
(subSection): subSection is SiteSection =>
subSection.object === 'site-section'
)
.flatMap((subSection) => subSection.siteSpaces);
case 'site-external-link':
return [];
default:
return assertNever(section, 'Unknown site structure node object type');
}
});
default:
return assertNever(siteStructure, 'Unknown site structure type');
}
return siteStructure.structure.flatMap((section) => {
if (section.object === 'site-section') {
return section.siteSpaces;
}
return flattenSectionsFromGroup<SiteSection | SiteSectionGroup>(section.children)
.filter((subSection): subSection is SiteSection => subSection.object === 'site-section')
.flatMap((subSection) => subSection.siteSpaces);
});
}
type SiteSpaceMatch = { siteSpace: SiteSpace; pagePath: string; baseLength: number };
@@ -250,24 +279,36 @@ export function findSiteSpaceBy(
}
for (const sectionOrGroup of siteStructure.structure) {
if (sectionOrGroup.object === 'site-section') {
const siteSpace = findSiteSpaceByIdInSiteSpaces(sectionOrGroup.siteSpaces, predicate);
if (siteSpace) {
return {
siteSpace,
siteSection: sectionOrGroup,
siteSectionGroup: null,
};
switch (sectionOrGroup.object) {
case 'site-section': {
const siteSpace = findSiteSpaceByIdInSiteSpaces(
sectionOrGroup.siteSpaces,
predicate
);
if (siteSpace) {
return {
siteSpace,
siteSection: sectionOrGroup,
siteSectionGroup: null,
};
}
break;
}
} else {
const found = findSiteSpaceByIdInGroupChildren(
sectionOrGroup.children,
predicate,
sectionOrGroup
);
if (found) {
return found;
case 'site-section-group': {
const found = findSiteSpaceByIdInGroupChildren(
sectionOrGroup.children,
predicate,
sectionOrGroup
);
if (found) {
return found;
}
break;
}
case 'site-external-link':
break;
default:
return assertNever(sectionOrGroup, 'Unknown site structure node object type');
}
}
@@ -318,7 +359,7 @@ export function getFallbackSiteSpacePath(context: GitBookSiteContext, siteSpace:
}
function findSiteSpaceByIdInGroupChildren(
children: (SiteSection | SiteSectionGroup)[],
children: SiteStructureNode[],
predicate: (siteSpace: SiteSpace) => boolean,
parentGroup: SiteSectionGroup
): {
@@ -327,20 +368,29 @@ function findSiteSpaceByIdInGroupChildren(
siteSectionGroup: SiteSectionGroup;
} | null {
for (const child of children) {
if (child.object === 'site-section') {
const siteSpace = child.siteSpaces.find(predicate) ?? null;
if (siteSpace) {
return {
siteSpace,
siteSection: child,
siteSectionGroup: parentGroup,
};
switch (child.object) {
case 'site-section': {
const siteSpace = child.siteSpaces.find(predicate) ?? null;
if (siteSpace) {
return {
siteSpace,
siteSection: child,
siteSectionGroup: parentGroup,
};
}
break;
}
} else if (child.object === 'site-section-group') {
const found = findSiteSpaceByIdInGroupChildren(child.children, predicate, child);
if (found) {
return found;
case 'site-section-group': {
const found = findSiteSpaceByIdInGroupChildren(child.children, predicate, child);
if (found) {
return found;
}
break;
}
case 'site-external-link':
break;
default:
return assertNever(child, 'Unknown site structure node object type');
}
}