Compare commits

..

32 Commits

Author SHA1 Message Date
Claire Chabas 0780396e81 keep useless import to keep PR in scope 2025-01-30 19:01:19 +01:00
Claire Chabas ce082549d8 Merge branch 'main' into claire/update-node-version-required
* main:
  Avoid dereferencing before caching (#2793)
2025-01-30 18:56:30 +01:00
Greg Bergé d9c8d57e8e Avoid dereferencing before caching (#2793) 2025-01-30 17:15:19 +01:00
Claire Chabas bae43d08ff Update Node version required 2025-01-30 17:05:35 +01:00
Greg Bergé 0d615e3888 Highlight code client-side (#2786) 2025-01-30 16:08:39 +01:00
Greg Bergé 68287d3df4 Cache API spec for 24 hours, revalidated every 2 hours (#2792) 2025-01-30 15:16:45 +01:00
Greg Bergé dff08ae3e3 Load Scalar only when the button is pressed (#2790) 2025-01-30 15:01:59 +01:00
Zeno Kapitein 8cfa67c1dd Fix default outline list styling (#2788) 2025-01-30 13:56:43 +01:00
Greg Bergé b41d425993 Improve OpenAPI performances (#2784) 2025-01-30 11:13:01 +01:00
Greg Bergé 12c7862250 Use Scalar OpenAPI parser (#2780) 2025-01-30 08:44:08 +01:00
Greg Bergé 6f54826296 Replace highlight colors (#2781) 2025-01-29 15:00:01 +01:00
Steven H ccf2cffc4b Hotfix docs.radiantdrift.com crashing in production. (#2782) 2025-01-29 12:42:01 +00:00
Zeno Kapitein f7b801b4c8 Support user comments after rating (#2771)
Co-authored-by: Samy Pessé <samypesse@gmail.com>
2025-01-28 09:39:32 +01:00
Samy Pessé 95f2aa45eb Integration with new site insights (#2777) 2025-01-28 09:18:50 +01:00
Samy Pessé 9524ff5f5a Migrate to bun text-based lockfile (#2770) 2025-01-28 08:47:40 +01:00
Steven H 1823101b03 Fix internal properties appearing in OpenAPI docs. (#2774) 2025-01-23 21:35:06 +00:00
Samy Pessé 1a8cfd2a8b Hide toolbar when previewing (#2773) 2025-01-23 21:58:02 +01:00
Greg Bergé 5b4e710770 Support llms.txt (#2768) 2025-01-23 16:37:45 +01:00
Zeno Kapitein 1762f85ef9 Reduce gap between subsequent header buttons (#2765) 2025-01-23 14:10:47 +01:00
Samy Pessé d370a3f0e3 Align the revisions/changes path for multi-id with the normal one (#2769) 2025-01-23 10:00:11 +01:00
Greg Bergé d3e573c2e9 Generate sitemap for all sections and spaces (#2767) 2025-01-22 16:57:57 +01:00
Zeno Kapitein 94876e3ad1 Fix regression issue with page icons for multi-line titles (#2764) 2025-01-21 17:03:33 +01:00
Zeno Kapitein ad190602c7 Tweak card background, better margins for card headings (#2763) 2025-01-21 17:03:22 +01:00
Scott Cazan 162b4b78b6 Add HTTP examples to API blocks (#2759) 2025-01-21 15:34:42 +01:00
Zeno Kapitein e5dc05e994 Restyle footer, allow >4 link groups (#2761) 2025-01-21 14:50:33 +01:00
Samy Pessé b46cff70c0 Fix insights prop passed to Link (#2760) 2025-01-21 07:52:00 +01:00
Greg Bergé b6c3870eda Add support for keyboard marks (#2758) 2025-01-20 12:06:38 +01:00
Taran Vohra 1c1f504601 Fix Image zoom e2e test (#2757) 2025-01-20 16:13:23 +05:30
Taran Vohra 1b8a45678c Fix image blocks zoomable behaviour (#2756) 2025-01-20 13:22:12 +05:30
Viktor Renkema 38de9eb9d7 Restore borders on embed blocks with Captions (#2755) 2025-01-17 12:50:23 +01:00
Greg Bergé eac1314a7d Lazy load iframely script (#2753) 2025-01-17 08:28:38 +01:00
Greg Bergé 29d59793dc Disable C/C++ highlight temporarily (#2754) 2025-01-17 08:28:10 +01:00
143 changed files with 61958 additions and 1356 deletions
+1 -1
View File
@@ -1 +1 @@
1.1.40
1.2.1
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Support llms.txt
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Add support for keyboard marks
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': minor
---
Track new events for site insights when ads are being clicked
+5
View File
@@ -0,0 +1,5 @@
---
'@gitbook/react-openapi': minor
---
Add in HTTP example code blocks
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Reduce gap between subsequent header buttons
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Fix default outline list styling
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Cache API spec for 24 hours, revalidated every 2 hours
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Update footer styling and allow for more than 4 footer groups
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Fix Image blocks zoomable behaviour
+6
View File
@@ -0,0 +1,6 @@
---
'@gitbook/react-openapi': patch
'gitbook': patch
---
Do not dereference before caching OpenAPI spec.
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Fix regression issue with page icons for multi-line titles
+5
View File
@@ -0,0 +1,5 @@
---
'@gitbook/react-openapi': patch
---
Fix an issue where a response object using a special ref would crash the page.
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Generate sitemap for all sections and spaces
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Add feedback form to page rating control
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Update the routes for changes/revisions in multi-id mode to match the normal mode
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Lazy load iframely script to make page more responsive
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Cards stand out slighly more on tinted and dark mode sites, and have better support for headings inside them
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
New highlight colors
+5
View File
@@ -0,0 +1,5 @@
---
'@gitbook/react-openapi': patch
---
Improve performances by loading Scalar API Client only when the button is clicked
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Disable C/C++ highlight temporarily
+6
View File
@@ -0,0 +1,6 @@
---
'@gitbook/react-openapi': minor
'gitbook': minor
---
Fix internal properties appearing in OpenAPI docs.
+8
View File
@@ -0,0 +1,8 @@
---
'@gitbook/react-openapi': major
---
Use `@scalar/openapi-parser` to be more resilient and perf on OpenAPI spec parsing:
- `fetcher.fetch` must now returns a valid OpenAPI document
- `parseOpenAPIV3` has been replaced by `parseOpenAPI`
+6
View File
@@ -0,0 +1,6 @@
---
'@gitbook/react-openapi': patch
'gitbook': patch
---
Improve OpenAPI rendering performances by caching markdown parsing
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Improve performances by highlighting code client-side if the code block is offscreen
+1 -1
View File
@@ -1 +1 @@
v20.6
v20.8
+4 -2
View File
@@ -35,8 +35,10 @@ To run a local version of this project, please follow these simple steps.
### Prerequisites
- Node.js (Version: >=18.x)
- Bun
- Node.js (Version: >=20.8)
- Use nvm for easy Node management
- Bun (Version: >=1.2.1)
- We use a text-based lockfile which isn't supported below 1.2.1
### Set up
+3587
View File
File diff suppressed because it is too large Load Diff
BIN
View File
Binary file not shown.
+3 -2
View File
@@ -4,7 +4,7 @@
"devDependencies": {
"@changesets/cli": "^2.27.7",
"prettier": "^3.0.3",
"turbo": "^2.1.2"
"turbo": "^2.3.4"
},
"packageManager": "bun@1.1.18",
"overrides": {
@@ -17,6 +17,7 @@
"dev": "turbo run dev --ui=stream",
"build": "turbo run build",
"build:cloudflare": "turbo run build:cloudflare",
"clean-deps": "rm -rf node_modules && rm -rf packages/*/node_modules",
"lint": "turbo run lint",
"lint:fix": "turbo run lint -- --fix",
"typecheck": "turbo run typecheck",
@@ -35,6 +36,6 @@
],
"patchedDependencies": {
"decode-named-character-reference@1.0.2": "patches/decode-named-character-reference@1.0.2.patch",
"@vercel/next@4.3.15": "patches/@vercel%2Fnext@4.3.15.patch"
"@vercel/next@4.4.2": "patches/@vercel%2Fnext@4.4.2.patch"
}
}
+33 -4
View File
@@ -142,7 +142,9 @@ const testCases: TestsCase[] = [
name: 'AI Search',
url: '?q=What+is+GitBook%3F&ask=true',
run: async (page) => {
await expect(page.getByTestId('search-ask-answer')).toBeVisible();
await expect(page.getByTestId('search-ask-answer')).toBeVisible({
timeout: 15_000,
});
},
screenshot: false,
},
@@ -322,7 +324,9 @@ const testCases: TestsCase[] = [
name: 'AI Search',
url: '?q=What+is+GitBook%3F&ask=true',
run: async (page) => {
await expect(page.getByTestId('search-ask-answer')).toBeVisible();
await expect(page.getByTestId('search-ask-answer')).toBeVisible({
timeout: 15_000,
});
},
screenshot: false,
},
@@ -378,11 +382,36 @@ const testCases: TestsCase[] = [
fullPage: true,
screenshot: { threshold: 0.8 },
},
{
name: 'Images (with zoom)',
url: 'blocks/block-images',
run: async (page) => {
await waitForCookiesDialog(page);
const zoomImage = page.getByTestId('zoom-image');
await zoomImage.first().click();
await expect(page.getByTestId('zoom-image-modal')).toBeVisible();
},
fullPage: true,
screenshot: { threshold: 0.8 },
},
{
name: 'Inline Images',
url: 'blocks/inline-images',
run: waitForCookiesDialog,
screenshot: { threshold: 0.8 },
run: async (page) => {
await waitForCookiesDialog(page);
// Make the text invisible to fix flakiness due to the text position.
await page.evaluate(() => {
for (const p of document.querySelectorAll('p')) {
if (
p.textContent?.includes(
'This image has intrinsic 400px width, but renders as 300px:',
)
) {
p.style.color = 'transparent';
}
}
});
},
},
{
name: 'Tabs',
+6 -3
View File
@@ -16,7 +16,7 @@
"clean": "rm -rf ./.next && rm -rf ./public/~gitbook/static"
},
"dependencies": {
"@gitbook/api": "^0.87.0",
"@gitbook/api": "^0.89.0",
"@gitbook/cache-do": "workspace:*",
"@gitbook/emoji-codepoints": "workspace:*",
"@gitbook/icons": "workspace:*",
@@ -40,8 +40,9 @@
"jsontoxml": "^1.0.1",
"katex": "^0.16.9",
"mathjax": "^3.2.2",
"mdast-util-to-markdown": "^2.1.2",
"memoizee": "^0.4.15",
"next": "14.2.15",
"next": "14.2.23",
"next-themes": "^0.2.1",
"nuqs": "^2.2.3",
"object-hash": "^3.0.0",
@@ -59,7 +60,7 @@
"remark-rehype": "^11.1.0",
"rison": "^0.1.1",
"server-only": "^0.0.1",
"shiki": "^1.26.1",
"shiki": "^1.27.2",
"tailwind-merge": "^2.2.0",
"tailwind-shades": "^1.1.2",
"unified": "^11.0.4",
@@ -69,11 +70,13 @@
"devDependencies": {
"@argos-ci/playwright": "^3.10.0",
"@cloudflare/next-on-pages": "1.13.7",
"vercel": "^39.3.0",
"@cloudflare/workers-types": "^4.20241230.0",
"@playwright/test": "^1.49.1",
"@types/js-cookie": "^3.0.6",
"@types/jsontoxml": "^1.0.5",
"@types/jsonwebtoken": "^9.0.6",
"@types/mdast": "^4.0.4",
"@types/node": "^20",
"@types/object-hash": "^3.0.6",
"@types/parse-cache-control": "^1.0.4",
@@ -109,10 +109,7 @@ export default async function Page(props: {
page={page}
ancestors={ancestors}
document={document}
withPageFeedback={
// Display the page feedback in the page footer if the aside is not visible
withPageFeedback && !page.layout.outline
}
withPageFeedback={withPageFeedback}
/>
</div>
<React.Suspense fallback={null}>
@@ -0,0 +1,153 @@
import { SiteSection, SiteSpace, SiteStructure } from '@gitbook/api';
import assertNever from 'assert-never';
import { Heading, ListItem, Paragraph, Root, RootContent } from 'mdast';
import { toMarkdown } from 'mdast-util-to-markdown';
import { NextRequest } from 'next/server';
import { getPublishedContentSite, getRevisionPages } from '@/lib/api';
import { getAbsoluteHref } from '@/lib/links';
import { getPagePath } from '@/lib/pages';
import { joinPath } from '@/lib/paths';
import { checkIsRootPointer, getSiteContentPointer } from '@/lib/pointer';
import { getIndexablePages } from '@/lib/sitemap';
export const runtime = 'edge';
/**
* Generate a llms.txt file for the site.
*/
export async function GET(req: NextRequest) {
const pointer = await getSiteContentPointer();
const { structure: siteStructure, site } = await getPublishedContentSite({
organizationId: pointer.organizationId,
siteId: pointer.siteId,
siteShareKey: pointer.siteShareKey,
});
if (!checkIsRootPointer(pointer, siteStructure)) {
return new Response('llms.txt is only served from the root of the site', { status: 404 });
}
const tree: Root = {
type: 'root',
children: [
{
type: 'heading',
depth: 1,
children: [{ type: 'text', value: site.title }],
},
...(await getNodesFromSiteStructure(siteStructure)),
],
};
return new Response(
toMarkdown(tree, {
bullet: '-',
}),
{
headers: {
'Content-Type': 'text/plain; charset=utf-8',
},
},
);
}
/**
* Get MDAST nodes from site structure.
*/
async function getNodesFromSiteStructure(siteStructure: SiteStructure): Promise<RootContent[]> {
switch (siteStructure.type) {
case 'sections':
return getNodesFromSections(siteStructure.structure);
case 'siteSpaces':
return getNodesFromSiteSpaces(siteStructure.structure, { heading: true });
default:
assertNever(siteStructure);
}
}
/**
* Get MDAST nodes from site sections.
*/
async function getNodesFromSections(siteSections: SiteSection[]): Promise<RootContent[]> {
const all = await Promise.all(
siteSections.map(async (siteSection): Promise<RootContent[]> => {
const siteSpaceNodes = await getNodesFromSiteSpaces(siteSection.siteSpaces, {
heading: false,
});
return [
{
type: 'heading',
depth: 2,
children: [{ type: 'text', value: siteSection.title }],
},
...siteSpaceNodes,
];
}),
);
return all.flat();
}
/**
* Get MDAST nodes from site spaces.
*/
async function getNodesFromSiteSpaces(
siteSpaces: SiteSpace[],
options: {
/**
* Includes a heading for each site space.
*/
heading?: boolean;
},
): Promise<RootContent[]> {
const all = await Promise.all(
siteSpaces.map(async (siteSpace): Promise<RootContent[]> => {
const siteSpaceUrl = siteSpace.urls.published;
if (!siteSpaceUrl) {
return [];
}
const rootPages = await getRevisionPages(siteSpace.space.id, siteSpace.space.revision, {
metadata: false,
});
const pages = getIndexablePages(rootPages);
const listChildren = await Promise.all(
pages.map(async ({ page }): Promise<ListItem> => {
const url = await getAbsoluteHref(
joinPath(new URL(siteSpaceUrl).pathname, getPagePath(rootPages, page)),
true,
);
const children: Paragraph['children'] = [
{
type: 'link',
url,
children: [{ type: 'text', value: page.title }],
},
];
if (page.description) {
children.push({ type: 'text', value: `: ${page.description}` });
}
return {
type: 'listItem',
children: [{ type: 'paragraph', children }],
};
}),
);
const nodes: RootContent[] = [];
if (options.heading) {
nodes.push({
type: 'heading',
depth: 2,
children: [{ type: 'text', value: siteSpace.title }],
});
}
nodes.push({
type: 'list',
spread: false,
children: listChildren,
});
return nodes;
}),
);
return all.flat();
}
@@ -0,0 +1,66 @@
import jsontoxml from 'jsontoxml';
import { getRevisionPages, getSpace } from '@/lib/api';
import { getAbsoluteHref } from '@/lib/links';
import { getPagePath } from '@/lib/pages';
import { getSiteContentPointer } from '@/lib/pointer';
import { getIndexablePages } from '@/lib/sitemap';
export const runtime = 'edge';
/**
* Generate a sitemap.xml for the current section / space.
*/
export async function GET() {
const pointer = await getSiteContentPointer();
const revisionId =
pointer.revisionId ?? (await getSpace(pointer.spaceId, pointer.siteShareKey)).revision;
const rootPages = await getRevisionPages(pointer.spaceId, revisionId, { metadata: false });
const pages = getIndexablePages(rootPages);
const urls = await Promise.all(
pages.map(async ({ page, depth }) => {
// Decay priority with depth
const priority = Math.pow(2, -0.25 * depth);
// Normalize to keep 2 decimals
const normalizedPriority = Math.floor(100 * priority) / 100;
const lastModified = page.updatedAt || page.createdAt;
const url: { loc: string; priority: number; lastmod?: string } = {
priority: normalizedPriority,
loc: await getAbsoluteHref(getPagePath(rootPages, page), true),
};
if (lastModified) {
url.lastmod = new Date(lastModified).toISOString();
}
return { url };
}),
);
const xml = jsontoxml(
[
{
name: 'urlset',
children: urls,
attrs: {
xmlns: 'http://www.sitemaps.org/schemas/sitemap/0.9',
},
},
],
{
xmlHeader: true,
prettyPrint: true,
},
);
return new Response(xml, {
headers: {
'Content-Type': 'application/xml',
},
});
}
@@ -1,57 +1,45 @@
import { RevisionPage, RevisionPageDocument, RevisionPageGroup } from '@gitbook/api';
import { SiteSection, SiteSpace, SiteStructure } from '@gitbook/api';
import assertNever from 'assert-never';
import jsontoxml from 'jsontoxml';
import { NextRequest } from 'next/server';
import { getSpaceContentData } from '@/lib/api';
import { getPublishedContentSite, SiteContentPointer } from '@/lib/api';
import { getAbsoluteHref } from '@/lib/links';
import { getPagePath } from '@/lib/pages';
import { getSiteContentPointer } from '@/lib/pointer';
import { isPageIndexable } from '@/lib/seo';
import { joinPath } from '@/lib/paths';
import { checkIsRootPointer, getSiteContentPointer } from '@/lib/pointer';
import { filterOutNullable } from '@/lib/typescript';
export const runtime = 'edge';
/**
* Generate a sitemap.xml for the current space.
* Generate a root sitemap that point to all sitemap-pages.xml.
*/
export async function GET(req: NextRequest) {
export async function GET() {
const pointer = await getSiteContentPointer();
const { pages: rootPages } = await getSpaceContentData(pointer, pointer.siteShareKey);
const pages = flattenPages(rootPages, (page) => !page.hidden && isPageIndexable([], page));
const urls = await Promise.all(
pages.map(async ({ page, depth }) => {
// Decay priority with depth
const priority = Math.pow(2, -0.25 * depth);
// Normalize to keep 2 decimals
const normalizedPriority = Math.floor(100 * priority) / 100;
const { structure: siteStructure } = await getPublishedContentSite({
organizationId: pointer.organizationId,
siteId: pointer.siteId,
siteShareKey: pointer.siteShareKey,
});
const lastModified = page.updatedAt || page.createdAt;
if (!checkIsRootPointer(pointer, siteStructure)) {
return new Response('Root sitemap is only served from the root of the site', {
status: 404,
});
}
return {
url: {
loc: await getAbsoluteHref(getPagePath(rootPages, page), true),
priority: normalizedPriority,
...(lastModified
? {
// lastmod format is YYYY-MM-DD
lastmod: new Date(lastModified).toISOString().split('T')[0],
}
: {}),
},
};
}),
);
const urls = await getUrlsFromSiteStructure(siteStructure);
const xml = jsontoxml(
[
{
name: 'urlset',
children: urls,
name: 'sitemapindex',
children: urls.map((url) => ({
name: 'sitemap',
children: [{ name: 'loc', text: url }],
})),
attrs: {
'xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance',
xmlns: 'http://www.sitemaps.org/schemas/sitemap/0.9',
'xsi:schemaLocation':
'http://www.sitemaps.org/schemas/sitemap/0.9 http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd',
},
},
],
@@ -68,30 +56,43 @@ export async function GET(req: NextRequest) {
});
}
type FlatPageEntry = { page: RevisionPageDocument; depth: number };
function flattenPages(
rootPags: RevisionPage[],
filter: (page: RevisionPageDocument | RevisionPageGroup) => boolean,
): FlatPageEntry[] {
const flattenPage = (
page: RevisionPageDocument | RevisionPageGroup,
depth: number,
): FlatPageEntry[] => {
const allowed = filter(page);
if (!allowed) {
return [];
}
return [
...(page.type === 'document' ? [{ page, depth }] : []),
...page.pages.flatMap((child) =>
child.type === 'document' ? flattenPage(child, depth + 1) : [],
),
];
};
return rootPags.flatMap((page) =>
page.type === 'group' || page.type === 'document' ? flattenPage(page, 0) : [],
);
/**
* Get Sitemap URLs from site structure.
*/
async function getUrlsFromSiteStructure(siteStructure: SiteStructure): Promise<string[]> {
switch (siteStructure.type) {
case 'sections':
return getUrlsFromSiteSections(siteStructure.structure);
case 'siteSpaces':
return getUrlsFromSiteSpaces(siteStructure.structure);
default:
assertNever(siteStructure);
}
}
/**
* Get Sitemap URLs from site sections.
*/
async function getUrlsFromSiteSections(siteSections: SiteSection[]): Promise<string[]> {
const urls = await Promise.all(
siteSections.map(async (siteSection) => getUrlsFromSiteSpaces(siteSection.siteSpaces), []),
);
return urls.flat();
}
/**
* Get Sitemap URLs from site spaces.
*/
async function getUrlsFromSiteSpaces(siteSpaces: SiteSpace[]): Promise<string[]> {
const urls = await Promise.all(
siteSpaces.map(async (siteSpace) => {
if (!siteSpace.urls.published) {
return null;
}
const url = new URL(siteSpace.urls.published);
url.pathname = joinPath(url.pathname, 'sitemap-pages.xml');
return url.toString();
}, []),
);
return urls.filter(filterOutNullable);
}
@@ -4,6 +4,7 @@ import {
RevisionPageDocument,
RevisionPageGroup,
SiteCustomizationSettings,
SiteInsightsTrademarkPlacement,
Space,
} from '@gitbook/api';
import { Icon } from '@gitbook/icons';
@@ -59,8 +60,10 @@ export default async function PDFHTMLOutput(props: {
searchParams: Promise<{ [key: string]: string }>;
}) {
const pointer = await getSiteOrSpacePointerForPDF();
const searchParams = new URLSearchParams(await props.searchParams);
const pdfParams = getPDFSearchParams(new URLSearchParams(searchParams));
// Build current PDF URL and preserve all search params
let currentPDFUrl = await getAbsoluteHref('~gitbook/pdf', true);
currentPDFUrl += '?' + searchParams.toString();
@@ -144,7 +147,11 @@ export default async function PDFHTMLOutput(props: {
limit={pdfParams.limit ?? DEFAULT_LIMIT}
trademark={
customization.trademark.enabled ? (
<TrademarkLink space={space} customization={customization} />
<TrademarkLink
space={space}
customization={customization}
placement={SiteInsightsTrademarkPlacement.Footer}
/>
) : null
}
/>
@@ -322,16 +329,6 @@ function selectPages(
}),
];
};
const range = (entries: FlatPageEntry[]) => {
if(params.from !== undefined && params.to) {
const sliced = entries.slice(params.from, params.to +1);
return {
pages: sliced,
total: params.to - params.from + 1,
};
}
return limitTo(entries)
}
const limitTo = (entries: FlatPageEntry[]) => {
return {
@@ -340,6 +337,7 @@ function selectPages(
total: entries.length,
};
};
if (params.page) {
const found = resolvePageId(rootPages, params.page);
if (!found) {
@@ -364,5 +362,5 @@ function selectPages(
return flattenPage(page, 0);
});
return range(allPages);
return limitTo(allPages);
}
@@ -1,5 +1,6 @@
import { Space } from '@gitbook/api';
import { Icon } from '@gitbook/icons';
import { headers } from 'next/headers';
import React from 'react';
import { getChangeRequest, getRevision, SiteContentPointer } from '@/lib/api';
@@ -43,8 +44,15 @@ function ToolbarLayout(props: { children: React.ReactNode }) {
/**
* Toolbar with information for the content admin when previewing a revision or change-request.
*/
export function AdminToolbar(props: AdminToolbarProps) {
export async function AdminToolbar(props: AdminToolbarProps) {
const { content } = props;
const mode = await headers().get('x-gitbook-mode');
if (mode === 'multi-id') {
// We don't show the admin toolbar in multi-id mode, as it's used for previewing in the dashboard.
return null;
}
if (content.changeRequestId) {
return (
<ChangeRequestToolbar
+36 -6
View File
@@ -1,12 +1,20 @@
'use client';
import { SiteAds, SiteAdsStatus } from '@gitbook/api';
import {
SiteAds,
SiteAdsStatus,
SiteInsightsAdPlacement,
SiteInsightsAd,
SiteInsightsTrademarkPlacement,
} from '@gitbook/api';
import * as React from 'react';
import { t, useLanguage } from '@/intl/client';
import { ClassValue, tcls } from '@/lib/tailwind';
import { renderAd } from './renderAd';
import { useTrackEvent } from '../Insights';
import { Link } from '../primitives';
/**
* Zone ID provided by BuySellAds for the preview.
@@ -28,7 +36,7 @@ export function Ad({
}: {
zoneId: string | null;
spaceId: string;
placement: string;
placement: SiteInsightsAdPlacement;
ignore: boolean;
style?: ClassValue;
siteAdsStatus?: SiteAds['status'];
@@ -36,7 +44,20 @@ export function Ad({
}) {
const containerRef = React.useRef<HTMLDivElement>(null);
const [visible, setVisible] = React.useState(false);
const [ad, setAd] = React.useState<React.ReactNode | undefined>(undefined);
const [ad, setAd] = React.useState<
{ children: React.ReactNode; insightsAd: SiteInsightsAd | null } | undefined
>(undefined);
const trackEvent = useTrackEvent();
// Track display of the ad
React.useEffect(() => {
if (ad?.insightsAd) {
trackEvent({
type: 'ad_display',
ad: ad.insightsAd,
});
}
}, [ad]);
// Observe the container visibility
React.useEffect(() => {
@@ -121,7 +142,7 @@ export function Ad({
<div ref={containerRef} className={tcls(style)} data-visual-test="removed">
{ad ? (
<>
{ad}
{ad.children}
<AdSponsoredLink spaceId={spaceId} />
</>
) : null}
@@ -132,6 +153,7 @@ export function Ad({
function AdSponsoredLink(props: { spaceId: string }) {
const { spaceId } = props;
const language = useLanguage();
const trackEvent = useTrackEvent();
const viaUrl = new URL('https://www.gitbook.com');
viaUrl.searchParams.set('utm_source', 'content');
@@ -149,9 +171,17 @@ function AdSponsoredLink(props: { spaceId: string }) {
'dark:text-light/5',
)}
>
<a target="_blank" href={viaUrl.toString()} className={tcls('hover:underline')}>
<Link
target="_blank"
href={viaUrl.toString()}
className={tcls('hover:underline')}
insights={{
type: 'trademark_click',
placement: SiteInsightsTrademarkPlacement.Ad,
}}
>
{t(language, 'sponsored_via_gitbook')}
</a>
</Link>
</p>
);
}
@@ -1,20 +1,38 @@
import { SiteInsightsAd } from '@gitbook/api';
import * as React from 'react';
import { getResizedImageURL } from '@/lib/images';
import { tcls } from '@/lib/tailwind';
import { AdItem } from './types';
import { Link } from '../primitives';
/**
* Classic rendering for an ad.
*/
export async function AdClassicRendering({ ad }: { ad: AdItem }) {
export async function AdClassicRendering({
ad,
insightsAd,
}: {
ad: AdItem;
insightsAd: SiteInsightsAd | null;
}) {
const smallImgSrc =
'smallImage' in ad ? await getResizedImageURL(ad.smallImage, { width: 192, dpr: 2 }) : null;
const logoSrc =
'logo' in ad ? await getResizedImageURL(ad.logo, { width: 192 - 48, dpr: 2 }) : null;
return (
<a
<Link
rel="sponsored noopener"
target="_blank"
insights={
insightsAd
? {
type: 'ad_click',
ad: insightsAd,
}
: undefined
}
className={tcls(
'flex',
'flex-col',
@@ -29,8 +47,6 @@ export async function AdClassicRendering({ ad }: { ad: AdItem }) {
'p-4',
)}
href={ad.statlink}
rel="sponsored noopener"
target="_blank"
>
{smallImgSrc && 'smallImage' in ad ? (
<div>
@@ -47,6 +63,6 @@ export async function AdClassicRendering({ ad }: { ad: AdItem }) {
<div className={tcls('flex', 'flex-col')}>
<div className={tcls('text-xs')}>{ad.description}</div>
</div>
</a>
</Link>
);
}
@@ -1,3 +1,4 @@
import { SiteInsightsAd } from '@gitbook/api';
import * as React from 'react';
import { hexToRgba } from '@/lib/colors';
@@ -5,15 +6,32 @@ import { getResizedImageURL } from '@/lib/images';
import { tcls } from '@/lib/tailwind';
import { AdCover } from './types';
import { Link } from '../primitives';
/**
* Cover rendering for an ad.
*/
export async function AdCoverRendering({ ad }: { ad: AdCover }) {
export async function AdCoverRendering({
ad,
insightsAd,
}: {
ad: AdCover;
insightsAd: SiteInsightsAd | null;
}) {
const largeImage = await getResizedImageURL(ad.largeImage, { width: 128, dpr: 2 });
return (
<a
<Link
rel="sponsored noopener"
target="_blank"
insights={
insightsAd
? {
type: 'ad_click',
ad: insightsAd,
}
: undefined
}
className={tcls(
'group/ad',
'relative',
@@ -33,8 +51,6 @@ export async function AdCoverRendering({ ad }: { ad: AdCover }) {
)}
style={{ backgroundColor: ad.backgroundColor, color: ad.textColor ?? '#ffffff' }}
href={ad.statlink}
rel="sponsored noopener"
target="_blank"
>
<div
className={tcls(
@@ -106,6 +122,6 @@ export async function AdCoverRendering({ ad }: { ad: AdCover }) {
backgroundColor: hexToRgba(ad.backgroundColor, 0.8),
}}
/>
</a>
</Link>
);
}
@@ -1,5 +1,6 @@
'use server';
import { SiteInsightsAd, SiteInsightsAdPlacement } from '@gitbook/api';
import { headers } from 'next/headers';
import { AdClassicRendering } from './AdClassicRendering';
@@ -20,7 +21,7 @@ interface FetchLiveAdOptions {
/** Mode to render the Ad */
mode: 'classic' | 'auto' | 'cover';
/** Name of the placement for the ad */
placement: string;
placement: SiteInsightsAdPlacement;
/** If true, we'll not track it as an impression */
ignore: boolean;
}
@@ -47,16 +48,28 @@ export async function renderAd(options: FetchAdOptions) {
const { ad } = result;
return (
<>
{mode === 'classic' || !('callToAction' in ad) ? (
<AdClassicRendering ad={ad} />
) : (
<AdCoverRendering ad={ad} />
)}
{ad.pixel ? <AdPixels rawPixel={ad.pixel} /> : null}
</>
);
const insightsAd: SiteInsightsAd | null =
options.source === 'live'
? {
placement: options.placement,
zoneId: options.zoneId,
domain: 'company' in ad ? ad.company : '',
}
: null;
return {
children: (
<>
{mode === 'classic' || !('callToAction' in ad) ? (
<AdClassicRendering ad={ad} insightsAd={insightsAd} />
) : (
<AdCoverRendering ad={ad} insightsAd={insightsAd} />
)}
{ad.pixel ? <AdPixels rawPixel={ad.pixel} /> : null}
</>
),
insightsAd,
};
}
async function fetchAd({
@@ -7,7 +7,7 @@ import { Blocks } from '../Blocks';
import { InlineProps } from '../Inline';
import { Inlines } from '../Inlines';
export async function Annotation(props: InlineProps<DocumentInlineAnnotation>) {
export function Annotation(props: InlineProps<DocumentInlineAnnotation>) {
const { inline, context, document, children } = props;
const fragment = getNodeFragmentByType(inline, 'annotation-body');
@@ -35,8 +35,11 @@ export async function BlockContentRef(props: BlockProps<DocumentBlockContentRef>
title={resolved.text}
style={style}
insights={{
target: block.data.ref,
position: SiteInsightsLinkPosition.Content,
type: 'link_click',
link: {
target: block.data.ref,
position: SiteInsightsLinkPosition.Content,
},
}}
/>
);
@@ -23,6 +23,7 @@ export function Caption(
fit?: boolean;
wrapperStyle?: ClassValue;
block: DocumentBlockImage | DocumentBlockDrawing | DocumentBlockEmbed | DocumentBlockFile;
withBorder?: boolean;
} & DocumentContextProps,
) {
const {
@@ -31,15 +32,18 @@ export function Caption(
block,
context,
fit = false,
withBorder = false,
wrapperStyle = [
'relative',
'overflow-hidden',
'after:block',
'after:absolute',
'after:-inset-[0]',
'dark:after:mix-blend-plus-lighter',
'after:pointer-events-none',
fit ? 'w-fit' : null,
withBorder
? 'rounded straight-corners:rounded-none after:border-dark/2 after:border after:rounded straight-corners:after:rounded-none dark:after:border-light/1 dark:after:mix-blend-plus-lighter after:pointer-events-none'
: null,
],
style,
} = props;
@@ -0,0 +1,28 @@
'use client';
import { DocumentBlockCode } from '@gitbook/api';
import { useEffect, useState } from 'react';
import type { HighlightLine, RenderedInline } from './highlight';
import type { BlockProps } from '../Block';
import './theme.css';
import { ClientCodeBlockRenderer } from './CodeBlockRenderer';
import { highlightAction } from './highlight-action';
import { plainHighlight } from './plain-highlight';
type ClientBlockProps = Pick<BlockProps<DocumentBlockCode>, 'block' | 'style'> & {
inlines: RenderedInline[];
};
/**
* Render a code-block client-side by calling a server actions to highlight the code.
* It allows us to defer some load to avoid blocking the rendering of the whole page with block highlighting.
*/
export function ClientCodeBlock(props: ClientBlockProps) {
const { block, style, inlines } = props;
const [lines, setLines] = useState<HighlightLine[]>(() => plainHighlight(block));
useEffect(() => {
highlightAction(block, inlines).then(setLines);
}, [block, inlines]);
return <ClientCodeBlockRenderer block={block} style={style} lines={lines} />;
}
@@ -1,291 +1,43 @@
import { DocumentBlockCode, JSONDocument } from '@gitbook/api';
import type { DocumentBlockCode } from '@gitbook/api';
import { tcls } from '@/lib/tailwind';
import { getNodeFragmentByType } from '@/lib/document';
import { CopyCodeButton } from './CopyCodeButton';
import { highlight, HighlightLine, HighlightToken, plainHighlighting } from './highlight';
import { BlockProps } from '../Block';
import { DocumentContext } from '../DocumentView';
import { Inline } from '../Inline';
import './theme.css';
import { ClientCodeBlock } from './ClientCodeBlock';
import { getInlines, RenderedInline } from './highlight';
import { Blocks } from '../Blocks';
import { ServerCodeBlock } from './ServerCodeBlock';
/**
* Render an entire code-block. The syntax highlighting is done server-side.
* Render a code block, can be client-side or server-side.
*/
export async function CodeBlock(props: BlockProps<DocumentBlockCode>) {
const { block, document, style, context } = props;
const lines = await highlight(block);
const id = block.key!;
const withLineNumbers = !!block.data.lineNumbers && block.nodes.length > 1;
const withWrap = block.data.overflow === 'wrap';
const title = block.data.title;
const titleRoundingStyle = [
'rounded-md',
'straight-corners:rounded-sm',
title ? 'rounded-ss-none' : null,
];
return (
<div className={tcls('group/codeblock', 'grid', 'grid-flow-col', style)}>
<div
className={tcls(
'flex',
'items-center',
'justify-start',
'[grid-area:1/1]',
'text-sm',
'gap-2',
)}
>
{title ? (
<div
className={tcls(
'text-xs',
'tracking-wide',
'text-dark/7',
'leading-none',
'inline-flex',
'items-center',
'justify-center',
'bg-light-2',
'rounded-t',
'straight-corners:rounded-t-s',
'px-3',
'py-2',
'dark:bg-dark-2',
'dark:text-light/7',
)}
>
{title}
</div>
) : null}
</div>
<CopyCodeButton
codeId={id}
style={[
'group-hover/codeblock:opacity-[1]',
'transition-opacity',
'duration-75',
'opacity-0',
'text-xs',
'[grid-area:2/1]',
'z-[2]',
'justify-self-end',
'backdrop-blur-md',
'leading-none',
'self-start',
'ring-1',
'ring-dark/2',
'text-dark/7',
'bg-transparent',
'rounded-md',
'mr-2',
'mt-2',
'p-1',
'hover:ring-dark/3',
'dark:ring-light/2',
'dark:text-light/7',
'dark:hover:ring-light/3',
]}
/>
<pre
className={tcls(
'[grid-area:2/1]',
'relative',
'overflow-auto',
'bg-light-2',
'dark:bg-dark-2',
'border-light-4',
'dark:border-dark-4',
'hide-scroll',
titleRoundingStyle,
)}
>
<code
id={id}
className={tcls(
'min-w-full',
'inline-grid',
'[grid-template-columns:auto_1fr]',
'py-2',
'px-2',
'[counter-reset:line]',
withWrap ? 'whitespace-pre-wrap' : '',
)}
>
{lines.map((line, index) => (
<CodeHighlightLine
block={block}
document={document}
key={index}
line={line}
lineIndex={index + 1}
isLast={index === lines.length - 1}
withLineNumbers={withLineNumbers}
withWrap={withWrap}
context={context}
/>
))}
</code>
</pre>
</div>
);
}
function CodeHighlightLine(props: {
block: DocumentBlockCode;
document: JSONDocument;
line: HighlightLine;
lineIndex: number;
isLast: boolean;
withLineNumbers: boolean;
withWrap: boolean;
context: DocumentContext;
}) {
const { block, document, line, isLast, withLineNumbers, context } = props;
return (
<span
className={tcls(
'grid',
'[grid-template-columns:subgrid]',
'col-span-2',
'relative',
'ring-1',
'ring-transparent',
'hover:ring-dark-4/5',
'hover:z-[1]',
'dark:hover:ring-light-4/4',
'rounded',
//first child
'[&.highlighted:first-child]:rounded-t-md',
'[&.highlighted:first-child>*]:mt-1',
//last child
'[&.highlighted:last-child]:rounded-b-md',
'[&.highlighted:last-child>*]:mb-1',
//is only child, dont hover effect line
'[&:only-child]:hover:ring-transparent',
//select all highlighted
'[&.highlighted]:rounded-none',
//select first in group
'[&:not(.highlighted)_+_.highlighted]:rounded-t-md',
'[&:not(.highlighted)_+_.highlighted>*]:mt-1',
//select last in group
'[&.highlighted:has(+:not(.highlighted))]:rounded-b-md',
'[&.highlighted:has(+:not(.highlighted))>*]:mb-1',
//select if highlight is singular in group
'[&:not(.highlighted)_+_.highlighted:has(+:not(.highlighted))]:rounded-md',
line.highlighted ? ['highlighted', 'bg-light-3', 'dark:bg-dark-3'] : null,
)}
>
{withLineNumbers ? (
<span
className={tcls(
'text-sm',
'text-right',
'pr-3.5',
'rounded-l',
'pl-2',
'sticky',
'left-[-3px]',
'bg-gradient-to-r',
'from-80%',
'from-light-2',
'to-transparent',
'dark:from-dark-2',
'dark:to-transparent',
withLineNumbers
? [
'before:text-dark/5',
'before:content-[counter(line)]',
'[counter-increment:line]',
'dark:before:text-light/4',
line.highlighted
? [
'before:text-dark/6',
'dark:before:text-light/8',
'bg-gradient-to-r',
'from-80%',
'from-light-3',
'to-transparent',
'dark:from-dark-3',
'dark:to-transparent',
]
: null,
]
: [],
)}
></span>
) : null}
<span className={tcls('ml-3', 'block', 'text-sm')}>
<CodeHighlightTokens tokens={line.tokens} document={document} context={context} />
{isLast ? null : !withLineNumbers && line.tokens.length === 0 && 0 ? (
<span className="ew">{'\u200B'}</span>
) : (
'\n'
)}
</span>
</span>
);
}
function CodeHighlightTokens(props: {
tokens: HighlightToken[];
document: JSONDocument;
context: DocumentContext;
}) {
const { tokens, document, context } = props;
return (
<>
{tokens.map((token, index) => (
<CodeHighlightToken
export function CodeBlock(props: BlockProps<DocumentBlockCode>) {
const { block, document, style, context, isEstimatedOffscreen } = props;
const inlines = getInlines(block);
const richInlines: RenderedInline[] = inlines.map((inline, index) => {
const body = (() => {
const fragment = getNodeFragmentByType(inline.inline, 'annotation-body');
if (!fragment) {
return null;
}
return (
<Blocks
key={index}
token={token}
document={document}
ancestorBlocks={[]}
context={context}
nodes={fragment.nodes}
style={['space-y-4']}
/>
))}
</>
);
}
function CodeHighlightToken(props: {
token: HighlightToken;
document: JSONDocument;
context: DocumentContext;
}) {
const { token, document, context } = props;
if (token.type === 'inline') {
return (
<Inline
inline={token.inline}
document={document}
context={context}
ancestorInlines={[]}
>
<CodeHighlightTokens
tokens={token.children}
document={document}
context={context}
/>
</Inline>
);
}
if (token.type === 'plain') {
return <>{token.content}</>;
}
if (!token.token.color) {
return <>{token.token.content}</>;
}
return <span style={{ color: token.token.color }}>{token.token.content}</span>;
);
})();
return { inline, body };
});
if (isEstimatedOffscreen) {
return <ClientCodeBlock block={block} style={style} inlines={richInlines} />;
}
return <ServerCodeBlock block={block} style={style} inlines={richInlines} />;
}
@@ -0,0 +1,36 @@
.highlight-line {
@apply grid [grid-template-columns:subgrid] col-span-2 relative ring-1 ring-transparent hover:ring-dark-4/5 hover:z-[1] dark:hover:ring-light-4/4 rounded;
@apply only:hover:ring-transparent;
&.highlighted {
@apply bg-light-3 dark:bg-dark-3;
@apply first:rounded-t-md *:first:mt-1;
@apply last:rounded-b-md *:last:mb-1;
@apply rounded-none;
}
&:not(.highlighted) + .highlighted {
@apply rounded-t-md *:mt-1;
}
&.highlighted:has(+ :not(.highlighted)) {
@apply rounded-b-md *:mb-1;
}
&:not(.highlighted) + .highlighted:has(+ :not(.highlighted)) {
@apply rounded-md;
}
}
.highlight-line-number {
@apply text-sm text-right pr-3.5 rounded-l pl-2 sticky left-[-3px] bg-gradient-to-r from-80% from-light-2 to-transparent dark:from-dark-2 dark:to-transparent;
@apply before:text-dark/5 before:content-[counter(line)] [counter-increment:line] dark:before:text-light/4;
&.highlighted {
@apply before:text-dark/6 dark:before:text-light/8 bg-gradient-to-r from-80% from-light-3 to-transparent dark:from-dark-3 dark:to-transparent;
}
}
.highlight-line-content {
@apply ml-3 block text-sm;
}
@@ -0,0 +1,190 @@
import { DocumentBlockCode, JSONDocument } from '@gitbook/api';
import { tcls } from '@/lib/tailwind';
import { CopyCodeButton } from './CopyCodeButton';
import type { HighlightLine, HighlightToken } from './highlight';
import { AnnotationPopover } from '../Annotation/AnnotationPopover';
import { BlockProps } from '../Block';
import './theme.css';
import './CodeBlockRenderer.css';
type CodeBlockRendererProps = Pick<BlockProps<DocumentBlockCode>, 'block' | 'style'> & {
lines: HighlightLine[];
};
/**
* The logic of rendering a code block from lines.
*/
export function ClientCodeBlockRenderer(props: CodeBlockRendererProps) {
const { block, style, lines } = props;
const id = block.key!;
const withLineNumbers = !!block.data.lineNumbers && block.nodes.length > 1;
const withWrap = block.data.overflow === 'wrap';
const title = block.data.title;
const titleRoundingStyle = [
'rounded-md',
'straight-corners:rounded-sm',
title ? 'rounded-ss-none' : null,
];
return (
<div className={tcls('group/codeblock grid grid-flow-col', style)}>
<div className="flex items-center justify-start [grid-area:1/1] text-sm gap-2">
{title ? (
<div
className={tcls(
'text-xs',
'tracking-wide',
'text-dark/7',
'leading-none',
'inline-flex',
'items-center',
'justify-center',
'bg-light-2',
'rounded-t',
'straight-corners:rounded-t-s',
'px-3',
'py-2',
'dark:bg-dark-2',
'dark:text-light/7',
)}
>
{title}
</div>
) : null}
</div>
<CopyCodeButton
codeId={id}
style={[
'group-hover/codeblock:opacity-[1]',
'transition-opacity',
'duration-75',
'opacity-0',
'text-xs',
'[grid-area:2/1]',
'z-[2]',
'justify-self-end',
'backdrop-blur-md',
'leading-none',
'self-start',
'ring-1',
'ring-dark/2',
'text-dark/7',
'bg-transparent',
'rounded-md',
'mr-2',
'mt-2',
'p-1',
'hover:ring-dark/3',
'dark:ring-light/2',
'dark:text-light/7',
'dark:hover:ring-light/3',
]}
/>
<pre
className={tcls(
'[grid-area:2/1]',
'relative',
'overflow-auto',
'bg-light-2',
'dark:bg-dark-2',
'border-light-4',
'dark:border-dark-4',
'hide-scroll',
titleRoundingStyle,
)}
>
<code
id={id}
className={tcls(
'min-w-full',
'inline-grid',
'[grid-template-columns:auto_1fr]',
'py-2',
'px-2',
'[counter-reset:line]',
withWrap ? 'whitespace-pre-wrap' : '',
)}
>
{lines.map((line, index) => (
<CodeHighlightLine
block={block}
key={index}
line={line}
lineIndex={index + 1}
isLast={index === lines.length - 1}
withLineNumbers={withLineNumbers}
withWrap={withWrap}
/>
))}
</code>
</pre>
</div>
);
}
function CodeHighlightLine(props: {
block: DocumentBlockCode;
line: HighlightLine;
lineIndex: number;
isLast: boolean;
withLineNumbers: boolean;
withWrap: boolean;
}) {
const { line, isLast, withLineNumbers } = props;
return (
<span className={tcls('highlight-line', line.highlighted && 'highlighted')}>
{withLineNumbers ? (
<span
className={tcls('highlight-line-number', line.highlighted && 'highlighted')}
></span>
) : null}
<span className="highlight-line-content">
<CodeHighlightTokens tokens={line.tokens} />
{isLast ? null : !withLineNumbers && line.tokens.length === 0 && 0 ? (
<span className="ew">{'\u200B'}</span>
) : (
'\n'
)}
</span>
</span>
);
}
function CodeHighlightTokens(props: { tokens: HighlightToken[] }) {
const { tokens } = props;
return (
<>
{tokens.map((token, index) => (
<CodeHighlightToken key={index} token={token} />
))}
</>
);
}
function CodeHighlightToken(props: { token: HighlightToken }) {
const { token } = props;
if (token.type === 'annotation') {
return (
<AnnotationPopover body={token.body}>
<CodeHighlightTokens tokens={token.children} />
</AnnotationPopover>
);
}
if (token.type === 'plain') {
return <>{token.content}</>;
}
if (!token.token.color) {
return <>{token.token.content}</>;
}
return <span style={{ color: token.token.color }}>{token.token.content}</span>;
}
@@ -53,7 +53,8 @@ export function PlainCodeBlock(props: { code: string; syntax: string }) {
}}
block={block}
ancestorBlocks={[]}
isEstimatedOffscreen={false}
// We optimize perf by default
isEstimatedOffscreen
/>
);
}
@@ -0,0 +1,19 @@
import { DocumentBlockCode } from '@gitbook/api';
import { highlight, RenderedInline } from './highlight';
import type { BlockProps } from '../Block';
import './theme.css';
import { ClientCodeBlockRenderer } from './CodeBlockRenderer';
type ClientBlockProps = Pick<BlockProps<DocumentBlockCode>, 'block' | 'style'> & {
inlines: RenderedInline[];
};
/**
* Render a code-block server-side.
*/
export async function ServerCodeBlock(props: ClientBlockProps) {
const { block, style, inlines } = props;
const lines = await highlight(block, inlines);
return <ClientCodeBlockRenderer block={block} style={style} lines={lines} />;
}
@@ -0,0 +1,14 @@
'use server';
import { DocumentBlockCode } from '@gitbook/api';
import { highlight, RenderedInline } from './highlight';
/**
* Server action to highlight a code block.
* By using a server action, we can avoid loading the highlighter on the client-side
* and increasing the bundle size.
*/
export async function highlightAction(block: DocumentBlockCode, inlines: RenderedInline[]) {
return highlight(block, inlines);
}
@@ -1,9 +1,18 @@
import type { DocumentBlockCode } from '@gitbook/api';
import { it, expect } from 'bun:test';
import { highlight } from './highlight';
import { getInlines, highlight, RenderedInline } from './highlight';
async function highlightWithInlines(block: DocumentBlockCode) {
const inlines: RenderedInline[] = getInlines(block).map((inline) => ({
inline,
body: null,
}));
return highlight(block, inlines);
}
it('should parse plain code', async () => {
const tokens = await highlight({
const tokens = await highlightWithInlines({
object: 'block',
type: 'code',
data: {},
@@ -38,32 +47,35 @@ it('should parse plain code', async () => {
it('should parse different code in parallel', async () => {
await Promise.all(
['shell', 'scss', 'markdown', 'less', 'scss', 'css', 'scss', 'yaml'].map(async (syntax) =>
highlight({
object: 'block',
type: 'code',
data: {
syntax: syntax,
},
nodes: [
{
object: 'block',
type: 'code-line',
data: {},
nodes: [
{
object: 'text',
leaves: [{ object: 'leaf', marks: [], text: 'Hello world' }],
},
],
highlight(
{
object: 'block',
type: 'code',
data: {
syntax: syntax,
},
],
}),
nodes: [
{
object: 'block',
type: 'code-line',
data: {},
nodes: [
{
object: 'text',
leaves: [{ object: 'leaf', marks: [], text: 'Hello world' }],
},
],
},
],
},
[],
),
),
);
});
it('should parse a multilines plain code', async () => {
const tokens = await highlight({
const tokens = await highlightWithInlines({
object: 'block',
type: 'code',
data: {},
@@ -138,7 +150,7 @@ it('should parse a multilines plain code', async () => {
});
it('should parse code with an inline on a single line', async () => {
const tokens = await highlight({
const tokens = await highlightWithInlines({
object: 'block',
type: 'code',
data: {
@@ -192,10 +204,8 @@ it('should parse code with an inline on a single line', async () => {
},
},
{
type: 'inline',
inline: {
type: 'annotation',
},
type: 'annotation',
body: null,
children: [
{
type: 'shiki',
@@ -229,7 +239,7 @@ it('should parse code with an inline on a single line', async () => {
});
it('should parse code with an inline on a multiple line', async () => {
const tokens = await highlight({
const tokens = await highlightWithInlines({
object: 'block',
type: 'code',
data: {
@@ -310,10 +320,8 @@ it('should parse code with an inline on a multiple line', async () => {
},
},
{
type: 'inline',
inline: {
type: 'annotation',
},
type: 'annotation',
body: null,
children: [
{
type: 'shiki',
@@ -347,10 +355,8 @@ it('should parse code with an inline on a multiple line', async () => {
},
},
{
type: 'inline',
inline: {
type: 'annotation',
},
type: 'annotation',
body: null,
children: [
{
type: 'shiki',
@@ -384,7 +390,7 @@ it('should parse code with an inline on a multiple line', async () => {
});
it('should support code token finishing before the end of the annotation', async () => {
const tokens = await highlight({
const tokens = await highlightWithInlines({
object: 'block',
type: 'code',
isVoid: false,
@@ -470,10 +476,8 @@ it('should support code token finishing before the end of the annotation', async
},
},
{
type: 'inline',
inline: {
type: 'annotation',
},
type: 'annotation',
body: null,
children: [
{
type: 'shiki',
@@ -507,7 +511,7 @@ it('should support code token finishing before the end of the annotation', async
});
it('should support multiple code tokens in an annotation', async () => {
const tokens = await highlight({
const tokens = await highlightWithInlines({
object: 'block',
type: 'code',
isVoid: false,
@@ -615,11 +619,8 @@ it('should support multiple code tokens in an annotation', async () => {
},
},
{
type: 'inline',
inline: {
object: 'inline',
type: 'annotation',
},
type: 'annotation',
body: null,
children: [
{
type: 'shiki',
@@ -653,7 +654,7 @@ it('should support multiple code tokens in an annotation', async () => {
});
it('should handle \\r', async () => {
const tokens = await highlight({
const tokens = await highlightWithInlines({
object: 'block',
type: 'code',
data: {
@@ -12,6 +12,8 @@ import { asyncMutexFunction, singleton } from '@/lib/async';
import { getNodeText } from '@/lib/document';
import { trace } from '@/lib/tracing';
import { plainHighlight } from './plain-highlight';
export type HighlightLine = {
highlighted: boolean;
tokens: HighlightToken[];
@@ -20,28 +22,31 @@ export type HighlightLine = {
export type HighlightToken =
| { type: 'plain'; content: string }
| { type: 'shiki'; token: ThemedToken }
| { type: 'inline'; inline: DocumentInlineAnnotation; children: HighlightToken[] };
| { type: 'annotation'; body: React.ReactNode; children: HighlightToken[] };
type InlineIndexed = { inline: any; start: number; end: number };
export type InlineIndexed = { inline: any; start: number; end: number };
type PositionedToken = ThemedToken & { start: number; end: number };
export type RenderedInline = {
inline: InlineIndexed;
body: React.ReactNode;
};
/**
* Highlight a code block while preserving inline elements.
*/
export async function highlight(block: DocumentBlockCode): Promise<HighlightLine[]> {
export async function highlight(
block: DocumentBlockCode,
inlines: RenderedInline[],
): Promise<HighlightLine[]> {
const langName = block.data.syntax ? getLanguageForSyntax(block.data.syntax) : null;
if (!langName) {
// Language not found, fallback to plain highlighting
return plainHighlighting(block);
return plainHighlight(block, inlines);
}
const inlines: InlineIndexed[] = [];
const code = getPlainCodeBlock(block, inlines);
inlines.sort((a, b) => {
return a.start - b.start;
});
const code = getPlainCodeBlock(block);
const highlighter = await loadHighlighter();
await loadHighlighterLanguage(highlighter, langName);
@@ -96,6 +101,12 @@ function getLanguageForSyntax(syntax: string): BundledLanguage | null {
// Normalize the syntax to lowercase.
syntax = syntax.toLowerCase();
// Temporary disable highlighting for C/C++ code blocks
// @see https://github.com/shikijs/shiki/issues/893
if (syntax === 'cpp' || syntax === 'c') {
return null;
}
// Check if the syntax is a bundled language.
if (checkIsBundledLanguage(syntax)) {
return syntax;
@@ -110,10 +121,25 @@ function getLanguageForSyntax(syntax: string): BundledLanguage | null {
return null;
}
export function getInlines(block: DocumentBlockCode) {
const inlines: InlineIndexed[] = [];
getPlainCodeBlock(block, inlines);
inlines.sort((a, b) => {
return a.start - b.start;
});
return inlines;
}
/**
* Parse a code block without highlighting it.
*/
export function plainHighlighting(block: DocumentBlockCode): HighlightLine[] {
export function plainHighlighting(
block: DocumentBlockCode,
inlines?: RenderedInline[],
): HighlightLine[] {
const inlinesCopy = Array.from(inlines ?? []);
return block.nodes.map((lineBlock) => {
const tokens: HighlightToken[] = [];
@@ -124,9 +150,10 @@ export function plainHighlighting(block: DocumentBlockCode): HighlightLine[] {
content: getNodeText(node),
});
} else {
const inline = inlinesCopy.shift();
tokens.push({
type: 'inline',
inline: node,
type: 'annotation',
body: inline?.body ?? null,
children: [
{
type: 'plain',
@@ -146,7 +173,7 @@ export function plainHighlighting(block: DocumentBlockCode): HighlightLine[] {
function matchTokenAndInlines(
eat: () => PositionedToken | null,
allInlines: InlineIndexed[],
allInlines: RenderedInline[],
): HighlightToken[] {
const initialToken = eat();
if (!initialToken) {
@@ -154,7 +181,7 @@ function matchTokenAndInlines(
}
const inlines = allInlines.filter(
(inline) => inline.start >= initialToken.start && inline.start < initialToken.end,
({ inline }) => inline.start >= initialToken.start && inline.start < initialToken.end,
);
let token = initialToken;
const result: HighlightToken[] = [];
@@ -170,7 +197,7 @@ function matchTokenAndInlines(
return;
}
const [before, afterBefore] = splitPositionedTokenAt(token, inline.start);
const [before, afterBefore] = splitPositionedTokenAt(token, inline.inline.start);
if (before) {
result.push({
type: 'shiki',
@@ -185,7 +212,7 @@ function matchTokenAndInlines(
const children: HighlightToken[] = [];
// If shiki token finished before the end of the annotation or the annotation contains multiple tokens
while (token.end < inline.end) {
while (token.end < inline.inline.end) {
children.push({
type: 'shiki',
token: token,
@@ -198,7 +225,7 @@ function matchTokenAndInlines(
token = next;
}
const [inside, after] = splitPositionedTokenAt(token, inline.end);
const [inside, after] = splitPositionedTokenAt(token, inline.inline.end);
if (!inside) {
throw new Error(`expect inside to not be empty`);
}
@@ -209,8 +236,8 @@ function matchTokenAndInlines(
});
result.push({
type: 'inline',
inline: inline.inline,
type: 'annotation',
body: inline.body,
children,
});
@@ -224,11 +251,11 @@ function matchTokenAndInlines(
return result;
}
function getPlainCodeBlock(code: DocumentBlockCode, inlines: InlineIndexed[]): string {
function getPlainCodeBlock(code: DocumentBlockCode, inlines?: InlineIndexed[]): string {
let content = '';
code.nodes.forEach((node, index) => {
const lineContent = getPlainCodeBlockLine(node, inlines, content.length);
const lineContent = getPlainCodeBlockLine(node, content.length, inlines);
content += lineContent;
if (index < code.nodes.length - 1) {
@@ -241,8 +268,8 @@ function getPlainCodeBlock(code: DocumentBlockCode, inlines: InlineIndexed[]): s
function getPlainCodeBlockLine(
parent: DocumentBlockCodeLine | DocumentInlineAnnotation,
inlines: InlineIndexed[],
index: number,
inlines?: InlineIndexed[],
): string {
let content = '';
@@ -251,14 +278,16 @@ function getPlainCodeBlockLine(
content += cleanupLine(node.leaves.map((leaf) => leaf.text).join(''));
} else {
const start = index + content.length;
content += getPlainCodeBlockLine(node, inlines, index + content.length);
content += getPlainCodeBlockLine(node, index + content.length, inlines);
const end = index + content.length;
inlines.push({
inline: node,
start,
end,
});
if (inlines) {
inlines.push({
inline: node,
start,
end,
});
}
}
}
@@ -335,7 +364,8 @@ const loadHighlighter = singleton(async () => {
});
const loadLanguagesMutex = asyncMutexFunction();
async function loadHighlighterLanguage(
const loadHighlighterLanguage = async function loadHighlighterLanguage(
highlighter: HighlighterGeneric<any, any>,
lang: keyof typeof bundledLanguages,
) {
@@ -349,4 +379,4 @@ async function loadHighlighterLanguage(
async () => await highlighter.loadLanguage(lang),
);
});
}
};
@@ -0,0 +1,44 @@
import { DocumentBlockCode } from '@gitbook/api';
import { getNodeText } from '@/lib/document';
import type { HighlightLine, HighlightToken, RenderedInline } from './highlight';
/**
* Parse a code block without highlighting it.
*/
export function plainHighlight(
block: DocumentBlockCode,
inlines?: RenderedInline[],
): HighlightLine[] {
const inlinesCopy = Array.from(inlines ?? []);
return block.nodes.map((lineBlock) => {
const tokens: HighlightToken[] = [];
for (const node of lineBlock.nodes) {
if (node.object === 'text') {
tokens.push({
type: 'plain',
content: getNodeText(node),
});
} else {
const inline = inlinesCopy.shift();
tokens.push({
type: 'annotation',
body: inline?.body ?? null,
children: [
{
type: 'plain',
content: getNodeText(node),
},
],
});
}
}
return {
highlighted: !!lineBlock.data.highlighted,
tokens,
};
});
}
@@ -1,5 +1,4 @@
import * as gitbookAPI from '@gitbook/api';
import { headers } from 'next/headers';
import Script from 'next/script';
import ReactDOM from 'react-dom';
@@ -23,7 +22,7 @@ export async function Embed(props: BlockProps<gitbookAPI.DocumentBlockEmbed>) {
: getEmbedByUrl(block.data.url));
return (
<Caption {...props}>
<Caption {...props} withBorder>
{embed.type === 'rich' ? (
<>
<div
@@ -32,7 +31,11 @@ export async function Embed(props: BlockProps<gitbookAPI.DocumentBlockEmbed>) {
}}
data-visual-test="blackout"
/>
<Script src="https://cdn.iframe.ly/embed.js" nonce={nonce} />
<Script
strategy="lazyOnload"
src="https://cdn.iframe.ly/embed.js"
nonce={nonce}
/>
</>
) : embed.type === 'integration' ? (
<IntegrationBlock
@@ -21,26 +21,25 @@ export async function File(props: BlockProps<DocumentBlockFile>) {
const contentType = getSimplifiedContentType(file.contentType);
return (
<Caption {...props} wrapperStyle={[]}>
<Caption {...props} withBorder>
<Link
href={file.downloadURL}
download={file.name}
insights={{
target: block.data.ref,
position: SiteInsightsLinkPosition.Content,
type: 'link_click',
link: {
target: block.data.ref,
position: SiteInsightsLinkPosition.Content,
},
}}
className={tcls(
'group/file',
'flex',
'flex-row',
'items-center',
'border',
'px-5',
'py-3',
'border-dark/3',
'rounded-lg',
'hover:text-primary-600',
'dark:border-light/3',
'dark:hover:text-primary-300',
)}
>
@@ -18,7 +18,10 @@ export function Heading(props: BlockProps<DocumentBlockHeading>) {
id = context.getId ? context.getId(id) : id;
return (
<Tag id={id} className={tcls(textStyle.textSize, 'group', 'relative', 'grid', style)}>
<Tag
id={id}
className={tcls(textStyle.textSize, 'heading', 'group', 'relative', 'grid', style)}
>
<div
className={tcls(
'grid',
@@ -27,8 +27,11 @@ export async function InlineLink(props: InlineProps<DocumentInlineLink>) {
href={resolved.href}
className="underline underline-offset-2 text-primary hover:text-primary-700 transition-colors"
insights={{
target: inline.data.ref,
position: SiteInsightsLinkPosition.Content,
type: 'link_click',
link: {
target: inline.data.ref,
position: SiteInsightsLinkPosition.Content,
},
}}
>
<Inlines
@@ -19,8 +19,11 @@ export async function Mention(props: InlineProps<DocumentInlineMention>) {
<StyledLink
href={resolved.href}
insights={{
target: inline.data.ref,
position: SiteInsightsLinkPosition.Content,
type: 'link_click',
link: {
target: inline.data.ref,
position: SiteInsightsLinkPosition.Content,
},
}}
>
{resolved.text}
@@ -17,12 +17,10 @@ import './scalar.css';
* Render an OpenAPI block.
*/
export async function OpenAPI(props: BlockProps<DocumentBlockOpenAPI>) {
const { block, style } = props;
const { style } = props;
return (
<div className={tcls('w-full', 'flex', 'flex-row', style, 'max-w-full')}>
<React.Suspense fallback={<OpenAPIFallback />}>
<OpenAPIBody {...props} />
</React.Suspense>
<OpenAPIBody {...props} />
</div>
);
}
@@ -35,14 +35,17 @@ export async function RecordCard(
'z-0',
'relative',
'grid',
'bg-light',
'bg-light-1',
'dark:bg-dark-1',
'w-[calc(100%+2px)]',
'h-[calc(100%+2px)]',
'inset-[-1px]',
'rounded-[7px]',
'straight-corners:rounded-none',
'overflow-hidden',
'dark:bg-dark',
'[&_.heading]:flip-heading-hash',
'[&_.blocks:first-child_.heading:first-child_div]:mt-0', // Remove margin on first heading in card
cover
? [
// On mobile, the cover is displayed on the left with 40% of the width
@@ -163,8 +166,11 @@ export async function RecordCard(
'dark:hover:before:ring-light/4',
])}
insights={{
target: targetRef,
position: SiteInsightsLinkPosition.Content,
type: 'link_click',
link: {
target: targetRef,
position: SiteInsightsLinkPosition.Content,
},
}}
>
{body}
@@ -126,6 +126,7 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
ancestorBlocks={[]}
nodes={fragment.nodes}
style={[
'blocks',
'w-full',
'space-y-2',
'lg:space-y-3',
@@ -167,11 +168,14 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
insights={
ref.file
? {
target: {
kind: 'file',
file: ref.file.id,
type: 'link_click',
link: {
target: {
kind: 'file',
file: ref.file.id,
},
position: SiteInsightsLinkPosition.Content,
},
position: SiteInsightsLinkPosition.Content,
}
: undefined
}
@@ -224,8 +228,11 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
insights={
contentRef
? {
target: contentRef,
position: SiteInsightsLinkPosition.Content,
type: 'link_click',
link: {
target: contentRef,
position: SiteInsightsLinkPosition.Content,
},
}
: undefined
}
@@ -259,8 +266,11 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
key={index}
href={resolved.href}
insights={{
target: contentRef,
position: SiteInsightsLinkPosition.Content,
type: 'link_click',
link: {
target: contentRef,
position: SiteInsightsLinkPosition.Content,
},
}}
>
{resolved.text}
@@ -6,6 +6,7 @@ import {
DocumentMarkStrikethrough,
DocumentText,
DocumentTextMark,
DocumentMarkKeyboard,
} from '@gitbook/api';
import React from 'react';
@@ -47,6 +48,7 @@ const MARK_STYLES = {
code: Code,
strikethrough: Strikethrough,
color: Color,
keyboard: Keyboard,
};
interface MarkedLeafProps<Mark extends DocumentTextMark> {
@@ -66,6 +68,14 @@ function Strikethrough(props: MarkedLeafProps<DocumentMarkStrikethrough>) {
return <s className={tcls('line-through')}>{props.children}</s>;
}
function Keyboard(props: MarkedLeafProps<DocumentMarkKeyboard>) {
return (
<kbd className="rounded border px-1 font-mono shadow-[0_1px_0_0_theme(borderColor.DEFAULT)]">
{props.children}
</kbd>
);
}
function Code(props: MarkedLeafProps<DocumentMarkCode>) {
return (
<code
@@ -120,10 +130,10 @@ const textColorToStyle: { [color in DocumentMarkColor['data']['text']]: ClassVal
const backgroundColorToStyle: { [color in DocumentMarkColor['data']['background']]: ClassValue } = {
default: [],
blue: ['bg-blue-200', 'dark:bg-blue-900'],
red: ['bg-red-200', 'dark:bg-red-900'],
green: ['bg-green-200', 'dark:bg-green-900'],
yellow: ['bg-yellow-100', 'dark:bg-yellow-900'],
purple: ['bg-purple-200', 'dark:bg-purple-900'],
orange: ['bg-orange-200', 'dark:bg-orange-900'],
blue: ['bg-mark-blue'],
red: ['bg-mark-red'],
green: ['bg-mark-green'],
yellow: ['bg-mark-yellow'],
purple: ['bg-mark-purple'],
orange: ['bg-mark-orange'],
};
+101 -108
View File
@@ -2,6 +2,7 @@ import { CustomizationSettings, SiteCustomizationSettings, Space } from '@gitboo
import React from 'react';
import { Image } from '@/components/utils';
import { partition } from '@/lib/arrays';
import { ContentRefContext } from '@/lib/references';
import { tcls } from '@/lib/tailwind';
@@ -9,6 +10,8 @@ import { FooterLinksGroup } from './FooterLinksGroup';
import { CONTAINER_STYLE } from '../layout';
import { ThemeToggler } from '../ThemeToggler';
const FOOTER_COLUMNS = 4;
export function Footer(props: {
space: Space;
context: ContentRefContext;
@@ -17,125 +20,115 @@ export function Footer(props: {
const { context, customization } = props;
return (
<div
className={tcls(
'border-t',
'border-dark/2',
'bg-light-2',
'dark:border-light/2',
'dark:bg-dark-2',
)}
>
<div className={tcls('scroll-nojump')}>
<footer className="border-t border-dark/2 dark:border-light/2 scroll-nojump">
<div
className={tcls(
CONTAINER_STYLE,
'py-8',
'gap-12',
'flex',
'flex-wrap',
'items-start',
)}
>
{/* Footer Logo */}
<div className="shrink sm:basis-72 page-no-toc:lg:basis-56 mr-auto order-1 empty:hidden empty:lg:block page-no-toc:empty:lg:hidden page-no-toc:empty:xl:block">
{customization.footer.logo ? (
<Image
alt="Logo"
sources={{
light: {
src: customization.footer.logo.light,
},
dark: customization.footer.logo.dark
? {
src: customization.footer.logo.dark,
}
: null,
}}
priority="lazy"
style={[
'w-auto',
'max-w-40',
'lg:max-w-64',
'max-h-12',
'object-contain',
'object-left',
'rounded',
'straight-corners:rounded-sm',
]}
sizes={[
{
width: 320,
},
]}
/>
) : null}
</div>
{/* Mode Switcher */}
<div
className={tcls(
'flex',
'flex-col',
CONTAINER_STYLE,
'py-6',
'gap-6',
'md:flex-row',
'md:gap-10',
//override CONTAINER_STYLE px
'px-0',
'sm:px-0',
'ml-auto',
'order-2 lg:order-4',
customization.footer.groups?.length < 3 &&
customization.footer.logo == undefined &&
'sm:order-4',
'xl:basis-56',
)}
>
{' '}
{customization.footer.logo ? (
<div
className={tcls(
'pt-8',
'border-t',
'border-dark/2',
'dark:border-light/1',
'px-4',
'sm:px-6',
'md:px-0',
'md:pr-10',
'md:border-r',
'md:border-t-0',
'md:pt-0',
)}
>
<Image
alt="Logo"
sources={{
light: {
src: customization.footer.logo.light,
},
dark: customization.footer.logo.dark
? {
src: customization.footer.logo.dark,
}
: null,
}}
priority="lazy"
style={['h-auto', 'w-full', 'max-w-12', 'object-contain']}
sizes={[
{
width: 320,
},
]}
/>
</div>
) : null}
<div
className={tcls(
'flex-1',
'flex',
'flex-col',
'gap-6',
'px-4',
'sm:px-6',
'md:px-0',
)}
>
{customization.footer.logo || customization.footer.groups?.length > 0 ? (
<div
className={tcls(
'flex',
'flex-col',
'gap-10',
'sm:gap-20',
'sm:flex-row',
'items-start',
'flex-wrap',
)}
>
{customization.footer.groups.map((group, index) => (
<FooterLinksGroup key={index} group={group} context={context} />
))}
</div>
) : null}
{customization.footer.copyright ? (
<p className={tcls('text-xs', 'text-dark/7', 'dark:text-light/6')}>
{customization.footer.copyright}
</p>
) : null}
</div>
{customization.themes.toggeable ? (
<div
className={tcls(
'flex',
'flex-col',
'items-start',
'order-[-1]',
'px-4',
'sm:px-6',
'md:px-0',
'md:order-1',
)}
>
<div className="flex items-center justify-end">
<React.Suspense fallback={null}>
<ThemeToggler />
</React.Suspense>
</div>
) : null}
</div>
{/* Navigation Groups (split into equal columns) */}
{customization.footer.groups?.length > 0 ? (
<div
className={tcls(
'flex flex-col sm:flex-row mx-auto grow lg:max-w-3xl gap-10 sm:gap-6 order-3',
'w-full lg:w-auto',
customization.footer.groups?.length < 3 &&
customization.footer.logo == undefined &&
'sm:w-auto',
)}
>
{partition(customization.footer.groups, FOOTER_COLUMNS).map(
(column, columnIndex) => (
<div key={columnIndex} className="flex w-full flex-col gap-10">
{column.map((group, groupIndex) => (
<FooterLinksGroup
key={groupIndex}
group={group}
context={context}
/>
))}
</div>
),
)}
</div>
) : null}
{/* Legal */}
<div
className={tcls(
'mx-auto w-full grow text-xs text-dark/7 dark:text-light/6 items-center text-center order-4 flex flex-col gap-2 empty:hidden',
customization.footer.groups.length == 0 &&
'order-2 lg:flex-1 lg:w-auto lg:items-start lg:max-w-3xl self-center lg:text-start',
customization.footer.groups.length == 0 &&
customization.footer.logo == undefined &&
'sm:w-auto sm:flex-1 sm:items-start sm:max-w-3xl sm:text-start',
)}
>
{customization.footer.copyright ? (
<p>{customization.footer.copyright}</p>
) : null}
</div>
</div>
</div>
</footer>
);
}
@@ -16,12 +16,18 @@ export function FooterLinksGroup(props: {
const { group, context } = props;
return (
<div className={tcls('flex', 'flex-col', 'gap-3')}>
<p className={tcls('text-base', 'font-semibold')}>{group.title}</p>
{group.links.map((link, index) => {
return <FooterLink key={index} link={link} context={context} />;
})}
</div>
<nav className="text-sm flex flex-col gap-4">
<h4 className="font-semibold">{group.title}</h4>
<ul className="flex flex-col items-start gap-4">
{group.links.map((link, index) => {
return (
<li key={index}>
<FooterLink link={link} context={context} />
</li>
);
})}
</ul>
</nav>
);
}
@@ -37,16 +43,22 @@ async function FooterLink(props: { link: CustomizationContentLink; context: Cont
<Link
href={resolved.href}
className={tcls(
'text-sm',
'font-normal',
'text-dark/8',
'hover:text-dark/9',
'hover:text-primary',
'dark:text-light/8',
'dark:hover:text-light/9',
'dark:hover:text-light',
'contrast-more:underline',
'contrast-more:text-dark',
'contrast-more:dark:text-light',
'underline-offset-2',
)}
insights={{
target: link.to,
position: SiteInsightsLinkPosition.Footer,
type: 'link_click',
link: {
target: link.to,
position: SiteInsightsLinkPosition.Footer,
},
}}
>
{link.title}
@@ -145,8 +145,11 @@ function HeaderItemButton(
}[linkStyle],
)}
insights={{
target: linkTarget,
position: SiteInsightsLinkPosition.Header,
type: 'link_click',
link: {
target: linkTarget,
position: SiteInsightsLinkPosition.Header,
},
}}
{...rest}
>
@@ -175,8 +178,11 @@ function HeaderItemLink(props: Omit<HeaderLinkNavItemProps, 'linkStyle'>) {
href={href}
className={getHeaderLinkClassName({ headerPreset })}
insights={{
target: linkTarget,
position: SiteInsightsLinkPosition.Header,
type: 'link_click',
link: {
target: linkTarget,
position: SiteInsightsLinkPosition.Header,
},
}}
{...rest}
>
@@ -219,7 +225,13 @@ async function SubHeaderLink(props: {
return (
<DropdownMenuItem
href={target.href}
insights={{ target: link.to, position: SiteInsightsLinkPosition.Header }}
insights={{
type: 'link_click',
link: {
target: link.to,
position: SiteInsightsLinkPosition.Header,
},
}}
>
{link.title}
</DropdownMenuItem>
@@ -82,8 +82,11 @@ async function MoreMenuLink(props: {
insights={
link.to
? {
target: link.to,
position: SiteInsightsLinkPosition.Header,
type: 'link_click',
link: {
target: link.to,
position: SiteInsightsLinkPosition.Header,
},
}
: undefined
}
@@ -13,7 +13,7 @@ export async function HeaderLinks({ children }: HeaderLinksProps) {
<div
className={tcls(
styles.containerHeaderlinks,
'grow shrink flex justify-end items-center gap-x-4 lg:gap-x-6 min-w-9 z-20',
'grow shrink flex justify-end items-center gap-x-4 lg:gap-x-6 min-w-9 z-20 lg:[&>.button+.button]:-ml-2',
)}
>
{children}
@@ -10,7 +10,7 @@ import * as cookies from '@/lib/cookies';
import { getSession } from './sessions';
import { getVisitorId } from './visitorId';
type SiteEventName = api.SiteInsightsEvent['type'];
export type InsightsEventName = api.SiteInsightsEvent['type'];
/**
* Global context for all events in the session.
@@ -46,15 +46,14 @@ interface InsightsEventOptions {
/**
* Input data for an event.
*/
type TrackEventInput<EventName extends SiteEventName> = { type: EventName } & Omit<
Extract<api.SiteInsightsEvent, { type: EventName }>,
'location' | 'session'
>;
export type TrackEventInput<EventName extends InsightsEventName = InsightsEventName> = {
type: EventName;
} & Omit<Extract<api.SiteInsightsEvent, { type: EventName }>, 'location' | 'session'>;
/**
* Callback to track an event.
*/
type TrackEventCallback = <EventName extends SiteEventName>(
type TrackEventCallback = <EventName extends InsightsEventName>(
event: TrackEventInput<EventName>,
ctx?: InsightsEventPageContext,
options?: InsightsEventOptions,
@@ -80,7 +79,7 @@ export function InsightsProvider(props: InsightsProviderProps) {
[pathname: string]:
| {
url: string;
events: TrackEventInput<SiteEventName>[];
events: TrackEventInput<InsightsEventName>[];
context: InsightsEventContext;
pageContext?: InsightsEventPageContext;
}
@@ -152,7 +151,7 @@ export function InsightsProvider(props: InsightsProviderProps) {
const trackEvent: TrackEventCallback = useEventCallback(
(
event: TrackEventInput<SiteEventName>,
event: TrackEventInput<InsightsEventName>,
ctx?: InsightsEventPageContext,
options?: InsightsEventOptions,
) => {
@@ -253,7 +252,7 @@ function sendEvents(args: {
*/
function transformEvents(input: {
url: string;
events: TrackEventInput<SiteEventName>[];
events: TrackEventInput<InsightsEventName>[];
context: InsightsEventContext;
pageContext: InsightsEventPageContext;
visitorId: string;
@@ -5,6 +5,7 @@ import {
Site,
SiteAdsStatus,
SiteCustomizationSettings,
SiteInsightsAdPlacement,
Space,
} from '@gitbook/api';
import { Icon } from '@gitbook/icons';
@@ -12,7 +13,7 @@ import React from 'react';
import urlJoin from 'url-join';
import { t, getSpaceLanguage } from '@/intl/server';
import { getDocumentSections } from '@/lib/document';
import { getDocumentSections } from '@/lib/document-sections';
import { getAbsoluteHref } from '@/lib/links';
import { ContentRefContext, resolveContentRef } from '@/lib/references';
import { tcls } from '@/lib/tailwind';
@@ -137,6 +138,7 @@ export async function PageAside(props: {
<div
className={tcls(
'overflow-auto',
'overflow-x-visible',
'flex-1',
'flex',
'flex-col',
@@ -186,7 +188,7 @@ export async function PageAside(props: {
'flex-row',
'items-center',
'text-sm',
'hover:text-primary',
'hover:text-tint',
'py-2',
)}
>
@@ -211,7 +213,7 @@ export async function PageAside(props: {
'flex-row',
'items-center',
'text-sm',
'hover:text-primary',
'hover:text-tint',
'py-2',
)}
>
@@ -226,7 +228,7 @@ export async function PageAside(props: {
zoneId={
site?.ads && site.ads.status === SiteAdsStatus.Live ? site.ads.zoneId : null
}
placement="page.aside"
placement={SiteInsightsAdPlacement.Aside}
spaceId={space.id}
siteAdsStatus={site?.ads && site.ads.status ? site.ads.status : undefined}
ignore={process.env.NODE_ENV !== 'production'}
@@ -3,7 +3,7 @@ import { motion } from 'framer-motion';
import React from 'react';
import { useScrollActiveId } from '@/components/hooks';
import { DocumentSection } from '@/lib/document';
import type { DocumentSection } from '@/lib/document-sections';
import { tcls } from '@/lib/tailwind';
import { AsideSectionHighlight } from './AsideSectionHighlight';
@@ -53,11 +53,11 @@ export function ScrollSectionsList(props: { sections: DocumentSection[] }) {
<AsideSectionHighlight
transition={springCurve}
className={tcls(
'sidebar-list-default:hidden',
section?.depth > 1
? [
'sidebar-list-default:rounded-l-none',
'sidebar-list-line:rounded-l-none',
'sidebar-list-default:border-l',
]
: [
'sidebar-list-default:ml-3',
@@ -113,6 +113,8 @@ export function ScrollSectionsList(props: { sections: DocumentSection[] }) {
'contrast-more:font-semibold',
'sidebar-list-default:border-tint',
'hover:bg-tint/3',
'dark:hover:bg-tint-400/3',
'sidebar-list-pill:hover:bg-transparent',
@@ -120,27 +120,30 @@ export function PageBody(props: {
className={tcls(
'flex',
'flex-row',
'flex-wrap',
'gap-4',
'items-center',
'mt-6',
'max-w-3xl',
'mx-auto',
'page-api-block:ml-0',
'text-dark/7',
'dark:text-light/7',
'contrast-more:text-dark',
'dark:contrast-more:text-light',
)}
>
{updatedAt ? (
<p
className={tcls(
'flex-1',
'text-sm',
'text-dark/6',
'dark:text-light/5',
)}
>
<p className={tcls('text-sm mr-auto')}>
{t(language, 'page_last_modified', <DateRelative value={updatedAt} />)}
</p>
) : null}
{withPageFeedback ? (
<PageFeedbackForm orientation="horizontal" pageId={page.id} />
<PageFeedbackForm
className={page.layout.outline ? 'xl:hidden' : ''}
orientation="horizontal"
pageId={page.id}
/>
) : null}
</div>
</main>
@@ -52,8 +52,11 @@ export async function PageBodyBlankslate(props: {
title={child.title}
href={resolved.href}
insights={{
target: child.target,
position: SiteInsightsLinkPosition.Content,
type: 'link_click',
link: {
target: child.target,
position: SiteInsightsLinkPosition.Content,
},
}}
/>
);
@@ -51,11 +51,14 @@ export async function PageFooterNavigation(props: {
title={previous.title}
href={previousHref}
insights={{
target: {
kind: 'page',
page: previous.id,
type: 'link_click',
link: {
target: {
kind: 'page',
page: previous.id,
},
position: SiteInsightsLinkPosition.Content,
},
position: SiteInsightsLinkPosition.Content,
}}
reversed
/>
@@ -67,11 +70,14 @@ export async function PageFooterNavigation(props: {
title={next.title}
href={nextHref}
insights={{
target: {
kind: 'page',
page: next.id,
type: 'link_click',
link: {
target: {
kind: 'page',
page: next.id,
},
position: SiteInsightsLinkPosition.Content,
},
position: SiteInsightsLinkPosition.Content,
}}
/>
) : null}
@@ -73,7 +73,10 @@ export async function PageHeader(props: {
)}
{page.layout.title ? (
<h1 className={tcls('text-4xl', 'font-bold', 'flex', 'items-center', 'gap-4')}>
<PageIcon page={page} style={['text-dark/6', 'dark:text-light/6']} />
<PageIcon
page={page}
style={['text-dark/6', 'dark:text-light/6', 'shrink-0']}
/>
{page.title}
</h1>
) : null}
@@ -1,7 +1,7 @@
'use client';
import { PageFeedbackRating } from '@gitbook/api';
import React from 'react';
import React, { ButtonHTMLAttributes } from 'react';
import { useLanguage } from '@/intl/client';
import { t, tString } from '@/intl/translate';
@@ -9,6 +9,9 @@ import { tcls } from '@/lib/tailwind';
import { getVisitorId, useTrackEvent } from '../Insights';
import { postPageFeedback } from './server-actions';
import { Button } from '../primitives';
const MAX_COMMENT_LENGTH = 512;
/**
* Form to submit feedback on a page.
@@ -21,10 +24,13 @@ export function PageFeedbackForm(props: {
const { orientation = 'vertical', pageId, className } = props;
const languages = useLanguage();
const trackEvent = useTrackEvent();
const inputRef = React.useRef<HTMLTextAreaElement>(null);
const [rating, setRating] = React.useState<PageFeedbackRating>();
const [comment, setComment] = React.useState('');
const [submitted, setSubmitted] = React.useState(false);
const onSubmit = async (rating: PageFeedbackRating) => {
setSubmitted(true);
const onSubmitRating = async (rating: PageFeedbackRating) => {
setRating(rating);
const visitorId = await getVisitorId();
await postPageFeedback({ pageId, visitorId, rating });
@@ -36,92 +42,128 @@ export function PageFeedbackForm(props: {
});
};
const onSubmitComment = (rating: PageFeedbackRating, comment: string) => {
setSubmitted(true);
trackEvent({
type: 'page_post_feedback_comment',
feedback: {
rating,
comment,
},
});
};
// Focus the comment input when the rating is submitted
React.useEffect(() => {
if (!!rating) {
inputRef.current?.focus();
}
}, [rating]);
return (
<div
className={tcls(
'flex',
orientation === 'vertical'
? ['flex-col', 'items-start']
: ['flex-row', 'items-center'],
'gap-2',
className,
)}
>
<p className={tcls('text-sm')}>{t(languages, 'was_this_helpful')}</p>
<div
className={tcls(
'inline-flex',
'items-center',
'justify-center',
'flex-row',
'rounded-full',
'straight-corners:rounded-sm',
'ring-1',
'ring-inset',
'ring-dark/2',
'h-8',
'dark:ring-light/1',
)}
>
{submitted ? (
<p className={tcls('text-sm', 'px-4')}>
{t(languages, 'was_this_helpful_thank_you')}
</p>
) : (
<div
className={tcls(
'inline-flex',
'[&>*:last-child]:rounded-r-full',
'[&>*:first-child]:rounded-l-full',
'straight-corners:[&>*:last-child]:rounded-none',
'straight-corners:[&>*:first-child]:rounded-none',
)}
>
<div className={tcls('flex flex-col gap-3 text-sm', className)}>
<div className="flex flex-wrap items-center gap-2">
<p>{t(languages, 'was_this_helpful')}</p>
<div className="bg-light-1 dark:bg-dark-1 border border-dark/2 dark:border-light/2 contrast-more:border-dark dark:contrast-more:border-light rounded-full">
<div className="flex">
<RatingButton
rating={0}
rating={PageFeedbackRating.Bad}
label={tString(languages, 'was_this_helpful_negative')}
onClick={() => onSubmit(PageFeedbackRating.Bad)}
onClick={() => onSubmitRating(PageFeedbackRating.Bad)}
active={rating == PageFeedbackRating.Bad}
disabled={rating !== undefined}
/>
<RatingButton
rating={1}
rating={PageFeedbackRating.Ok}
label={tString(languages, 'was_this_helpful_neutral')}
onClick={() => onSubmit(PageFeedbackRating.Ok)}
onClick={() => onSubmitRating(PageFeedbackRating.Ok)}
active={rating == PageFeedbackRating.Ok}
disabled={rating !== undefined}
/>
<RatingButton
rating={2}
rating={PageFeedbackRating.Good}
label={tString(languages, 'was_this_helpful_positive')}
onClick={() => onSubmit(PageFeedbackRating.Good)}
onClick={() => onSubmitRating(PageFeedbackRating.Good)}
active={rating == PageFeedbackRating.Good}
disabled={rating !== undefined}
/>
</div>
)}
</div>
</div>
{rating ? (
<div className="flex flex-col gap-2">
{!submitted ? (
<>
<textarea
ref={inputRef}
name="comment"
className="grow ring-1 ring-inset bg-light-1 dark:bg-dark-1 ring-dark/2 dark:ring-light/2 contrast-more:ring-dark dark:contrast-more:ring-light min-h-16 max-h-40 rounded straight-corners:rounded-none p-2 placeholder:text-sm placeholder:text-dark/6 dark:placeholder:text-light/6 contrast-more:placeholder:text-dark dark:contrast-more:placeholder:text-light"
placeholder={tString(languages, 'was_this_helpful_comment')}
aria-label={tString(languages, 'was_this_helpful_comment')}
onChange={(e) => setComment(e.target.value)}
value={comment}
rows={3}
maxLength={MAX_COMMENT_LENGTH}
/>
<div className="flex gap-4 items-center justify-between">
<Button
size="small"
onClick={() => onSubmitComment(rating, comment)}
>
{t(languages, 'submit')}
</Button>
{comment.length > MAX_COMMENT_LENGTH * 0.8 ? (
<span
className={
comment.length == MAX_COMMENT_LENGTH
? 'text-red-500'
: ''
}
>
{comment.length} / {MAX_COMMENT_LENGTH}
</span>
) : null}
</div>
</>
) : (
<p>{t(languages, 'was_this_helpful_thank_you')}</p>
)}
</div>
) : null}
</div>
);
}
function RatingButton(props: { rating: number; label: string; onClick: () => void }) {
const { rating, label, onClick } = props;
function RatingButton(
props: {
rating: PageFeedbackRating;
label: string;
onClick: () => void;
active: boolean;
} & ButtonHTMLAttributes<HTMLButtonElement>,
) {
const { rating, label, onClick, active, ...attr } = props;
const ratingIcon =
rating === 0 ? <Icon0 /> : rating === 1 ? <Icon1 /> : rating === 2 ? <Icon2 /> : null;
{
bad: <IconBad />,
ok: <IconOk />,
good: <IconGood />,
}[rating] ?? null;
return (
<button
className={tcls(
'flex',
'flex-col',
'items-center',
'justify-center',
'h-8',
'w-8',
'rounded-sm',
'text-dark/7',
'hover:bg-tint/4',
'hover:text-tint-600',
'dark:text-light/7',
'dark:hover:text-tint-300',
'dark:hover:bg-tint-300/2',
'p-2 hover:text-tint dark:hover:text-tint-400 hover:bg-tint/2 first:pl-2.5 last:pr-2.5 first:rounded-l-full last:rounded-r-full',
'disabled:cursor-not-allowed disabled:hover:bg-inherit disabled:hover:text-inherit disabled:dark:hover:text-inherit',
'contrast-more:hover:ring-1 ring-tint',
active
? 'bg-tint/4 text-tint-700 dark:text-tint-300 disabled:hover:bg-tint/4 disabled:hover:text-tint-700 dark:disabled:hover:text-tint-300 contrast-more:ring-2 contrast-more:hover:ring-2'
: 'disabled:opacity-7 disabled:contrast-more:ring-0',
)}
type="button"
{...attr}
aria-label={label}
title={label}
onClick={onClick}
@@ -131,9 +173,9 @@ function RatingButton(props: { rating: number; label: string; onClick: () => voi
);
}
const Icon0 = () => (
const IconBad = () => (
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="9" cy="9" r="9" fill="currentColor" fillOpacity="0.24" />
<circle cx="9" cy="9" r="9" fill="currentColor" fillOpacity="0.2" />
<path
fillRule="evenodd"
clipRule="evenodd"
@@ -142,9 +184,9 @@ const Icon0 = () => (
/>
</svg>
);
const Icon1 = () => (
const IconOk = () => (
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="9" cy="9" r="9" fill="currentColor" fillOpacity="0.24" />
<circle cx="9" cy="9" r="9" fill="currentColor" fillOpacity="0.2" />
<path
fillRule="evenodd"
clipRule="evenodd"
@@ -153,9 +195,9 @@ const Icon1 = () => (
/>
</svg>
);
const Icon2 = () => (
const IconGood = () => (
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="9" cy="9" r="9" fill="currentColor" fillOpacity="0.24" />
<circle cx="9" cy="9" r="9" fill="currentColor" fillOpacity="0.2" />
<path
fillRule="evenodd"
clipRule="evenodd"
@@ -29,11 +29,14 @@ export async function PageDocumentItem(props: {
href={href}
pathname={getPagePath(rootPages, page)}
insights={{
target: {
kind: 'page',
page: page.id,
type: 'link_click',
link: {
target: {
kind: 'page',
page: page.id,
},
position: SiteInsightsLinkPosition.Sidebar,
},
position: SiteInsightsLinkPosition.Sidebar,
}}
descendants={
page.pages && page.pages.length ? (
@@ -36,8 +36,11 @@ export async function PageLinkItem(props: { page: RevisionPageLink; context: Con
'dark:hover:bg-light/2',
)}
insights={{
target: page.target,
position: SiteInsightsLinkPosition.Sidebar,
type: 'link_click',
link: {
target: page.target,
position: SiteInsightsLinkPosition.Sidebar,
},
}}
>
<TOCPageIcon page={page} />
@@ -4,6 +4,7 @@ import {
RevisionPageDocument,
RevisionPageGroup,
SiteCustomizationSettings,
SiteInsightsTrademarkPlacement,
Space,
} from '@gitbook/api';
import React from 'react';
@@ -135,7 +136,11 @@ export function TableOfContents(props: {
)}
/>
{customization.trademark.enabled ? (
<Trademark space={space} customization={customization} />
<Trademark
space={space}
customization={customization}
placement={SiteInsightsTrademarkPlacement.Sidebar}
/>
) : null}
</TOCScrollContainer>
</div>
@@ -1,15 +1,23 @@
import { CustomizationSettings, SiteCustomizationSettings, Space } from '@gitbook/api';
import {
CustomizationSettings,
SiteCustomizationSettings,
Space,
SiteInsightsTrademarkPlacement,
} from '@gitbook/api';
import { Icon } from '@gitbook/icons';
import { t, getSpaceLanguage } from '@/intl/server';
import { tcls } from '@/lib/tailwind';
import { Link } from '../primitives';
/**
* Trademark link to the GitBook.
*/
export function Trademark(props: {
space: Space;
customization: CustomizationSettings | SiteCustomizationSettings;
placement: SiteInsightsTrademarkPlacement;
}) {
return (
<div
@@ -63,8 +71,9 @@ export function Trademark(props: {
export function TrademarkLink(props: {
space: Space;
customization: CustomizationSettings | SiteCustomizationSettings;
placement: SiteInsightsTrademarkPlacement;
}) {
const { space, customization } = props;
const { space, customization, placement } = props;
const language = getSpaceLanguage(customization);
const url = new URL('https://www.gitbook.com');
@@ -73,7 +82,7 @@ export function TrademarkLink(props: {
url.searchParams.set('utm_campaign', space.id);
return (
<a
<Link
target="_blank"
href={url.toString()}
className={tcls(
@@ -104,9 +113,13 @@ export function TrademarkLink(props: {
'transition-colors',
'pointer-events-auto',
)}
insights={{
type: 'trademark_click',
placement,
}}
>
<Icon icon="gitbook" className={tcls('size-5', 'mr-3')} />
{t(language, 'powered_by_gitbook')}
</a>
</Link>
);
}
@@ -27,18 +27,7 @@ export function ThemeToggler(props: {}) {
};
return (
<div
role="radiogroup"
className={tcls(
'flex',
'flex-row',
'rounded-full',
'straight-corners:rounded-sm',
'border',
'border-dark/3',
'dark:border-light/2',
)}
>
<div role="radiogroup" className="flex flex-row gap-2">
<ThemeButton
active={mounted && theme === 'light'}
icon="sun-bright"
@@ -74,25 +63,37 @@ function ThemeButton(props: {
role="radio"
onClick={onClick}
aria-label={title}
title={title}
aria-checked={active}
className={tcls(
'p-1',
'm-1',
'group',
'rounded-full',
'straight-corners:rounded-sm',
active ? ['bg-primary-600/4', 'dark:bg-primary-400/2'] : null,
'text-dark',
'dark:text-light/7',
'p-2',
'rounded',
'straight-corners:rounded-none',
'transition-all',
'text-dark/8',
'dark:text-light/8',
'contrast-more:text-dark',
'dark:contrast-more:text-light',
'hover:bg-dark/1',
'dark:hover:bg-light/1',
'contrast-more:hover:ring-2',
'contrast-more:focus:ring-2',
'ring-dark',
'dark:ring-light',
active && [
'bg-tint/2',
'hover:bg-tint/2',
'text-tint-600',
'dark:text-tint-400',
'contrast-more:text-tint-600',
'contrast-more:dark:text-tint-400',
'contrast-more:ring-1',
'ring-tint-600',
'dark:ring-tint-400',
],
)}
>
<Icon
icon={icon}
className={tcls(
'size-4',
active ? ['text-primary-600', 'dark:text-primary-400'] : null,
)}
/>
<Icon icon={icon} className={tcls('size-4')} />
</button>
);
}
@@ -62,6 +62,7 @@ export function Button({
const sizeClasses = sizes[size] || sizes.default;
const domClassName = tcls(
'button',
'inline-block',
'rounded-md',
'straight-corners:rounded-none',
@@ -1,10 +1,9 @@
'use client';
import * as api from '@gitbook/api';
import NextLink, { LinkProps as NextLinkProps } from 'next/link';
import React from 'react';
import { useTrackEvent } from '../Insights';
import { useTrackEvent, TrackEventInput } from '../Insights';
// Props from Next, which includes NextLinkProps and all the things anchor elements support.
type BaseLinkProps = Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, keyof NextLinkProps> &
@@ -13,8 +12,11 @@ type BaseLinkProps = Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, keyof N
} & React.RefAttributes<HTMLAnchorElement>;
export type LinkInsightsProps = {
/** Target of the link, for insights. */
insights?: api.SiteInsightsEventLinkClick['link'];
/** Event to track when the link is clicked. */
insights?:
| TrackEventInput<'ad_click'>
| TrackEventInput<'link_click'>
| TrackEventInput<'trademark_click'>;
};
export type LinkProps = Omit<BaseLinkProps, 'href'> &
@@ -40,7 +42,7 @@ export const Link = React.forwardRef(function Link(
const onClick = (event: React.MouseEvent<HTMLAnchorElement>) => {
if (insights) {
trackEvent({ type: 'link_click', link: insights }, undefined, {
trackEvent(insights, undefined, {
immediate: isExternal,
});
}
@@ -57,7 +59,7 @@ export const Link = React.forwardRef(function Link(
}
return (
<NextLink ref={ref} {...props} onClick={onClick}>
<NextLink ref={ref} href={href} prefetch={prefetch} {...domProps} onClick={onClick}>
{children}
</NextLink>
);
@@ -142,6 +142,7 @@ export function ZoomImage(
) : (
// When zooming, remove the image from the DOM to let the browser animates it with View Transition.
<img
data-testid="zoom-image"
ref={imgRef}
{...props}
alt={alt ?? ''}
@@ -206,6 +207,7 @@ function ZoomImageModal(props: {
return (
<div
data-testid="zoom-image-modal"
className={classNames(
styles.zoomModal,
tcls(
@@ -28,6 +28,8 @@ export const de = {
was_this_helpful_neutral: 'Nicht sicher',
was_this_helpful_negative: 'Nein',
was_this_helpful_thank_you: 'Danke!',
was_this_helpful_comment: 'Möchten Sie etwas hinzufügen?',
submit: 'Absenden',
annotation_button_label: 'Kommentar öffnen',
code_copied: 'Kopiert!',
code_copy: 'Kopieren',
@@ -28,6 +28,8 @@ export const en = {
was_this_helpful_neutral: 'Not sure',
was_this_helpful_negative: 'No',
was_this_helpful_thank_you: 'Thank you!',
was_this_helpful_comment: "Anything you'd like to add?",
submit: 'Submit',
annotation_button_label: 'Open annotation',
code_copied: 'Copied!',
code_copy: 'Copy',
@@ -30,6 +30,8 @@ export const es: TranslationLanguage = {
was_this_helpful_neutral: 'No estoy seguro',
was_this_helpful_negative: 'No',
was_this_helpful_thank_you: '¡Gracias!',
was_this_helpful_comment: '¿Algo más que te gustaría añadir?',
submit: 'Enviar',
annotation_button_label: 'Abrir anotación',
code_copied: '¡Copiado!',
code_copy: 'Copiar',
+3 -1
View File
@@ -29,7 +29,9 @@ export const fr: TranslationLanguage = {
was_this_helpful_positive: "Oui, ça l'a été !",
was_this_helpful_neutral: 'Pas sûr',
was_this_helpful_negative: 'Non',
was_this_helpful_thank_you: 'Merci !',
was_this_helpful_thank_you: 'Merci!',
was_this_helpful_comment: 'Quelque chose à ajouter?',
submit: 'Soumettre',
annotation_button_label: "Ouvrir l'annotation",
code_copied: 'Copié !',
code_copy: 'Copier',
@@ -30,6 +30,8 @@ export const ja: TranslationLanguage = {
was_this_helpful_neutral: 'わかりません',
was_this_helpful_negative: 'いいえ',
was_this_helpful_thank_you: 'ありがとうございます!',
was_this_helpful_comment: '何か追加したいことはありますか?',
submit: '送信',
annotation_button_label: '注釈を開く',
code_copied: 'コピーしました!',
code_copy: 'コピー',
@@ -30,6 +30,8 @@ export const nl: TranslationLanguage = {
was_this_helpful_neutral: 'Niet zeker',
was_this_helpful_negative: 'Nee',
was_this_helpful_thank_you: 'Bedankt!',
was_this_helpful_comment: 'Nog iets toe te voegen?',
submit: 'Versturen',
annotation_button_label: 'Open annotatie',
code_copied: 'Gekopieerd!',
code_copy: 'Kopiëren',
@@ -30,6 +30,8 @@ export const no: TranslationLanguage = {
was_this_helpful_neutral: 'Ikke sikker',
was_this_helpful_negative: 'Nei',
was_this_helpful_thank_you: 'Takk!',
was_this_helpful_comment: 'Noe du vil legge til?',
submit: 'Send inn',
annotation_button_label: 'Åpne merknad',
code_copied: 'Kopiert!',
code_copy: 'Kopier',
@@ -28,6 +28,8 @@ export const pt_br = {
was_this_helpful_neutral: 'Não sei',
was_this_helpful_negative: 'Não',
was_this_helpful_thank_you: 'Obrigado!',
was_this_helpful_comment: 'Gostaria de adicionar algo?',
submit: 'Enviar',
annotation_button_label: 'Abrir anotação',
code_copied: 'Copiado!',
code_copy: 'Copiar',
@@ -29,6 +29,8 @@ export const zh: TranslationLanguage = {
was_this_helpful_neutral: '不确定',
was_this_helpful_negative: '不',
was_this_helpful_thank_you: '谢谢!',
was_this_helpful_comment: '您有什么想补充的吗?',
submit: '提交',
annotation_button_label: '打开批注',
code_copied: '已复制!',
code_copy: '复制',
+22
View File
@@ -0,0 +1,22 @@
/**
* Partition an array into equal (+ rest items) parts.
* Adapted from https://stackoverflow.com/a/68917937
*/
export function partition(
/**
* Array to split up
*/
array: any[],
/**
* Amount of parts you want to split into
*/
parts: number,
) {
let rest = array.length % parts;
let size = Math.floor(array.length / parts);
let j = 0;
return Array.from({ length: Math.min(array.length, parts) }, (_, i) =>
array.slice(j, (j += size + (i < rest ? 1 : 0))),
);
}
+7 -7
View File
@@ -221,13 +221,13 @@ export function cache<Args extends any[], Result>(
// Log
if (process.env.SILENT !== 'true') {
// //console.log(
// `cache: ${key} ${cacheStatus}${
// cacheStatus === 'hit' ? ` on ${backendName}` : ''
// } in total ${totalDuration.toFixed(0)}ms, fetch in ${fetchDuration.toFixed(
// 0,
// )}ms, read in ${readCacheDuration.toFixed(0)}ms`,
// );
console.log(
`cache: ${key} ${cacheStatus}${
cacheStatus === 'hit' ? ` on ${backendName}` : ''
} in total ${totalDuration.toFixed(0)}ms, fetch in ${fetchDuration.toFixed(
0,
)}ms, read in ${readCacheDuration.toFixed(0)}ms`,
);
}
if (savedEntry.meta.revalidatesAt && savedEntry.meta.revalidatesAt < Date.now()) {
@@ -0,0 +1,53 @@
import { JSONDocument, ContentRef } from '@gitbook/api';
import { getNodeText } from './document';
import { fetchOpenAPIBlock } from './openapi';
import { ResolvedContentRef } from './references';
export interface DocumentSection {
id: string;
tag?: string;
title: string;
depth: number;
}
/**
* Extract a list of sections from a document.
*/
export async function getDocumentSections(
document: JSONDocument,
resolveContentRef: (ref: ContentRef) => Promise<ResolvedContentRef | null>,
): Promise<DocumentSection[]> {
const sections: DocumentSection[] = [];
let depth = 0;
for (const block of document.nodes) {
if ((block.type === 'heading-1' || block.type === 'heading-2') && block.meta?.id) {
if (block.type === 'heading-1') {
depth = 1;
}
const title = getNodeText(block);
const id = block.meta.id;
sections.push({
id,
title,
depth: block.type === 'heading-1' ? 1 : depth > 0 ? 2 : 1,
});
}
if (block.type === 'swagger' && block.meta?.id) {
const { data: operation } = await fetchOpenAPIBlock(block, resolveContentRef);
if (operation) {
sections.push({
id: block.meta.id,
tag: operation.method.toUpperCase(),
title: operation.operation.summary ?? operation.path,
depth: 1,
});
}
}
}
return sections;
}
+1 -46
View File
@@ -1,16 +1,12 @@
import {
import type {
DocumentText,
DocumentInline,
DocumentFragment,
JSONDocument,
DocumentBlock,
ContentRef,
} from '@gitbook/api';
import assertNever from 'assert-never';
import { fetchOpenAPIBlock } from './openapi';
import { ResolvedContentRef } from './references';
export interface DocumentSection {
id: string;
tag?: string;
@@ -34,47 +30,6 @@ export function hasFullWidthBlock(document: JSONDocument): boolean {
return false;
}
/**
* Extract a list of sections from a document.
*/
export async function getDocumentSections(
document: JSONDocument,
resolveContentRef: (ref: ContentRef) => Promise<ResolvedContentRef | null>,
): Promise<DocumentSection[]> {
const sections: DocumentSection[] = [];
let depth = 0;
for (const block of document.nodes) {
if ((block.type === 'heading-1' || block.type === 'heading-2') && block.meta?.id) {
if (block.type === 'heading-1') {
depth = 1;
}
const title = getNodeText(block);
const id = block.meta.id;
sections.push({
id,
title,
depth: block.type === 'heading-1' ? 1 : depth > 0 ? 2 : 1,
});
}
if (block.type === 'swagger' && block.meta?.id) {
const { data: operation } = await fetchOpenAPIBlock(block, resolveContentRef);
if (operation) {
sections.push({
id: block.meta.id,
tag: operation.method.toUpperCase(),
title: operation.operation.summary ?? operation.path,
depth: 1,
});
}
}
}
return sections;
}
/**
* Get the text of a block/inline.
*/
+4 -3
View File
@@ -9,13 +9,14 @@ import { unified } from 'unified';
* Parse markdown and output HTML.
*/
export async function parseMarkdown(markdown: string): Promise<string> {
const file = await unified()
const promise = unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkRehype)
.use(rehypeSanitize)
.use(rehypeStringify)
.process(markdown);
.process(markdown)
.then((file) => file.toString());
return file.toString();
return promise;
}
+11 -9
View File
@@ -3,11 +3,11 @@ import {
OpenAPIOperationData,
fetchOpenAPIOperation,
OpenAPIFetcher,
parseOpenAPIV3,
OpenAPIFetchError,
parseOpenAPI,
OpenAPIParseError,
} from '@gitbook/react-openapi';
import { cache, parseCacheResponse, noCacheFetchOptions, CacheFunctionOptions } from '@/lib/cache';
import { cache, noCacheFetchOptions, CacheFunctionOptions } from '@/lib/cache';
import { parseMarkdown } from './markdown';
import { ResolvedContentRef } from './references';
@@ -20,7 +20,7 @@ export async function fetchOpenAPIBlock(
resolveContentRef: (ref: ContentRef) => Promise<ResolvedContentRef | null>,
): Promise<
| { data: OpenAPIOperationData | null; specUrl: string | null; error?: undefined }
| { error: OpenAPIFetchError; data?: undefined; specUrl?: undefined }
| { error: OpenAPIParseError; data?: undefined; specUrl?: undefined }
> {
const resolved = block.data.ref ? await resolveContentRef(block.data.ref) : null;
if (!resolved || !block.data.path || !block.data.method) {
@@ -39,7 +39,7 @@ export async function fetchOpenAPIBlock(
return { data, specUrl: resolved.href };
} catch (error) {
if (error instanceof OpenAPIFetchError) {
if (error instanceof OpenAPIParseError) {
return { error };
}
@@ -49,7 +49,7 @@ export async function fetchOpenAPIBlock(
const fetcher: OpenAPIFetcher = {
fetch: cache({
name: 'openapi.fetch',
name: 'openapi.fetch.v4',
get: async (url: string, options: CacheFunctionOptions) => {
// Wrap the raw string to prevent invalid URLs from being passed to fetch.
// This can happen if the URL has whitespace, which is currently handled differently by Cloudflare's implementation of fetch:
@@ -66,12 +66,14 @@ const fetcher: OpenAPIFetcher = {
}
const text = await response.text();
const data = await parseOpenAPIV3(url, text);
const data = await parseOpenAPI({ url, value: text, parseMarkdown });
return {
...parseCacheResponse(response),
// Cache for 4 hours
ttl: 24 * 60 * 60,
// Revalidate every 2 hours
revalidateBefore: 22 * 60 * 60,
data,
};
},
}),
parseMarkdown,
};

Some files were not shown because too many files have changed in this diff Show More