Compare commits

...

31 Commits

Author SHA1 Message Date
Johan Preynat c773f8b5e7 Bump next@14.2.4 2024-06-24 12:07:31 +02:00
Johan Preynat f5df40e17d Use a singleton to store the memory cache in an internal WeakMap for the current request (#2356) 2024-06-24 11:13:03 +02:00
Johan Preynat e13c76efed Set preconnect links to main domains on render (#2354) 2024-06-21 17:02:02 +02:00
Johan Preynat 4a11d8d9eb Share memory cache between middleware and request handler (#2347) 2024-06-21 11:13:06 +02:00
spastorelli 009ed8cc6e Call trackViewInSiteById API endpoint when tracking view for sites (#2350) 2024-06-21 10:01:35 +02:00
vibhanshub 106cbbd987 Add support for examples in OpenAPI block (#2314) 2024-06-20 16:02:58 +05:30
Johan Preynat 56e5c3c3f8 Parse cache tags returned from the API set them in response (#2349) 2024-06-20 10:09:02 +02:00
Taran Vohra 1dc3234003 Fix page metadata title (#2348) 2024-06-18 20:52:12 +05:30
Johan Preynat 22dc0d4ff5 Fix passing revalidateBefore option when calling cacheResponse to ensure these go through background revalidation (#2342) 2024-06-17 17:16:12 +02:00
Taran Vohra 3a4caf062c Add getContentTitle to properly render the title for sites & legacy published content (#2343) 2024-06-17 20:28:21 +05:30
Taran Vohra 8ad0657159 Use site's share key as context while fetching site spaces (#2341) 2024-06-14 20:59:05 +05:30
Taran Vohra d8a4ecd0c6 Pass site url as context to listSiteSpaces API (#2339) 2024-06-14 10:40:18 +05:30
David Burghoff 65c7968438 Add German Locale support (#2317)
Co-authored-by: David Burghoff <david.burghoff@swot.de>
Co-authored-by: taranvohra <taranvohra@outlook.com>
2024-06-13 15:04:49 +02:00
Rodrigo Castro 3914f11150 Add Brazilian Portuguese translation (#2285)
Co-authored-by: taranvohra <taranvohra@outlook.com>
2024-06-13 14:54:47 +02:00
Addison d363908297 Add contributors to readme, bump API (#2333) 2024-06-12 15:24:18 +02:00
Greg Bergé 414556ac7a Remove cache client-side (#2337) 2024-06-10 11:17:48 +02:00
Steven H c768e9a90a Add another space to ignore list whilst we debug issues. (#2334) 2024-05-28 19:02:07 +01:00
Taran Vohra 553f1f228f Bump @gitbook/api to 0.48.0 (#2328) 2024-05-24 20:10:25 +05:30
Taran Vohra 30986528a7 bump @gitbook/api to 0.47 (#2327) 2024-05-24 19:07:54 +05:30
Steven H c70c2ddad8 Bump next-on-pages version to fix SSRF attack. (#2319) 2024-05-16 17:05:58 +01:00
Marc Laventure cf74d00758 chore: update scalar client (#2316) 2024-05-14 11:52:09 -07:00
Steven H c2256bfd14 Fix an issue where attackers could use the Nextjs image endpoint to redirect to any URL. (#2312) 2024-05-14 11:34:33 +01:00
fuyangpengqi 5d73a0f062 chore: fix some typos in README.md (#2290)
Signed-off-by: fuyangpengqi <995764973@qq.com>
Co-authored-by: Samy Pessé <samypesse@gmail.com>
2024-05-13 16:22:07 +02:00
Samy Pessé 8f5f6c61f8 Setup the pull_request workflow on actions (#2311) 2024-05-13 16:10:15 +02:00
Taran Vohra 9a86965ba1 Fix VA cookie to be only set if basePaths match (#2300) 2024-05-06 21:02:48 +05:30
Scott Cazan c8d5f825fc Update GitBook trademark (#2305) 2024-05-02 12:18:02 +02:00
Steven H 5db3cbc2ce Render Embed blocks that can be handled by an integration in the Space. (#2302) 2024-04-25 15:25:53 +01:00
Steven H f8d148bec4 Use plain code highlighting on some spaces that are currently crashing. (#2301) 2024-04-25 09:50:08 +01:00
Steven H 198dd9f158 Investigate an issue where images with 127.0.0.1 in the URL are not correctly resolved. (#2298) 2024-04-19 17:21:22 +01:00
Steven H 2b63bc158a Improve rendering of File column in tables. (#2297) 2024-04-19 16:58:46 +01:00
spastorelli 4a8097d968 Use site space context from token in multi-id mode when provided (#2295) 2024-04-19 16:51:12 +02:00
48 changed files with 764 additions and 246 deletions
+5 -1
View File
@@ -1,5 +1,9 @@
on: [push]
name: CI
on:
pull_request:
push:
branches:
- main
jobs:
deploy:
+8 -2
View File
@@ -75,7 +75,7 @@ GitBook's rendering engine is fully open-source and built on top of [Next.js](ht
### Types of contributions
We encourage you to contribute to GitBook to help us build the best tool for doucmenting techincal knowledge. If you're looking for some quick ways to contribute, continue reading to learn more about popular contributions.
We encourage you to contribute to GitBook to help us build the best tool for documenting technical knowledge. If you're looking for some quick ways to contribute, continue reading to learn more about popular contributions.
#### Translations
@@ -92,7 +92,7 @@ Encounter a bug or find an issue you'd like to fix? Helping us fix issues relate
>
> _Looking to add a specific feature in GitBook? Head to our [contributing guide](/.github/CONTRIBUTING.md) to get started._
>
> Self-hosting this project puts the responsibility of maintaining and merging future updates on **you**. We cannot guarantee support, maintainance, or updates to forked and self-hosted instances of this project.
> Self-hosting this project puts the responsibility of maintaining and merging future updates on **you**. We cannot guarantee support, maintenance, or updates to forked and self-hosted instances of this project.
>
> We want to make it as easy as possible for our community to collaborate and push the future of GitBook, which is why we encourage you to contribute to our product directly instead of creating your own version.
@@ -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).
BIN
View File
Binary file not shown.
+9
View File
@@ -39,6 +39,15 @@ module.exports = withSentryConfig(
assetPrefix: process.env.GITBOOK_ASSETS_PREFIX,
poweredByHeader: false,
images: {
remotePatterns: [
{
protocol: 'https',
hostname: '*.gitbook.io',
}
]
}
},
{
silent: true,
+4 -4
View File
@@ -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.43.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",
+1 -1
View File
@@ -3,7 +3,7 @@
"exports": "./src/index.ts",
"dependencies": {
"classnames": "^2.5.1",
"@gitbook/api": "^0.41.0",
"@gitbook/api": "^0.51.0",
"assert-never": "^1.2.1"
},
"peerDependencies": {
+2 -2
View File
@@ -2,8 +2,8 @@
"name": "@gitbook/react-openapi",
"exports": "./src/index.ts",
"dependencies": {
"@scalar/api-client-react": "^0.2.9",
"@scalar/oas-utils": "0.1.1",
"@scalar/api-client-react": "^0.3.7",
"@scalar/oas-utils": "0.1.6",
"classnames": "^2.5.1",
"flatted": "^3.2.9",
"openapi-types": "^12.1.3",
@@ -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,
+10 -1
View File
@@ -7,6 +7,7 @@ import {
getRequestFromOperation,
Query,
Header,
RequestBody,
} from '@scalar/oas-utils';
import React from 'react';
@@ -98,7 +99,15 @@ export function ScalarApiClient(props: { children: React.ReactNode }) {
{
url: operationData.path,
},
getRequestFromOperation(operation, { requiredOnly: false }),
getRequestFromOperation(
{
...operation,
information: {
requestBody: operationData.operation.requestBody as RequestBody,
},
},
{ requiredOnly: false },
),
);
return {
+5 -2
View File
@@ -1,6 +1,7 @@
import { NextRequest } from 'next/server';
import { verifyImageSignature, resizeImage, CloudflareImageOptions } from '@/lib/images';
import { parseImageAPIURL } from '@/lib/urls';
export const runtime = 'edge';
@@ -10,12 +11,14 @@ export const runtime = 'edge';
* Fetch and resize an image.
*/
export async function GET(request: NextRequest) {
const url = request.nextUrl.searchParams.get('url');
let urlParam = request.nextUrl.searchParams.get('url');
const signature = request.nextUrl.searchParams.get('sign');
if (!url || !signature) {
if (!urlParam || !signature) {
return new Response('Missing url/sign parameters', { status: 400 });
}
const url = parseImageAPIURL(urlParam);
// Prevent infinite loops
if (url.includes('/~gitbook/image')) {
return new Response('Invalid url parameter', { status: 400 });
@@ -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 ?? '',
+9 -1
View File
@@ -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>
+22 -4
View File
@@ -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> = {};
@@ -15,7 +15,7 @@ import './theme.css';
*/
export async function CodeBlock(props: BlockProps<DocumentBlockCode>) {
const { block, document, style, context } = props;
const withHighlighting = context.shouldHighlightCode();
const withHighlighting = context.shouldHighlightCode(context.content?.spaceId);
const lines = withHighlighting ? await highlight(block) : plainHighlighting(block);
const id = block.key!;
@@ -1,5 +1,15 @@
const CODE_HIGHLIGHT_BLOCK_LIMIT = 50;
/**
* Spaces where we'll always use plain highlighting.
*/
const PLAIN_HIGHLIGHTING_SPACES: string[] = [
'V9geAO9ITPi8WOYK5o0r',
'puRmcwVxGFtHph8IjXaf',
'e3jwbMOrr4RhKtZ9C0XL',
'ryjzVNizLfd6pYogN3dm',
];
/**
* Protect against memory issues when highlighting a large number of code blocks.
* This context only allows 50 code blocks per render to be highlighted.
@@ -9,7 +19,11 @@ const CODE_HIGHLIGHT_BLOCK_LIMIT = 50;
*/
export function createHighlightingContext() {
let count = 0;
return () => {
return (spaceId?: string | undefined) => {
if (!spaceId || PLAIN_HIGHLIGHTING_SPACES.includes(spaceId)) {
return false;
}
count += 1;
return count < CODE_HIGHLIGHT_BLOCK_LIMIT;
};
+1 -1
View File
@@ -45,7 +45,7 @@ export interface DocumentContext {
*
* https://linear.app/gitbook-x/issue/RND-3588/gitbook-open-code-syntax-highlighting-runs-out-of-memory-after-a
*/
shouldHighlightCode: () => boolean;
shouldHighlightCode: (spaceId: string | undefined) => boolean;
}
export interface DocumentContextProps {
+41 -4
View File
@@ -1,5 +1,6 @@
import { DocumentBlockEmbed } from '@gitbook/api';
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';
@@ -7,11 +8,16 @@ import { tcls } from '@/lib/tailwind';
import { BlockProps } from './Block';
import { Caption } from './Caption';
import { IntegrationBlock } from './Integration';
export async function Embed(props: BlockProps<DocumentBlockEmbed>) {
const { block } = props;
export async function Embed(props: BlockProps<gitbookAPI.DocumentBlockEmbed>) {
const { block, context, ...otherProps } = props;
ReactDOM.preconnect('https://cdn.iframe.ly');
const { data: embed } = await api().urls.getEmbedByUrl({ url: block.data.url });
const { data: embed } = await (context.content
? api().spaces.getEmbedByUrlInSpace(context.content.spaceId, { url: block.data.url })
: api().urls.getEmbedByUrl({ url: block.data.url }));
return (
<Caption {...props}>
@@ -25,6 +31,12 @@ export async function Embed(props: BlockProps<DocumentBlockEmbed>) {
{/* We load the iframely script to resize the embed iframes dynamically */}
<Script src="https://cdn.iframe.ly/embed.js" defer async />
</>
) : embed.type === 'integration' ? (
<IntegrationBlock
{...otherProps}
context={context}
block={createIntegrationBlock(block.data.url, embed.integration, embed.block)}
/>
) : (
<Card
leadingIcon={
@@ -40,3 +52,28 @@ export async function Embed(props: BlockProps<DocumentBlockEmbed>) {
</Caption>
);
}
/**
* Create an integration block with an unfurl action from the GitBook Embed response.
*/
function createIntegrationBlock(
url: string,
integration: string,
block: gitbookAPI.IntegrationBlock,
): gitbookAPI.DocumentBlockIntegration {
return {
object: 'block',
type: 'integration',
isVoid: true,
data: {
integration,
block: block.id,
props: {},
action: {
action: '@link.unfurl',
url,
},
url,
},
};
}
+6 -41
View File
@@ -1,12 +1,10 @@
import IconDownload from '@geist-ui/icons/download';
import IconFileText from '@geist-ui/icons/fileText';
import IconImage from '@geist-ui/icons/image';
import IconPaperClip from '@geist-ui/icons/paperclip';
import { DocumentBlockFile } from '@gitbook/api';
import { getSimplifiedContentType } from '@/lib/files';
import { tcls } from '@/lib/tailwind';
import { BlockProps } from './Block';
import { FileIcon } from './FileIcon';
export async function File(props: BlockProps<DocumentBlockFile>) {
const { block, context, style } = props;
@@ -18,42 +16,7 @@ export async function File(props: BlockProps<DocumentBlockFile>) {
return null;
}
const contentType = (() => {
switch (file.contentType) {
case 'application/pdf':
case 'application/x-pdf':
return 'pdf';
case 'image/png':
case 'image/jpeg':
case 'image/gif':
case 'image/webp':
case 'image/tiff':
case 'image/svg+xml':
return 'image';
case 'application/zip':
case 'application/x-7z-compressed':
case 'application/x-zip-compressed':
case 'application/x-tar':
case 'application/x-rar-compressed':
case 'application/vnd.rar':
return 'archive';
default:
return null;
}
})();
const icon = (() => {
switch (contentType) {
case 'pdf':
return <IconFileText />;
case 'image':
return <IconImage />;
case 'archive':
return <IconPaperClip />;
default:
return <IconDownload />;
}
})();
const contentType = getSimplifiedContentType(file.contentType);
return (
<a
@@ -103,7 +66,9 @@ export async function File(props: BlockProps<DocumentBlockFile>) {
'dark:border-light/2',
)}
>
<div className={tcls('*:w-5', '*:h-5', '*:stroke-primary')}>{icon}</div>
<div className={tcls('*:w-5', '*:h-5', '*:stroke-primary')}>
<FileIcon contentType={contentType} />
</div>
<div
className={tcls(
'text-xs',
+24
View File
@@ -0,0 +1,24 @@
import IconDownload from '@geist-ui/icons/download';
import IconFileText from '@geist-ui/icons/fileText';
import IconImage from '@geist-ui/icons/image';
import IconPaperClip from '@geist-ui/icons/paperclip';
import { SimplifiedFileType } from '@/lib/files';
/**
* Render an appropriate icon for a file.
*/
export function FileIcon(props: { contentType: SimplifiedFileType | null }) {
const { contentType } = props;
switch (contentType) {
case 'pdf':
return <IconFileText />;
case 'image':
return <IconImage />;
case 'archive':
return <IconPaperClip />;
default:
return <IconDownload />;
}
}
@@ -70,6 +70,7 @@ export async function IntegrationBlock(props: BlockProps<DocumentBlockIntegratio
const initialInput = {
componentId: block.data.block,
props: block.data.props,
action: block.data.action,
context: contentKitContext,
};
+82 -74
View File
@@ -1,115 +1,123 @@
.light .scalar-modal-layout,
.light .scalar {
--theme-color-1: color-mix(
--scalar-color-1: color-mix(
in srgb,
rgb(var(--primary-base-300, 180 180 180)),
rgb(var(--dark-base, 23 23 23)) 96%
);
--theme-color-2: color-mix(in srgb, var(--theme-color-1), transparent calc(100% - 100% * 0.72));
--theme-color-3: color-mix(in srgb, var(--theme-color-1), transparent calc(100% - 100% * 0.4));
--theme-color-accent: #007d9c;
--scalar-color-2: color-mix(in srgb, var(--scalar-color-1), transparent calc(100% - 100% * 0.72));
--scalar-color-3: color-mix(in srgb, var(--scalar-color-1), transparent calc(100% - 100% * 0.4));
--scalar-color-accent: #007d9c;
--theme-background-1: rgb(var(--light-base, 255 255 255));
--theme-background-2: color-mix(
--scalar-background-1: rgb(var(--light-base, 255 255 255));
--scalar-background-2: color-mix(
in srgb,
rgb(var(--primary-base-800, 30 30 30)),
var(--theme-background-1) 96%
var(--scalar-background-1) 96%
);
--theme-background-3: color-mix(
--scalar-background-3: color-mix(
in srgb,
rgb(var(--primary-base-800, 30 30 30)),
var(--theme-background-1) 90%
var(--scalar-background-1) 90%
);
--theme-background-accent: #007d9c1f;
--theme-code-language-color-supersede: var(--theme-color-1);
--theme-code-languages-background-supersede: var(--theme-background-1);
--theme-border-color: color-mix(
--scalar-background-accent: #007d9c1f;
--scalar-code-language-color-supersede: var(--scalar-color-1);
--scalar-code-languages-background-supersede: var(--scalar-background-1);
--scalar-border-color: color-mix(
in srgb,
var(--theme-color-1),
var(--scalar-color-1),
transparent calc(100% - 100% * 0.08)
);
--theme-color-green: #0a6355;
--theme-color-red: #dc1b19;
--theme-color-yellow: #ffc90d;
--theme-color-blue: rgb(var(--primary-color-500, 52 109 219));
--theme-color-orange: #ff8d4d;
--theme-color-purple: #8250df;
--scalar-color-green: #0a6355;
--scalar-color-red: #dc1b19;
--scalar-color-yellow: #ffc90d;
--scalar-color-blue: rgb(var(--primary-color-500, 52 109 219));
--scalar-color-orange: #ff8d4d;
--scalar-color-purple: #8250df;
--theme-scrollbar-color: rgba(255, 255, 255, 0.24);
--theme-scrollbar-color-active: rgba(255, 255, 255, 0.48);
--scalar-scrollbar-color: rgba(255, 255, 255, 0.24);
--scalar-scrollbar-color-active: rgba(255, 255, 255, 0.48);
--scalar-button-1: rgb(49 53 56);
--scalar-button-1-color: #fff;
--scalar-button-1-hover: rgb(28 31 33);
}
.dark .scalar-modal-layout,
.dark .scalar {
--theme-color-1: color-mix(
--scalar-color-1: color-mix(
in srgb,
rgb(var(--primary-base-700, 70 70 70)),
rgb(var(--light-base, 255 255 255)) 100%
);
--theme-color-2: color-mix(in srgb, var(--theme-color-1), transparent calc(100% - 100% * 0.64));
--theme-color-3: color-mix(in srgb, var(--theme-color-1), transparent calc(100% - 100% * 0.4));
--theme-color-accent: #50b7e0;
--scalar-color-2: color-mix(in srgb, var(--scalar-color-1), transparent calc(100% - 100% * 0.64));
--scalar-color-3: color-mix(in srgb, var(--scalar-color-1), transparent calc(100% - 100% * 0.4));
--scalar-color-accent: #50b7e0;
--theme-background-1: rgb(var(--dark-base, 22 22 22));
--theme-background-2: color-mix(
--scalar-background-1: rgb(var(--dark-base, 22 22 22));
--scalar-background-2: color-mix(
in srgb,
rgb(var(--primary-base-200, 200 200 200)),
var(--theme-background-1) 92%
var(--scalar-background-1) 92%
);
--theme-background-3: color-mix(
--scalar-background-3: color-mix(
in srgb,
rgb(var(--primary-base-200, 200 200 200)),
var(--theme-background-1) 88%
var(--scalar-background-1) 88%
);
--theme-background-accent: #8ab4f81f;
--theme-code-languages-background-supersede: var(--theme-background-1);
--theme-border-color: color-mix(
--scalar-background-accent: #8ab4f81f;
--scalar-code-languages-background-supersede: var(--scalar-background-1);
--scalar-border-color: color-mix(
in srgb,
var(--theme-color-1),
var(--scalar-color-1),
transparent calc(100% - 100% * 0.08)
);
--theme-color-green: #56b6c2;
--theme-color-red: rgb(245 124 97);
--theme-color-yellow: #edbe20;
--theme-color-blue: rgb(var(--primary-color-400, 93 138 226));
--theme-color-orange: #d19a66;
--theme-color-purple: #5203d1;
--scalar-color-green: #56b6c2;
--scalar-color-red: rgb(245 124 97);
--scalar-color-yellow: #edbe20;
--scalar-color-blue: rgb(var(--primary-color-400, 93 138 226));
--scalar-color-orange: #d19a66;
--scalar-color-purple: #5203d1;
--theme-scrollbar-color: rgba(0, 0, 0, 0.18);
--theme-scrollbar-color-active: rgba(0, 0, 0, 0.36);
--scalar-scrollbar-color: rgba(0, 0, 0, 0.18);
--scalar-scrollbar-color-active: rgba(0, 0, 0, 0.36);
--scalar-button-1: #f6f6f6;
--scalar-button-1-color: #000;
--scalar-button-1-hover: #e7e7e7;
}
.scalar-modal-layout,
.scalar {
--theme-font: initial;
--theme-font-code: var(--font-mono);
--scalar-font: initial;
--scalar-font-code: var(--font-mono);
--theme-paragraph: 16px;
--theme-small: 14px;
--theme-mini: 13px;
--theme-micro: 12px;
--scalar-paragraph: 16px;
--scalar-small: 14px;
--scalar-mini: 13px;
--scalar-micro: 12px;
--theme-bold: 600;
--theme-semibold: 500;
--theme-regular: 400;
--scalar-bold: 600;
--scalar-semibold: 500;
--scalar-regular: 400;
/* Font sizes for interactive applications (not rendered text content) */
--theme-font-size-1: 24px;
--theme-font-size-2: 16px;
--theme-font-size-3: 14px;
--theme-font-size-4: 13px;
--theme-font-size-5: 12px;
--scalar-font-size-1: 24px;
--scalar-font-size-2: 16px;
--scalar-font-size-3: 14px;
--scalar-font-size-4: 13px;
--scalar-font-size-5: 12px;
--theme-line-height-1: 32px;
--theme-line-height-2: 24px;
--theme-line-height-3: 20px;
--theme-line-height-4: 18px;
--theme-line-height-5: 16px;
--scalar-line-height-1: 32px;
--scalar-line-height-2: 24px;
--scalar-line-height-3: 20px;
--scalar-line-height-4: 18px;
--scalar-line-height-5: 16px;
--scalar-app-header-height: 35px;
}
.scalar input::placeholder {
color: var(--theme-color-3);
color: var(--scalar-color-3);
}
.scalar .scalar-app-header {
width: 100%;
@@ -134,15 +142,15 @@
display: flex;
align-items: center;
background: transparent;
color: var(--theme-color-1);
font-size: var(--theme-small);
font-weight: var(--theme-semibold);
color: var(--scalar-color-1);
font-size: var(--scalar-small);
font-weight: var(--scalar-semibold);
}
.scalar-api-client__close:hover {
cursor: pointer;
}
.scalar .scalar-app {
background: var(--theme-background-3);
background: var(--scalar-background-3);
height: calc(100dvh - 100px);
max-width: 1280px;
width: 100%;
@@ -227,10 +235,10 @@
line-height: normal;
}
.scalar .scalar-app-header span {
color: var(--theme-color-3);
color: var(--scalar-color-3);
}
.scalar .scalar-app-header a {
color: var(--theme-color-1);
color: var(--scalar-color-1);
}
.scalar .scalar-app-header a:hover {
text-decoration: underline;
@@ -251,7 +259,7 @@
display: flex;
gap: 6px;
align-items: center;
color: var(--theme-color-blue);
color: var(--scalar-color-blue);
appearance: none;
outline: none;
border: none;
@@ -261,7 +269,7 @@
padding: 0 0.5rem;
}
.scalar-activate:hover .scalar-activate-button {
background: var(--theme-background-3);
background: var(--scalar-background-3);
border-radius: 3px;
}
@@ -293,12 +301,12 @@
scrollbar-color: rgba(255, 255, 255, 0.24) transparent;
}
.scalar .custom-scroll:hover::-webkit-scrollbar-thumb {
background: var(--theme-scrollbar-color, var(--default-theme-scrollbar-color));
background: var(--scalar-scrollbar-color, var(--default-theme-scrollbar-color));
background-clip: content-box;
border: 3px solid transparent;
}
.scalar .custom-scroll::-webkit-scrollbar-thumb:active {
background: var(--theme-scrollbar-color-active, var(--default-theme-scrollbar-color-active));
background: var(--scalar-scrollbar-color-active, var(--default-theme-scrollbar-color-active));
background-clip: content-box;
border: 3px solid transparent;
}
@@ -322,4 +330,4 @@
.scalar .custom-scroll {
padding-right: 12px;
}
}
}
@@ -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 {
@@ -4,7 +4,9 @@ import assertNever from 'assert-never';
import { Checkbox, Emoji } from '@/components/primitives';
import { StyledLink } from '@/components/primitives';
import { Image } from '@/components/utils';
import { getNodeFragmentByName } from '@/lib/document';
import { getSimplifiedContentType } from '@/lib/files';
import { tcls } from '@/lib/tailwind';
import { filterOutNullable } from '@/lib/typescript';
@@ -12,6 +14,7 @@ import { TableRecordKV } from './Table';
import { getColumnAlignment } from './utils';
import { BlockProps } from '../Block';
import { Blocks } from '../Blocks';
import { FileIcon } from '../FileIcon';
/**
* Render the value for a column in a record.
@@ -125,11 +128,41 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
return (
<Tag className={tcls('text-base')}>
{files.filter(filterOutNullable).map((file, index) => (
<StyledLink key={index} href={file.href}>
{file.text}
</StyledLink>
))}
{files.filter(filterOutNullable).map((ref, index) => {
const contentType = ref.file
? getSimplifiedContentType(ref.file.contentType)
: null;
return (
<StyledLink
key={index}
href={ref.href}
target="_blank"
style={['flex', 'flex-row', 'items-center', 'gap-2']}
>
{contentType === 'image' ? (
<Image
style={['max-h-[1lh]', 'h-[1lh]']}
alt={ref.text}
sizes={[{ width: 24 }]}
sources={{
light: {
src: ref.href,
size: {
width: 24,
height: 24,
},
},
}}
priority="lazy"
/>
) : (
<FileIcon contentType={contentType} />
)}
{ref.text}
</StyledLink>
);
})}
</Tag>
);
case 'content-ref': {
+2 -1
View File
@@ -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>
</>
);
+22 -4
View File
@@ -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>
</>
);
+60 -16
View File
@@ -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);
}
+5 -1
View File
@@ -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,
]);
@@ -75,7 +75,7 @@ export function TableOfContents(props: {
'dark:group-hover:[&::-webkit-scrollbar-thumb]:bg-light/3',
'navigation-open:flex', // can be auto height animated as such https://stackoverflow.com/a/76944290
'lg:-ml-5',
customization.trademark.enabled ? 'lg:pb-16' : 'lg:pb-4',
customization.trademark.enabled ? 'lg:pb-20' : 'lg:pb-4',
)}
>
<PagesList
+7 -3
View File
@@ -84,16 +84,20 @@ export function TrademarkLink(props: {
'flex-row',
'items-center',
'hover:bg-dark/1',
'bg-light',
'dark:bg-dark',
'px-4',
'py-2',
'rounded-md',
'py-4',
'rounded-lg',
'straight-corners:rounded-none',
'hover:backdrop-blur-sm',
'lg:ring-0',
'tracking-[-0.016em]',
'dark:hover:bg-light/1',
'dark:ring-light/1',
'dark:font-normal',
'border',
'border-dark/2',
'dark:border-light/2',
)}
>
<IconLogo className={tcls('w-5', 'h-5', 'mr-3')} />
+6 -3
View File
@@ -1,14 +1,16 @@
import { tcls } from '@/lib/tailwind';
import { ClassValue, tcls } from '@/lib/tailwind';
import { Link, LinkProps } from '../primitives/Link';
/**
* Styled version of Link component.
*/
export function StyledLink(props: LinkProps) {
export function StyledLink(props: Omit<LinkProps, 'style'> & { style?: ClassValue }) {
const { style, ...rest } = props;
return (
<Link
{...props}
{...rest}
className={tcls(
'underline',
'underline-offset-2',
@@ -16,6 +18,7 @@ export function StyledLink(props: LinkProps) {
'text-primary',
'hover:text-primary-700',
'transition-colors',
style,
)}
>
{props.children}
+52
View File
@@ -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.',
};
+4
View File
@@ -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,
};
+55
View File
@@ -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
View File
@@ -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 })],
});
},
);
+19 -1
View File
@@ -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')]);
+1 -1
View File
@@ -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;
+5 -1
View File
@@ -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,
};
+9 -15
View File
@@ -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());
+25
View File
@@ -0,0 +1,25 @@
export type SimplifiedFileType = 'image' | 'pdf' | 'archive';
/**
* Get a simplified content type for the given mime type.
*/
export function getSimplifiedContentType(mimeType: string): SimplifiedFileType | null {
if (mimeType.startsWith('image')) {
return 'image';
}
switch (mimeType) {
case 'application/pdf':
case 'application/x-pdf':
return 'pdf';
case 'application/zip':
case 'application/x-7z-compressed':
case 'application/x-zip-compressed':
case 'application/x-tar':
case 'application/x-rar-compressed':
case 'application/vnd.rar':
return 'archive';
default:
return null;
}
}
+2 -1
View File
@@ -3,6 +3,7 @@ import 'server-only';
import { noCacheFetchOptions } from '@/lib/cache/http';
import { rootUrl } from './links';
import { getImageAPIUrl } from './urls';
export interface CloudflareImageJsonFormat {
width: number;
@@ -76,7 +77,7 @@ export async function getResizedImageURL(
return (options) => {
const url = new URL('/~gitbook/image', rootUrl());
url.searchParams.set('url', input);
url.searchParams.set('url', getImageAPIUrl(input));
if (options.width) {
url.searchParams.set('width', options.width.toString());
+18
View File
@@ -65,3 +65,21 @@ export function getPDFUrlSearchParams(
return searchParams;
}
/**
* Because of a bug in Cloudflare, 127.0.0.1 is replaced by localhost.
* We protect against it by converting to a special token, and then parsing
* the token in the image API.
*/
const GITBOOK_LOCALHOST_TOKEN = '$GITBOOK_LOCALHOST$';
/**
* Prepare a URL for the GitBook Open Image API.
*/
export function getImageAPIUrl(url: string): string {
return url.replaceAll('127.0.0.1', GITBOOK_LOCALHOST_TOKEN);
}
export function parseImageAPIURL(url: string): string {
return url.replaceAll(GITBOOK_LOCALHOST_TOKEN, '127.0.0.1');
}
+25
View File
@@ -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;
}
+21 -4
View File
@@ -2,6 +2,7 @@ import { it, describe, expect } from 'bun:test';
import { NextRequest } from 'next/server';
import {
VisitorAuthCookieValue,
getVisitorAuthCookieName,
getVisitorAuthCookieValue,
getVisitorAuthToken,
@@ -17,14 +18,18 @@ describe('getVisitorAuthToken', () => {
const request = nextRequest('https://example.com', {
[getVisitorAuthCookieName('/')]: { value: getVisitorAuthCookieValue('/', '123') },
});
expect(getVisitorAuthToken(request, request.nextUrl)).toEqual('123');
const visitorAuth = getVisitorAuthToken(request, request.nextUrl);
assertVisitorAuthCookieValue(visitorAuth);
expect(visitorAuth.token).toEqual('123');
});
it('should return the token from the cookie root basepath for a sub-path', () => {
const request = nextRequest('https://example.com/hello/world', {
[getVisitorAuthCookieName('/')]: { value: getVisitorAuthCookieValue('/', '123') },
});
expect(getVisitorAuthToken(request, request.nextUrl)).toEqual('123');
const visitorAuth = getVisitorAuthToken(request, request.nextUrl);
assertVisitorAuthCookieValue(visitorAuth);
expect(visitorAuth.token).toEqual('123');
});
it('should return the closest token from the path', () => {
@@ -34,7 +39,9 @@ describe('getVisitorAuthToken', () => {
value: getVisitorAuthCookieValue('/hello/', '123'),
},
});
expect(getVisitorAuthToken(request, request.nextUrl)).toEqual('123');
const visitorAuth = getVisitorAuthToken(request, request.nextUrl);
assertVisitorAuthCookieValue(visitorAuth);
expect(visitorAuth.token).toEqual('123');
});
it('should return the token from the cookie in a collection type url', () => {
@@ -43,7 +50,9 @@ describe('getVisitorAuthToken', () => {
value: getVisitorAuthCookieValue('/hello/v/space1/', '123'),
},
});
expect(getVisitorAuthToken(request, request.nextUrl)).toEqual('123');
const visitorAuth = getVisitorAuthToken(request, request.nextUrl);
assertVisitorAuthCookieValue(visitorAuth);
expect(visitorAuth.token).toEqual('123');
});
it('should return undefined if no cookie and no query param', () => {
@@ -52,6 +61,14 @@ describe('getVisitorAuthToken', () => {
});
});
function assertVisitorAuthCookieValue(value: unknown): asserts value is VisitorAuthCookieValue {
if (value && typeof value === 'object' && 'token' in value) {
return;
}
throw new Error('Expected a VisitorAuthCookieValue');
}
function nextRequest(url: string, cookies: Record<string, { value: string }> = {}) {
const nextUrl = new URL(url);
// @ts-ignore
+20 -11
View File
@@ -16,7 +16,10 @@ export type VisitorAuthCookieValue = {
* Get the visitor authentication token for the request. This token can either be in the
* query parameters or stored as a cookie.
*/
export function getVisitorAuthToken(request: NextRequest, url: URL): string | undefined {
export function getVisitorAuthToken(
request: NextRequest,
url: URL,
): string | VisitorAuthCookieValue | undefined {
return url.searchParams.get(VISITOR_AUTH_PARAM) ?? getVisitorAuthTokenFromCookies(request, url);
}
@@ -68,7 +71,10 @@ function getUrlBasePathCombinations(url: URL): string[] {
* checking all cookies for a matching "visitor authentication cookie" and returning the
* best possible match for the current URL.
*/
function getVisitorAuthTokenFromCookies(request: NextRequest, url: URL): string | undefined {
function getVisitorAuthTokenFromCookies(
request: NextRequest,
url: URL,
): VisitorAuthCookieValue | undefined {
const urlBasePaths = getUrlBasePathCombinations(url);
// Try to find a visitor authentication token for the current URL. The request
// for the content could be hosted on a base path like `/foo/v/bar` or `/foo` or just `/`
@@ -90,14 +96,17 @@ function getVisitorAuthTokenFromCookies(request: NextRequest, url: URL): string
function findVisitorAuthCookieForBasePath(
request: NextRequest,
basePath: string,
): string | undefined {
return Array.from(request.cookies).reduce<string | undefined>((acc, [name, cookie]) => {
if (name === getVisitorAuthCookieName(basePath)) {
const value = JSON.parse(cookie.value) as VisitorAuthCookieValue;
if (value.basePath === basePath) {
acc = value.token;
): VisitorAuthCookieValue | undefined {
return Array.from(request.cookies).reduce<VisitorAuthCookieValue | undefined>(
(acc, [name, cookie]) => {
if (name === getVisitorAuthCookieName(basePath)) {
const value = JSON.parse(cookie.value) as VisitorAuthCookieValue;
if (value.basePath === basePath) {
acc = value;
}
}
}
return acc;
}, undefined);
return acc;
},
undefined,
);
}
+56 -23
View File
@@ -22,6 +22,7 @@ import { buildVersion } from '@/lib/build';
import { createContentSecurityPolicyNonce, getContentSecurityPolicy } from '@/lib/csp';
import { getURLLookupAlternatives, normalizeURL } from '@/lib/middleware';
import {
VisitorAuthCookieValue,
getVisitorAuthCookieName,
getVisitorAuthCookieValue,
getVisitorAuthToken,
@@ -79,6 +80,7 @@ interface ContentAPITokenPayload {
spaces: string[];
collection?: string;
site?: string;
siteSpace?: string;
}
/**
@@ -206,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);
}
@@ -259,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' &&
@@ -484,15 +490,15 @@ async function lookupSpaceInMultiIdMode(request: NextRequest, url: URL): Promise
};
}
const decoded = jwt.decode(apiToken) as ContentAPITokenPayload;
const { organization, site, siteSpace } = jwt.decode(apiToken) as ContentAPITokenPayload;
const siteLookupResult =
typeof decoded.site === 'string' &&
decoded.site &&
typeof decoded.organization === 'string' &&
decoded.organization
? { site: decoded.site, organization: decoded.organization }
typeof organization === 'string' && organization && typeof site === 'string' && site
? {
organization,
site,
...(typeof siteSpace === 'string' && siteSpace ? { siteSpace } : {}),
}
: {};
return {
space: spaceId,
changeRequest: changeRequestId,
@@ -587,7 +593,7 @@ async function lookupSpaceInMultiPathMode(request: NextRequest, url: URL): Promi
*/
async function lookupSpaceByAPI(
lookupURL: URL,
visitorAuthToken: string | undefined,
visitorAuthToken: ReturnType<typeof getVisitorAuthToken>,
): Promise<LookupResult> {
const url = stripURLSearch(lookupURL);
const lookup = getURLLookupAlternatives(url);
@@ -597,9 +603,17 @@ async function lookupSpaceByAPI(
);
const result = await race(lookup.urls, async (alternative, { signal }) => {
const data = await getPublishedContentByUrl(alternative.url, visitorAuthToken, {
signal,
});
const data = await getPublishedContentByUrl(
alternative.url,
typeof visitorAuthToken === 'undefined'
? undefined
: typeof visitorAuthToken === 'string'
? visitorAuthToken
: visitorAuthToken.token,
{
signal,
},
);
if ('error' in data) {
if (alternative.primary) {
@@ -650,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;
});
@@ -671,22 +690,36 @@ async function lookupSpaceByAPI(
*/
function getLookupResultForVisitorAuth(
basePath: string,
visitorAuthToken: string,
visitorAuthToken: string | VisitorAuthCookieValue,
): Partial<LookupResult> {
return {
// No caching for content served with visitor auth
cacheMaxAge: undefined,
cacheTags: [],
cookies: {
[getVisitorAuthCookieName(basePath)]: {
value: getVisitorAuthCookieValue(basePath, visitorAuthToken),
options: {
httpOnly: true,
sameSite: 'none',
secure: process.env.NODE_ENV === 'production',
maxAge: 7 * 24 * 60 * 60,
},
},
/**
* If the visitorAuthToken has been retrieved from a cookie, we set it back only
* if the basePath matches the current one. This is to avoid setting cookie for
* different base paths.
*/
...(typeof visitorAuthToken === 'string' || visitorAuthToken.basePath === basePath
? {
[getVisitorAuthCookieName(basePath)]: {
value: getVisitorAuthCookieValue(
basePath,
typeof visitorAuthToken === 'string'
? visitorAuthToken
: visitorAuthToken.token,
),
options: {
httpOnly: true,
sameSite: 'none',
secure: process.env.NODE_ENV === 'production',
maxAge: 7 * 24 * 60 * 60,
},
},
}
: {}),
},
};
}