diff --git a/.changeset/external-navigation-links.md b/.changeset/external-navigation-links.md new file mode 100644 index 000000000..9e0a0728b --- /dev/null +++ b/.changeset/external-navigation-links.md @@ -0,0 +1,5 @@ +--- +"gitbook": minor +--- + +Support external links in published site navigation. diff --git a/bun.lock b/bun.lock index 9f6aa6bf0..398bbbebe 100644 --- a/bun.lock +++ b/bun.lock @@ -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"], diff --git a/package.json b/package.json index cf797b3f6..933691688 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.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", diff --git a/packages/gitbook/src/components/Header/Header.tsx b/packages/gitbook/src/components/Header/Header.tsx index 29abf62d6..04d0119d8 100644 --- a/packages/gitbook/src/components/Header/Header.tsx +++ b/packages/gitbook/src/components/Header/Header.tsx @@ -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 diff --git a/packages/gitbook/src/components/PageBody/PageHeader.tsx b/packages/gitbook/src/components/PageBody/PageHeader.tsx index e6cdfbad5..4b7d822e6 100644 --- a/packages/gitbook/src/components/PageBody/PageHeader.tsx +++ b/packages/gitbook/src/components/PageBody/PageHeader.tsx @@ -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 { + const ids = new Set(); + 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 { - const ids = new Set(); - 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 diff --git a/packages/gitbook/src/components/Search/search-props.ts b/packages/gitbook/src/components/Search/search-props.ts index baa526542..b7c14fab3 100644 --- a/packages/gitbook/src/components/Search/search-props.ts +++ b/packages/gitbook/src/components/Search/search-props.ts @@ -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 diff --git a/packages/gitbook/src/components/SiteSections/SiteSectionList.tsx b/packages/gitbook/src/components/SiteSections/SiteSectionList.tsx index b94303cb4..9f33aefaf 100644 --- a/packages/gitbook/src/components/SiteSections/SiteSectionList.tsx +++ b/packages/gitbook/src/components/SiteSections/SiteSectionList.tsx @@ -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 >
{sectionsAndGroups.map((item) => { - if (item.object === 'site-section-group') { - return ( - - ); + switch (item.object) { + case 'site-section-group': + return ( + + ); + case 'site-section': + case 'site-external-link': + return ( + + ); + default: + return assertNever(item, 'Unknown client site structure node'); } - - return ( - - ); })}
@@ -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 ( - {section.icon ? ( - + {item.icon ? ( + ) : ( - - {section.title.substring(0, 2)} + + {item.title.substring(0, 2)} )} - {section.title} + {item.title} ); } @@ -219,24 +228,31 @@ export function SiteSectionGroupItem(props: { {hasDescendants ? ( {group.children.map((child) => { - if (child.object === 'site-section') { - return ( - - ); + switch (child.object) { + case 'site-section': + case 'site-external-link': + return ( + + ); + case 'site-section-group': + return ( + + ); + default: + return assertNever(child, 'Unknown client site structure node'); } - - return ( - - ); })} ) : null} diff --git a/packages/gitbook/src/components/SiteSections/SiteSectionTabs.tsx b/packages/gitbook/src/components/SiteSections/SiteSectionTabs.tsx index 002292254..5b5f53086 100644 --- a/packages/gitbook/src/components/SiteSections/SiteSectionTabs.tsx +++ b/packages/gitbook/src/components/SiteSections/SiteSectionTabs.tsx @@ -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 ( {isGroup && structureItem.children.length > 0 ? ( @@ -137,7 +139,7 @@ export function SiteSectionTabs(props: { render={ 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 ? (
    - {sections.map((section) => ( + {navigableItems.map((item) => ( ))}
) : null; - // Grouped sections + // Grouped navigation items. const groupsPanel = hasGroups ? (
- {sectionsLead ? ( + {navigableItemsLead ? ( <> - {sectionsPanel} + {navigableItemsPanel} {groupsPanel} ) : ( <> {groupsPanel} - {sectionsPanel} + {navigableItemsPanel} )}
@@ -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 (
  • = {}): 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( + + + + ); + } + + 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'); + }); +}); diff --git a/packages/gitbook/src/components/SiteSections/encodeClientSiteSections.ts b/packages/gitbook/src/components/SiteSections/encodeClientSiteSections.ts index 1fdb4a9eb..8ee71f269 100644 --- a/packages/gitbook/src/components/SiteSections/encodeClientSiteSections.ts +++ b/packages/gitbook/src/components/SiteSections/encodeClientSiteSections.ts @@ -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 & { - 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 { diff --git a/packages/gitbook/src/components/SiteSections/index.ts b/packages/gitbook/src/components/SiteSections/index.ts index cc70f417a..d1f9c72ef 100644 --- a/packages/gitbook/src/components/SiteSections/index.ts +++ b/packages/gitbook/src/components/SiteSections/index.ts @@ -1,3 +1,4 @@ export * from './encodeClientSiteSections'; export * from './SiteSectionList'; export * from './SiteSectionTabs'; +export * from './shouldRenderSiteSectionNavigation'; diff --git a/packages/gitbook/src/components/SiteSections/shouldRenderSiteSectionNavigation.ts b/packages/gitbook/src/components/SiteSections/shouldRenderSiteSectionNavigation.ts new file mode 100644 index 000000000..787fc456b --- /dev/null +++ b/packages/gitbook/src/components/SiteSections/shouldRenderSiteSectionNavigation.ts @@ -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; +} diff --git a/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx b/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx index e87558945..df6b60f32 100644 --- a/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx +++ b/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx @@ -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); diff --git a/packages/gitbook/src/lib/context.test.ts b/packages/gitbook/src/lib/context.test.ts new file mode 100644 index 000000000..46cf5fd07 --- /dev/null +++ b/packages/gitbook/src/lib/context.test.ts @@ -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]); + }); +}); diff --git a/packages/gitbook/src/lib/context.ts b/packages/gitbook/src/lib/context.ts index 36d667fb3..20a1917db 100644 --- a/packages/gitbook/src/lib/context.ts +++ b/packages/gitbook/src/lib/context.ts @@ -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); } diff --git a/packages/gitbook/src/lib/icons/inline.test.ts b/packages/gitbook/src/lib/icons/inline.test.ts index 1240d2359..4d0b7db02 100644 --- a/packages/gitbook/src/lib/icons/inline.test.ts +++ b/packages/gitbook/src/lib/icons/inline.test.ts @@ -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: [ diff --git a/packages/gitbook/src/lib/icons/inline.ts b/packages/gitbook/src/lib/icons/inline.ts index 8d0154983..c4f925e47 100644 --- a/packages/gitbook/src/lib/icons/inline.ts +++ b/packages/gitbook/src/lib/icons/inline.ts @@ -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'); } } } diff --git a/packages/gitbook/src/lib/sites.test.ts b/packages/gitbook/src/lib/sites.test.ts index 3115b9654..d6d412f59 100644 --- a/packages/gitbook/src/lib/sites.test.ts +++ b/packages/gitbook/src/lib/sites.test.ts @@ -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)]; diff --git a/packages/gitbook/src/lib/sites.ts b/packages/gitbook/src/lib/sites.ts index 516012948..b4a2eb7c6 100644 --- a/packages/gitbook/src/lib/sites.ts +++ b/packages/gitbook/src/lib/sites.ts @@ -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(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(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(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(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'); } }