Compare commits

..

13 Commits

Author SHA1 Message Date
Steven Hall 0ca9a8b303 Explore an approach where pages will send a 404 instead of 200 2024-11-15 09:17:48 +00:00
Taran Vohra 7675c2c411 use getPublishedContentSite API (#2570)
Co-authored-by: Samy Pessé <samypesse@gmail.com>
2024-11-15 09:21:08 +01:00
Brett Jephson 7c71363af8 Set adjustFallbackFont to false for mono font (#2569) 2024-11-12 13:03:25 +00:00
Steven H d48926eb2a Fix an issue where the space dropdown was shown under the site sections in Safari. (#2567) 2024-11-05 11:49:33 +00:00
Steven H d843e5e06f Fix an issue where the space dropdown could appear behind the header. (#2566) 2024-11-05 09:47:32 +00:00
Steven H a2e564738e Fix the styling of site section tabs on smaller screens. (#2565) 2024-11-04 17:36:10 +00:00
Greg Bergé 54a797a0b1 Nested list support (#2562) 2024-10-31 14:02:42 +01:00
Amrit Kahlon a78c1ecb4d Bump scalar api client package (#2563) 2024-10-30 11:45:56 -07:00
Amrit Kahlon 4771c78001 Fix Scalar ApiClient routing (#2561) 2024-10-28 12:38:18 -07:00
Samy Pessé b7a5106f36 Disable logs for cache-do (#2560) 2024-10-27 08:16:56 -04:00
Samy Pessé 4bcbdc50ff Output caching headers on RSC/prefetch responses (#2559) 2024-10-26 10:57:52 +02:00
Brett Jephson 5d72b35c7f Improve animation of section tabs (#2553) 2024-10-25 14:00:27 +01:00
Amrit Kahlon ff50ac215c Bump @scalar/api-client-react version (#2555)
Co-authored-by: taranvohra <taranvohra@outlook.com>
Co-authored-by: Samy Pessé <samypesse@gmail.com>
2024-10-25 12:06:49 +05:30
51 changed files with 681 additions and 802 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@gitbook/cache-do': patch
---
Disable cloudflare observability in production
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Smoother tab transition for sections
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Don't adjust fallback font for mono font.
+5
View File
@@ -0,0 +1,5 @@
---
'@gitbook/react-openapi': patch
---
Fixed scalar api client routing
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Optimize performances by using new API endpoint for fetching site data.
+5
View File
@@ -0,0 +1,5 @@
---
'@gitbook/react-openapi': patch
---
Bump @scalar/api-client-react version
+5
View File
@@ -0,0 +1,5 @@
---
'@gitbook/react-openapi': patch
---
Bumped scalar api client pacakge
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': minor
---
Fix an issue where the space dropdown was shown under the site sections in Safari.
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': minor
---
Fix an issue where the space dropdown could appear behind the header.
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': minor
---
Fix the styling of site section tabs on smaller screens.
+7 -7
View File
@@ -18,7 +18,7 @@ jobs:
checks: write
statuses: write
outputs:
deployment_url: ${{ steps.deploy.outputs.url }}
deployment_url: ${{ steps.deploy.outputs.deployment-url }}
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -58,7 +58,7 @@ jobs:
SENTRY_DSN: ${{ vars.SENTRY_DSN }}
- id: deploy
name: Deploy to Cloudflare
uses: cloudflare/wrangler-action@v3
uses: cloudflare/wrangler-action@v3.11.0
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
@@ -67,7 +67,7 @@ jobs:
command: pages deploy ./packages/gitbook/.vercel/output/static --project-name=${{ vars.CLOUDFLARE_PROJECT_NAME }} --branch=${{ github.ref == 'refs/heads/main' && 'main' || format('pr{0}', github.event.pull_request.number) }}
- name: Outputs
run: |
echo "URL: ${{ steps.deploy.outputs.url }}"
echo "URL: ${{ steps.deploy.outputs.deployment-url }}"
echo "Alias URL: ${{ steps.deploy.outputs.deployment-alias-url }}"
- name: Archive build output
uses: actions/upload-artifact@v4
@@ -77,12 +77,12 @@ jobs:
# Until https://github.com/cloudflare/wrangler-action/issues/301 is done
- name: Update Deployment Status to Success
env:
DEPLOYMENT_URL: ${{ steps.deploy.outputs.url }}
DEPLOYMENT_URL: ${{ steps.deploy.outputs.deployment-url }}
run: |
curl -X POST \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
-H "Accept: application/vnd.github.v3+json" \
-d '{"state": "success", "target_url": "${{ steps.deploy.outputs.url }}", "description": "Deployed Preview URL for commit", "context": "cloudflare/preview"}' \
-d '{"state": "success", "target_url": "${{ steps.deploy.outputs.deployment-url }}", "description": "Deployed Preview URL for commit", "context": "cloudflare/preview"}' \
https://api.github.com/repos/${{ github.repository }}/statuses/${{ github.sha }}
- name: Find GitHub Comment
@@ -102,8 +102,8 @@ jobs:
issue-number: ${{ github.event.pull_request.number }}
body: |
**GitBook Preview**
Latest commit: [${{ steps.deploy.outputs.url }}](${{ steps.deploy.outputs.url }})
PR: [${{ steps.deploy.outputs.alias }}](${{ steps.deploy.outputs.alias }})
Latest commit: [${{ steps.deploy.outputs.deployment-url }}](${{ steps.deploy.outputs.deployment-url }})
PR: [${{ steps.deploy.outputs.deployment-alias-url }}](${{ steps.deploy.outputs.deployment-alias-url }})
edit-mode: replace
visual-testing:
runs-on: ubuntu-latest
BIN
View File
Binary file not shown.
+2 -1
View File
@@ -8,7 +8,8 @@
},
"packageManager": "bun@1.1.18",
"patchedDependencies": {
"@vercel/next@4.3.15": "patches/@vercel%2Fnext@4.3.15.patch"
"@vercel/next@4.3.15": "patches/@vercel%2Fnext@4.3.15.patch",
"@cloudflare/next-on-pages@1.13.5": "patches/@cloudflare%2Fnext-on-pages@1.13.5.patch"
},
"private": true,
"scripts": {
+2 -3
View File
@@ -11,8 +11,7 @@ migrations = [
]
[observability]
enabled = true
head_sampling_rate = 0.01
enabled = false
[env.preview]
name = "gitbook-open-cache-preview"
@@ -25,4 +24,4 @@ migrations = [
[env.preview.observability]
enabled = true
head_sampling_rate = 1
head_sampling_rate = 0.1
+2 -2
View File
@@ -5,7 +5,7 @@
"scripts": {
"dev": "env-cmd --silent -f ../../.env.local next dev",
"build": "next build",
"build:cloudflare": "next-on-pages",
"build:cloudflare": "next-on-pages --custom-entrypoint=./src/cloudflare-entrypoint.ts",
"start": "next start",
"lint": "next lint",
"typecheck": "tsc --noEmit",
@@ -16,7 +16,7 @@
"clean": "rm -rf ./.next && rm -rf ./public/~gitbook/static"
},
"dependencies": {
"@gitbook/api": "^0.73.0",
"@gitbook/api": "^0.76.0",
"@gitbook/cache-do": "workspace:*",
"@gitbook/emoji-codepoints": "workspace:*",
"@gitbook/icons": "workspace:*",
@@ -1,28 +0,0 @@
import { SkeletonHeading, SkeletonParagraph } from '@/components/primitives';
import { tcls } from '@/lib/tailwind';
/**
* Placeholder when loading a page.
*/
export default function PageSkeleton() {
return (
<div
className={tcls(
'flex',
'flex-row',
'flex-1',
'relative',
'py-8',
'lg:px-16',
'xl:mr-56',
'items-center',
'lg:items-start',
)}
>
<div className={tcls('flex-1', 'max-w-3xl', 'mx-auto', 'page-full-width:mx-0')}>
<SkeletonHeading style={tcls('mb-8')} />
<SkeletonParagraph style={tcls('mb-4')} />
</div>
</div>
);
}
@@ -1,11 +1,11 @@
import { getSpaceLanguage, t } from '@/intl/server';
import { getSiteLayoutData } from '@/lib/api';
import { getSiteData } from '@/lib/api';
import { getSiteContentPointer } from '@/lib/pointer';
import { tcls } from '@/lib/tailwind';
export default async function NotFound() {
const pointer = getSiteContentPointer();
const { customization } = await getSiteLayoutData(pointer);
const { customization } = await getSiteData(pointer);
const language = getSpaceLanguage(customization);
@@ -73,7 +73,7 @@ export default async function Page(props: {
page,
};
const withSections = Boolean(sections && sections.length > 0);
const withSections = Boolean(sections && sections.list.length > 0);
const headerOffset = { sectionsHeader: withSections, topHeader: withTopHeader };
return (
@@ -1,7 +1,6 @@
import { ContentVisibility } from '@gitbook/api';
import { NextRequest } from 'next/server';
import { getCollection, getSite, getSpace } from '@/lib/api';
import { getSpace, getSite } from '@/lib/api';
import { absoluteHref } from '@/lib/links';
import { getSiteContentPointer } from '@/lib/pointer';
import { isSpaceIndexable } from '@/lib/seo';
@@ -3,7 +3,7 @@ import { ImageResponse } from 'next/og';
import { NextRequest } from 'next/server';
import React from 'react';
import { getCurrentSiteCustomization, getSite, getSpace } from '@/lib/api';
import { getSite, getSiteData, getSpace } from '@/lib/api';
import { getEmojiForCode } from '@/lib/emojis';
import { getSiteContentPointer } from '@/lib/pointer';
import { tcls } from '@/lib/tailwind';
@@ -38,9 +38,9 @@ export async function GET(req: NextRequest) {
const pointer = getSiteContentPointer();
const spaceId = pointer.spaceId;
const [space, customization] = await Promise.all([
const [space, { customization }] = await Promise.all([
getSpace(spaceId, pointer.siteShareKey),
getCurrentSiteCustomization(pointer),
getSiteData(pointer),
]);
const site = await getSite(pointer.organizationId, pointer.siteId);
const contentTitle = getContentTitle(space, customization, site);
+11 -107
View File
@@ -1,14 +1,11 @@
import { RevisionPage, SiteSection, SiteSpace, Space } from '@gitbook/api';
import { assert } from 'ts-essentials';
import { RevisionPage } from '@gitbook/api';
import {
getRevisionPageByPath,
getDocument,
ContentTarget,
getSpaceContentData,
getSiteData,
getSite,
getCurrentSiteCustomization,
getSiteStructure,
} from '@/lib/api';
import { resolvePagePath, resolvePageId } from '@/lib/pages';
import { getSiteContentPointer } from '@/lib/pointer';
@@ -21,35 +18,18 @@ export interface PageIdParams {
pageId: string;
}
type SectionsList = { list: SiteSection[]; section: SiteSection };
/**
* Fetch all the data needed to render the content layout.
*/
export async function fetchContentData() {
const content = getSiteContentPointer();
const [{ space, contentTarget, pages, customization, scripts }, siteStructure] =
const [{ space, contentTarget, pages }, { customization, site, sections, spaces, scripts }] =
await Promise.all([
getSpaceContentData(content, content.siteShareKey),
getSiteData(content),
fetchSiteStructure({
organizationId: content.organizationId,
siteId: content.siteId,
siteShareKey: content.siteShareKey,
}),
]);
const site = siteStructure.site;
const siteSections =
content.siteSectionId && siteStructure.sections
? parseSiteSectionsList(content.siteSectionId, siteStructure.sections)
: null;
const spaces =
siteStructure.spaces ??
(siteSections ? parseSpacesFromSiteSpaces(siteSections.section.siteSpaces) : []);
// we grab the space attached to the parent as it contains overriden customizations
const spaceRelativeToParent = spaces?.find((space) => space.id === content.spaceId);
@@ -58,8 +38,8 @@ export async function fetchContentData() {
contentTarget,
space: spaceRelativeToParent ?? space,
pages,
sections: siteSections,
site,
sections,
spaces,
customization,
scripts,
@@ -67,44 +47,21 @@ export async function fetchContentData() {
};
}
function parseSiteSectionsList(siteSectionId: string, sections: SiteSection[]) {
const section = sections.find((section) => section.id === siteSectionId);
assert(sectionIsDefined(section), 'A section must be defined when there are multiple sections');
return { list: sections, section } satisfies SectionsList;
}
function sectionIsDefined(section?: SiteSection): section is NonNullable<SiteSection> {
return typeof section !== 'undefined' && section !== null;
}
/**
* Fetch all the data needed to render the content.
* Optimized to fetch in parallel as much as possible.
*/
export async function fetchPageData(params: PagePathParams | PageIdParams) {
const content = getSiteContentPointer();
const contentData = await fetchContentData();
const { space, contentTarget, pages, customization, scripts } = await getSiteData(content);
const page = await resolvePage(contentTarget, pages, params);
const [siteStructure, document] = await Promise.all([
fetchSiteStructure({
organizationId: content.organizationId,
siteId: content.siteId,
siteShareKey: content.siteShareKey,
}),
page?.page.documentId ? getDocument(space.id, page.page.documentId) : null,
]);
const page = await resolvePage(contentData.contentTarget, contentData.pages, params);
const document = page?.page.documentId
? await getDocument(contentData.space.id, page.page.documentId)
: null;
return {
content,
contentTarget,
space,
pages,
customization,
scripts,
ancestors: [],
...contentData,
...page,
...siteStructure,
document,
};
}
@@ -150,59 +107,6 @@ async function resolvePage(
return undefined;
}
/**
* Fetch the structure of an organization site.
* This includes the site and its sections or spaces.
*/
async function fetchSiteStructure(args: {
organizationId: string;
siteId: string;
siteShareKey: string | undefined;
}) {
const { organizationId, siteId, siteShareKey } = args;
const [orgSite, siteStructure, siteParentCustomizations] = await Promise.all([
getSite(organizationId, siteId),
getSiteStructure({ organizationId, siteId, siteShareKey }),
getCurrentSiteCustomization({ organizationId, siteId, siteSpaceId: undefined }),
]);
const siteSections =
siteStructure.type === 'sections' && siteStructure.structure
? siteStructure.structure
: null;
const siteSpaces =
siteStructure.type === 'siteSpaces' && siteStructure.structure
? parseSpacesFromSiteSpaces(siteStructure.structure)
: null;
// override the title with the customization title
const site = {
...orgSite,
...(siteParentCustomizations?.title ? { title: siteParentCustomizations.title } : {}),
};
return {
site,
spaces: siteSpaces,
sections: siteSections,
};
}
function parseSpacesFromSiteSpaces(siteSpaces: SiteSpace[]) {
const spaces: Record<string, Space> = {};
siteSpaces.forEach((siteSpace) => {
spaces[siteSpace.space.id] = {
...siteSpace.space,
title: siteSpace.title ?? siteSpace.space.title,
urls: {
...siteSpace.space.urls,
published: siteSpace.urls.published,
},
};
});
return Object.values(spaces);
}
/**
* Get the page path from the params.
*/
+2 -2
View File
@@ -1,5 +1,5 @@
import { CustomizationRootLayout } from '@/components/RootLayout';
import { getSiteLayoutData } from '@/lib/api';
import { getSiteData } from '@/lib/api';
import { getSiteContentPointer } from '@/lib/pointer';
/**
@@ -10,7 +10,7 @@ export default async function SiteRootLayout(props: { children: React.ReactNode
const { children } = props;
const pointer = getSiteContentPointer();
const { customization } = await getSiteLayoutData(pointer);
const { customization } = await getSiteData(pointer);
return (
<CustomizationRootLayout customization={customization}>{children}</CustomizationRootLayout>
@@ -1,7 +1,7 @@
import { SpaceIntegrationScript } from '@gitbook/api';
import { CustomizationRootLayout } from '@/components/RootLayout';
import { getSiteLayoutData, getSpaceCustomization } from '@/lib/api';
import { getSiteData, getSpaceCustomization } from '@/lib/api';
import { getSiteOrSpacePointerForPDF } from './pointer';
@@ -14,7 +14,7 @@ export default async function PDFRootLayout(props: { children: React.ReactNode }
const pointer = getSiteOrSpacePointerForPDF();
const { customization } = await ('siteId' in pointer
? getSiteLayoutData(pointer)
? getSiteData(pointer)
: getSpaceLayoutData(pointer.spaceId));
return (
@@ -26,7 +26,7 @@ export default async function PDFRootLayout(props: { children: React.ReactNode }
* Fetch all the layout data about a space at once.
*/
async function getSpaceLayoutData(spaceId: string) {
const [customization, scripts] = await Promise.all([
const [{ customization }, scripts] = await Promise.all([
getSpaceCustomization(spaceId),
[] as SpaceIntegrationScript[],
]);
@@ -21,7 +21,7 @@ import {
getSpace,
getSpaceCustomization,
getSpaceContentData,
getCurrentSiteCustomization,
getSiteData,
} from '@/lib/api';
import { pagePDFContainerId, PageHrefContext, absoluteHref } from '@/lib/links';
import { resolvePageId } from '@/lib/pages';
@@ -40,11 +40,9 @@ export const runtime = 'edge';
export async function generateMetadata(): Promise<Metadata> {
const pointer = getSiteOrSpacePointerForPDF();
const [space, customization] = await Promise.all([
const [space, { customization }] = await Promise.all([
getSpace(pointer.spaceId, 'siteId' in pointer ? pointer.siteShareKey : undefined),
'siteId' in pointer
? getCurrentSiteCustomization(pointer)
: getSpaceCustomization(pointer.spaceId),
'siteId' in pointer ? getSiteData(pointer) : getSpaceCustomization(pointer.spaceId),
]);
return {
@@ -67,10 +65,8 @@ export default async function PDFHTMLOutput(props: { searchParams: { [key: strin
currentPDFUrl += '?' + searchParams.toString();
// Load the content,
const [customization, { space, contentTarget, pages: rootPages }] = await Promise.all([
'siteId' in pointer
? getCurrentSiteCustomization(pointer)
: getSpaceCustomization(pointer.spaceId),
const [{ customization }, { space, contentTarget, pages: rootPages }] = await Promise.all([
'siteId' in pointer ? getSiteData(pointer) : getSpaceCustomization(pointer.spaceId),
getSpaceContentData(pointer, 'siteId' in pointer ? pointer.siteShareKey : undefined),
]);
const language = getSpaceLanguage(customization);
@@ -0,0 +1,18 @@
// @ts-ignore
import nextOnPagesHandler from '@cloudflare/next-on-pages/fetch-handler';
import { withMiddlewareHeadersStorage } from './lib/middleware';
/**
* We use a custom entrypoint until we can move to opennext (https://github.com/opennextjs/opennextjs-cloudflare/issues/92).
* There is a bug in next-on-pages where headers can't be set on the response in the middleware for RSC requests (https://github.com/cloudflare/next-on-pages/issues/897).
*/
export default {
async fetch(request, env, ctx) {
const response = await withMiddlewareHeadersStorage(() =>
nextOnPagesHandler.fetch(request, env, ctx),
);
return response;
},
} as ExportedHandler<{ ASSETS: Fetcher }>;
@@ -22,10 +22,8 @@ import { Heading } from './Heading';
import { Hint } from './Hint';
import { Images } from './Images';
import { IntegrationBlock } from './Integration';
import { List } from './List';
import { ListItem } from './ListItem';
import { ListOrdered } from './ListOrdered';
import { ListTasks } from './ListTasks';
import { ListUnordered } from './ListUnordered';
import { BlockMath } from './Math';
import { OpenAPI } from './OpenAPI';
import { Paragraph } from './Paragraph';
@@ -65,11 +63,9 @@ export function Block<T extends DocumentBlock>(props: BlockProps<T>) {
case 'heading-3':
return <Heading {...props} block={block} />;
case 'list-ordered':
return <ListOrdered {...props} block={block} />;
case 'list-unordered':
return <ListUnordered {...props} block={block} />;
case 'list-tasks':
return <ListTasks {...props} block={block} />;
return <List {...props} block={block} />;
case 'list-item':
return <ListItem {...props} block={block} />;
case 'code':
@@ -48,7 +48,7 @@ async function SpaceRefCard(
return null;
}
const spaceCustomization = await ignoreAPIError(getSpaceCustomization(spaceId));
const { customization: spaceCustomization } = getSpaceCustomization(spaceId);
const customFavicon = spaceCustomization?.favicon;
const customEmoji = customFavicon && 'emoji' in customFavicon ? customFavicon.emoji : undefined;
const customIcon = customFavicon && 'icon' in customFavicon ? customFavicon.icon : undefined;
@@ -0,0 +1,44 @@
import {
DocumentBlockListOrdered,
DocumentBlockListTasks,
DocumentBlockListUnordered,
} from '@gitbook/api';
import assertNever from 'assert-never';
import { BlockProps } from './Block';
import { Blocks } from './Blocks';
export function List(
props: BlockProps<
DocumentBlockListUnordered | DocumentBlockListOrdered | DocumentBlockListTasks
>,
) {
const { block, style, ancestorBlocks, ...contextProps } = props;
return (
<Blocks
{...contextProps}
tag={getListTag(block.type)}
nodes={block.nodes}
ancestorBlocks={[...ancestorBlocks, block]}
style={['space-y-2', style]}
/>
);
}
function getListTag(
type:
| DocumentBlockListUnordered['type']
| DocumentBlockListOrdered['type']
| DocumentBlockListTasks['type'],
) {
switch (type) {
case 'list-ordered':
return 'ol';
case 'list-unordered':
case 'list-tasks':
return 'ul';
default:
assertNever(type);
}
}
@@ -1,13 +0,0 @@
.olListItemBullet {
/* Align the bullet container with the first line of text */
line-height: inherit;
height: 1lh;
/* Align the bullet content with the middle of the text */
display: flex;
align-items: center;
}
.olListItemBullet::before {
@apply text-base;
--tw-content: attr(data-value) '. ';
content: var(--tw-content);
}
@@ -1,114 +1,231 @@
import {
DocumentBlock,
DocumentBlockListItem,
DocumentBlockListOrdered,
DocumentBlockListTasks,
DocumentBlockListUnordered,
} from '@gitbook/api';
import classNames from 'classnames';
import assertNever from 'assert-never';
import { assert } from 'ts-essentials';
import { Checkbox } from '@/components/primitives';
import { tcls } from '@/lib/tailwind';
import { BlockProps } from './Block';
import { Blocks } from './Blocks';
import styles from './ListItem.module.css';
import { getBlockTextStyle } from './spacing';
export function ListItem(props: BlockProps<DocumentBlockListItem>) {
const { block, ancestorBlocks, ...contextProps } = props;
const textStyle = getBlockTextStyle(block);
const parent = ancestorBlocks[ancestorBlocks.length - 1];
assert(
(parent && parent.type === 'list-ordered') ||
parent.type === 'list-unordered' ||
parent.type === 'list-tasks',
'Invalid parent list type',
);
const parent = ancestorBlocks[ancestorBlocks.length - 1] as
| DocumentBlockListOrdered
| DocumentBlockListUnordered
| DocumentBlockListTasks
| undefined;
const blocksElement = (
<Blocks
{...contextProps}
nodes={block.nodes}
ancestorBlocks={[...ancestorBlocks, block]}
blockStyle={tcls(
'min-h-[1lh]',
// flip heading hash icon if list item is a heading
'flip-heading-hash',
// remove margin-top for the first heading in a list
'[&:is(h2)>div]:mt-0',
'[&:is(h3)>div]:mt-0',
'[&:is(h4)>div]:mt-0',
)}
style="space-y-2 flex flex-col flex-1"
/>
);
const ListItemType = () => {
switch (parent?.type) {
case 'list-tasks':
return (
<li>
<div className={tcls('flex', 'flex-row')}>
<div
className={tcls(
textStyle.textSize,
'flex',
'flex-col',
'justify-center',
'h-[1lh]',
)}
>
<Checkbox
id={block.key!}
disabled
checked={block.data?.checked}
className={tcls('relative')}
/>
</div>
<label htmlFor={block.key} className={tcls('flex-1')}>
<Blocks
{...contextProps}
nodes={block.nodes}
ancestorBlocks={[...ancestorBlocks, block]}
blockStyle={tcls('flip-heading-hash')}
style={tcls('ml-2', 'space-y-2')}
/>
</label>
</div>
</li>
);
case 'list-ordered':
const start = parent.data.start ?? 1;
const indexInParent = parent.nodes.findIndex((node) => node.key === block.key) ?? 0;
const index = indexInParent + start;
return (
<li value={index} className={tcls(textStyle.lineHeight)}>
<div
data-value={index}
className={classNames(
'bullet',
styles.olListItemBullet,
tcls(textStyle.textSize),
)}
></div>
{/* zero width space to force layouts with empty lists */}
<Blocks
{...contextProps}
nodes={block.nodes}
ancestorBlocks={[...ancestorBlocks, block]}
style={tcls('space-y-2', 'flex-1', 'flex', 'flex-col')}
blockStyle={tcls(
textStyle.lineHeight,
'min-h-[1lh]',
//flip heading hash icon if list item is a heading
'flip-heading-hash',
)}
switch (parent.type) {
case 'list-tasks':
return (
<ListItemLI block={block}>
<ListItemPrefix block={block}>
<Checkbox
id={block.key!}
disabled
checked={block.data?.checked}
className="relative"
size="small"
/>
</li>
);
default:
return (
<li className={tcls(textStyle.lineHeight)}>
<div className={tcls('bullet', textStyle.textSize)}></div>
<Blocks
{...contextProps}
nodes={block.nodes}
ancestorBlocks={[...ancestorBlocks, block]}
style={tcls('space-y-2', 'flex', 'flex-col')}
blockStyle={tcls(
'min-h-[1lh]',
//flip heading hash icon if list item is a heading
'flip-heading-hash',
)}
/>
</li>
);
}
};
</ListItemPrefix>
return <ListItemType />;
<label htmlFor={block.key} className={tcls('flex-1')}>
{blocksElement}
</label>
</ListItemLI>
);
case 'list-ordered':
return (
<ListItemLI block={block}>
<ListItemPrefix block={block}>
<PseudoBefore
content={getOrderedListItemPrefixContent({
depth: getListItemDepth({ ancestorBlocks, type: parent.type }),
block,
parent,
})}
style={{
fontSize: 'min(1em, 24px)',
}}
/>
</ListItemPrefix>
{blocksElement}
</ListItemLI>
);
case 'list-unordered':
return (
<ListItemLI block={block}>
<ListItemPrefix block={block}>
<PseudoBefore
content={getUnorderedListItemsPrefixContent({
depth: getListItemDepth({ ancestorBlocks, type: parent.type }),
})}
fontFamily="Arial"
style={{ fontSize: 'min(1.5em, 24px)', lineHeight: 1 }}
/>
</ListItemPrefix>
{blocksElement}
</ListItemLI>
);
default:
assertNever(parent);
}
}
function getListItemDepth(input: {
ancestorBlocks: DocumentBlock[];
type: DocumentBlockListOrdered['type'] | DocumentBlockListUnordered['type'];
}): number {
const { ancestorBlocks, type } = input;
let depth = -1;
for (let i = ancestorBlocks.length - 1; i >= 0; i--) {
const block = ancestorBlocks[i];
if (block.type === type) {
depth = depth + 1;
continue;
}
if (block.type === 'list-item') {
continue;
}
break;
}
return depth;
}
function ListItemLI(props: { block: DocumentBlockListItem; children: React.ReactNode }) {
const textStyle = getBlockTextStyle(props.block);
return <li className={tcls(textStyle.lineHeight, 'flex items-start')}>{props.children}</li>;
}
function ListItemPrefix(props: { block: DocumentBlockListItem; children: React.ReactNode }) {
const textStyle = getBlockTextStyle(props.block);
return (
<div
className={tcls(
textStyle.textSize,
textStyle.lineHeight,
'flex items-center justify-center mr-1 min-h-[1lh] min-w-6 text-dark/6 dark:text-light/5',
)}
>
{props.children}
</div>
);
}
function getUnorderedListItemsPrefixContent(input: { depth: number }): string {
switch (input.depth % 3) {
case 0:
return '•';
case 1:
return '◦';
case 2:
return '▪';
default:
return '•';
}
}
function PseudoBefore(props: {
style?: React.CSSProperties;
content: string;
fontFamily?: string;
}) {
return (
<div
className="before:font-var before:content-[--pseudoBefore--content]"
style={
{
'--pseudoBefore--content': `'${props.content}'`,
'--font-family': props.fontFamily ?? 'inherit',
...props.style,
} as React.CSSProperties
}
/>
);
}
function getOrderedListItemPrefixContent(input: {
depth: number;
parent: DocumentBlockListOrdered;
block: DocumentBlockListItem;
}): string {
const { parent, block } = input;
const start = parent.data.start ?? 1;
const index = parent.nodes.findIndex((node) => node.key === block.key) ?? 0;
const value = index + start;
switch (input.depth % 3) {
// Use numbers
case 0: {
return `${value}.`;
}
// Use letters
case 1: {
const letters = 'abcdefghijklmnopqrstuvwxyz';
return `${letters[(value - 1) % letters.length]}.`;
}
// Use roman numbers
case 2: {
return `${toRoman(value).toLowerCase()}.`;
}
default:
return '•';
}
}
function toRoman(input: number): string {
const lookup = {
M: 1000,
CM: 900,
D: 500,
CD: 400,
C: 100,
XC: 90,
L: 50,
XL: 40,
X: 10,
IX: 9,
V: 5,
IV: 4,
I: 1,
};
let roman = '';
let number = input;
for (const i in lookup) {
while (number >= lookup[i as keyof typeof lookup]) {
roman += i;
number -= lookup[i as keyof typeof lookup];
}
}
return roman;
}
@@ -1,45 +0,0 @@
import { DocumentBlockListOrdered } from '@gitbook/api';
import { BlockProps } from './Block';
import { Blocks } from './Blocks';
export function ListOrdered(props: BlockProps<DocumentBlockListOrdered>) {
const { block, style, ancestorBlocks, ...contextProps } = props;
return (
<Blocks
{...contextProps}
tag="ol"
nodes={block.nodes}
ancestorBlocks={[...ancestorBlocks, block]}
style={[
'space-y-2',
'flex',
'flex-col',
'[&>li]:gap-[1ch]',
'[counter-reset:list-decimal]',
'[&>li]:flex',
'[&>li]:flex-row',
/* '[&>li>.bullet]:w-[1ch]', */
'[&>li>.bullet]:tabular-nums',
'[&>li>.bullet]:whitespace-nowrap',
'[&>li>.bullet]:list-decimal',
'[&>li>.bullet]:before:h-[1lh]',
'[&>li>.bullet]:before:leading-[inherit]',
'[&>li>.bullet]:before:flex',
/* '[&>li>.bullet]:before:pr-[1ch]', */
'[&>li>.bullet]:text-dark/6',
//remove any spacing when using heading as list item
'[&>li>div_div]:mt-0',
'dark:[&>li>.bullet]:text-light/6',
style,
]}
/>
);
}
@@ -1,25 +0,0 @@
import { DocumentBlockListTasks, DocumentBlockListUnordered } from '@gitbook/api';
import { BlockProps } from './Block';
import { Blocks } from './Blocks';
export function ListTasks(props: BlockProps<DocumentBlockListTasks>) {
const { block, style, ancestorBlocks, ...contextProps } = props;
return (
<Blocks
{...contextProps}
tag="ul"
nodes={block.nodes}
ancestorBlocks={[...ancestorBlocks, block]}
style={[
'list-none',
'[&>li]:pl-[.25ch]',
//remove any spacing when using heading as list item
'[&>li>div_div]:mt-0',
'space-y-2',
style,
]}
/>
);
}
@@ -1,51 +0,0 @@
import { DocumentBlockListUnordered } from '@gitbook/api';
import { BlockProps } from './Block';
import { Blocks } from './Blocks';
export function ListUnordered(props: BlockProps<DocumentBlockListUnordered>) {
const { block, style, ancestorBlocks, ...contextProps } = props;
const nestedBulletStyle = [
// Level 1
'[&>li>.bullet:before]:bullet-circleFilled',
// Level 2
`[&_&>li>.bullet:before]:bullet-circle`,
// Level 3
`[&_&_&>li>.bullet:before]:bullet-dash`,
// Level 4
`[&_&_&_&>li>.bullet:before]:bullet-squareFilled`,
// Level 5
`[&_&_&_&_&>li>.bullet:before]:bullet-square`,
// Level 6 // reset back to reg bullets here
`[&_&_&_&_&_&>li>.bullet:before]:bullet-circleFilled`,
];
return (
<Blocks
{...contextProps}
tag="ul"
nodes={block.nodes}
ancestorBlocks={[...ancestorBlocks, block]}
style={[
'list-none',
'space-y-2',
'[&>li]:relative',
'[&>li]:ps-[2.25ch]',
//remove any spacing when using heading as list item
'[&>li>div_div]:mt-0',
//custom content setup for lists
'[&>li>.bullet]:before:bg-dark/6',
'[&>li>.bullet]:before:absolute',
'[&>li>.bullet]:before:left-0',
'[&>li>.bullet]:before:w-[1ch]',
'[&>li>.bullet]:before:h-[1lh]',
'[&>li>.bullet]:before:[mask-repeat:no-repeat]',
'[&>li>.bullet]:before:[mask-position:left]',
'dark:[&>li>.bullet]:before:bg-light/6',
nestedBulletStyle,
style,
]}
/>
);
}
@@ -6,46 +6,44 @@ import {
} from '@gitbook/api';
import { assertNever } from 'assert-never';
import { ClassValue } from '@/lib/tailwind';
/**
* Get the line height of a block
*/
export function getBlockTextStyle(block: DocumentBlock): {
/** Tailwind class for the text size */
textSize: ClassValue;
textSize: string;
/** Tailwind class for the height (h-*) */
lineHeight: ClassValue;
lineHeight: string;
/** Tailwind class for the margin top (mt-*) */
marginTop?: ClassValue;
marginTop?: string;
} {
switch (block.type) {
case 'paragraph':
return {
textSize: ['text-base'],
textSize: 'text-base',
lineHeight: 'leading-normal',
};
case 'heading-1':
return {
textSize: ['text-3xl', 'font-semibold'],
textSize: 'text-3xl font-semibold',
lineHeight: 'leading-tight',
marginTop: '[margin-top:_1em]',
marginTop: 'mt-[1em]',
};
case 'heading-2':
return {
textSize: ['text-2xl', 'font-semibold'],
textSize: 'text-2xl font-semibold',
lineHeight: 'leading-snug',
marginTop: '[margin-top:_0.75em]',
marginTop: 'mt-[0.75em]',
};
case 'heading-3':
return {
textSize: ['text-base', 'font-semibold'],
textSize: 'text-base font-semibold',
lineHeight: 'leading-snug',
marginTop: '[margin-top:_0.5em]',
marginTop: 'mt-[0.5em]',
};
case 'divider':
return {
textSize: [],
textSize: '',
lineHeight: 'leading-none',
};
case 'list-ordered':
@@ -56,7 +54,7 @@ export function getBlockTextStyle(block: DocumentBlock): {
return getBlockTextStyle(block.nodes[0]);
default:
return {
textSize: ['text-base'],
textSize: 'text-base',
lineHeight: 'leading-normal',
};
}
@@ -44,7 +44,7 @@ export function Dropdown<E extends HTMLElement>(props: {
'absolute',
'top-full',
'left-0',
'z-10',
'z-20',
'origin-top-left',
'invisible',
'transition-opacity',
@@ -1,15 +1,10 @@
import {
CustomizationSettings,
Site,
SiteCustomizationSettings,
SiteSection,
Space,
} from '@gitbook/api';
import { CustomizationSettings, Site, SiteCustomizationSettings, Space } from '@gitbook/api';
import { CustomizationHeaderPreset } from '@gitbook/api';
import { Suspense } from 'react';
import { CONTAINER_STYLE, HEADER_HEIGHT_DESKTOP } from '@/components/layout';
import { t, getSpaceLanguage } from '@/intl/server';
import type { SectionsList } from '@/lib/api';
import { ContentRefContext } from '@/lib/references';
import { tcls } from '@/lib/tailwind';
@@ -27,14 +22,12 @@ export function Header(props: {
space: Space;
site: Site | null;
spaces: Space[];
sections: { list: SiteSection[]; section: SiteSection } | null;
sections: SectionsList | null;
context: ContentRefContext;
customization: CustomizationSettings | SiteCustomizationSettings;
withTopHeader?: boolean;
children?: React.ReactNode;
}) {
const { children, context, space, site, spaces, sections, customization, withTopHeader } =
props;
const { context, space, site, spaces, sections, customization, withTopHeader } = props;
const isCustomizationDefault =
customization.header.preset === CustomizationHeaderPreset.Default;
const hasSiteSections = sections && sections.list.length > 1;
@@ -149,12 +142,12 @@ export function Header(props: {
{sections ? (
<div
className={tcls(
'w-full shadow-thintop dark:shadow-light/1 bg-light dark:bg-dark z-[9] mt-0.5',
'w-full shadow-thintop dark:shadow-light/1 bg-light dark:bg-dark mt-0.5',
// Handle long section tabs, particularly on smaller screens.
'overflow-x-auto hide-scroll',
)}
>
<div className={tcls(CONTAINER_STYLE)}>
<SiteSectionTabs sections={sections.list} section={sections.section} />
</div>
<SiteSectionTabs {...sections} />
</div>
) : null}
</header>
@@ -126,23 +126,6 @@
@apply [&:is(h1,h2,h3,h4)>div:first-child]:[grid-area:1/2];
@apply [&:is(h1,h2,h3,h4)>div:first-child]:ml-1;
}
/* bullet icons */
.bullet-dash {
@apply [mask-image:url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAiIGhlaWdodD0iNyIgdmlld0JveD0iMCAwIDEwIDciIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxyZWN0IHg9IjEiIHk9IjIuNSIgd2lkdGg9IjgiIGhlaWdodD0iMS41IiByeD0iMC42NiIgZmlsbD0id2hpdGUiLz4KPC9zdmc+Cg==")];
}
.bullet-circleFilled {
@apply [mask-image:url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAiIGhlaWdodD0iNyIgdmlld0JveD0iMCAwIDEwIDciIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxyZWN0IHg9IjIiIHdpZHRoPSI2IiBoZWlnaHQ9IjYiIHJ4PSIzIiBmaWxsPSJ3aGl0ZSIvPgo8L3N2Zz4K")];
}
.bullet-circle {
@apply [mask-image:url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAiIGhlaWdodD0iNyIgdmlld0JveD0iMCAwIDEwIDciIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxyZWN0IHg9IjIuNzUiIHk9IjAuNzUiIHdpZHRoPSI1LjUiIGhlaWdodD0iNS41IiByeD0iMi43NSIgc3Ryb2tlPSJ3aGl0ZSIgc3Ryb2tlLXdpZHRoPSIxLjUiLz4KPC9zdmc+Cg==")];
}
.bullet-squareFilled {
@apply [mask-image:url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAiIGhlaWdodD0iNyIgdmlld0JveD0iMCAwIDEwIDciIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxyZWN0IHg9IjIiIHdpZHRoPSI2IiBoZWlnaHQ9IjYiIHJ4PSIxIiBmaWxsPSJ3aGl0ZSIvPgo8L3N2Zz4K")];
}
.bullet-square {
@apply [mask-image:url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAiIGhlaWdodD0iNyIgdmlld0JveD0iMCAwIDEwIDciIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxtYXNrIGlkPSJwYXRoLTEtaW5zaWRlLTFfOV80OCIgZmlsbD0id2hpdGUiPgo8cmVjdCB4PSIyIiB3aWR0aD0iNyIgaGVpZ2h0PSI3IiByeD0iMSIvPgo8L21hc2s+CjxyZWN0IHg9IjIiIHdpZHRoPSI3IiBoZWlnaHQ9IjciIHJ4PSIxIiBzdHJva2U9IndoaXRlIiBzdHJva2Utd2lkdGg9IjMiIG1hc2s9InVybCgjcGF0aC0xLWluc2lkZS0xXzlfNDgpIi8+Cjwvc3ZnPgo=")];
}
}
@layer utilities {
@@ -68,16 +68,11 @@ async function searchSiteContent(args: {
scope.mode === 'current' ||
(scope.mode === 'specific' && scope.siteSpaceIds.length > 1);
const [searchResults, siteStructure] = await Promise.all([
const [searchResults, siteData] = await Promise.all([
api.searchSiteContent(pointer.organizationId, pointer.siteId, query, scope, cacheBust),
needsStructure
? api.getSiteStructure({
organizationId: pointer.organizationId,
siteId: pointer.siteId,
siteShareKey: pointer.siteShareKey,
})
: null,
needsStructure ? api.getSiteData(pointer) : null,
]);
const siteStructure = siteData?.structure;
const siteSpaces = siteStructure
? siteStructure.type === 'siteSpaces'
@@ -1,17 +1,20 @@
'use client';
import { SiteSection } from '@gitbook/api';
import { Icon } from '@gitbook/icons';
import React from 'react';
import { tcls } from '@/lib/tailwind';
import { Button, Link } from '../primitives';
import { Link } from '../primitives';
/**
* A set of tabs representing site sections for multi-section sites
* A set of navigational tabs representing site sections for multi-section sites
*/
export function SiteSectionTabs(props: { sections: SiteSection[]; section: SiteSection }) {
const { sections, section: currentSection } = props;
export function SiteSectionTabs(props: {
list: SiteSection[];
section: SiteSection;
index: number;
}) {
const { list: sections, section: currentSection, index: currentIndex } = props;
const tabs = sections.map((section) => ({
id: section.id,
@@ -22,86 +25,100 @@ export function SiteSectionTabs(props: { sections: SiteSection[]; section: SiteS
const currentTabRef = React.useRef<HTMLAnchorElement>(null);
const navRef = React.useRef<HTMLDivElement>(null);
const [currentIndex, setCurrentIndex] = React.useState(
sections.findIndex((section) => section.id === currentSection?.id),
);
const [tabDimensions, setTabDimensions] = React.useState<{
left: number;
width: number;
} | null>(null);
React.useEffect(() => {
const updateTabDimensions = React.useCallback(() => {
if (currentTabRef.current && navRef.current) {
const rect = currentTabRef.current.getBoundingClientRect();
const navRect = navRef.current.getBoundingClientRect();
setTabDimensions({ left: rect.left - navRect.left, width: rect.width });
}
}, [currentIndex]);
React.useLayoutEffect(() => {
function onResize() {
if (currentTabRef.current && navRef.current) {
const rect = currentTabRef.current.getBoundingClientRect();
const navRect = navRef.current.getBoundingClientRect();
setTabDimensions({ left: rect.left - navRect.left, width: rect.width });
}
setTabDimensions({
left: rect.left - navRect.left,
width: rect.width,
});
}
window.addEventListener('resize', onResize);
() => window.removeEventListener('resize', onResize);
}, []);
React.useEffect(() => {
updateTabDimensions();
}, [currentIndex, updateTabDimensions]);
React.useLayoutEffect(() => {
window.addEventListener('load', updateTabDimensions);
window.addEventListener('resize', updateTabDimensions);
() => {
window.removeEventListener('resize', updateTabDimensions);
window.removeEventListener('load', updateTabDimensions);
};
}, [updateTabDimensions]);
const opacity = Boolean(tabDimensions) ? 1 : 0.0;
const scale = (tabDimensions?.width ?? 0) * 0.01;
const startPos = `${tabDimensions?.left ?? 0}px`;
const hasMoreSections = false; /** TODO: determine whether we need to show the more button */
return tabs.length > 0 ? (
<nav
aria-label="Sections"
ref={navRef}
className="flex flex-nowrap items-center max-w-screen mb-px"
className="flex flex-nowrap items-center mb-px max-w-screen-2xl mx-auto page-full-width:max-w-full"
style={
{
'--tab-opacity': `${opacity}`,
'--tab-scale': `${scale}`,
'--tab-start': `${startPos}`,
} as React.CSSProperties
}
>
<div
className={tcls(
'relative',
'flex',
'gap-2',
'bg-transparent',
/* add a pseudo element for active tab indicator */
'after:block',
"after:content-['']",
'after:origin-left',
'after:absolute',
'after:-bottom-px',
'after:left-0',
'after:scale-x-[--tab-scale]',
'after:transition-transform',
'after:translate-x-[var(--tab-start)]',
'after:h-0.5',
'after:w-[100px]',
'after:bg-primary',
'dark:after:bg-primary-400',
)}
role="tablist"
>
{tabs.map((tab, index) => (
<Tab
active={currentIndex === index}
key={index + tab.path}
label={tab.label}
href={tab.path}
ref={currentIndex === index ? currentTabRef : null}
onClick={() => setCurrentIndex(index)}
/>
))}
<div className="flex flex-col bg-transparent">
{/* An element for the tabs which includes the page padding */}
<div
role="tablist"
className={tcls(
'flex flex-row gap-2',
// Horizontal padding, which is the layout padding minus the padding of the tabs themselves.
'px-1',
'sm:px-3',
'md:px-5',
)}
>
{tabs.map((tab, index) => (
<Tab
active={currentIndex === index}
key={index + tab.path}
label={tab.label}
href={tab.path}
ref={currentIndex === index ? currentTabRef : null}
/>
))}
</div>
{/* A container for a pseudo element for active tab indicator. A container is needed so we can set
a relative position without breaking the z-index of other parts of the header. */}
<div
className={tcls(
'flex',
'relative',
'after:block',
"after:content-['']",
'after:origin-left',
'after:absolute',
'after:-bottom-px',
'after:left-0',
'after:opacity-[--tab-opacity]',
'after:scale-x-[--tab-scale]',
'after:[transition:_opacity_150ms_25ms,transform_150ms]',
'after:motion-reduce:transition-none',
'after:translate-x-[var(--tab-start)]',
'after:will-change-transform',
'after:h-0.5',
'after:w-[100px]',
'after:bg-primary',
'dark:after:bg-primary-400',
)}
></div>
</div>
{hasMoreSections ? <MoreSectionsButton /> : null}
</nav>
) : null;
}
@@ -109,39 +126,24 @@ export function SiteSectionTabs(props: { sections: SiteSection[]; section: SiteS
/**
* The tab item - a link to a site section
*/
const Tab = React.forwardRef<
HTMLSpanElement,
{ active: boolean; href: string; label: string; onClick?: () => void }
>(function Tab(props, ref) {
const { active, href, label, onClick } = props;
return (
<Link
className={tcls(
'px-3 py-1 my-2 rounded straight-corners:rounded-none transition-colors',
active && 'text-primary dark:text-primary-400',
!active &&
'text-dark/8 hover:bg-dark/1 hover:text-dark/9 dark:text-light/8 dark:hover:bg-light/2 dark:hover:text-light/9',
)}
role="tab"
href={href}
onClick={onClick}
>
<span ref={ref} className={tcls('inline-flex w-full truncate')}>
{label}
</span>
</Link>
);
});
/**
* Dropdown trigger for when there are too many sections to show them all
*/
function MoreSectionsButton() {
return (
<div>
<Button variant="secondary" size="small">
<Icon icon="ellipsis-h" size={12} />
</Button>
</div>
);
}
const Tab = React.forwardRef<HTMLSpanElement, { active: boolean; href: string; label: string }>(
function Tab(props, ref) {
const { active, href, label } = props;
return (
<Link
className={tcls(
'px-3 py-1 my-2 rounded straight-corners:rounded-none transition-colors',
active && 'text-primary dark:text-primary-400',
!active &&
'text-dark/8 hover:bg-dark/1 hover:text-dark/9 dark:text-light/8 dark:hover:bg-light/2 dark:hover:text-light/9',
)}
role="tab"
href={href}
>
<span ref={ref} className={tcls('inline-flex w-full truncate')}>
{label}
</span>
</Link>
);
},
);
@@ -6,7 +6,6 @@ import {
RevisionPageGroup,
Site,
SiteCustomizationSettings,
SiteSection,
Space,
} from '@gitbook/api';
import React from 'react';
@@ -17,7 +16,7 @@ import { CONTAINER_STYLE } from '@/components/layout';
import { ColorDebugger } from '@/components/primitives/ColorDebugger';
import { SearchModal } from '@/components/Search';
import { TableOfContents } from '@/components/TableOfContents';
import { ContentTarget, SiteContentPointer } from '@/lib/api';
import { ContentTarget, type SectionsList, SiteContentPointer } from '@/lib/api';
import { ContentRefContext } from '@/lib/references';
import { tcls } from '@/lib/tailwind';
@@ -31,7 +30,7 @@ export function SpaceLayout(props: {
contentTarget: ContentTarget;
space: Space;
site: Site | null;
sections: { list: SiteSection[]; section: SiteSection } | null;
sections: SectionsList | null;
spaces: Space[];
customization: CustomizationSettings | SiteCustomizationSettings;
pages: Revision['pages'];
@@ -6,16 +6,22 @@ import React from 'react';
import { tcls } from '@/lib/tailwind';
export type CheckboxProps = React.ComponentProps<typeof CheckboxPrimitive.Root> & {
/**
* The size of the checkbox.
* @default medium
*/
size?: 'small' | 'medium';
};
export const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
CheckboxProps
>(({ className, size = 'medium', ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={tcls(
'peer',
'h-5',
'w-5',
'shrink-0',
'rounded-sm',
'straight-corners:rounded-none',
@@ -32,11 +38,12 @@ export const Checkbox = React.forwardRef<
'dark:ring-light/3',
'dark:contrast-more:ring-light/6',
'dark:data-[state=checked]:bg-primary-500',
{ small: 'size-4', medium: 'size-5' }[size],
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator className={tcls('relative', 'text-current')}>
<CheckboxPrimitive.Indicator className="relative text-current">
{props.checked ? (
<Icon icon="check" iconStyle={IconStyle.Solid} className={'size-3'} />
) : null}
+1
View File
@@ -39,6 +39,7 @@ export const ibmPlexMono = IBM_Plex_Mono({
display: 'swap',
preload: false,
fallback: ['monospace'],
adjustFontFallback: false,
});
const firaSans = Fira_Sans_Extra_Condensed({
+86 -201
View File
@@ -13,11 +13,15 @@ import {
RevisionFile,
SiteCustomizationSettings,
RevisionReusableContent,
SiteSpace,
Space,
SiteSection,
PublishedSiteContent,
} from '@gitbook/api';
import assertNever from 'assert-never';
import { headers } from 'next/headers';
import rison from 'rison';
import { DeepPartial } from 'ts-essentials';
import { assert, DeepPartial } from 'ts-essentials';
import { batch } from './async';
import { buildVersion } from './build';
@@ -131,22 +135,10 @@ export function withAPI<T>(client: GitBookAPI, fn: () => Promise<T>): Promise<T>
return apiSyncStorage.run(client, fn);
}
// FIXME: Fix @gitbook/api types for PublishedSiteContentLookup
type SpaceContentLookup = {
kind: 'space';
/** ID of the space matching. */
space: string;
/** Identifier of the change request being previewed in this URL. */
changeRequest?: string;
/** Identifier of the revision being previewed in this URL. */
revision?: string;
/** Path of the content relative to the space */
pathname: string;
/** Prefix of the path in the URL dedicated to the space */
basePath: string;
/** Short-lived API token to fetch content related to the space in the context of the URL. */
apiToken: string;
};
type SpaceContentLookup = Pick<
PublishedSiteContent,
'space' | 'changeRequest' | 'revision' | 'pathname' | 'basePath' | 'apiToken'
> & { kind: 'space' };
export type PublishedContentWithCache =
| ((SpaceContentLookup | PublishedSiteContentLookup) & {
@@ -628,84 +620,6 @@ export const getDocument = cache({
timeout: 20 * 1000,
});
/**
* Get the customization settings for a site-space from the API.
*/
const getSiteSpaceCustomizationFromAPI = cache({
name: 'api.getSiteSpaceCustomizationById',
tag: (organizationId, siteId, siteSpaceId) => getAPICacheTag({ tag: 'site', site: siteId }),
get: async (
organizationId: string,
siteId: string,
siteSpaceId: string,
options: CacheFunctionOptions,
) => {
const response = await api().orgs.getSiteSpaceCustomizationById(
organizationId,
siteId,
siteSpaceId,
{},
{
signal: options.signal,
...noCacheFetchOptions,
},
);
return cacheResponse(response, {
revalidateBefore: 60 * 60,
});
},
});
/**
* Get the customization settings for a site from the API.
*/
const getSiteCustomizationFromAPI = cache({
name: 'api.getSiteCustomizationById',
tag: (organizationId, siteId) => getAPICacheTag({ tag: 'site', site: siteId }),
get: async (organizationId: string, siteId: string, options: CacheFunctionOptions) => {
const response = await api().orgs.getSiteCustomizationById(
organizationId,
siteId,
{},
{
signal: options.signal,
...noCacheFetchOptions,
},
);
return cacheResponse(response, {
revalidateBefore: 60 * 60,
});
},
});
/**
* Get the customization settings for a site space from the API.
*/
async function getSiteSpaceCustomization(args: {
organizationId: string;
siteId: string;
siteSpaceId: string;
}): Promise<SiteCustomizationSettings> {
const raw = await getSiteSpaceCustomizationFromAPI(
args.organizationId,
args.siteId,
args.siteSpaceId,
);
return mergeCustomizationWithExtend(raw);
}
/**
* Get the customization settings for a site space from the API.
*/
async function getSiteCustomization(args: {
organizationId: string;
siteId: string;
}): Promise<SiteCustomizationSettings> {
const raw = await getSiteCustomizationFromAPI(args.organizationId, args.siteId);
return mergeCustomizationWithExtend(raw);
}
/**
* Get the infos about a site by its ID.
*/
@@ -724,59 +638,24 @@ export const getSite = cache({
});
/**
* List all the site-spaces variants published in a site.
* Get the published content site data for a site's published experience
*/
export const getSiteSpaces = cache({
name: 'api.getSiteSpaces',
export const getPublishedContentSite = cache({
name: 'api.getPublishedContentSite',
tag: ({ siteId }) => getAPICacheTag({ tag: 'site', site: siteId }),
get: async (
args: {
organizationId: string;
siteId: string;
/** Site share key that can be used as context to resolve site space published urls */
siteId: string /** Site share key that can be used as context to resolve site space published urls */;
siteShareKey: string | undefined;
},
options: CacheFunctionOptions,
) => {
const response = await getAll((params) =>
api().orgs.listSiteSpaces(
args.organizationId,
args.siteId,
{
...params,
...(args.siteShareKey ? { shareKey: args.siteShareKey } : {}),
},
{
...noCacheFetchOptions,
signal: options.signal,
},
),
);
return cacheResponse(response, {
revalidateBefore: 60 * 60,
data: response.data.items.map((siteSpace) => siteSpace),
});
},
});
export const getSiteStructure = cache({
name: 'api.getSiteStructure',
tag: ({ siteId }) => getAPICacheTag({ tag: 'site', site: siteId }),
get: async (
args: {
organizationId: string;
siteId: string;
/** Site share key that can be used as context to resolve site space published urls */
siteShareKey: string | undefined;
},
options: CacheFunctionOptions,
) => {
const response = await api().orgs.getSiteStructure(
const response = await api().orgs.getPublishedContentSite(
args.organizationId,
args.siteId,
{
...(args.siteShareKey ? { shareKey: args.siteShareKey } : {}),
shareKey: args.siteShareKey,
},
{
...noCacheFetchOptions,
@@ -785,89 +664,93 @@ export const getSiteStructure = cache({
);
return cacheResponse(response, {
revalidateBefore: 60 * 60,
data: response.data,
});
},
});
/**
* List the scripts to load for the site.
*/
export const getSiteIntegrationScripts = cache({
name: 'api.getSiteIntegrationScripts',
tag: (organizationId, siteId) => getAPICacheTag({ tag: 'site', site: siteId }),
get: async (organizationId: string, siteId: string, options: CacheFunctionOptions) => {
const response = await api().orgs.listSiteIntegrationScripts(organizationId, siteId, {
...noCacheFetchOptions,
signal: options.signal,
});
return cacheResponse(response, {
revalidateBefore: 60 * 60,
});
},
});
export type SectionsList = { list: SiteSection[]; section: SiteSection; index: number };
/**
* Fetch all the data to render the current site at once.
*/
export async function getSiteData(pointer: SiteContentPointer) {
const [{ space, pages, contentTarget }, { customization, scripts }] = await Promise.all([
getSpaceContentData(pointer, pointer.siteShareKey),
getSiteLayoutData(pointer),
]);
function parseSpacesFromSiteSpaces(siteSpaces: SiteSpace[]) {
const spaces: Record<string, Space> = {};
siteSpaces.forEach((siteSpace) => {
spaces[siteSpace.space.id] = {
...siteSpace.space,
title: siteSpace.title ?? siteSpace.space.title,
urls: {
...siteSpace.space.urls,
published: siteSpace.urls.published,
},
};
});
return Object.values(spaces);
}
return {
space,
pages,
contentTarget,
customization,
scripts,
};
function parseSiteSectionsList(siteSectionId: string, sections: SiteSection[]) {
const section = sections.find((section) => section.id === siteSectionId);
assert(section, 'A section must be defined when there are multiple sections');
return { list: sections, section, index: sections.indexOf(section) } satisfies SectionsList;
}
/**
* Fetch all the layout data about the current site at once.
* This function fetches the published content site data to render the published
* experience for the site (structure, customizations, scripts etc)
*/
export async function getSiteLayoutData(args: {
organizationId: string;
siteId: string;
siteSpaceId: string | undefined;
}) {
const [customization, scripts] = await Promise.all([
getCurrentSiteCustomization(args),
getSiteIntegrationScripts(args.organizationId, args.siteId),
]);
export async function getSiteData(
pointer: Pick<
SiteContentPointer,
'organizationId' | 'siteId' | 'siteSectionId' | 'siteSpaceId' | 'siteShareKey'
>,
) {
const {
site: orgSite,
structure: siteStructure,
customizations,
scripts,
} = await getPublishedContentSite({
organizationId: pointer.organizationId,
siteId: pointer.siteId,
siteShareKey: pointer.siteShareKey,
});
const siteSections =
siteStructure.type === 'sections' && siteStructure.structure
? siteStructure.structure
: null;
const siteSpaces =
siteStructure.type === 'siteSpaces' && siteStructure.structure
? parseSpacesFromSiteSpaces(siteStructure.structure)
: null;
// override the title with the customization title
const site = {
...orgSite,
...(customizations.site?.title ? { title: customizations.site.title } : {}),
};
const sections =
pointer.siteSectionId && siteSections
? parseSiteSectionsList(pointer.siteSectionId, siteSections)
: null;
const spaces =
siteSpaces ?? (sections ? parseSpacesFromSiteSpaces(sections.section.siteSpaces) : []);
const customization = mergeCustomizationWithExtend(
pointer.siteSpaceId ? customizations.siteSpaces[pointer.siteSpaceId] : customizations.site,
);
return {
customization,
site,
structure: siteStructure,
sections,
spaces,
scripts,
};
}
/**
* Get the customization settings for the current site from the API.
*/
export async function getCurrentSiteCustomization(args: {
organizationId: string;
siteId: string;
siteSpaceId: string | undefined;
}): Promise<SiteCustomizationSettings> {
return args.siteSpaceId
? getSiteSpaceCustomization({
organizationId: args.organizationId,
siteId: args.siteId,
siteSpaceId: args.siteSpaceId,
})
: getSiteCustomization({
organizationId: args.organizationId,
siteId: args.siteId,
});
}
/**
* Get the customization settings for a space from the API.
*/
export async function getSpaceCustomization(spaceId: string): Promise<CustomizationSettings> {
export function getSpaceCustomization(spaceId: string): { customization: CustomizationSettings } {
const headersList = headers();
const raw = defaultCustomizationForSpace();
@@ -875,7 +758,7 @@ export async function getSpaceCustomization(spaceId: string): Promise<Customizat
if (extend) {
try {
const parsed = rison.decode_object<Partial<CustomizationSettings>>(extend);
return { ...raw, ...parsed };
return { customization: { ...raw, ...parsed } };
} catch (error) {
console.error(
`Failed to parse x-gitbook-customization header (ignored): ${
@@ -885,7 +768,9 @@ export async function getSpaceCustomization(spaceId: string): Promise<Customizat
}
}
return raw;
return {
customization: raw,
};
}
/**
+42
View File
@@ -1,3 +1,45 @@
import { AsyncLocalStorage } from 'node:async_hooks';
/**
* Set a header on the middleware response.
* We do this because of https://github.com/opennextjs/opennextjs-cloudflare/issues/92
* It can be removed as soon as we move to opennext where hopefully this is fixed.
*/
export function setMiddlewareHeader(response: Response, name: string, value: string) {
const responseHeadersLocalStorage =
// @ts-ignore
globalThis.responseHeadersLocalStorage as AsyncLocalStorage<Headers> | undefined;
const responseHeaders = responseHeadersLocalStorage?.getStore();
response.headers.set(name, value);
if (responseHeaders) {
responseHeaders.set(name, value);
}
}
/**
* Wrap some middleware with a the storage to store headers.
*/
export async function withMiddlewareHeadersStorage(
handler: () => Promise<Response>,
): Promise<Response> {
const responseHeadersLocalStorage =
// @ts-ignore
(globalThis.responseHeadersLocalStorage as AsyncLocalStorage<Headers>) ??
new AsyncLocalStorage<Headers>();
// @ts-ignore
globalThis.responseHeadersLocalStorage = responseHeadersLocalStorage;
const responseHeaders = new Headers();
const response = await responseHeadersLocalStorage.run(responseHeaders, handler);
for (const [name, value] of responseHeaders.entries()) {
response.headers.set(name, value);
}
return response;
}
/**
* For a given GitBook URL, return a list of alternative URLs that could be matched against to lookup the content.
* The approach is optimized to aim at reusing cached lookup results as much as possible.
+31 -35
View File
@@ -14,13 +14,13 @@ import {
userAgent,
withAPI,
DEFAULT_API_ENDPOINT,
getSiteLayoutData,
getSite,
getPublishedContentSite,
getSiteData,
} from '@/lib/api';
import { race } from '@/lib/async';
import { buildVersion } from '@/lib/build';
import { createContentSecurityPolicyNonce, getContentSecurityPolicy } from '@/lib/csp';
import { getURLLookupAlternatives, normalizeURL } from '@/lib/middleware';
import { getURLLookupAlternatives, normalizeURL, setMiddlewareHeader } from '@/lib/middleware';
import {
VisitorAuthCookieValue,
getVisitorAuthCookieName,
@@ -175,10 +175,12 @@ export async function middleware(request: NextRequest) {
);
const { scripts } = await ('site' in resolved
? getSiteLayoutData({
? getSiteData({
organizationId: resolved.organization,
siteId: resolved.site,
siteSectionId: resolved.siteSection,
siteSpaceId: resolved.siteSpace,
siteShareKey: resolved.shareKey,
})
: { scripts: [] });
return getContentSecurityPolicy(scripts, nonce);
@@ -253,43 +255,31 @@ export async function middleware(request: NextRequest) {
resolved.cookies,
);
response.headers.set('x-gitbook-version', buildVersion());
setMiddlewareHeader(response, 'x-gitbook-version', buildVersion());
// Add Content Security Policy header
response.headers.set('content-security-policy', csp);
setMiddlewareHeader(response, 'content-security-policy', csp);
// Basic security headers
response.headers.set('strict-transport-security', 'max-age=31536000');
response.headers.set('referrer-policy', 'no-referrer-when-downgrade');
response.headers.set('x-content-type-options', 'nosniff');
setMiddlewareHeader(response, 'strict-transport-security', 'max-age=31536000');
setMiddlewareHeader(response, 'referrer-policy', 'no-referrer-when-downgrade');
setMiddlewareHeader(response, 'x-content-type-options', 'nosniff');
const isPrefetch = request.headers.has('x-middleware-prefetch');
if (typeof resolved.cacheMaxAge === 'number') {
const cacheControl = `public, max-age=0, s-maxage=${resolved.cacheMaxAge}, stale-if-error=0`;
if (isPrefetch) {
// To avoid cache poisoning, we don't cache prefetch requests
response.headers.set(
'cache-control',
'private, no-cache, no-store, max-age=0, must-revalidate',
);
} else {
if (typeof resolved.cacheMaxAge === 'number') {
const cacheControl = `public, max-age=0, s-maxage=${resolved.cacheMaxAge}, stale-if-error=0`;
if (
process.env.GITBOOK_OUTPUT_CACHE === 'true' &&
process.env.NODE_ENV !== 'development'
) {
response.headers.set('cache-control', cacheControl);
response.headers.set('Cloudflare-CDN-Cache-Control', cacheControl);
} else {
response.headers.set('x-gitbook-cache-control', cacheControl);
}
if (process.env.GITBOOK_OUTPUT_CACHE === 'true' && process.env.NODE_ENV !== 'development') {
setMiddlewareHeader(response, 'cache-control', cacheControl);
setMiddlewareHeader(response, 'Cloudflare-CDN-Cache-Control', cacheControl);
} else {
setMiddlewareHeader(response, 'x-gitbook-cache-control', cacheControl);
}
}
// }
if (resolved.cacheTags && resolved.cacheTags.length > 0) {
const headerCacheTag = resolved.cacheTags.join(',');
response.headers.set('cache-tag', headerCacheTag);
response.headers.set('x-gitbook-cache-tag', headerCacheTag);
}
if (resolved.cacheTags && resolved.cacheTags.length > 0) {
const headerCacheTag = resolved.cacheTags.join(',');
setMiddlewareHeader(response, 'cache-tag', headerCacheTag);
setMiddlewareHeader(response, 'x-gitbook-cache-tag', headerCacheTag);
}
return response;
@@ -492,7 +482,13 @@ async function lookupSiteOrSpaceInMultiIdMode(
// Verify access to the site to avoid leaking cached data in this mode
// (the cache is not dependend on the auth token, so it could leak data)
if (source.kind === 'site') {
await withAPI(gitbookAPI, () => getSite.revalidate(decoded.organization, source.id));
await withAPI(gitbookAPI, () =>
getPublishedContentSite.revalidate({
organizationId: decoded.organization,
siteId: source.id,
siteShareKey: undefined,
}),
);
}
const cookies: LookupCookies = {
+1
View File
@@ -64,6 +64,7 @@ const config: Config = {
fontFamily: {
sans: ['var(--font-content)'],
mono: ['var(--font-mono)'],
var: ['var(--font-family)'],
},
colors: {
// Dynamic colors matching the customization settings
+1 -1
View File
@@ -10,7 +10,7 @@
},
"dependencies": {
"classnames": "^2.5.1",
"@gitbook/api": "^0.72.0",
"@gitbook/api": "^0.76.0",
"assert-never": "^1.2.1"
},
"peerDependencies": {
+1 -1
View File
@@ -10,7 +10,7 @@
},
"version": "0.7.0",
"dependencies": {
"@scalar/api-client-react": "1.0.65",
"@scalar/api-client-react": "1.0.76",
"classnames": "^2.5.1",
"flatted": "^3.2.9",
"openapi-types": "^12.1.3",
@@ -119,7 +119,7 @@ export function OpenAPICodeSample(props: {
tabs={samples}
overlay={
data['x-hideTryItPanel'] || data.operation['x-hideTryItPanel'] ? null : (
<ScalarApiButton />
<ScalarApiButton method={data.method} path={data.path} />
)
}
/>
@@ -6,12 +6,15 @@ import React from 'react';
/**
* Button which launches the Scalar API Client
*/
export function ScalarApiButton() {
export function ScalarApiButton({ method, path }: { method: string; path: string }) {
const client = useApiClientModal();
return (
<div className="scalar scalar-activate">
<button className="scalar-activate-button" onClick={() => client?.open()}>
<button
className="scalar-activate-button"
onClick={() => client?.open({ method, path })}
>
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="12" fill="none">
<path
stroke="currentColor"
@@ -0,0 +1,12 @@
diff --git a/dist/index.js b/dist/index.js
index 32fec63484ec332eb291a7253e5e168223627535..653dee64794140bafe57219356c712b197c17530 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -6983,6 +6983,7 @@ async function buildWorkerFile({ vercelConfig, vercelOutput }, {
outfile: outputFile,
allowOverwrite: true,
bundle: true,
+ external: ["node:*", "cloudflare:*"],
plugins: [
{
name: "custom-entrypoint-import-plugin",