mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-12 05:48:57 +00:00
Support external links (#4561)
Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": minor
|
||||
---
|
||||
|
||||
Support external links in published site navigation.
|
||||
@@ -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
@@ -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);
|
||||
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)];
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user