mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-14 06:35:17 +00:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c773f8b5e7 | |||
| f5df40e17d | |||
| e13c76efed | |||
| 4a11d8d9eb | |||
| 009ed8cc6e | |||
| 106cbbd987 | |||
| 56e5c3c3f8 | |||
| 1dc3234003 | |||
| 22dc0d4ff5 | |||
| 3a4caf062c | |||
| 8ad0657159 | |||
| d8a4ecd0c6 | |||
| 65c7968438 | |||
| 3914f11150 | |||
| d363908297 | |||
| 414556ac7a | |||
| c768e9a90a | |||
| 553f1f228f | |||
| 30986528a7 | |||
| c70c2ddad8 |
@@ -119,6 +119,12 @@ GitBook wouldn't be possible without these projects:
|
||||
- [Tailwind CSS](https://tailwindcss.com/)
|
||||
- [Framer Motion](https://www.npmjs.com/package/framer-motion)
|
||||
|
||||
## Contributors
|
||||
|
||||
<a href="https://github.com/gitbookIO/gitbook/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=gitbookIO/gitbook" />
|
||||
</a>
|
||||
|
||||
## Legacy GitBook (Deprecated)
|
||||
|
||||
Our previous version of GitBook and it's CLI tool are now deprecated. You can still view the old repository and it's commits on this [branch](https://github.com/GitbookIO/gitbook/tree/legacy).
|
||||
|
||||
+4
-4
@@ -11,7 +11,7 @@
|
||||
"format": "prettier ./ --ignore-unknown --write",
|
||||
"format:check": "prettier ./ --ignore-unknown --list-different",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"unit": "bun test {src,packages}/**/*.test.ts",
|
||||
"unit": "bun test {src,packages}",
|
||||
"e2e": "playwright test",
|
||||
"postinstall": "rm -rf ./public/~gitbook/static/mathjax@3.2.2 && mkdir -p ./public/~gitbook/static/ && cp -R node_modules/mathjax/es5 ./public/~gitbook/static/mathjax@3.2.2"
|
||||
},
|
||||
@@ -20,7 +20,7 @@
|
||||
],
|
||||
"dependencies": {
|
||||
"@geist-ui/icons": "^1.0.2",
|
||||
"@gitbook/api": "^0.46.0",
|
||||
"@gitbook/api": "^0.51.0",
|
||||
"@radix-ui/react-checkbox": "^1.0.4",
|
||||
"@radix-ui/react-popover": "^1.0.7",
|
||||
"@sentry/nextjs": "^7.94.1",
|
||||
@@ -38,7 +38,7 @@
|
||||
"katex": "^0.16.9",
|
||||
"mathjax": "^3.2.2",
|
||||
"memoizee": "^0.4.15",
|
||||
"next": "^14.1.3",
|
||||
"next": "^14.2.4",
|
||||
"next-themes": "^0.2.1",
|
||||
"nuqs": "^1.15.4",
|
||||
"object-hash": "^3.0.0",
|
||||
@@ -64,7 +64,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@argos-ci/playwright": "^2.0.0",
|
||||
"@cloudflare/next-on-pages": "^1.9.0",
|
||||
"@cloudflare/next-on-pages": "^1.11.3",
|
||||
"@cloudflare/workers-types": "^4.20231218.0",
|
||||
"@playwright/test": "^1.42.1",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"exports": "./src/index.ts",
|
||||
"dependencies": {
|
||||
"classnames": "^2.5.1",
|
||||
"@gitbook/api": "^0.46.0",
|
||||
"@gitbook/api": "^0.51.0",
|
||||
"assert-never": "^1.2.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -47,6 +47,9 @@ export function OpenAPISchemaProperty(
|
||||
? null
|
||||
: getSchemaAlternatives(schema, new Set(circularRefs.keys()));
|
||||
|
||||
const shouldDisplayExample = (schema: OpenAPIV3.SchemaObject): boolean => {
|
||||
return (typeof schema.example === 'string' || typeof schema.example === 'number' || typeof schema.example === 'boolean')
|
||||
}
|
||||
return (
|
||||
<InteractiveSection
|
||||
id={id}
|
||||
@@ -92,6 +95,9 @@ export function OpenAPISchemaProperty(
|
||||
className="openapi-schema-description"
|
||||
/>
|
||||
) : null}
|
||||
{shouldDisplayExample(schema) ? (
|
||||
<span className="openapi-schema-example">Example: <code>{JSON.stringify(schema.example)}</code></span>
|
||||
) : null}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -48,6 +48,7 @@ export function OpenAPISpec(props: { rawData: any; context: OpenAPIClientContext
|
||||
// Description of the parameter is defined at the parameter level
|
||||
// we use display it if the schema doesn't override it
|
||||
description: parameter.description,
|
||||
example: parameter.example,
|
||||
...(noReference(parameter.schema) ?? {}),
|
||||
},
|
||||
required: parameter.required,
|
||||
|
||||
@@ -9,6 +9,7 @@ import { PageHrefContext, absoluteHref, pageHref } from '@/lib/links';
|
||||
import { getPagePath } from '@/lib/pages';
|
||||
import { ContentRefContext } from '@/lib/references';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { getContentTitle } from '@/lib/utils';
|
||||
|
||||
import { PageClientLayout } from './PageClientLayout';
|
||||
import { PagePathParams, fetchPageData, getPathnameParam, normalizePathname } from '../../fetch';
|
||||
@@ -22,8 +23,15 @@ export default async function Page(props: { params: PagePathParams }) {
|
||||
const { params } = props;
|
||||
|
||||
const rawPathname = getPathnameParam(params);
|
||||
const { contentTarget, space, customization, pages, page, document } =
|
||||
await fetchPageData(params);
|
||||
const {
|
||||
content: contentPointer,
|
||||
contentTarget,
|
||||
space,
|
||||
customization,
|
||||
pages,
|
||||
page,
|
||||
document,
|
||||
} = await fetchPageData(params);
|
||||
const linksContext: PageHrefContext = {};
|
||||
|
||||
if (!page) {
|
||||
@@ -62,6 +70,7 @@ export default async function Page(props: { params: PagePathParams }) {
|
||||
<div className={tcls('flex', 'flex-row')}>
|
||||
<PageBody
|
||||
space={space}
|
||||
contentPointer={contentPointer}
|
||||
contentTarget={contentTarget}
|
||||
customization={customization}
|
||||
context={contentRefContext}
|
||||
@@ -110,7 +119,7 @@ export async function generateMetadata({ params }: { params: PagePathParams }):
|
||||
}
|
||||
|
||||
return {
|
||||
title: [page.title, customization.title ?? space.title, parent?.title]
|
||||
title: [page.title, getContentTitle(space, customization, parent)]
|
||||
.filter(Boolean)
|
||||
.join(' | '),
|
||||
description: page.description ?? '',
|
||||
|
||||
@@ -8,10 +8,13 @@ import { AdminToolbar } from '@/components/AdminToolbar';
|
||||
import { CookiesToast } from '@/components/Cookies';
|
||||
import { LoadIntegrations } from '@/components/Integrations';
|
||||
import { SpaceLayout } from '@/components/SpaceLayout';
|
||||
import { api } from '@/lib/api';
|
||||
import { assetsDomain } from '@/lib/assets';
|
||||
import { buildVersion } from '@/lib/build';
|
||||
import { getContentSecurityPolicyNonce } from '@/lib/csp';
|
||||
import { absoluteHref, baseUrl } from '@/lib/links';
|
||||
import { shouldIndexSpace } from '@/lib/seo';
|
||||
import { getContentTitle } from '@/lib/utils';
|
||||
|
||||
import { ClientContexts } from './ClientContexts';
|
||||
import { RocketLoaderDetector } from './RocketLoaderDetector';
|
||||
@@ -38,6 +41,11 @@ export default async function ContentLayout(props: { children: React.ReactNode }
|
||||
scripts,
|
||||
} = await fetchSpaceData();
|
||||
|
||||
ReactDOM.preconnect(api().endpoint);
|
||||
if (assetsDomain) {
|
||||
ReactDOM.preconnect(assetsDomain);
|
||||
}
|
||||
|
||||
scripts.forEach(({ script }) => {
|
||||
ReactDOM.preload(script, {
|
||||
as: 'script',
|
||||
@@ -104,7 +112,7 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
const customIcon = 'icon' in customization.favicon ? customization.favicon.icon : null;
|
||||
|
||||
return {
|
||||
title: `${parent ? parent.title : customization.title ?? space.title}`,
|
||||
title: getContentTitle(space, customization, parent),
|
||||
generator: `GitBook (${buildVersion()})`,
|
||||
metadataBase: new URL(baseUrl()),
|
||||
icons: {
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from '@/lib/api';
|
||||
import { getEmojiForCode } from '@/lib/emojis';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { getContentTitle } from '@/lib/utils';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
@@ -54,7 +55,7 @@ export async function GET(req: NextRequest) {
|
||||
: space.visibility === ContentVisibility.InCollection && space.parent
|
||||
? await getCollection(space.parent)
|
||||
: null;
|
||||
const contentTitle = parent?.title ?? customization.title ?? space.title;
|
||||
const contentTitle = getContentTitle(space, customization, parent);
|
||||
|
||||
return new ImageResponse(
|
||||
(
|
||||
|
||||
@@ -3,6 +3,8 @@ import { ImageResponse } from 'next/og';
|
||||
import { NextRequest } from 'next/server';
|
||||
import React from 'react';
|
||||
|
||||
import { getContentTitle } from '@/lib/utils';
|
||||
|
||||
import { PageIdParams, fetchPageData } from '../../../../fetch';
|
||||
|
||||
export const runtime = 'edge';
|
||||
@@ -31,7 +33,7 @@ export async function GET(req: NextRequest, { params }: { params: PageIdParams }
|
||||
}}
|
||||
>
|
||||
<h2 tw="text-7xl font-bold tracking-tight text-left">
|
||||
{parent?.title ?? customization.title ?? space.title}
|
||||
{getContentTitle(space, customization, parent)}
|
||||
</h2>
|
||||
<div tw="flex flex-1">
|
||||
<p tw="text-4xl">{page ? page.title : 'Not found'}</p>
|
||||
|
||||
@@ -40,6 +40,7 @@ export function getContentPointer(): ContentPointer | SiteContentPointer {
|
||||
if (siteId) {
|
||||
const organizationId = headerSet.get('x-gitbook-content-organization');
|
||||
const siteSpaceId = headerSet.get('x-gitbook-content-site-space');
|
||||
const siteShareKey = headerSet.get('x-gitbook-content-site-share-key');
|
||||
if (!organizationId) {
|
||||
throw new Error('Missing site content headers');
|
||||
}
|
||||
@@ -48,6 +49,7 @@ export function getContentPointer(): ContentPointer | SiteContentPointer {
|
||||
siteId,
|
||||
spaceId,
|
||||
siteSpaceId: siteSpaceId ?? undefined,
|
||||
siteShareKey: siteShareKey ?? undefined,
|
||||
organizationId,
|
||||
revisionId: headerSet.get('x-gitbook-content-revision') ?? undefined,
|
||||
changeRequestId: headerSet.get('x-gitbook-content-changerequest') ?? undefined,
|
||||
@@ -71,7 +73,14 @@ export async function fetchSpaceData() {
|
||||
|
||||
const [{ space, contentTarget, pages, customization, scripts }, parentSite] = await Promise.all(
|
||||
'siteId' in content
|
||||
? [getCurrentSiteData(content), fetchParentSite(content.organizationId, content.siteId)]
|
||||
? [
|
||||
getCurrentSiteData(content),
|
||||
fetchParentSite({
|
||||
organizationId: content.organizationId,
|
||||
siteId: content.siteId,
|
||||
siteShareKey: content.siteShareKey,
|
||||
}),
|
||||
]
|
||||
: [getSpaceData(content)],
|
||||
);
|
||||
|
||||
@@ -102,7 +111,11 @@ export async function fetchPageData(params: PagePathParams | PageIdParams) {
|
||||
const page = await resolvePage(contentTarget, pages, params);
|
||||
const [parent, document] = await Promise.all([
|
||||
'siteId' in content
|
||||
? fetchParentSite(content.organizationId, content.siteId)
|
||||
? fetchParentSite({
|
||||
organizationId: content.organizationId,
|
||||
siteId: content.siteId,
|
||||
siteShareKey: content.siteShareKey,
|
||||
})
|
||||
: fetchParentCollection(space),
|
||||
page?.page.documentId ? getDocument(space.id, page.page.documentId) : null,
|
||||
]);
|
||||
@@ -173,10 +186,15 @@ async function fetchParentCollection(space: Space) {
|
||||
return { parent: collection, spaces };
|
||||
}
|
||||
|
||||
async function fetchParentSite(organizationId: string, siteId: string) {
|
||||
async function fetchParentSite(args: {
|
||||
organizationId: string;
|
||||
siteId: string;
|
||||
siteShareKey: string | undefined;
|
||||
}) {
|
||||
const { organizationId, siteId, siteShareKey } = args;
|
||||
const [site, siteSpaces] = await Promise.all([
|
||||
getSite(organizationId, siteId),
|
||||
getSiteSpaces(organizationId, siteId),
|
||||
getSiteSpaces({ organizationId, siteId, siteShareKey }),
|
||||
]);
|
||||
|
||||
const spaces: Record<string, Space> = {};
|
||||
|
||||
@@ -7,6 +7,7 @@ const PLAIN_HIGHLIGHTING_SPACES: string[] = [
|
||||
'V9geAO9ITPi8WOYK5o0r',
|
||||
'puRmcwVxGFtHph8IjXaf',
|
||||
'e3jwbMOrr4RhKtZ9C0XL',
|
||||
'ryjzVNizLfd6pYogN3dm',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as gitbookAPI from '@gitbook/api';
|
||||
import Script from 'next/script';
|
||||
import ReactDOM from 'react-dom';
|
||||
|
||||
import { Card } from '@/components/primitives';
|
||||
import { api } from '@/lib/api';
|
||||
@@ -11,6 +12,8 @@ import { IntegrationBlock } from './Integration';
|
||||
|
||||
export async function Embed(props: BlockProps<gitbookAPI.DocumentBlockEmbed>) {
|
||||
const { block, context, ...otherProps } = props;
|
||||
|
||||
ReactDOM.preconnect('https://cdn.iframe.ly');
|
||||
|
||||
const { data: embed } = await (context.content
|
||||
? api().spaces.getEmbedByUrlInSpace(context.content.spaceId, { url: block.data.url })
|
||||
|
||||
@@ -172,6 +172,10 @@
|
||||
@apply prose-sm;
|
||||
}
|
||||
|
||||
.openapi-schema-example {
|
||||
@apply prose-sm mt-2 text-dark/10 dark:text-light/10;
|
||||
}
|
||||
|
||||
/** Authentication */
|
||||
|
||||
.openapi-securities {
|
||||
|
||||
@@ -11,6 +11,7 @@ import { HeaderMobileMenu } from '@/components/Header/HeaderMobileMenu';
|
||||
import { Image } from '@/components/utils';
|
||||
import { absoluteHref } from '@/lib/links';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { getContentTitle } from '@/lib/utils';
|
||||
|
||||
import { Link } from '../primitives';
|
||||
|
||||
@@ -145,7 +146,7 @@ function LogoFallback(props: HeaderLogoProps) {
|
||||
: 'text-header-link',
|
||||
)}
|
||||
>
|
||||
{parent ? parent.title : customization.title ?? space.title}
|
||||
{getContentTitle(space, customization, parent)}
|
||||
</h1>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -9,7 +9,7 @@ import React from 'react';
|
||||
|
||||
import { getSpaceLanguage } from '@/intl/server';
|
||||
import { t } from '@/intl/translate';
|
||||
import { ContentTarget, api } from '@/lib/api';
|
||||
import { ContentPointer, ContentTarget, SiteContentPointer, api } from '@/lib/api';
|
||||
import { hasFullWidthBlock, isNodeEmpty } from '@/lib/document';
|
||||
import { ContentRefContext, resolveContentRef } from '@/lib/references';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
@@ -25,6 +25,7 @@ import { DateRelative } from '../primitives';
|
||||
|
||||
export function PageBody(props: {
|
||||
space: Space;
|
||||
contentPointer: ContentPointer | SiteContentPointer;
|
||||
contentTarget: ContentTarget;
|
||||
customization: CustomizationSettings | SiteCustomizationSettings;
|
||||
page: RevisionPageDocument;
|
||||
@@ -32,13 +33,25 @@ export function PageBody(props: {
|
||||
context: ContentRefContext;
|
||||
withPageFeedback: boolean;
|
||||
}) {
|
||||
const { space, contentTarget, customization, context, page, document, withPageFeedback } =
|
||||
props;
|
||||
const {
|
||||
space,
|
||||
contentPointer,
|
||||
contentTarget,
|
||||
customization,
|
||||
context,
|
||||
page,
|
||||
document,
|
||||
withPageFeedback,
|
||||
} = props;
|
||||
|
||||
const asFullWidth = document ? hasFullWidthBlock(document) : false;
|
||||
const language = getSpaceLanguage(customization);
|
||||
const updatedAt = page.updatedAt ?? page.createdAt;
|
||||
const shouldHighlightCode = createHighlightingContext();
|
||||
const sitePointer =
|
||||
'siteId' in contentPointer
|
||||
? { organizationId: contentPointer.organizationId, siteId: contentPointer.siteId }
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -129,7 +142,12 @@ export function PageBody(props: {
|
||||
</div>
|
||||
</main>
|
||||
<React.Suspense fallback={null}>
|
||||
<TrackPageView spaceId={space.id} pageId={page.id} apiHost={api().endpoint} />
|
||||
<TrackPageView
|
||||
sitePointer={sitePointer}
|
||||
spaceId={space.id}
|
||||
pageId={page.id}
|
||||
apiHost={api().endpoint}
|
||||
/>
|
||||
</React.Suspense>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,28 +1,66 @@
|
||||
'use client';
|
||||
|
||||
import type { RequestSpaceTrackPageView } from '@gitbook/api';
|
||||
import type { RequestSiteTrackPageView, RequestSpaceTrackPageView } from '@gitbook/api';
|
||||
import cookies from 'js-cookie';
|
||||
import * as React from 'react';
|
||||
|
||||
import { getVisitorId } from '@/lib/analytics';
|
||||
import { SiteContentPointer } from '@/lib/api';
|
||||
|
||||
/**
|
||||
* Track the page view for the current page to integrations.
|
||||
*/
|
||||
export function TrackPageView(props: {
|
||||
apiHost: string;
|
||||
sitePointer?: Pick<SiteContentPointer, 'siteId' | 'organizationId'>;
|
||||
spaceId: string;
|
||||
pageId: string | undefined;
|
||||
}) {
|
||||
const { apiHost, spaceId, pageId } = props;
|
||||
const { apiHost, sitePointer, spaceId, pageId } = props;
|
||||
|
||||
React.useEffect(() => {
|
||||
trackPageView(apiHost, spaceId, pageId);
|
||||
}, [apiHost, spaceId, pageId]);
|
||||
trackPageView({ apiHost, sitePointer, spaceId, pageId });
|
||||
}, [apiHost, spaceId, pageId, sitePointer]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function sendSpaceTrackPageViewRequest(args: {
|
||||
apiHost: string;
|
||||
spaceId: string;
|
||||
body: RequestSpaceTrackPageView;
|
||||
}) {
|
||||
const { apiHost, spaceId, body } = args;
|
||||
const url = new URL(apiHost);
|
||||
url.pathname = `/v1/spaces/${spaceId}/insights/track_view`;
|
||||
|
||||
await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
async function sendSiteTrackPageViewRequest(args: {
|
||||
apiHost: string;
|
||||
sitePointer: Pick<SiteContentPointer, 'siteId' | 'organizationId'>;
|
||||
body: RequestSiteTrackPageView;
|
||||
}) {
|
||||
const { apiHost, sitePointer, body } = args;
|
||||
const url = new URL(apiHost);
|
||||
url.pathname = `/v1/orgs/${sitePointer.organizationId}/sites/${sitePointer.siteId}/insights/track_view`;
|
||||
|
||||
await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
let latestPageId: string | undefined | null = null;
|
||||
|
||||
/**
|
||||
@@ -30,7 +68,13 @@ let latestPageId: string | undefined | null = null;
|
||||
* We don't use the API client to avoid shipping 80kb of JS to the client.
|
||||
* And instead use a simple fetch.
|
||||
*/
|
||||
async function trackPageView(apiHost: string, spaceId: string, pageId: string | undefined) {
|
||||
async function trackPageView(args: {
|
||||
apiHost: string;
|
||||
sitePointer?: Pick<SiteContentPointer, 'siteId' | 'organizationId'>;
|
||||
spaceId: string;
|
||||
pageId: string | undefined;
|
||||
}) {
|
||||
const { apiHost, sitePointer, pageId, spaceId } = args;
|
||||
if (pageId === latestPageId) {
|
||||
// The hook can be called multiple times, we only want to track once.
|
||||
return;
|
||||
@@ -39,7 +83,7 @@ async function trackPageView(apiHost: string, spaceId: string, pageId: string |
|
||||
latestPageId = pageId;
|
||||
|
||||
const visitorId = await getVisitorId();
|
||||
const body: RequestSpaceTrackPageView = {
|
||||
const sharedTrackedProps = {
|
||||
url: window.location.href,
|
||||
pageId,
|
||||
visitor: {
|
||||
@@ -51,17 +95,17 @@ async function trackPageView(apiHost: string, spaceId: string, pageId: string |
|
||||
referrer: document.referrer,
|
||||
};
|
||||
|
||||
const url = new URL(apiHost);
|
||||
url.pathname = `/v1/spaces/${spaceId}/insights/track_view`;
|
||||
|
||||
try {
|
||||
await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
sitePointer
|
||||
? await sendSiteTrackPageViewRequest({
|
||||
apiHost,
|
||||
sitePointer,
|
||||
body: {
|
||||
...sharedTrackedProps,
|
||||
spaceId,
|
||||
},
|
||||
})
|
||||
: await sendSpaceTrackPageViewRequest({ apiHost, spaceId, body: sharedTrackedProps });
|
||||
} catch (error) {
|
||||
console.error('Failed to track page view', error);
|
||||
}
|
||||
|
||||
@@ -76,7 +76,11 @@ export async function searchParentContent(
|
||||
api.searchParentContent(parent.id, query),
|
||||
parent.object === 'collection' ? api.getCollectionSpaces(parent.id) : null,
|
||||
parent.object === 'site' && 'organizationId' in pointer
|
||||
? api.getSiteSpaces(pointer.organizationId, parent.id)
|
||||
? api.getSiteSpaces({
|
||||
organizationId: pointer.organizationId,
|
||||
siteId: parent.id,
|
||||
siteShareKey: pointer.siteShareKey,
|
||||
})
|
||||
: null,
|
||||
]);
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
export const de = {
|
||||
locale: 'de',
|
||||
powered_by_gitbook: 'Bereitgestellt von GitBook',
|
||||
switch_to_dark_theme: 'Zum dunklen Modus wechseln',
|
||||
switch_to_light_theme: 'Zum hellen Modus wechseln',
|
||||
switch_to_system_theme: 'Zum Systemmodus wechseln',
|
||||
search: 'Suche',
|
||||
search_or_ask: 'Fragen oder Suchen',
|
||||
search_input_placeholder: 'Inhalt durchsuchen',
|
||||
search_ask_input_placeholder: 'Inhalt durchsuchen oder eine Frage stellen',
|
||||
search_no_results: 'Keine Ergebnisse für "${1}".',
|
||||
search_scope_space: 'Nur in ${1}',
|
||||
search_scope_all: 'Alle Inhalte',
|
||||
search_ask: 'Fragen "${1}"',
|
||||
search_ask_sources: 'Quellen',
|
||||
search_ask_no_answer: 'Es konnte keine Antwort auf Ihre Frage gefunden werden, versuchen Sie es mit einer anderen Frage.',
|
||||
search_ask_error: 'Etwas ist schief gelaufen. Bitte versuchen Sie es später noch einmal.',
|
||||
on_this_page: 'Auf dieser Seite',
|
||||
next_page: 'Nächste',
|
||||
previous_page: 'Vorherige',
|
||||
page_last_modified: 'Zuletzt aktualisiert ${1}',
|
||||
was_this_helpful: 'War das hilfreich?',
|
||||
was_this_helpful_positive: 'Ja, das war es!',
|
||||
was_this_helpful_neutral: 'Nicht sicher',
|
||||
was_this_helpful_negative: 'Nein',
|
||||
was_this_helpful_thank_you: 'Danke!',
|
||||
annotation_button_label: 'Kommentar öffnen',
|
||||
code_copied: 'Kopiert!',
|
||||
code_copy: 'Kopieren',
|
||||
table_of_contents_button_label: 'Inhaltsverzeichnis öffnen',
|
||||
cookies_title: 'Cookies',
|
||||
cookies_prompt:
|
||||
'Diese Website verwendet Cookies, um ihre Dienste bereitzustellen und den Datenverkehr zu analysieren. Durch die Nutzung dieser Website akzeptieren Sie die ${1}.',
|
||||
cookies_prompt_privacy: 'Datenschutzrichtlinie',
|
||||
cookies_accept: 'Akzeptieren',
|
||||
cookies_reject: 'Ablehnen',
|
||||
cookies_close: 'Schließen',
|
||||
edit_on_git: 'Bearbeiten auf ${1}',
|
||||
notfound_title: 'Seite nicht gefunden',
|
||||
notfound: 'Die gesuchte Seite existiert nicht.',
|
||||
unexpected_error_title: 'Ein Fehler ist aufgetreten',
|
||||
unexpected_error: 'Entschuldigung, ein unerwarteter Fehler ist aufgetreten. Bitte versuchen Sie es später erneut.',
|
||||
unexpected_error_retry: 'Erneut versuchen',
|
||||
pdf_download: 'Als PDF exportieren',
|
||||
pdf_goback: 'Zurück zum Inhalt',
|
||||
pdf_print: 'Drucken oder als PDF speichern',
|
||||
pdf_page_of: '${1} von ${2}',
|
||||
pdf_mode_only_page: 'Nur diese Seite',
|
||||
pdf_mode_all: 'Alle Seiten',
|
||||
pdf_limit_reached: 'Das PDF konnte für ${1} Seiten nicht generiert werden, Generierung wurde bei ${2} gestoppt.',
|
||||
pdf_limit_reached_continue: 'Mit ${1} weiteren Seiten erweitern.',
|
||||
};
|
||||
@@ -1,9 +1,11 @@
|
||||
import { CustomizationLocale } from '@gitbook/api';
|
||||
|
||||
import { de } from './de';
|
||||
import { en } from './en';
|
||||
import { es } from './es';
|
||||
import { fr } from './fr';
|
||||
import { ja } from './ja';
|
||||
import { pt_br } from './pt-br';
|
||||
import { TranslationLanguage } from './types';
|
||||
import { zh } from './zh';
|
||||
|
||||
@@ -12,9 +14,11 @@ export * from './types';
|
||||
export const languages: {
|
||||
[locale in CustomizationLocale]: TranslationLanguage;
|
||||
} = {
|
||||
de,
|
||||
en,
|
||||
fr,
|
||||
es,
|
||||
zh,
|
||||
ja,
|
||||
'pt-br': pt_br,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
export const pt_br = {
|
||||
locale: 'pt-br',
|
||||
powered_by_gitbook: 'Powered by GitBook',
|
||||
switch_to_dark_theme: 'Mudar para modo escuro',
|
||||
switch_to_light_theme: 'Mudar para modo claro',
|
||||
switch_to_system_theme: 'Mudar para configuração do sistema',
|
||||
search: 'Busca',
|
||||
search_or_ask: 'Perguntar ou buscar',
|
||||
search_input_placeholder: 'Buscar conteúdo',
|
||||
search_ask_input_placeholder: 'Buscar conteúdo ou fazer uma pergunta',
|
||||
search_no_results: 'Sem resultados para "${1}".',
|
||||
search_scope_space: 'Somente em ${1}',
|
||||
search_scope_all: 'Todo o conteúdo',
|
||||
search_ask: 'Perguntar "${1}"',
|
||||
search_ask_sources: 'Fontes',
|
||||
search_ask_no_answer:
|
||||
'Nenhuma resposta foi encontrada para sua pergunta, tente outra pergunta.',
|
||||
search_ask_error: 'Algo deu errado. Por favor tente novamente mais tarde.',
|
||||
on_this_page: 'Nesta página',
|
||||
next_page: 'Próximo',
|
||||
previous_page: 'Anterior',
|
||||
page_last_modified: 'Atualizado ${1}',
|
||||
was_this_helpful: 'Isto foi útil?',
|
||||
was_this_helpful_positive: 'Sim, foi!',
|
||||
was_this_helpful_neutral: 'Não sei',
|
||||
was_this_helpful_negative: 'Não',
|
||||
was_this_helpful_thank_you: 'Obrigado!',
|
||||
annotation_button_label: 'Abrir anotação',
|
||||
code_copied: 'Copiado!',
|
||||
code_copy: 'Copiar',
|
||||
table_of_contents_button_label: 'Abrir o índice',
|
||||
cookies_title: 'Cookies',
|
||||
cookies_prompt:
|
||||
'Este site usa cookies para entregar suas funcionalidades e analizar o tráfego. Ao navegar neste site, você aceita a ${1}.',
|
||||
cookies_prompt_privacy: 'política de privacidade',
|
||||
cookies_accept: 'Aceitar',
|
||||
cookies_reject: 'Rejeitar',
|
||||
cookies_close: 'Fechar',
|
||||
edit_on_git: 'Editar no ${1}',
|
||||
notfound_title: 'Página não encontrada',
|
||||
notfound: 'A página que você está procurando não existe.',
|
||||
unexpected_error_title: 'Aconteceu um erro',
|
||||
unexpected_error:
|
||||
'Desculpe, aconteceu um erro inesperado. Por favor tente novamente mais tarde.',
|
||||
unexpected_error_retry: ' Tentar novamente',
|
||||
pdf_download: 'Exportar como PDF',
|
||||
pdf_goback: 'Voltar ao conteúdo',
|
||||
pdf_print: 'Imprimir ou salvar como PDF',
|
||||
pdf_page_of: '${1} de ${2}',
|
||||
pdf_mode_only_page: 'Somente esta página',
|
||||
pdf_mode_all: 'Todas as páginas',
|
||||
pdf_limit_reached:
|
||||
'Não foi possível gerar o PDF para ${1} páginas, generation stopped at ${2}.',
|
||||
pdf_limit_reached_continue: 'Extender com mais ${1} páginas.',
|
||||
};
|
||||
+37
-10
@@ -47,6 +47,11 @@ export interface SiteContentPointer extends ContentPointer {
|
||||
* ID of the siteSpace can be undefined when rendering in multi-id mode (for site previews)
|
||||
*/
|
||||
siteSpaceId: string | undefined;
|
||||
/**
|
||||
* Share key of the site that was used in lookup. Only set for `multi` and `multi-path` modes
|
||||
* where an URL with the share-link key is involved in the lookup/resolution.
|
||||
*/
|
||||
siteShareKey: string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -610,6 +615,7 @@ const getSiteSpaceCustomizationFromAPI = cache(
|
||||
organizationId,
|
||||
siteId,
|
||||
siteSpaceId,
|
||||
{},
|
||||
{
|
||||
signal: options.signal,
|
||||
...noCacheFetchOptions,
|
||||
@@ -633,10 +639,15 @@ const getSiteSpaceCustomizationFromAPI = cache(
|
||||
const getSiteCustomizationFromAPI = cache(
|
||||
'api.getSiteCustomizationById',
|
||||
async (organizationId: string, siteId: string, options: CacheFunctionOptions) => {
|
||||
const response = await api().orgs.getSiteCustomizationById(organizationId, siteId, {
|
||||
signal: options.signal,
|
||||
...noCacheFetchOptions,
|
||||
});
|
||||
const response = await api().orgs.getSiteCustomizationById(
|
||||
organizationId,
|
||||
siteId,
|
||||
{},
|
||||
{
|
||||
signal: options.signal,
|
||||
...noCacheFetchOptions,
|
||||
},
|
||||
);
|
||||
return cacheResponse(response, {
|
||||
revalidateBefore: 60 * 60,
|
||||
tags: [
|
||||
@@ -730,18 +741,34 @@ export const getSite = cache(
|
||||
*/
|
||||
export const getSiteSpaces = cache(
|
||||
'api.getSiteSpaces',
|
||||
async (organizationId: string, siteId: string, options: CacheFunctionOptions) => {
|
||||
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 getAll((params) =>
|
||||
api().orgs.listSiteSpaces(organizationId, siteId, params, {
|
||||
...noCacheFetchOptions,
|
||||
signal: options.signal,
|
||||
}),
|
||||
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),
|
||||
tags: [getAPICacheTag({ tag: 'site', site: siteId })],
|
||||
tags: [getAPICacheTag({ tag: 'site', site: args.siteId })],
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
Vendored
+19
-1
@@ -7,21 +7,24 @@ describe('cache', () => {
|
||||
|
||||
let fn: CacheFunction<[string], string>;
|
||||
let testId = 0;
|
||||
let getTtl: () => number;
|
||||
|
||||
beforeEach(() => {
|
||||
impl.mockClear();
|
||||
|
||||
testId += 1;
|
||||
getTtl = () => 1000;
|
||||
|
||||
fn = cache(`cache-${testId}`, async (arg: string, options: CacheFunctionOptions) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
return {
|
||||
data: impl(arg),
|
||||
ttl: getTtl(),
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
it('should only execute once for same argument', async () => {
|
||||
it('should only execute once for same argument if there is a ttl', async () => {
|
||||
const result = await Promise.all([fn('a'), fn('a')]);
|
||||
|
||||
expect(result).toEqual(['test-a', 'test-a']);
|
||||
@@ -34,6 +37,21 @@ describe('cache', () => {
|
||||
expect(impl).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should execute multiple times for same argument if ttl is 0 or undefined', async () => {
|
||||
getTtl = () => 0;
|
||||
|
||||
const result = await Promise.all([fn('a'), fn('a')]);
|
||||
|
||||
expect(result).toEqual(['test-a', 'test-a']);
|
||||
|
||||
expect(impl).toHaveBeenCalled();
|
||||
expect(impl).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(await fn('a')).toEqual('test-a');
|
||||
|
||||
expect(impl).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should execute for different arguments', async () => {
|
||||
const result = await Promise.all([fn('a'), fn('b')]);
|
||||
|
||||
|
||||
Vendored
+1
-1
@@ -202,7 +202,7 @@ export function cache<Args extends any[], Result>(
|
||||
|
||||
if (savedEntry.meta.revalidatesAt && savedEntry.meta.revalidatesAt < Date.now()) {
|
||||
// Revalidate in the background
|
||||
waitUntil(revalidate(key, undefined, ...args));
|
||||
await waitUntil(revalidate(key, undefined, ...args));
|
||||
}
|
||||
|
||||
return savedEntry.data;
|
||||
|
||||
Vendored
+5
-1
@@ -26,9 +26,12 @@ export function parseCacheResponse(response: Response): {
|
||||
const cacheControlHeader = response.headers.get('cache-control');
|
||||
const cacheControl = cacheControlHeader ? parseCacheControl(cacheControlHeader) : null;
|
||||
|
||||
const cacheTagHeader = response.headers.get('x-gitbook-cache-tag');
|
||||
const tags = !cacheTagHeader ? [] : cacheTagHeader.split(',');
|
||||
|
||||
const entry = {
|
||||
ttl: 60 * 60 * 24,
|
||||
tags: [],
|
||||
tags,
|
||||
};
|
||||
|
||||
if (cacheControl && cacheControl['max-age']) {
|
||||
@@ -50,6 +53,7 @@ export function cacheResponse<Result, DefaultData = Result>(
|
||||
return {
|
||||
ttl: defaultEntry.ttl ?? parsed.ttl,
|
||||
tags: [...(defaultEntry.tags ?? []), ...parsed.tags],
|
||||
revalidateBefore: defaultEntry.revalidateBefore,
|
||||
// @ts-ignore
|
||||
data: defaultEntry.data ?? response.data,
|
||||
};
|
||||
|
||||
Vendored
+9
-15
@@ -1,11 +1,12 @@
|
||||
import { CacheBackend, CacheEntry } from './types';
|
||||
import { CacheBackend } from './types';
|
||||
import { NON_IMMUTABLE_LOCAL_CACHE_MAX_AGE_SECONDS, isCacheEntryImmutable } from './utils';
|
||||
import { singleton } from '../async';
|
||||
|
||||
export const memoryCache: CacheBackend = {
|
||||
name: 'memory',
|
||||
replication: 'local',
|
||||
async get(key) {
|
||||
const memoryCache = getMemoryCache();
|
||||
const memoryCache = await getMemoryCache();
|
||||
const memoryEntry = memoryCache.get(key);
|
||||
|
||||
if (!memoryEntry) {
|
||||
@@ -21,7 +22,7 @@ export const memoryCache: CacheBackend = {
|
||||
return null;
|
||||
},
|
||||
async set(key, entry) {
|
||||
const memoryCache = getMemoryCache();
|
||||
const memoryCache = await getMemoryCache();
|
||||
// When the entry is immutable, we can cache it for the entire duration.
|
||||
// Else we cache it for a very short time.
|
||||
const expiresAt =
|
||||
@@ -41,11 +42,11 @@ export const memoryCache: CacheBackend = {
|
||||
memoryCache.set(key, { ...entry, meta });
|
||||
},
|
||||
async del(keys) {
|
||||
const memoryCache = getMemoryCache();
|
||||
const memoryCache = await getMemoryCache();
|
||||
keys.forEach((key) => memoryCache.delete(key));
|
||||
},
|
||||
async revalidateTags(tags) {
|
||||
const memoryCache = getMemoryCache();
|
||||
const memoryCache = await getMemoryCache();
|
||||
const keys: string[] = [];
|
||||
|
||||
memoryCache.forEach((entry, key) => {
|
||||
@@ -65,14 +66,7 @@ export const memoryCache: CacheBackend = {
|
||||
/**
|
||||
* With next-on-pages, the code seems to be isolated between the middleware and the handler.
|
||||
* To share the cache between the two, we use a global variable.
|
||||
* By using a singleton, we ensure that the cache is only created once and stored in the
|
||||
* current request context.
|
||||
*/
|
||||
function getMemoryCache(): Map<string, CacheEntry> {
|
||||
// @ts-ignore
|
||||
if (!globalThis.gitbookMemoryCache) {
|
||||
// @ts-ignore
|
||||
globalThis.gitbookMemoryCache = new Map();
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
return globalThis.gitbookMemoryCache;
|
||||
}
|
||||
const getMemoryCache = singleton(async () => new Map());
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import {
|
||||
Collection,
|
||||
CustomizationSettings,
|
||||
Site,
|
||||
SiteCustomizationSettings,
|
||||
Space,
|
||||
} from '@gitbook/api';
|
||||
|
||||
/**
|
||||
* Get the title to display for a content.
|
||||
*/
|
||||
export function getContentTitle(
|
||||
space: Space,
|
||||
customization: CustomizationSettings | SiteCustomizationSettings,
|
||||
parent: Site | Collection | null,
|
||||
) {
|
||||
// When we are rendering a site, always give priority to the customization title first
|
||||
// and then fallback to the site title
|
||||
if (parent?.object === 'site') {
|
||||
return customization.title ?? parent.title ?? space.title;
|
||||
}
|
||||
|
||||
// Otherwise the legacy behavior is not changed to avoid regressions
|
||||
return parent ? parent.title : customization.title ?? space.title;
|
||||
}
|
||||
+11
-2
@@ -208,7 +208,11 @@ export async function middleware(request: NextRequest) {
|
||||
if (resolved.siteSpace) {
|
||||
headers.set('x-gitbook-content-site-space', resolved.siteSpace);
|
||||
}
|
||||
if (resolved.shareKey) {
|
||||
headers.set('x-gitbook-content-site-share-key', resolved.shareKey);
|
||||
}
|
||||
}
|
||||
|
||||
if (resolved.revision) {
|
||||
headers.set('x-gitbook-content-revision', resolved.revision);
|
||||
}
|
||||
@@ -261,7 +265,7 @@ export async function middleware(request: NextRequest) {
|
||||
);
|
||||
} else {
|
||||
if (resolved.cacheMaxAge) {
|
||||
const cacheControl = `public, max-age=60, s-maxage=${resolved.cacheMaxAge}, stale-while-revalidate=60, stale-if-error=0`;
|
||||
const cacheControl = `public, max-age=0, s-maxage=${resolved.cacheMaxAge}, stale-if-error=0`;
|
||||
|
||||
if (
|
||||
process.env.GITBOOK_OUTPUT_CACHE === 'true' &&
|
||||
@@ -660,7 +664,12 @@ async function lookupSpaceByAPI(
|
||||
cacheMaxAge: data.cacheMaxAge,
|
||||
cacheTags: data.cacheTags,
|
||||
...('site' in data
|
||||
? { site: data.site, siteSpace: data.siteSpace, organization: data.organization }
|
||||
? {
|
||||
site: data.site,
|
||||
siteSpace: data.siteSpace,
|
||||
organization: data.organization,
|
||||
shareKey: data.shareKey,
|
||||
}
|
||||
: {}),
|
||||
} as PublishedContentWithCache;
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user