mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-11-01 09:49:53 +00:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0ca9a8b303 | |||
| 7675c2c411 | |||
| 7c71363af8 | |||
| d48926eb2a | |||
| d843e5e06f | |||
| a2e564738e | |||
| 54a797a0b1 | |||
| a78c1ecb4d | |||
| 4771c78001 | |||
| b7a5106f36 | |||
| 4bcbdc50ff | |||
| 5d72b35c7f | |||
| ff50ac215c |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@gitbook/cache-do': patch
|
||||
---
|
||||
|
||||
Disable cloudflare observability in production
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Smoother tab transition for sections
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Don't adjust fallback font for mono font.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@gitbook/react-openapi': patch
|
||||
---
|
||||
|
||||
Fixed scalar api client routing
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Optimize performances by using new API endpoint for fetching site data.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@gitbook/react-openapi': patch
|
||||
---
|
||||
|
||||
Bump @scalar/api-client-react version
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@gitbook/react-openapi': patch
|
||||
---
|
||||
|
||||
Bumped scalar api client pacakge
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': minor
|
||||
---
|
||||
|
||||
Fix an issue where the space dropdown was shown under the site sections in Safari.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': minor
|
||||
---
|
||||
|
||||
Fix an issue where the space dropdown could appear behind the header.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': minor
|
||||
---
|
||||
|
||||
Fix the styling of site section tabs on smaller screens.
|
||||
@@ -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
|
||||
|
||||
+2
-1
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
Reference in New Issue
Block a user