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 checks: write
statuses: write statuses: write
outputs: outputs:
deployment_url: ${{ steps.deploy.outputs.url }} deployment_url: ${{ steps.deploy.outputs.deployment-url }}
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -58,7 +58,7 @@ jobs:
SENTRY_DSN: ${{ vars.SENTRY_DSN }} SENTRY_DSN: ${{ vars.SENTRY_DSN }}
- id: deploy - id: deploy
name: Deploy to Cloudflare name: Deploy to Cloudflare
uses: cloudflare/wrangler-action@v3 uses: cloudflare/wrangler-action@v3.11.0
with: with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} 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) }} 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 - name: Outputs
run: | run: |
echo "URL: ${{ steps.deploy.outputs.url }}" echo "URL: ${{ steps.deploy.outputs.deployment-url }}"
echo "Alias URL: ${{ steps.deploy.outputs.deployment-alias-url }}" echo "Alias URL: ${{ steps.deploy.outputs.deployment-alias-url }}"
- name: Archive build output - name: Archive build output
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
@@ -77,12 +77,12 @@ jobs:
# Until https://github.com/cloudflare/wrangler-action/issues/301 is done # Until https://github.com/cloudflare/wrangler-action/issues/301 is done
- name: Update Deployment Status to Success - name: Update Deployment Status to Success
env: env:
DEPLOYMENT_URL: ${{ steps.deploy.outputs.url }} DEPLOYMENT_URL: ${{ steps.deploy.outputs.deployment-url }}
run: | run: |
curl -X POST \ curl -X POST \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
-H "Accept: application/vnd.github.v3+json" \ -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 }} https://api.github.com/repos/${{ github.repository }}/statuses/${{ github.sha }}
- name: Find GitHub Comment - name: Find GitHub Comment
@@ -102,8 +102,8 @@ jobs:
issue-number: ${{ github.event.pull_request.number }} issue-number: ${{ github.event.pull_request.number }}
body: | body: |
**GitBook Preview** **GitBook Preview**
Latest commit: [${{ steps.deploy.outputs.url }}](${{ steps.deploy.outputs.url }}) Latest commit: [${{ steps.deploy.outputs.deployment-url }}](${{ steps.deploy.outputs.deployment-url }})
PR: [${{ steps.deploy.outputs.alias }}](${{ steps.deploy.outputs.alias }}) PR: [${{ steps.deploy.outputs.deployment-alias-url }}](${{ steps.deploy.outputs.deployment-alias-url }})
edit-mode: replace edit-mode: replace
visual-testing: visual-testing:
runs-on: ubuntu-latest runs-on: ubuntu-latest
BIN
View File
Binary file not shown.
+2 -1
View File
@@ -8,7 +8,8 @@
}, },
"packageManager": "bun@1.1.18", "packageManager": "bun@1.1.18",
"patchedDependencies": { "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, "private": true,
"scripts": { "scripts": {
+2 -3
View File
@@ -11,8 +11,7 @@ migrations = [
] ]
[observability] [observability]
enabled = true enabled = false
head_sampling_rate = 0.01
[env.preview] [env.preview]
name = "gitbook-open-cache-preview" name = "gitbook-open-cache-preview"
@@ -25,4 +24,4 @@ migrations = [
[env.preview.observability] [env.preview.observability]
enabled = true enabled = true
head_sampling_rate = 1 head_sampling_rate = 0.1
+2 -2
View File
@@ -5,7 +5,7 @@
"scripts": { "scripts": {
"dev": "env-cmd --silent -f ../../.env.local next dev", "dev": "env-cmd --silent -f ../../.env.local next dev",
"build": "next build", "build": "next build",
"build:cloudflare": "next-on-pages", "build:cloudflare": "next-on-pages --custom-entrypoint=./src/cloudflare-entrypoint.ts",
"start": "next start", "start": "next start",
"lint": "next lint", "lint": "next lint",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
@@ -16,7 +16,7 @@
"clean": "rm -rf ./.next && rm -rf ./public/~gitbook/static" "clean": "rm -rf ./.next && rm -rf ./public/~gitbook/static"
}, },
"dependencies": { "dependencies": {
"@gitbook/api": "^0.73.0", "@gitbook/api": "^0.76.0",
"@gitbook/cache-do": "workspace:*", "@gitbook/cache-do": "workspace:*",
"@gitbook/emoji-codepoints": "workspace:*", "@gitbook/emoji-codepoints": "workspace:*",
"@gitbook/icons": "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 { getSpaceLanguage, t } from '@/intl/server';
import { getSiteLayoutData } from '@/lib/api'; import { getSiteData } from '@/lib/api';
import { getSiteContentPointer } from '@/lib/pointer'; import { getSiteContentPointer } from '@/lib/pointer';
import { tcls } from '@/lib/tailwind'; import { tcls } from '@/lib/tailwind';
export default async function NotFound() { export default async function NotFound() {
const pointer = getSiteContentPointer(); const pointer = getSiteContentPointer();
const { customization } = await getSiteLayoutData(pointer); const { customization } = await getSiteData(pointer);
const language = getSpaceLanguage(customization); const language = getSpaceLanguage(customization);
@@ -73,7 +73,7 @@ export default async function Page(props: {
page, page,
}; };
const withSections = Boolean(sections && sections.length > 0); const withSections = Boolean(sections && sections.list.length > 0);
const headerOffset = { sectionsHeader: withSections, topHeader: withTopHeader }; const headerOffset = { sectionsHeader: withSections, topHeader: withTopHeader };
return ( return (
@@ -1,7 +1,6 @@
import { ContentVisibility } from '@gitbook/api';
import { NextRequest } from 'next/server'; import { NextRequest } from 'next/server';
import { getCollection, getSite, getSpace } from '@/lib/api'; import { getSpace, getSite } from '@/lib/api';
import { absoluteHref } from '@/lib/links'; import { absoluteHref } from '@/lib/links';
import { getSiteContentPointer } from '@/lib/pointer'; import { getSiteContentPointer } from '@/lib/pointer';
import { isSpaceIndexable } from '@/lib/seo'; import { isSpaceIndexable } from '@/lib/seo';
@@ -3,7 +3,7 @@ import { ImageResponse } from 'next/og';
import { NextRequest } from 'next/server'; import { NextRequest } from 'next/server';
import React from 'react'; import React from 'react';
import { getCurrentSiteCustomization, getSite, getSpace } from '@/lib/api'; import { getSite, getSiteData, getSpace } from '@/lib/api';
import { getEmojiForCode } from '@/lib/emojis'; import { getEmojiForCode } from '@/lib/emojis';
import { getSiteContentPointer } from '@/lib/pointer'; import { getSiteContentPointer } from '@/lib/pointer';
import { tcls } from '@/lib/tailwind'; import { tcls } from '@/lib/tailwind';
@@ -38,9 +38,9 @@ export async function GET(req: NextRequest) {
const pointer = getSiteContentPointer(); const pointer = getSiteContentPointer();
const spaceId = pointer.spaceId; const spaceId = pointer.spaceId;
const [space, customization] = await Promise.all([ const [space, { customization }] = await Promise.all([
getSpace(spaceId, pointer.siteShareKey), getSpace(spaceId, pointer.siteShareKey),
getCurrentSiteCustomization(pointer), getSiteData(pointer),
]); ]);
const site = await getSite(pointer.organizationId, pointer.siteId); const site = await getSite(pointer.organizationId, pointer.siteId);
const contentTitle = getContentTitle(space, customization, site); const contentTitle = getContentTitle(space, customization, site);
+11 -107
View File
@@ -1,14 +1,11 @@
import { RevisionPage, SiteSection, SiteSpace, Space } from '@gitbook/api'; import { RevisionPage } from '@gitbook/api';
import { assert } from 'ts-essentials';
import { import {
getRevisionPageByPath, getRevisionPageByPath,
getDocument, getDocument,
ContentTarget, ContentTarget,
getSpaceContentData,
getSiteData, getSiteData,
getSite,
getCurrentSiteCustomization,
getSiteStructure,
} from '@/lib/api'; } from '@/lib/api';
import { resolvePagePath, resolvePageId } from '@/lib/pages'; import { resolvePagePath, resolvePageId } from '@/lib/pages';
import { getSiteContentPointer } from '@/lib/pointer'; import { getSiteContentPointer } from '@/lib/pointer';
@@ -21,35 +18,18 @@ export interface PageIdParams {
pageId: string; pageId: string;
} }
type SectionsList = { list: SiteSection[]; section: SiteSection };
/** /**
* Fetch all the data needed to render the content layout. * Fetch all the data needed to render the content layout.
*/ */
export async function fetchContentData() { export async function fetchContentData() {
const content = getSiteContentPointer(); const content = getSiteContentPointer();
const [{ space, contentTarget, pages, customization, scripts }, siteStructure] = const [{ space, contentTarget, pages }, { customization, site, sections, spaces, scripts }] =
await Promise.all([ await Promise.all([
getSpaceContentData(content, content.siteShareKey),
getSiteData(content), 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 // we grab the space attached to the parent as it contains overriden customizations
const spaceRelativeToParent = spaces?.find((space) => space.id === content.spaceId); const spaceRelativeToParent = spaces?.find((space) => space.id === content.spaceId);
@@ -58,8 +38,8 @@ export async function fetchContentData() {
contentTarget, contentTarget,
space: spaceRelativeToParent ?? space, space: spaceRelativeToParent ?? space,
pages, pages,
sections: siteSections,
site, site,
sections,
spaces, spaces,
customization, customization,
scripts, 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. * Fetch all the data needed to render the content.
* Optimized to fetch in parallel as much as possible. * Optimized to fetch in parallel as much as possible.
*/ */
export async function fetchPageData(params: PagePathParams | PageIdParams) { 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(contentData.contentTarget, contentData.pages, params);
const page = await resolvePage(contentTarget, pages, params); const document = page?.page.documentId
const [siteStructure, document] = await Promise.all([ ? await getDocument(contentData.space.id, page.page.documentId)
fetchSiteStructure({ : null;
organizationId: content.organizationId,
siteId: content.siteId,
siteShareKey: content.siteShareKey,
}),
page?.page.documentId ? getDocument(space.id, page.page.documentId) : null,
]);
return { return {
content, ...contentData,
contentTarget,
space,
pages,
customization,
scripts,
ancestors: [],
...page, ...page,
...siteStructure,
document, document,
}; };
} }
@@ -150,59 +107,6 @@ async function resolvePage(
return undefined; 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. * Get the page path from the params.
*/ */
+2 -2
View File
@@ -1,5 +1,5 @@
import { CustomizationRootLayout } from '@/components/RootLayout'; import { CustomizationRootLayout } from '@/components/RootLayout';
import { getSiteLayoutData } from '@/lib/api'; import { getSiteData } from '@/lib/api';
import { getSiteContentPointer } from '@/lib/pointer'; import { getSiteContentPointer } from '@/lib/pointer';
/** /**
@@ -10,7 +10,7 @@ export default async function SiteRootLayout(props: { children: React.ReactNode
const { children } = props; const { children } = props;
const pointer = getSiteContentPointer(); const pointer = getSiteContentPointer();
const { customization } = await getSiteLayoutData(pointer); const { customization } = await getSiteData(pointer);
return ( return (
<CustomizationRootLayout customization={customization}>{children}</CustomizationRootLayout> <CustomizationRootLayout customization={customization}>{children}</CustomizationRootLayout>
@@ -1,7 +1,7 @@
import { SpaceIntegrationScript } from '@gitbook/api'; import { SpaceIntegrationScript } from '@gitbook/api';
import { CustomizationRootLayout } from '@/components/RootLayout'; import { CustomizationRootLayout } from '@/components/RootLayout';
import { getSiteLayoutData, getSpaceCustomization } from '@/lib/api'; import { getSiteData, getSpaceCustomization } from '@/lib/api';
import { getSiteOrSpacePointerForPDF } from './pointer'; import { getSiteOrSpacePointerForPDF } from './pointer';
@@ -14,7 +14,7 @@ export default async function PDFRootLayout(props: { children: React.ReactNode }
const pointer = getSiteOrSpacePointerForPDF(); const pointer = getSiteOrSpacePointerForPDF();
const { customization } = await ('siteId' in pointer const { customization } = await ('siteId' in pointer
? getSiteLayoutData(pointer) ? getSiteData(pointer)
: getSpaceLayoutData(pointer.spaceId)); : getSpaceLayoutData(pointer.spaceId));
return ( return (
@@ -26,7 +26,7 @@ export default async function PDFRootLayout(props: { children: React.ReactNode }
* Fetch all the layout data about a space at once. * Fetch all the layout data about a space at once.
*/ */
async function getSpaceLayoutData(spaceId: string) { async function getSpaceLayoutData(spaceId: string) {
const [customization, scripts] = await Promise.all([ const [{ customization }, scripts] = await Promise.all([
getSpaceCustomization(spaceId), getSpaceCustomization(spaceId),
[] as SpaceIntegrationScript[], [] as SpaceIntegrationScript[],
]); ]);
@@ -21,7 +21,7 @@ import {
getSpace, getSpace,
getSpaceCustomization, getSpaceCustomization,
getSpaceContentData, getSpaceContentData,
getCurrentSiteCustomization, getSiteData,
} from '@/lib/api'; } from '@/lib/api';
import { pagePDFContainerId, PageHrefContext, absoluteHref } from '@/lib/links'; import { pagePDFContainerId, PageHrefContext, absoluteHref } from '@/lib/links';
import { resolvePageId } from '@/lib/pages'; import { resolvePageId } from '@/lib/pages';
@@ -40,11 +40,9 @@ export const runtime = 'edge';
export async function generateMetadata(): Promise<Metadata> { export async function generateMetadata(): Promise<Metadata> {
const pointer = getSiteOrSpacePointerForPDF(); const pointer = getSiteOrSpacePointerForPDF();
const [space, customization] = await Promise.all([ const [space, { customization }] = await Promise.all([
getSpace(pointer.spaceId, 'siteId' in pointer ? pointer.siteShareKey : undefined), getSpace(pointer.spaceId, 'siteId' in pointer ? pointer.siteShareKey : undefined),
'siteId' in pointer 'siteId' in pointer ? getSiteData(pointer) : getSpaceCustomization(pointer.spaceId),
? getCurrentSiteCustomization(pointer)
: getSpaceCustomization(pointer.spaceId),
]); ]);
return { return {
@@ -67,10 +65,8 @@ export default async function PDFHTMLOutput(props: { searchParams: { [key: strin
currentPDFUrl += '?' + searchParams.toString(); currentPDFUrl += '?' + searchParams.toString();
// Load the content, // Load the content,
const [customization, { space, contentTarget, pages: rootPages }] = await Promise.all([ const [{ customization }, { space, contentTarget, pages: rootPages }] = await Promise.all([
'siteId' in pointer 'siteId' in pointer ? getSiteData(pointer) : getSpaceCustomization(pointer.spaceId),
? getCurrentSiteCustomization(pointer)
: getSpaceCustomization(pointer.spaceId),
getSpaceContentData(pointer, 'siteId' in pointer ? pointer.siteShareKey : undefined), getSpaceContentData(pointer, 'siteId' in pointer ? pointer.siteShareKey : undefined),
]); ]);
const language = getSpaceLanguage(customization); 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 { Hint } from './Hint';
import { Images } from './Images'; import { Images } from './Images';
import { IntegrationBlock } from './Integration'; import { IntegrationBlock } from './Integration';
import { List } from './List';
import { ListItem } from './ListItem'; import { ListItem } from './ListItem';
import { ListOrdered } from './ListOrdered';
import { ListTasks } from './ListTasks';
import { ListUnordered } from './ListUnordered';
import { BlockMath } from './Math'; import { BlockMath } from './Math';
import { OpenAPI } from './OpenAPI'; import { OpenAPI } from './OpenAPI';
import { Paragraph } from './Paragraph'; import { Paragraph } from './Paragraph';
@@ -65,11 +63,9 @@ export function Block<T extends DocumentBlock>(props: BlockProps<T>) {
case 'heading-3': case 'heading-3':
return <Heading {...props} block={block} />; return <Heading {...props} block={block} />;
case 'list-ordered': case 'list-ordered':
return <ListOrdered {...props} block={block} />;
case 'list-unordered': case 'list-unordered':
return <ListUnordered {...props} block={block} />;
case 'list-tasks': case 'list-tasks':
return <ListTasks {...props} block={block} />; return <List {...props} block={block} />;
case 'list-item': case 'list-item':
return <ListItem {...props} block={block} />; return <ListItem {...props} block={block} />;
case 'code': case 'code':
@@ -48,7 +48,7 @@ async function SpaceRefCard(
return null; return null;
} }
const spaceCustomization = await ignoreAPIError(getSpaceCustomization(spaceId)); const { customization: spaceCustomization } = getSpaceCustomization(spaceId);
const customFavicon = spaceCustomization?.favicon; const customFavicon = spaceCustomization?.favicon;
const customEmoji = customFavicon && 'emoji' in customFavicon ? customFavicon.emoji : undefined; const customEmoji = customFavicon && 'emoji' in customFavicon ? customFavicon.emoji : undefined;
const customIcon = customFavicon && 'icon' in customFavicon ? customFavicon.icon : 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 { import {
DocumentBlock,
DocumentBlockListItem, DocumentBlockListItem,
DocumentBlockListOrdered, DocumentBlockListOrdered,
DocumentBlockListTasks,
DocumentBlockListUnordered, DocumentBlockListUnordered,
} from '@gitbook/api'; } from '@gitbook/api';
import classNames from 'classnames'; import assertNever from 'assert-never';
import { assert } from 'ts-essentials';
import { Checkbox } from '@/components/primitives'; import { Checkbox } from '@/components/primitives';
import { tcls } from '@/lib/tailwind'; import { tcls } from '@/lib/tailwind';
import { BlockProps } from './Block'; import { BlockProps } from './Block';
import { Blocks } from './Blocks'; import { Blocks } from './Blocks';
import styles from './ListItem.module.css';
import { getBlockTextStyle } from './spacing'; import { getBlockTextStyle } from './spacing';
export function ListItem(props: BlockProps<DocumentBlockListItem>) { export function ListItem(props: BlockProps<DocumentBlockListItem>) {
const { block, ancestorBlocks, ...contextProps } = props; 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 const blocksElement = (
| DocumentBlockListOrdered <Blocks
| DocumentBlockListUnordered {...contextProps}
| DocumentBlockListTasks nodes={block.nodes}
| undefined; 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) {
switch (parent?.type) {
case 'list-tasks': case 'list-tasks':
return ( return (
<li> <ListItemLI block={block}>
<div className={tcls('flex', 'flex-row')}> <ListItemPrefix block={block}>
<div
className={tcls(
textStyle.textSize,
'flex',
'flex-col',
'justify-center',
'h-[1lh]',
)}
>
<Checkbox <Checkbox
id={block.key!} id={block.key!}
disabled disabled
checked={block.data?.checked} checked={block.data?.checked}
className={tcls('relative')} className="relative"
size="small"
/> />
</div> </ListItemPrefix>
<label htmlFor={block.key} className={tcls('flex-1')}> <label htmlFor={block.key} className={tcls('flex-1')}>
<Blocks {blocksElement}
{...contextProps}
nodes={block.nodes}
ancestorBlocks={[...ancestorBlocks, block]}
blockStyle={tcls('flip-heading-hash')}
style={tcls('ml-2', 'space-y-2')}
/>
</label> </label>
</div> </ListItemLI>
</li>
); );
case 'list-ordered': 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 ( return (
<li value={index} className={tcls(textStyle.lineHeight)}> <ListItemLI block={block}>
<div <ListItemPrefix block={block}>
data-value={index} <PseudoBefore
className={classNames( content={getOrderedListItemPrefixContent({
'bullet', depth: getListItemDepth({ ancestorBlocks, type: parent.type }),
styles.olListItemBullet, block,
tcls(textStyle.textSize), parent,
)} })}
></div> style={{
{/* zero width space to force layouts with empty lists */} fontSize: 'min(1em, 24px)',
<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',
)}
/> />
</li> </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: default:
return ( assertNever(parent);
<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>
);
} }
}; }
return <ListItemType />; 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'; } from '@gitbook/api';
import { assertNever } from 'assert-never'; import { assertNever } from 'assert-never';
import { ClassValue } from '@/lib/tailwind';
/** /**
* Get the line height of a block * Get the line height of a block
*/ */
export function getBlockTextStyle(block: DocumentBlock): { export function getBlockTextStyle(block: DocumentBlock): {
/** Tailwind class for the text size */ /** Tailwind class for the text size */
textSize: ClassValue; textSize: string;
/** Tailwind class for the height (h-*) */ /** Tailwind class for the height (h-*) */
lineHeight: ClassValue; lineHeight: string;
/** Tailwind class for the margin top (mt-*) */ /** Tailwind class for the margin top (mt-*) */
marginTop?: ClassValue; marginTop?: string;
} { } {
switch (block.type) { switch (block.type) {
case 'paragraph': case 'paragraph':
return { return {
textSize: ['text-base'], textSize: 'text-base',
lineHeight: 'leading-normal', lineHeight: 'leading-normal',
}; };
case 'heading-1': case 'heading-1':
return { return {
textSize: ['text-3xl', 'font-semibold'], textSize: 'text-3xl font-semibold',
lineHeight: 'leading-tight', lineHeight: 'leading-tight',
marginTop: '[margin-top:_1em]', marginTop: 'mt-[1em]',
}; };
case 'heading-2': case 'heading-2':
return { return {
textSize: ['text-2xl', 'font-semibold'], textSize: 'text-2xl font-semibold',
lineHeight: 'leading-snug', lineHeight: 'leading-snug',
marginTop: '[margin-top:_0.75em]', marginTop: 'mt-[0.75em]',
}; };
case 'heading-3': case 'heading-3':
return { return {
textSize: ['text-base', 'font-semibold'], textSize: 'text-base font-semibold',
lineHeight: 'leading-snug', lineHeight: 'leading-snug',
marginTop: '[margin-top:_0.5em]', marginTop: 'mt-[0.5em]',
}; };
case 'divider': case 'divider':
return { return {
textSize: [], textSize: '',
lineHeight: 'leading-none', lineHeight: 'leading-none',
}; };
case 'list-ordered': case 'list-ordered':
@@ -56,7 +54,7 @@ export function getBlockTextStyle(block: DocumentBlock): {
return getBlockTextStyle(block.nodes[0]); return getBlockTextStyle(block.nodes[0]);
default: default:
return { return {
textSize: ['text-base'], textSize: 'text-base',
lineHeight: 'leading-normal', lineHeight: 'leading-normal',
}; };
} }
@@ -44,7 +44,7 @@ export function Dropdown<E extends HTMLElement>(props: {
'absolute', 'absolute',
'top-full', 'top-full',
'left-0', 'left-0',
'z-10', 'z-20',
'origin-top-left', 'origin-top-left',
'invisible', 'invisible',
'transition-opacity', 'transition-opacity',
@@ -1,15 +1,10 @@
import { import { CustomizationSettings, Site, SiteCustomizationSettings, Space } from '@gitbook/api';
CustomizationSettings,
Site,
SiteCustomizationSettings,
SiteSection,
Space,
} from '@gitbook/api';
import { CustomizationHeaderPreset } from '@gitbook/api'; import { CustomizationHeaderPreset } from '@gitbook/api';
import { Suspense } from 'react'; import { Suspense } from 'react';
import { CONTAINER_STYLE, HEADER_HEIGHT_DESKTOP } from '@/components/layout'; import { CONTAINER_STYLE, HEADER_HEIGHT_DESKTOP } from '@/components/layout';
import { t, getSpaceLanguage } from '@/intl/server'; import { t, getSpaceLanguage } from '@/intl/server';
import type { SectionsList } from '@/lib/api';
import { ContentRefContext } from '@/lib/references'; import { ContentRefContext } from '@/lib/references';
import { tcls } from '@/lib/tailwind'; import { tcls } from '@/lib/tailwind';
@@ -27,14 +22,12 @@ export function Header(props: {
space: Space; space: Space;
site: Site | null; site: Site | null;
spaces: Space[]; spaces: Space[];
sections: { list: SiteSection[]; section: SiteSection } | null; sections: SectionsList | null;
context: ContentRefContext; context: ContentRefContext;
customization: CustomizationSettings | SiteCustomizationSettings; customization: CustomizationSettings | SiteCustomizationSettings;
withTopHeader?: boolean; withTopHeader?: boolean;
children?: React.ReactNode;
}) { }) {
const { children, context, space, site, spaces, sections, customization, withTopHeader } = const { context, space, site, spaces, sections, customization, withTopHeader } = props;
props;
const isCustomizationDefault = const isCustomizationDefault =
customization.header.preset === CustomizationHeaderPreset.Default; customization.header.preset === CustomizationHeaderPreset.Default;
const hasSiteSections = sections && sections.list.length > 1; const hasSiteSections = sections && sections.list.length > 1;
@@ -149,12 +142,12 @@ export function Header(props: {
{sections ? ( {sections ? (
<div <div
className={tcls( 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} />
<SiteSectionTabs sections={sections.list} section={sections.section} />
</div>
</div> </div>
) : null} ) : null}
</header> </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]:[grid-area:1/2];
@apply [&:is(h1,h2,h3,h4)>div:first-child]:ml-1; @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 { @layer utilities {
@@ -68,16 +68,11 @@ async function searchSiteContent(args: {
scope.mode === 'current' || scope.mode === 'current' ||
(scope.mode === 'specific' && scope.siteSpaceIds.length > 1); (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), api.searchSiteContent(pointer.organizationId, pointer.siteId, query, scope, cacheBust),
needsStructure needsStructure ? api.getSiteData(pointer) : null,
? api.getSiteStructure({
organizationId: pointer.organizationId,
siteId: pointer.siteId,
siteShareKey: pointer.siteShareKey,
})
: null,
]); ]);
const siteStructure = siteData?.structure;
const siteSpaces = siteStructure const siteSpaces = siteStructure
? siteStructure.type === 'siteSpaces' ? siteStructure.type === 'siteSpaces'
@@ -1,17 +1,20 @@
'use client'; 'use client';
import { SiteSection } from '@gitbook/api'; import { SiteSection } from '@gitbook/api';
import { Icon } from '@gitbook/icons';
import React from 'react'; import React from 'react';
import { tcls } from '@/lib/tailwind'; 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 }) { export function SiteSectionTabs(props: {
const { sections, section: currentSection } = props; list: SiteSection[];
section: SiteSection;
index: number;
}) {
const { list: sections, section: currentSection, index: currentIndex } = props;
const tabs = sections.map((section) => ({ const tabs = sections.map((section) => ({
id: section.id, id: section.id,
@@ -22,73 +25,64 @@ export function SiteSectionTabs(props: { sections: SiteSection[]; section: SiteS
const currentTabRef = React.useRef<HTMLAnchorElement>(null); const currentTabRef = React.useRef<HTMLAnchorElement>(null);
const navRef = React.useRef<HTMLDivElement>(null); const navRef = React.useRef<HTMLDivElement>(null);
const [currentIndex, setCurrentIndex] = React.useState(
sections.findIndex((section) => section.id === currentSection?.id),
);
const [tabDimensions, setTabDimensions] = React.useState<{ const [tabDimensions, setTabDimensions] = React.useState<{
left: number; left: number;
width: number; width: number;
} | null>(null); } | null>(null);
React.useEffect(() => { const updateTabDimensions = React.useCallback(() => {
if (currentTabRef.current && navRef.current) { if (currentTabRef.current && navRef.current) {
const rect = currentTabRef.current.getBoundingClientRect(); const rect = currentTabRef.current.getBoundingClientRect();
const navRect = navRef.current.getBoundingClientRect(); const navRect = navRef.current.getBoundingClientRect();
setTabDimensions({ left: rect.left - navRect.left, width: rect.width });
}
}, [currentIndex]);
React.useLayoutEffect(() => { setTabDimensions({
function onResize() { left: rect.left - navRect.left,
if (currentTabRef.current && navRef.current) { width: rect.width,
const rect = currentTabRef.current.getBoundingClientRect(); });
const navRect = navRef.current.getBoundingClientRect();
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 scale = (tabDimensions?.width ?? 0) * 0.01;
const startPos = `${tabDimensions?.left ?? 0}px`; const startPos = `${tabDimensions?.left ?? 0}px`;
const hasMoreSections = false; /** TODO: determine whether we need to show the more button */
return tabs.length > 0 ? ( return tabs.length > 0 ? (
<nav <nav
aria-label="Sections" aria-label="Sections"
ref={navRef} 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={ style={
{ {
'--tab-opacity': `${opacity}`,
'--tab-scale': `${scale}`, '--tab-scale': `${scale}`,
'--tab-start': `${startPos}`, '--tab-start': `${startPos}`,
} as React.CSSProperties } as React.CSSProperties
} }
> >
<div className="flex flex-col bg-transparent">
{/* An element for the tabs which includes the page padding */}
<div <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" 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) => ( {tabs.map((tab, index) => (
<Tab <Tab
@@ -97,11 +91,34 @@ export function SiteSectionTabs(props: { sections: SiteSection[]; section: SiteS
label={tab.label} label={tab.label}
href={tab.path} href={tab.path}
ref={currentIndex === index ? currentTabRef : null} ref={currentIndex === index ? currentTabRef : null}
onClick={() => setCurrentIndex(index)}
/> />
))} ))}
</div> </div>
{hasMoreSections ? <MoreSectionsButton /> : null} {/* 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>
</nav> </nav>
) : null; ) : null;
} }
@@ -109,11 +126,9 @@ export function SiteSectionTabs(props: { sections: SiteSection[]; section: SiteS
/** /**
* The tab item - a link to a site section * The tab item - a link to a site section
*/ */
const Tab = React.forwardRef< const Tab = React.forwardRef<HTMLSpanElement, { active: boolean; href: string; label: string }>(
HTMLSpanElement, function Tab(props, ref) {
{ active: boolean; href: string; label: string; onClick?: () => void } const { active, href, label } = props;
>(function Tab(props, ref) {
const { active, href, label, onClick } = props;
return ( return (
<Link <Link
className={tcls( className={tcls(
@@ -124,24 +139,11 @@ const Tab = React.forwardRef<
)} )}
role="tab" role="tab"
href={href} href={href}
onClick={onClick}
> >
<span ref={ref} className={tcls('inline-flex w-full truncate')}> <span ref={ref} className={tcls('inline-flex w-full truncate')}>
{label} {label}
</span> </span>
</Link> </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>
);
}
@@ -6,7 +6,6 @@ import {
RevisionPageGroup, RevisionPageGroup,
Site, Site,
SiteCustomizationSettings, SiteCustomizationSettings,
SiteSection,
Space, Space,
} from '@gitbook/api'; } from '@gitbook/api';
import React from 'react'; import React from 'react';
@@ -17,7 +16,7 @@ import { CONTAINER_STYLE } from '@/components/layout';
import { ColorDebugger } from '@/components/primitives/ColorDebugger'; import { ColorDebugger } from '@/components/primitives/ColorDebugger';
import { SearchModal } from '@/components/Search'; import { SearchModal } from '@/components/Search';
import { TableOfContents } from '@/components/TableOfContents'; 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 { ContentRefContext } from '@/lib/references';
import { tcls } from '@/lib/tailwind'; import { tcls } from '@/lib/tailwind';
@@ -31,7 +30,7 @@ export function SpaceLayout(props: {
contentTarget: ContentTarget; contentTarget: ContentTarget;
space: Space; space: Space;
site: Site | null; site: Site | null;
sections: { list: SiteSection[]; section: SiteSection } | null; sections: SectionsList | null;
spaces: Space[]; spaces: Space[];
customization: CustomizationSettings | SiteCustomizationSettings; customization: CustomizationSettings | SiteCustomizationSettings;
pages: Revision['pages']; pages: Revision['pages'];
@@ -6,16 +6,22 @@ import React from 'react';
import { tcls } from '@/lib/tailwind'; 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< export const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>, React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root> CheckboxProps
>(({ className, ...props }, ref) => ( >(({ className, size = 'medium', ...props }, ref) => (
<CheckboxPrimitive.Root <CheckboxPrimitive.Root
ref={ref} ref={ref}
className={tcls( className={tcls(
'peer', 'peer',
'h-5',
'w-5',
'shrink-0', 'shrink-0',
'rounded-sm', 'rounded-sm',
'straight-corners:rounded-none', 'straight-corners:rounded-none',
@@ -32,11 +38,12 @@ export const Checkbox = React.forwardRef<
'dark:ring-light/3', 'dark:ring-light/3',
'dark:contrast-more:ring-light/6', 'dark:contrast-more:ring-light/6',
'dark:data-[state=checked]:bg-primary-500', 'dark:data-[state=checked]:bg-primary-500',
{ small: 'size-4', medium: 'size-5' }[size],
className, className,
)} )}
{...props} {...props}
> >
<CheckboxPrimitive.Indicator className={tcls('relative', 'text-current')}> <CheckboxPrimitive.Indicator className="relative text-current">
{props.checked ? ( {props.checked ? (
<Icon icon="check" iconStyle={IconStyle.Solid} className={'size-3'} /> <Icon icon="check" iconStyle={IconStyle.Solid} className={'size-3'} />
) : null} ) : null}
+1
View File
@@ -39,6 +39,7 @@ export const ibmPlexMono = IBM_Plex_Mono({
display: 'swap', display: 'swap',
preload: false, preload: false,
fallback: ['monospace'], fallback: ['monospace'],
adjustFontFallback: false,
}); });
const firaSans = Fira_Sans_Extra_Condensed({ const firaSans = Fira_Sans_Extra_Condensed({
+94 -209
View File
@@ -13,11 +13,15 @@ import {
RevisionFile, RevisionFile,
SiteCustomizationSettings, SiteCustomizationSettings,
RevisionReusableContent, RevisionReusableContent,
SiteSpace,
Space,
SiteSection,
PublishedSiteContent,
} from '@gitbook/api'; } from '@gitbook/api';
import assertNever from 'assert-never'; import assertNever from 'assert-never';
import { headers } from 'next/headers'; import { headers } from 'next/headers';
import rison from 'rison'; import rison from 'rison';
import { DeepPartial } from 'ts-essentials'; import { assert, DeepPartial } from 'ts-essentials';
import { batch } from './async'; import { batch } from './async';
import { buildVersion } from './build'; import { buildVersion } from './build';
@@ -131,22 +135,10 @@ export function withAPI<T>(client: GitBookAPI, fn: () => Promise<T>): Promise<T>
return apiSyncStorage.run(client, fn); return apiSyncStorage.run(client, fn);
} }
// FIXME: Fix @gitbook/api types for PublishedSiteContentLookup type SpaceContentLookup = Pick<
type SpaceContentLookup = { PublishedSiteContent,
kind: 'space'; 'space' | 'changeRequest' | 'revision' | 'pathname' | 'basePath' | 'apiToken'
/** ID of the space matching. */ > & { kind: 'space' };
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;
};
export type PublishedContentWithCache = export type PublishedContentWithCache =
| ((SpaceContentLookup | PublishedSiteContentLookup) & { | ((SpaceContentLookup | PublishedSiteContentLookup) & {
@@ -628,84 +620,6 @@ export const getDocument = cache({
timeout: 20 * 1000, 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. * 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({ export const getPublishedContentSite = cache({
name: 'api.getSiteSpaces', name: 'api.getPublishedContentSite',
tag: ({ siteId }) => getAPICacheTag({ tag: 'site', site: siteId }), tag: ({ siteId }) => getAPICacheTag({ tag: 'site', site: siteId }),
get: async ( get: async (
args: { args: {
organizationId: string; organizationId: string;
siteId: string; siteId: string /** Site share key that can be used as context to resolve site space published urls */;
/** Site share key that can be used as context to resolve site space published urls */
siteShareKey: string | undefined; siteShareKey: string | undefined;
}, },
options: CacheFunctionOptions, options: CacheFunctionOptions,
) => { ) => {
const response = await getAll((params) => const response = await api().orgs.getPublishedContentSite(
api().orgs.listSiteSpaces(
args.organizationId, args.organizationId,
args.siteId, args.siteId,
{ {
...params, shareKey: args.siteShareKey,
...(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(
args.organizationId,
args.siteId,
{
...(args.siteShareKey ? { shareKey: args.siteShareKey } : {}),
}, },
{ {
...noCacheFetchOptions, ...noCacheFetchOptions,
@@ -785,89 +664,93 @@ export const getSiteStructure = cache({
); );
return cacheResponse(response, { return cacheResponse(response, {
revalidateBefore: 60 * 60, revalidateBefore: 60 * 60,
data: response.data,
}); });
}, },
}); });
/** export type SectionsList = { list: SiteSection[]; section: SiteSection; index: number };
* List the scripts to load for the site.
*/ function parseSpacesFromSiteSpaces(siteSpaces: SiteSpace[]) {
export const getSiteIntegrationScripts = cache({ const spaces: Record<string, Space> = {};
name: 'api.getSiteIntegrationScripts', siteSpaces.forEach((siteSpace) => {
tag: (organizationId, siteId) => getAPICacheTag({ tag: 'site', site: siteId }), spaces[siteSpace.space.id] = {
get: async (organizationId: string, siteId: string, options: CacheFunctionOptions) => { ...siteSpace.space,
const response = await api().orgs.listSiteIntegrationScripts(organizationId, siteId, { title: siteSpace.title ?? siteSpace.space.title,
...noCacheFetchOptions, urls: {
signal: options.signal, ...siteSpace.space.urls,
}); published: siteSpace.urls.published,
return cacheResponse(response, {
revalidateBefore: 60 * 60,
});
}, },
});
/**
* 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),
]);
return {
space,
pages,
contentTarget,
customization,
scripts,
}; };
}
/**
* Fetch all the layout data about the current site at once.
*/
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),
]);
return {
customization,
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,
}); });
return Object.values(spaces);
}
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;
}
/**
* This function fetches the published content site data to render the published
* experience for the site (structure, customizations, scripts etc)
*/
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 a space from the API. * 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 headersList = headers();
const raw = defaultCustomizationForSpace(); const raw = defaultCustomizationForSpace();
@@ -875,7 +758,7 @@ export async function getSpaceCustomization(spaceId: string): Promise<Customizat
if (extend) { if (extend) {
try { try {
const parsed = rison.decode_object<Partial<CustomizationSettings>>(extend); const parsed = rison.decode_object<Partial<CustomizationSettings>>(extend);
return { ...raw, ...parsed }; return { customization: { ...raw, ...parsed } };
} catch (error) { } catch (error) {
console.error( console.error(
`Failed to parse x-gitbook-customization header (ignored): ${ `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. * 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. * The approach is optimized to aim at reusing cached lookup results as much as possible.
+25 -29
View File
@@ -14,13 +14,13 @@ import {
userAgent, userAgent,
withAPI, withAPI,
DEFAULT_API_ENDPOINT, DEFAULT_API_ENDPOINT,
getSiteLayoutData, getPublishedContentSite,
getSite, getSiteData,
} from '@/lib/api'; } from '@/lib/api';
import { race } from '@/lib/async'; import { race } from '@/lib/async';
import { buildVersion } from '@/lib/build'; import { buildVersion } from '@/lib/build';
import { createContentSecurityPolicyNonce, getContentSecurityPolicy } from '@/lib/csp'; import { createContentSecurityPolicyNonce, getContentSecurityPolicy } from '@/lib/csp';
import { getURLLookupAlternatives, normalizeURL } from '@/lib/middleware'; import { getURLLookupAlternatives, normalizeURL, setMiddlewareHeader } from '@/lib/middleware';
import { import {
VisitorAuthCookieValue, VisitorAuthCookieValue,
getVisitorAuthCookieName, getVisitorAuthCookieName,
@@ -175,10 +175,12 @@ export async function middleware(request: NextRequest) {
); );
const { scripts } = await ('site' in resolved const { scripts } = await ('site' in resolved
? getSiteLayoutData({ ? getSiteData({
organizationId: resolved.organization, organizationId: resolved.organization,
siteId: resolved.site, siteId: resolved.site,
siteSectionId: resolved.siteSection,
siteSpaceId: resolved.siteSpace, siteSpaceId: resolved.siteSpace,
siteShareKey: resolved.shareKey,
}) })
: { scripts: [] }); : { scripts: [] });
return getContentSecurityPolicy(scripts, nonce); return getContentSecurityPolicy(scripts, nonce);
@@ -253,43 +255,31 @@ export async function middleware(request: NextRequest) {
resolved.cookies, resolved.cookies,
); );
response.headers.set('x-gitbook-version', buildVersion()); setMiddlewareHeader(response, 'x-gitbook-version', buildVersion());
// Add Content Security Policy header // Add Content Security Policy header
response.headers.set('content-security-policy', csp); setMiddlewareHeader(response, 'content-security-policy', csp);
// Basic security headers // Basic security headers
response.headers.set('strict-transport-security', 'max-age=31536000'); setMiddlewareHeader(response, 'strict-transport-security', 'max-age=31536000');
response.headers.set('referrer-policy', 'no-referrer-when-downgrade'); setMiddlewareHeader(response, 'referrer-policy', 'no-referrer-when-downgrade');
response.headers.set('x-content-type-options', 'nosniff'); setMiddlewareHeader(response, 'x-content-type-options', 'nosniff');
const isPrefetch = request.headers.has('x-middleware-prefetch');
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') { if (typeof resolved.cacheMaxAge === 'number') {
const cacheControl = `public, max-age=0, s-maxage=${resolved.cacheMaxAge}, stale-if-error=0`; const cacheControl = `public, max-age=0, s-maxage=${resolved.cacheMaxAge}, stale-if-error=0`;
if ( if (process.env.GITBOOK_OUTPUT_CACHE === 'true' && process.env.NODE_ENV !== 'development') {
process.env.GITBOOK_OUTPUT_CACHE === 'true' && setMiddlewareHeader(response, 'cache-control', cacheControl);
process.env.NODE_ENV !== 'development' setMiddlewareHeader(response, 'Cloudflare-CDN-Cache-Control', cacheControl);
) {
response.headers.set('cache-control', cacheControl);
response.headers.set('Cloudflare-CDN-Cache-Control', cacheControl);
} else { } else {
response.headers.set('x-gitbook-cache-control', cacheControl); setMiddlewareHeader(response, 'x-gitbook-cache-control', cacheControl);
} }
} }
// }
if (resolved.cacheTags && resolved.cacheTags.length > 0) { if (resolved.cacheTags && resolved.cacheTags.length > 0) {
const headerCacheTag = resolved.cacheTags.join(','); const headerCacheTag = resolved.cacheTags.join(',');
response.headers.set('cache-tag', headerCacheTag); setMiddlewareHeader(response, 'cache-tag', headerCacheTag);
response.headers.set('x-gitbook-cache-tag', headerCacheTag); setMiddlewareHeader(response, 'x-gitbook-cache-tag', headerCacheTag);
}
} }
return response; return response;
@@ -492,7 +482,13 @@ async function lookupSiteOrSpaceInMultiIdMode(
// Verify access to the site to avoid leaking cached data in this mode // 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) // (the cache is not dependend on the auth token, so it could leak data)
if (source.kind === 'site') { 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 = { const cookies: LookupCookies = {
+1
View File
@@ -64,6 +64,7 @@ const config: Config = {
fontFamily: { fontFamily: {
sans: ['var(--font-content)'], sans: ['var(--font-content)'],
mono: ['var(--font-mono)'], mono: ['var(--font-mono)'],
var: ['var(--font-family)'],
}, },
colors: { colors: {
// Dynamic colors matching the customization settings // Dynamic colors matching the customization settings
+1 -1
View File
@@ -10,7 +10,7 @@
}, },
"dependencies": { "dependencies": {
"classnames": "^2.5.1", "classnames": "^2.5.1",
"@gitbook/api": "^0.72.0", "@gitbook/api": "^0.76.0",
"assert-never": "^1.2.1" "assert-never": "^1.2.1"
}, },
"peerDependencies": { "peerDependencies": {
+1 -1
View File
@@ -10,7 +10,7 @@
}, },
"version": "0.7.0", "version": "0.7.0",
"dependencies": { "dependencies": {
"@scalar/api-client-react": "1.0.65", "@scalar/api-client-react": "1.0.76",
"classnames": "^2.5.1", "classnames": "^2.5.1",
"flatted": "^3.2.9", "flatted": "^3.2.9",
"openapi-types": "^12.1.3", "openapi-types": "^12.1.3",
@@ -119,7 +119,7 @@ export function OpenAPICodeSample(props: {
tabs={samples} tabs={samples}
overlay={ overlay={
data['x-hideTryItPanel'] || data.operation['x-hideTryItPanel'] ? null : ( 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 * Button which launches the Scalar API Client
*/ */
export function ScalarApiButton() { export function ScalarApiButton({ method, path }: { method: string; path: string }) {
const client = useApiClientModal(); const client = useApiClientModal();
return ( return (
<div className="scalar scalar-activate"> <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"> <svg xmlns="http://www.w3.org/2000/svg" width="10" height="12" fill="none">
<path <path
stroke="currentColor" 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",