Compare commits

...

53 Commits

Author SHA1 Message Date
Zeno Kapitein 36877d92a6 Format 2025-04-03 13:57:22 +02:00
Zeno Kapitein 23433d1287 Change type 2025-04-03 13:56:16 +02:00
Zeno Kapitein 5d9183e1b4 Format 2025-04-03 13:53:41 +02:00
Zeno Kapitein a1d5326ffa Remove adaptive pane (for now) 2025-04-03 13:52:23 +02:00
Zeno Kapitein cc26fb34aa Remove other AI experiments (for now) 2025-04-03 13:52:23 +02:00
Zeno Kapitein b1abbf603b Use new customisation option 2025-04-03 13:52:23 +02:00
Zeno Kapitein a70e8cd7dc Refactor to split components into Link and LinkTooltip 2025-04-03 13:51:53 +02:00
Zeno Kapitein b38eed850f Improve prompt & layout 2025-04-03 13:51:53 +02:00
Zeno Kapitein f4c50c7e3c Add page paths 2025-04-03 13:51:53 +02:00
Zeno Kapitein 5d308c764e Update AIPageLinkSummary.tsx 2025-04-03 13:51:53 +02:00
Zeno Kapitein 08e780496b Tweak link summaries 2025-04-03 13:51:53 +02:00
Zeno Kapitein e4cd67ebe2 Tweak prompt 2025-04-03 13:51:53 +02:00
Zeno Kapitein 939b160bfa Make link summaries work 2025-04-03 13:51:53 +02:00
Zeno Kapitein 689e78d8e7 Fix typing 2025-04-03 13:51:53 +02:00
Zeno Kapitein 37c1d4b8ea Initial UI for tooltip experiment 2025-04-03 13:51:53 +02:00
Samy Pessé 75855585f5 Start panel 2025-04-03 13:51:53 +02:00
Samy Pessé dcf0f9aac5 New experiment 2025-04-03 13:51:53 +02:00
Samy Pessé 1d7253307a Show list of recommended questions at the bottom 2025-04-03 13:51:53 +02:00
Nolann B. 2b6c593289 Remove stable from x-stability (#3083) 2025-04-03 11:33:39 +02:00
Samy Pessé c0b339e406 Version Packages (#3075)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-04-02 13:36:04 +02:00
Nolann B. da485f5144 Fix read-only in generateSchemaExample (#3069) 2025-04-02 13:03:51 +02:00
Nolann B. 139a8050a5 Fix OpenAPI enum display (#3077) 2025-04-02 10:15:19 +02:00
Nolann B. da7b369ffd Fix missing headers in OpenAPIResponses (#3074) 2025-04-01 21:57:36 +02:00
Samy Pessé 432da64919 Version Packages (#3073)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-04-01 18:13:52 +02:00
Nolann B. 7d0b422200 Handle grouped OpenAPISchemas (#3071) 2025-04-01 17:24:43 +02:00
Zeno Kapitein fb90eb0acc Limit tinted background on bold theme to sites with filled sidebar (#3072) 2025-04-01 16:43:08 +02:00
Samy Pessé b647538e41 Version Packages (#3033)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-04-01 16:35:09 +02:00
Samy Pessé e50797a5c1 Adapt new computed content and add tag for translation (#3070) 2025-04-01 15:10:55 +02:00
Nolann B. 5b1e01c786 Support for x-stability property (#3064) 2025-03-31 13:49:37 +02:00
Nolann B. 813b2af06b Support for x-enumDescriptions and x-gitbook-enum (#3066) 2025-03-31 12:18:57 +02:00
Nolann B. 6f71da88d8 Fix padding in OpenAPI schemas (#3067) 2025-03-31 12:03:53 +02:00
Samy Pessé fa91eb7eaf Fix dark mode PDF (#3065) 2025-03-28 18:00:33 +01:00
Zeno Kapitein c756761591 Add breadcrumbs to search results (#2772) 2025-03-28 13:29:05 +01:00
Samy Pessé 434af90366 Fix image resizing when using proxy by passing signing identifier in site URL data (#3063)
Co-authored-by: Taran Vohra <taranvohra@outlook.com>
2025-03-28 12:28:12 +01:00
Valentino Hudhra 0f41e194cb Fix dismissed annoucement banner ignored when switching sites (#3057) 2025-03-28 10:56:18 +01:00
Nolann B. bd353489e9 Fix missing alternative schemas (#3058) 2025-03-28 06:53:28 +01:00
Samy Pessé 77fd393f15 Fix click on search items when embedded in an iframe and track announcement links (#3061) 2025-03-27 20:13:14 +01:00
Taran Vohra 8701b5e078 Use the site canonical URL when rewriting in the middleware (#3060) 2025-03-27 19:39:39 +01:00
Nolann B. 61db166f92 Add OpenAPI write-only indicator (#3059) 2025-03-27 18:21:38 +01:00
Zeno Kapitein 0d9b9cea0f Fix announcement banner margin (#3056) 2025-03-27 15:59:47 +00:00
Nolann B. 7bb37c7f0a Move filterSelectedOpenAPISchemas to @gitbook/openapi-parser (#3054) 2025-03-27 15:53:41 +01:00
Taran Vohra 5b2bf828e3 Use stable site URL data for route rewrite in the middleware (#3053) 2025-03-27 18:26:06 +05:30
Samy Pessé c85d3f9752 Serve a markdown version of the page when adding a .md extension (#3055)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-03-27 12:35:18 +00:00
Zeno Kapitein 4d7088bbf0 Stop variant selector from growing too large (#3043) 2025-03-27 10:42:18 +01:00
Samy Pessé dd6ab6044c Fix crash when using user mention or accessing invalid change requests (#3051) 2025-03-27 01:04:00 +01:00
Samy Pessé d236bf029c Fix flash for site with dark default mode (#3050) 2025-03-26 18:12:45 +01:00
Zeno Kapitein d70d566abe Support site announcement banner (#3008)
Co-authored-by: Valentino Hudhra <v.hudhra@gmail.com>
2025-03-26 16:37:05 +01:00
Nolann B. a25fdedea9 Replace $ref with $reference in json-decycle (#3049) 2025-03-26 14:16:03 +00:00
Nolann B. 373183a2cd Safe parse OpenAPI JSON schema (#3046) 2025-03-26 13:55:22 +01:00
Samy Pessé e9fa50dfb5 Trim search query before doing search (#3042) 2025-03-26 10:18:18 +00:00
Samy Pessé 57ca4e0e88 Fix crash when integration is not installed on the space (#3040) 2025-03-26 10:19:57 +01:00
Samy Pessé bc90adb73a Allow indexation of images to ensure favicons display on Google (#3041) 2025-03-26 10:17:57 +01:00
Nolann B. 1505ddbd5a Fix multiple request examples selector not showing (#3039) 2025-03-25 20:48:57 +01:00
120 changed files with 3179 additions and 1984 deletions
-5
View File
@@ -1,5 +0,0 @@
---
'gitbook': patch
---
Fix OpenAPI tabs indicator overflow
-5
View File
@@ -1,5 +0,0 @@
---
'@gitbook/react-openapi': patch
---
Fix XML in code sample
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Disallow crawling by web-robots of search/ask URLs
+7
View File
@@ -0,0 +1,7 @@
---
'@gitbook/openapi-parser': patch
'@gitbook/react-openapi': patch
'gitbook': patch
---
Remove stable from x-stability
-6
View File
@@ -1,6 +0,0 @@
---
"@gitbook/react-openapi": patch
"gitbook": patch
---
Fix spec properties rendering and missing keys
+876 -1319
View File
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -12,7 +12,7 @@
"@codemirror/state": "6.4.1",
"react": "18.3.1",
"react-dom": "18.3.1",
"@gitbook/api": "0.102.0"
"@gitbook/api": "0.107.0"
},
"private": true,
"scripts": {
@@ -38,5 +38,8 @@
"patchedDependencies": {
"decode-named-character-reference@1.0.2": "patches/decode-named-character-reference@1.0.2.patch",
"@vercel/next@4.4.2": "patches/@vercel%2Fnext@4.4.2.patch"
},
"dependencies": {
"@radix-ui/react-tooltip": "^1.1.8"
}
}
+32 -9
View File
@@ -60,7 +60,7 @@ export function getCacheTag(
| {
tag: 'computed-document';
space: string;
integration: string;
sourceType: string;
}
/**
* All data related to the URL of a content
@@ -84,6 +84,14 @@ export function getCacheTag(
organization: string;
openAPISpec: string;
}
/**
* All data related to a translation
*/
| {
tag: 'translation';
organization: string;
translationSettings: string;
}
): string {
switch (spec.tag) {
case 'user':
@@ -99,13 +107,15 @@ export function getCacheTag(
case 'document':
return `space:${spec.space}:document:${spec.document}`;
case 'computed-document':
return `space:${spec.space}:computed-document:${spec.integration}`;
return `space:${spec.space}:computed-document:${spec.sourceType}`;
case 'site':
return `site:${spec.site}`;
case 'integration':
return `integration:${spec.integration}`;
case 'openapi':
return `organization:${spec.organization}:openapi:${spec.openAPISpec}`;
case 'translation':
return `organization:${spec.organization}:translation:${spec.translationSettings}`;
default:
assertNever(spec);
}
@@ -157,6 +167,15 @@ export function getComputedContentSourceCacheTags(
})
);
break;
case 'translation-language':
tags.push(
getCacheTag({
tag: 'translation',
organization: inContext.organizationId,
translationSettings: dependency.ref.translationSettings,
})
);
break;
default:
// Do not throw for unknown dependency types
// as it might mean we are lacking behind the API version
@@ -169,18 +188,22 @@ export function getComputedContentSourceCacheTags(
getCacheTag({
tag: 'computed-document',
space: inContext.spaceId,
integration: source.integration,
sourceType: source.type,
})
);
}
// We invalidate the computed content when a new version of the integration is deployed.
tags.push(
getCacheTag({
tag: 'integration',
integration: source.integration,
})
);
if (source.type.startsWith('integration:')) {
const integration = source.type.split(':')[1];
tags.push(
getCacheTag({
tag: 'integration',
integration,
})
);
}
return tags;
}
+6
View File
@@ -1,5 +1,11 @@
# @gitbook/colors
## 0.3.1
### Patch Changes
- fb90eb0: Reduce chroma of first color scale step
## 0.3.0
### Minor Changes
+1 -1
View File
@@ -8,7 +8,7 @@
"default": "./dist/index.js"
}
},
"version": "0.3.0",
"version": "0.3.1",
"devDependencies": {
"typescript": "^5.5.3"
},
+1 -1
View File
@@ -220,7 +220,7 @@ export function colorScale(
continue;
}
const chromaRatio = index < 8 ? (index + 1) * 0.05 : 1;
const chromaRatio = index < 8 ? index * 0.05 : 1;
const shade = {
L: targetL, // Blend lightness
+6
View File
@@ -1,5 +1,11 @@
# gitbook-v2
## 0.2.3
### Patch Changes
- 5b2bf82: Use stable site URL data for route rewrite in the middleware
## 0.2.2
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "gitbook-v2",
"version": "0.2.2",
"version": "0.2.3",
"private": true,
"dependencies": {
"next": "^15.2.3",
@@ -20,7 +20,7 @@ export default async function SiteDynamicLayout({
const forcedTheme = await getThemeFromMiddleware();
return (
<CustomizationRootLayout customization={context.customization}>
<CustomizationRootLayout forcedTheme={forcedTheme} customization={context.customization}>
<SiteLayout
context={context}
forcedTheme={forcedTheme}
@@ -1,12 +0,0 @@
import type { NextRequest } from 'next/server';
import { serveLLMsTxt } from '@/routes/llms';
import { type RouteLayoutParams, getDynamicSiteContext } from '@v2/app/utils';
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<RouteLayoutParams> }
) {
const { context } = await getDynamicSiteContext(await params);
return serveLLMsTxt(context);
}
@@ -1,12 +0,0 @@
import type { NextRequest } from 'next/server';
import { serveRobotsTxt } from '@/routes/robots';
import { type RouteLayoutParams, getDynamicSiteContext } from '@v2/app/utils';
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<RouteLayoutParams> }
) {
const { context } = await getDynamicSiteContext(await params);
return serveRobotsTxt(context);
}
@@ -1,12 +0,0 @@
import type { NextRequest } from 'next/server';
import { servePagesSitemap } from '@/routes/sitemap';
import { type RouteLayoutParams, getDynamicSiteContext } from '@v2/app/utils';
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<RouteLayoutParams> }
) {
const { context } = await getDynamicSiteContext(await params);
return servePagesSitemap(context);
}
@@ -1,12 +0,0 @@
import type { NextRequest } from 'next/server';
import { serveRootSitemap } from '@/routes/sitemap';
import { type RouteLayoutParams, getDynamicSiteContext } from '@v2/app/utils';
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<RouteLayoutParams> }
) {
const { context } = await getDynamicSiteContext(await params);
return serveRootSitemap(context);
}
@@ -10,5 +10,5 @@ export async function GET(
{ params }: { params: Promise<RouteLayoutParams> }
) {
const { context } = await getStaticSiteContext(await params);
return serveLLMsTxt(context);
return serveLLMsTxt(context, { withMarkdownPages: true });
}
@@ -0,0 +1,11 @@
import { servePageMarkdown } from '@/routes/markdownPage';
import { type RouteParams, getPagePathFromParams, getStaticSiteContext } from '@v2/app/utils';
import type { NextRequest } from 'next/server';
export const dynamic = 'force-static';
export async function GET(_request: NextRequest, { params }: { params: Promise<RouteParams> }) {
const { context } = await getStaticSiteContext(await params);
const pathname = getPagePathFromParams(await params);
return servePageMarkdown(context, pathname);
}
+3 -3
View File
@@ -1,6 +1,6 @@
import { getVisitorAuthClaims, getVisitorAuthClaimsFromToken } from '@/lib/adaptive';
import type { PublishedSiteContent, SiteAPIToken } from '@gitbook/api';
import { fetchSiteContextByURLLookup, getBaseContext } from '@v2/lib/context';
import type { SiteAPIToken } from '@gitbook/api';
import { type SiteURLData, fetchSiteContextByURLLookup, getBaseContext } from '@v2/lib/context';
import { jwtDecode } from 'jwt-decode';
import { forbidden } from 'next/navigation';
import rison from 'rison';
@@ -98,7 +98,7 @@ function getModeFromParams(mode: string): RouteParamMode {
/**
* Get the decoded site data from the params.
*/
function getSiteURLDataFromParams(params: RouteLayoutParams): PublishedSiteContent {
function getSiteURLDataFromParams(params: RouteLayoutParams): SiteURLData {
const decoded = decodeURIComponent(params.siteData);
return rison.decode(decoded);
}
+51 -8
View File
@@ -13,12 +13,42 @@ import type {
SiteStructure,
Space,
} from '@gitbook/api';
import { type GitBookDataFetcher, createDataFetcher, throwIfDataError } from '@v2/lib/data';
import {
type GitBookDataFetcher,
createDataFetcher,
getDataOrNull,
throwIfDataError,
} from '@v2/lib/data';
import { notFound } from 'next/navigation';
import { assert } from 'ts-essentials';
import { GITBOOK_URL } from './env';
import { type ImageResizer, createImageResizer } from './images';
import { type GitBookLinker, createLinker } from './links';
/**
* Data about the site URL. Provided by the middleware.
* These data are stable between pages in the same site space.
*/
export type SiteURLData = Pick<
PublishedSiteContent,
| 'organization'
| 'apiToken'
| 'site'
| 'siteSpace'
| 'space'
| 'revision'
| 'changeRequest'
| 'shareKey'
| 'siteSection'
| 'siteBasePath'
| 'basePath'
> & {
/**
* Identifier used for image resizing.
*/
imagesContextId: string;
};
/**
* Generic context when rendering content.
*/
@@ -105,7 +135,7 @@ export type GitBookPageContext = (GitBookSpaceContext | GitBookSiteContext) & {
*/
export function getBaseContext(input: {
siteURL: URL | string;
siteURLData: PublishedSiteContent;
siteURLData: SiteURLData;
urlMode: 'url' | 'url-host';
}) {
const { urlMode, siteURLData } = input;
@@ -139,7 +169,7 @@ export function getBaseContext(input: {
}
const imageResizer = createImageResizer({
host: siteURL.host,
imagesContextId: siteURLData.imagesContextId,
// To ensure image resizing work for proxied sites,
// we serve images from the root of the site.
linker: linker,
@@ -157,7 +187,7 @@ export function getBaseContext(input: {
*/
export async function fetchSiteContextByURLLookup(
baseContext: GitBookBaseContext,
data: PublishedSiteContent
data: SiteURLData
): Promise<GitBookSiteContext> {
return await fetchSiteContextByIds(baseContext, {
organization: data.organization,
@@ -277,7 +307,7 @@ export async function fetchSpaceContextByIds(
})
),
ids.changeRequest
? throwIfDataError(
? getDataOrNull(
dataFetcher.getChangeRequest({
spaceId: ids.space,
changeRequestId: ids.changeRequest,
@@ -286,17 +316,30 @@ export async function fetchSpaceContextByIds(
: null,
]);
const revisionId = changeRequest?.revision ?? ids.revision ?? space.revision;
if (ids.changeRequest && !changeRequest) {
// When trying to render a change request with an invalid / non-existing ID,
// we should return a 404.
notFound();
}
const pages = await throwIfDataError(
const revisionId = ids.revision ?? changeRequest?.revision ?? space.revision;
const pages = await getDataOrNull(
dataFetcher.getRevisionPages({
spaceId: ids.space,
revisionId,
// We only care about the Git metadata when the Git sync is enabled,
// otherwise we can optimize performance by not fetching it
metadata: !!space.gitSync,
})
}),
// When trying to render a revision with an invalid / non-existing ID,
// we should handle gracefully the 404 and throw notFound.
ids.revision ? [404] : undefined
);
if (!pages) {
notFound();
}
return {
...baseContext,
+45 -6
View File
@@ -8,7 +8,7 @@ import {
import { getCacheTag, getComputedContentSourceCacheTags } from '@gitbook/cache-tags';
import { GITBOOK_API_TOKEN, GITBOOK_API_URL, GITBOOK_USER_AGENT } from '@v2/lib/env';
import { unstable_cacheLife as cacheLife, unstable_cacheTag as cacheTag } from 'next/cache';
import { wrapDataFetcherError } from './errors';
import { DataFetcherError, wrapDataFetcherError } from './errors';
import { memoize } from './memoize';
import type { GitBookDataFetcher } from './types';
@@ -95,6 +95,15 @@ export function createDataFetcher(
})
);
},
getRevisionPageMarkdown(params) {
return trace('getRevisionPageMarkdown', () =>
getRevisionPageMarkdown(input, {
spaceId: params.spaceId,
revisionId: params.revisionId,
pageId: params.pageId,
})
);
},
getReusableContent(params) {
return trace('getReusableContent', () =>
getReusableContent(input, {
@@ -166,12 +175,9 @@ export function createDataFetcher(
})
);
},
//
// API that are not tied to the token
// where the data is the same for all users
//
getUserById(userId) {
return trace('getUserById', () => getUserById({ apiToken: null }, { userId }));
return trace('getUserById', () => getUserById(input, { userId }));
},
};
}
@@ -327,6 +333,39 @@ const getRevisionFile = memoize(async function getRevisionFile(
});
});
const getRevisionPageMarkdown = memoize(async function getRevisionPageMarkdown(
input: DataFetcherInput,
params: {
spaceId: string;
revisionId: string;
pageId: string;
}
) {
'use cache';
return trace('getRevisionPageMarkdown.uncached', () => {
cacheLife('max');
return wrapDataFetcherError(async () => {
const api = await apiClient(input);
const res = await api.spaces.getPageInRevisionById(
params.spaceId,
params.revisionId,
params.pageId,
{
format: 'markdown',
}
);
if (!('markdown' in res.data)) {
throw new DataFetcherError('Page is not a document', 404);
}
return res.data.markdown;
});
});
});
const getRevisionPageByPath = memoize(async function getRevisionPageByPath(
input: DataFetcherInput,
params: {
+9 -1
View File
@@ -1,5 +1,5 @@
import { race, tryCatch } from '@/lib/async';
import { joinPath } from '@/lib/paths';
import { joinPath, joinPathWithBaseURL } from '@/lib/paths';
import { trace } from '@/lib/tracing';
import type { PublishedSiteContentLookup } from '@gitbook/api';
import { apiClient } from './api';
@@ -36,6 +36,13 @@ export async function getPublishedContentByURL(input: {
url: alternative.url,
visitorAuthToken: input.visitorAuthToken ?? undefined,
redirectOnError: input.redirectOnError,
// As this endpoint is cached by our API, we version the request
// to void getting stale data with missing properties.
// this could be improved by ensuring our API cache layer is versioned
// or invalidated when needed
// @ts-expect-error - cacheVersion is not a real query param
cacheVersion: 'v2',
},
{
signal,
@@ -100,6 +107,7 @@ export async function getPublishedContentByURL(input: {
const siteResult: PublishedSiteContentLookup = {
...data,
canonicalUrl: joinPathWithBaseURL(data.canonicalUrl, alternative.extraPath),
basePath: joinPath(data.basePath, lookup.basePath ?? ''),
pathname: joinPath(data.pathname, alternative.extraPath),
...(changeRequest ? { changeRequest } : {}),
@@ -97,6 +97,15 @@ export interface GitBookDataFetcher {
path: string;
}): Promise<DataFetcherResponse<api.RevisionPageDocument | api.RevisionPageGroup>>;
/**
* Get the markdown content of a page by its path.
*/
getRevisionPageMarkdown(params: {
spaceId: string;
revisionId: string;
pageId: string;
}): Promise<DataFetcherResponse<string>>;
/**
* Get a document by its space ID and document ID.
*/
@@ -33,13 +33,13 @@ export interface CloudflareImageOptions {
* Create an image resizer for a rendering context.
*/
export function createImageResizer({
host,
imagesContextId,
linker,
}: {
/** The linker to use to create URLs. */
linker: GitBookLinker;
/** The host name of the current site. */
host: string;
/** The site identifier to use for verifying the image signature. */
imagesContextId: string;
}): ImageResizer {
if (!GITBOOK_IMAGE_RESIZE_URL || !GITBOOK_IMAGE_RESIZE_SIGNING_KEY) {
return createNoopImageResizer();
@@ -58,7 +58,7 @@ export function createImageResizer({
return async (options) => {
cachedSignature ??= await generateImageSignature({
host,
imagesContextId,
url: urlInput,
});
@@ -0,0 +1,14 @@
/**
* Get the site identifier to use for image resizing for an incoming request.
* This identifier can be obtained before resolving the request URL.
*/
export function getImageResizingContextId(url: URL): string {
if (url.host === 'proxy.gitbook.site' || url.host === 'proxy.gitbook-staging.site') {
// For proxy requests, we extract the site ID from the pathname
// e.g. https://proxy.gitbook.site/site/siteId/...
const pathname = url.pathname.slice(1).split('/');
return pathname.slice(0, 2).join('/');
}
return url.host;
}
@@ -2,3 +2,4 @@ export * from './types';
export * from './createImageResizer';
export * from './signatures';
export * from './utils';
export * from './getImageResizingContextId';
@@ -18,7 +18,7 @@ export const CURRENT_SIGNATURE_VERSION: SignatureVersion = '2';
type SignFnInput = {
url: string;
host: string;
imagesContextId: string;
};
type SignFn = (input: SignFnInput) => MaybePromise<string>;
@@ -60,7 +60,7 @@ const generateSignatureV2: SignFn = async (input) => {
assert(GITBOOK_IMAGE_RESIZE_SIGNING_KEY, 'GITBOOK_IMAGE_RESIZE_SIGNING_KEY is not set');
const all = [
input.url,
input.host, // The hostname is used to avoid serving images from other sites on the same domain
input.imagesContextId, // The hostname is used to avoid serving images from other sites on the same domain
GITBOOK_IMAGE_RESIZE_SIGNING_KEY,
]
.filter(Boolean)
+3 -2
View File
@@ -1,5 +1,6 @@
import { CustomizationThemeMode, type PublishedSiteContent } from '@gitbook/api';
import { CustomizationThemeMode } from '@gitbook/api';
import { headers } from 'next/headers';
import type { SiteURLData } from './context';
export enum MiddlewareHeaders {
/**
@@ -57,7 +58,7 @@ export async function getURLModeFromMiddleware(): Promise<'url' | 'url-host'> {
* Get the site URL data from the middleware headers.
* This function should only be called in a server action or a dynamic route.
*/
export async function getSiteURLDataFromMiddleware(): Promise<PublishedSiteContent> {
export async function getSiteURLDataFromMiddleware(): Promise<SiteURLData> {
const headersList = await headers();
const siteURLData = headersList.get(MiddlewareHeaders.SiteURLData);
+63 -14
View File
@@ -21,7 +21,9 @@ import {
throwIfDataError,
} from '@v2/lib/data';
import { isGitBookAssetsHostURL, isGitBookHostURL } from '@v2/lib/env';
import { getImageResizingContextId } from '@v2/lib/images';
import { MiddlewareHeaders } from '@v2/lib/middleware';
import type { SiteURLData } from './lib/context';
export const config = {
matcher: [
@@ -63,7 +65,8 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
return null;
}
const { url: siteURL, mode } = match;
const { url: siteRequestURL, mode } = match;
const imagesContextId = getImageResizingContextId(siteRequestURL);
/**
* Serve image resizing requests (all requests containing `/~gitbook/image`).
@@ -73,9 +76,9 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
* In GitBook v1: image resizing was done at the root of the hostname (docs.company.com/~gitbook/image)
* In GitBook v2: image resizing is done at the content level (docs.company.com/section/variant/~gitbook/image)
*/
if (siteURL.pathname.endsWith('/~gitbook/image')) {
if (siteRequestURL.pathname.endsWith('/~gitbook/image')) {
return await serveResizedImage(request, {
host: siteURL.host,
imagesContextId: imagesContextId,
});
}
@@ -85,13 +88,13 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
// @ts-ignore - request typing
const visitorToken = getVisitorToken({
cookies: request.cookies.getAll(),
url: siteURL,
url: siteRequestURL,
});
const withAPIToken = async (apiToken: string | null) => {
const siteURLData = await throwIfDataError(
getPublishedContentByURL({
url: siteURL.toString(),
url: siteRequestURL.toString(),
visitorAuthToken: visitorToken?.token ?? null,
// When the visitor auth token is pulled from the cookie, set redirectOnError when calling getPublishedContentByUrl to allow
// redirecting when the token is invalid as we could be dealing with stale token stored in the cookie.
@@ -136,11 +139,15 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
cookies.push(...getResponseCookiesForVisitorAuth(siteURLData.siteBasePath, visitorToken));
// We use the host/origin from the canonical URL to ensure the links are
// correctly generated when the site is proxied. e.g. https://proxy.gitbook.com/site/siteId/...
const siteCanonicalURL = new URL(siteURLData.canonicalUrl);
//
// Make sure the URL is clean of any va token after a successful lookup
// The token is stored in a cookie that is set on the redirect response
//
const incomingURL = mode === 'url' ? requestURL : siteURL;
const incomingURL = mode === 'url' ? requestURL : siteCanonicalURL;
const requestURLWithoutToken = normalizeVisitorAuthURL(incomingURL);
if (
requestURLWithoutToken !== incomingURL &&
@@ -163,16 +170,19 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
const requestHeaders = new Headers(request.headers);
requestHeaders.set(MiddlewareHeaders.RouteType, routeType);
requestHeaders.set(MiddlewareHeaders.URLMode, mode);
requestHeaders.set(MiddlewareHeaders.SiteURL, `${siteURL.origin}${siteURLData.basePath}`);
requestHeaders.set(
MiddlewareHeaders.SiteURL,
`${siteCanonicalURL.origin}${siteURLData.basePath}`
);
requestHeaders.set(MiddlewareHeaders.SiteURLData, JSON.stringify(siteURLData));
// Preview of customization/theme
const customization = siteURL.searchParams.get('customization');
const customization = siteRequestURL.searchParams.get('customization');
if (customization && validateSerializedCustomization(customization)) {
routeType = 'dynamic';
requestHeaders.set(MiddlewareHeaders.Customization, customization);
}
const theme = siteURL.searchParams.get('theme');
const theme = siteRequestURL.searchParams.get('theme');
if (theme === CustomizationThemeMode.Dark || theme === CustomizationThemeMode.Light) {
routeType = 'dynamic';
requestHeaders.set(MiddlewareHeaders.Theme, theme);
@@ -188,18 +198,44 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
requestHeaders.set('x-forwarded-host', request.nextUrl.host);
requestHeaders.set('origin', request.nextUrl.origin);
const siteURLWithoutProtocol = `${siteURL.host}${siteURLData.basePath}`;
const siteURLWithoutProtocol = `${siteCanonicalURL.host}${siteURLData.basePath}`;
const { pathname, routeType: routeTypeFromPathname } = encodePathInSiteContent(
siteURLData.pathname
);
routeType = routeTypeFromPathname ?? routeType;
// We pick only stable data from the siteURL data to prevent re-rendering of
// the root layout when changing pages..
const stableSiteURLData: SiteURLData = {
site: siteURLData.site,
siteSection: siteURLData.siteSection,
siteSpace: siteURLData.siteSpace,
siteBasePath: siteURLData.siteBasePath,
basePath: siteURLData.basePath,
space: siteURLData.space,
organization: siteURLData.organization,
changeRequest: siteURLData.changeRequest,
revision: siteURLData.revision,
shareKey: siteURLData.shareKey,
apiToken: siteURLData.apiToken,
imagesContextId: imagesContextId,
};
const route = [
'sites',
routeType,
mode,
encodeURIComponent(siteURLWithoutProtocol),
encodeURIComponent(rison.encode(siteURLData)),
encodeURIComponent(
rison.encode(
// rison can't encode undefined values
Object.fromEntries(
Object.entries(stableSiteURLData).filter(
([_, v]) => typeof v !== 'undefined'
)
)
)
),
pathname,
].join('/');
@@ -226,10 +262,10 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
};
// For https://preview/<siteURL> requests,
if (siteURL.hostname === 'preview') {
if (siteRequestURL.hostname === 'preview') {
return serveWithQueryAPIToken(
// We scope the API token to the site ID.
`${siteURL.hostname}/${requestURL.pathname.slice(1).split('/')[0]}`,
`${siteRequestURL.hostname}/${requestURL.pathname.slice(1).split('/')[0]}`,
request,
withAPIToken
);
@@ -382,13 +418,26 @@ function encodePathInSiteContent(rawPathname: string): {
return { pathname };
}
// If the pathname is a markdown file, we rewrite it to ~gitbook/markdown/:pathname
if (pathname.match(/\.md$/)) {
const pagePathWithoutMD = pathname.slice(0, -3);
return {
pathname: `~gitbook/markdown/${encodeURIComponent(pagePathWithoutMD)}`,
// The markdown content is always static and doesn't depend on the dynamic parameter (customization, theme, etc)
routeType: 'static',
};
}
switch (pathname) {
case '~gitbook/icon':
return { pathname };
case 'llms.txt':
case 'sitemap.xml':
case 'sitemap-pages.xml':
case 'robots.txt':
return { pathname };
// LLMs.txt, sitemap, sitemap-pages and robots.txt are always static
// as they only depend on the site structure / pages.
return { pathname, routeType: 'static' };
case '~gitbook/pdf':
// PDF routes are always dynamic as they depend on the search params.
return { pathname, routeType: 'dynamic' };
+61
View File
@@ -1,5 +1,66 @@
# gitbook
## 0.9.2
### Patch Changes
- da7b369: Fix missing headers in OpenAPIResponses
- 139a805: Fix OpenAPI enum display
- Updated dependencies [da7b369]
- Updated dependencies [da485f5]
- Updated dependencies [139a805]
- @gitbook/react-openapi@1.1.9
## 0.9.1
### Patch Changes
- fb90eb0: Limit tinted background on bold theme to sites with filled sidebar
- 7d0b422: Handle grouped OpenAPISchemas
- Updated dependencies [7d0b422]
- Updated dependencies [fb90eb0]
- @gitbook/react-openapi@1.1.8
- @gitbook/colors@0.3.1
## 0.9.0
### Minor Changes
- 77fd393: Track event when visitor is opening a search result.
- d70d566: Support site announcement banner
- 77fd393: Track event when clicking announcement banner link.
### Patch Changes
- e84a46a: Fix OpenAPI tabs indicator overflow
- bc90adb: Fix favicon not being displayed in Google because `robots.txt` was preventing the indexation of the image route
- 434af90: Fix image resizing when using the proxy feature in a site.
- c756761: Add breadcrumbs to search results
- 40e8e69: Disallow crawling by web-robots of search/ask URLs
- 77fd393: Fix clicking search results when the site is embedded in an iframe.
- 1505ddb: Fix multiple request examples selector not showing
- 61db166: Add OpenAPI write-only indicator
- 6f71da8: Fix padding in schemas
- fa91eb7: Fix PDF generation when user has dark mode configured.
- 5b1e01c: Support for x-stability property
- 57ca4e0: Fix a crash when a page contains a block of an integration that is no longer installed
- d236bf0: Fix flash when loading sites with dark mode as default theme
- cd99ed5: Fix spec properties rendering and missing keys
- 813b2af: Support for x-enumDescriptions and x-gitbook-enum
- e9fa50d: Trim the search query to avoid showing a loading state when typing
- Updated dependencies [bd35348]
- Updated dependencies [ae78fc5]
- Updated dependencies [7bb37c7]
- Updated dependencies [373183a]
- Updated dependencies [1505ddb]
- Updated dependencies [61db166]
- Updated dependencies [5b1e01c]
- Updated dependencies [cd99ed5]
- Updated dependencies [813b2af]
- Updated dependencies [a25fded]
- @gitbook/react-openapi@1.1.7
- @gitbook/openapi-parser@2.1.2
## 0.8.2
### Patch Changes
+29
View File
@@ -28,6 +28,7 @@ import {
headerLinks,
runTestCases,
waitForCookiesDialog,
waitForNotFound,
} from './util';
const testCases: TestsCase[] = [
@@ -308,6 +309,18 @@ const testCases: TestsCase[] = [
url: '~/revisions/S55pwsEr5UVoroaOiWnP/blocks/headings',
run: waitForCookiesDialog,
},
{
name: 'Invalid revision',
url: '~/revisions/idnotfound/blocks/headings',
run: waitForNotFound,
screenshot: false,
},
{
name: 'Invalid change request',
url: '~/changes/idnotfound/blocks/headings',
run: waitForNotFound,
screenshot: false,
},
],
},
{
@@ -390,6 +403,22 @@ const testCases: TestsCase[] = [
},
],
},
{
name: 'Markdown page',
skip: process.env.ARGOS_BUILD_NAME !== 'v2-vercel',
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/',
tests: [
{
name: 'Text page',
url: 'text-page.md',
screenshot: false,
run: async (_page, response) => {
expect(response?.status()).toBe(200);
expect(response?.headers()['content-type']).toContain('text/markdown');
},
},
],
},
{
name: 'Content tests',
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/',
+9 -4
View File
@@ -16,7 +16,7 @@ import {
type CustomizationThemedColor,
type SiteCustomizationSettings,
} from '@gitbook/api';
import { type BrowserContext, type Page, expect, test } from '@playwright/test';
import { type BrowserContext, type Page, type Response, expect, test } from '@playwright/test';
import deepMerge from 'deepmerge';
import rison from 'rison';
import type { DeepPartial } from 'ts-essentials';
@@ -33,7 +33,7 @@ export interface Test {
/**
* Test to run
*/
run?: (page: Page) => Promise<unknown>;
run?: (page: Page, response: Response | null) => Promise<unknown>;
/**
* Whether the test should be fullscreened during testing.
*/
@@ -138,6 +138,11 @@ export async function waitForCookiesDialog(page: Page) {
await expect(dialog).toBeVisible();
}
export async function waitForNotFound(_page: Page, response: Response | null) {
expect(response).not.toBeNull();
expect(response?.status()).toBe(404);
}
/**
* Transform test cases into Playwright tests and run it.
*/
@@ -183,9 +188,9 @@ export function runTestCases(testCases: TestsCase[]) {
}
});
await page.goto(url);
const response = await page.goto(url);
if (testEntry.run) {
await testEntry.run(page);
await testEntry.run(page, response);
}
const screenshotOptions = testEntry.screenshot;
if (screenshotOptions !== false) {
+8 -5
View File
@@ -1,6 +1,6 @@
{
"name": "gitbook",
"version": "0.8.2",
"version": "0.9.2",
"private": true,
"scripts": {
"dev": "env-cmd --silent -f ../../.env.local next dev",
@@ -32,9 +32,7 @@
"@sindresorhus/fnv1a": "^3.1.0",
"@tailwindcss/container-queries": "^0.1.1",
"@tailwindcss/typography": "^0.5.16",
"@upstash/redis": "^1.27.1",
"ai": "^4.1.46",
"ajv": "^8.12.0",
"ai": "^4.2.2",
"assert-never": "^1.2.1",
"bun-types": "^1.1.20",
"classnames": "^2.5.1",
@@ -68,7 +66,12 @@
"tailwind-shades": "^1.1.2",
"unified": "^11.0.5",
"url-join": "^5.0.0",
"usehooks-ts": "^3.1.0"
"usehooks-ts": "^3.1.0",
"zod": "^3.24.2",
"zod-to-json-schema": "^3.24.5",
"event-iterator": "^2.0.0",
"partial-json": "^0.1.7",
"zustand": "^5.0.3"
},
"devDependencies": {
"@argos-ci/playwright": "^4.3.0",
@@ -1,7 +1,7 @@
'use client';
import { Button } from '@/components/primitives/Button';
import { t, useLanguage } from '@/intl/client';
import { t, tString, useLanguage } from '@/intl/client';
import { tcls } from '@/lib/tailwind';
export default function ErrorPage(props: {
@@ -35,9 +35,8 @@ export default function ErrorPage(props: {
}}
variant="secondary"
size="small"
>
{t(language, 'unexpected_error_retry')}
</Button>
label={tString(language, 'unexpected_error_retry')}
/>
</div>
</div>
</div>
@@ -0,0 +1,77 @@
'use client';
import { Icon } from '@gitbook/icons';
import { useEffect, useState } from 'react';
import { Loading } from '../primitives';
import { streamLinkPageSummary } from './server-actions/streamLinkPageSummary';
/**
* Summarise a page's content for use in a link preview
*/
export function AIPageLinkSummary(props: {
currentSpaceId: string;
currentPageId: string;
currentPageTitle: string;
targetSpaceId: string;
targetPageId: string;
linkPreview?: string;
linkTitle?: string;
showTrademark: boolean;
}) {
const {
currentSpaceId,
currentPageId,
targetSpaceId,
targetPageId,
linkPreview,
linkTitle,
showTrademark = true,
} = props;
const [highlight, setHighlight] = useState('');
useEffect(() => {
let canceled = false;
setHighlight('');
(async () => {
const stream = await streamLinkPageSummary({
currentSpaceId,
currentPageId,
targetSpaceId,
targetPageId,
linkPreview,
linkTitle,
previousPageIds: [],
});
for await (const highlight of stream) {
if (canceled) return;
setHighlight(highlight ?? '');
}
})();
return () => {
canceled = true;
};
}, [currentSpaceId, currentPageId, targetSpaceId, targetPageId, linkPreview, linkTitle]);
return (
<div className="flex flex-col gap-1">
<div className="flex w-screen items-center gap-1 font-semibold text-tint text-xs uppercase leading-tight tracking-wide">
{showTrademark ? (
<Loading className="size-4" busy={!highlight || highlight.length === 0} />
) : (
<Icon icon="sparkle" className="size-3" />
)}
<h6 className="text-tint">Page highlight</h6>
</div>
{highlight.length > 0 ? <p>{highlight}</p> : null}
{highlight.length > 0 ? (
<div className="text-tint-subtle text-xs">
Based on your context. May contain mistakes.
</div>
) : null}
</div>
);
}
@@ -0,0 +1 @@
export * from './AIPageLinkSummary';
@@ -0,0 +1,124 @@
'use server';
import { type AIMessageInput, AIModel, type AIStreamResponse } from '@gitbook/api';
import type { GitBookBaseContext } from '@v2/lib/context';
import { EventIterator } from 'event-iterator';
import type { MaybePromise } from 'p-map';
import * as partialJson from 'partial-json';
import type { DeepPartial } from 'ts-essentials';
import type { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';
/**
* Get the latest value from a stream and the response id.
*/
export async function generate<T>(
promise: MaybePromise<{
stream: EventIterator<T>;
response: Promise<{ responseId: string }>;
}>
) {
const input = await promise;
let value: T | undefined;
for await (const event of input.stream) {
value = event;
}
const { responseId } = await input.response;
return {
responseId,
value,
};
}
/**
* Stream the generation of an object using the AI.
*/
export async function streamGenerateObject<T>(
context: GitBookBaseContext,
{
organizationId,
siteId,
}: {
organizationId: string;
siteId: string;
},
{
schema,
messages,
model = AIModel.Fast,
}: {
schema: z.ZodSchema<T>;
messages: AIMessageInput[];
model?: AIModel;
previousResponseId?: string;
}
) {
const apiClient = await context.dataFetcher.api();
const rawStream = apiClient.orgs.streamAiResponseInSite(organizationId, siteId, {
input: messages,
output: {
type: 'object',
schema: zodToJsonSchema(schema),
},
model,
});
let json = '';
return parseResponse<DeepPartial<T>>(rawStream, (event) => {
if (event.type === 'response_object') {
json += event.jsonChunk;
const parsed = partialJson.parse(json, partialJson.ALL);
return parsed;
}
});
}
/**
* Parse a stream from the API to extract the responseId.
*/
function parseResponse<T>(
responseStream: EventIterator<AIStreamResponse>,
parse: (response: AIStreamResponse) => T | undefined
): {
stream: EventIterator<T>;
response: Promise<{ responseId: string }>;
} {
let resolveResponse: (value: { responseId: string }) => void;
const response = new Promise<{ responseId: string }>((resolve) => {
resolveResponse = resolve;
});
const stream = new EventIterator<T>((queue) => {
(async () => {
let foundResponse = false;
for await (const event of responseStream) {
if (event.type === 'response_finish') {
foundResponse = true;
resolveResponse({ responseId: event.responseId });
} else {
const parsed = parse(event);
if (parsed !== undefined) {
queue.push(parsed);
}
}
}
if (!foundResponse) {
throw new Error('No response found');
}
})().then(
() => {
queue.stop();
},
(error) => {
queue.fail(error);
}
);
});
return { stream, response };
}
@@ -0,0 +1 @@
export * from './streamLinkPageSummary';
@@ -0,0 +1,127 @@
'use server';
import { getV1BaseContext } from '@/lib/v1';
import { isV2 } from '@/lib/v2';
import { AIMessageRole } from '@gitbook/api';
import { getSiteURLDataFromMiddleware } from '@v2/lib/middleware';
import { fetchServerActionSiteContext, getServerActionBaseContext } from '@v2/lib/server-actions';
import { z } from 'zod';
import { streamGenerateObject } from './api';
/**
* Get a summary of a page, in the context of another page
*/
export async function* streamLinkPageSummary({
currentSpaceId,
currentPageId,
targetSpaceId,
targetPageId,
linkPreview,
linkTitle,
}: {
currentSpaceId: string;
currentPageId: string;
targetSpaceId: string;
targetPageId: string;
linkPreview?: string;
linkTitle?: string;
previousPageIds?: string[];
}) {
const baseContext = isV2() ? await getServerActionBaseContext() : await getV1BaseContext();
const siteURLData = await getSiteURLDataFromMiddleware();
const [{ stream }] = await Promise.all([
streamGenerateObject(
baseContext,
{
organizationId: siteURLData.organization,
siteId: siteURLData.site,
},
{
schema: z.object({
highlight: z.string().describe('The most important content of the target page'),
// questions: z.array(z.string().describe('The questions to sea')).max(3),
}),
messages: [
{
role: AIMessageRole.Developer,
content: `# Role
You are a documentation navigator. Your job is to help the user read documentation more efficiently. Your aim is to prevent the user from having to read the target page by giving them all the information they need to know.
# Task
Using both the current page context and the target page content, produce a page highlight that:
- Highlights the key facts from the target page.
- Relates strongly to the topic the user is currently reading about.
- Is very succinct and direct, using only one or two short sentences (each sentence using no more than one comma).
- Remains strictly factual, without referring to “the page”.
# Instructions
1. Identify the key paragraph surrounding the link's text (e.g., “change request”) from the current page.
2. Extract and combine relevant information from the target page to address the link's context.
3. Combine in one or two short sentences that are direct and brief.
# Examples
## Example 1
- Link context: “This feature is only available on the Ultimate plan.”
- Link preview: “Pricing: Learn about our different pricing tiers.”
- Response: “The Ultimate plan costs $25 per month. A Pro plan is available too.”
## Example 2
- Link context: “You can use keyboard shortcuts to get to the Search menu faster.”
- Link preview: “Keyboard shortcuts: A quick reference guide to all the keyboard shortcuts available.”
- Response: “To open the Search menu, use the keyboard shortcut ⌘K or Ctrl+K.”
## Example 3
- Link context: “This feature can only be enabled by an admin.”
- Link preview: “Roles: An overview of the different roles on the platform.”
- Response: “The admin role is reserved for the creator of the organisation.”`,
},
{
role: AIMessageRole.Developer,
content: `# Context
## Current page
The content of the current page is:`,
attachments: [
{
type: 'page',
spaceId: currentSpaceId,
pageId: currentPageId,
},
],
},
{
role: AIMessageRole.Developer,
content: `## Target page
The content of the target page is:`,
attachments: [
{
type: 'page',
spaceId: targetSpaceId,
pageId: targetPageId,
},
],
},
{
role: AIMessageRole.Developer,
content: `## Link preview
The content of the link preview is:
> ${linkPreview}`,
},
{
role: AIMessageRole.User,
content: `I'm considering reading the link titled "${linkTitle}" to page ID ${targetPageId}. Give the most relevant information from this page. Relate it to my current page and in particular the paragraph I'm currently reading. Be very concise.`,
},
],
}
),
fetchServerActionSiteContext(baseContext),
]);
for await (const value of stream) {
const highlight = value.highlight;
if (!highlight) {
continue;
}
yield highlight;
}
}
@@ -0,0 +1,32 @@
import { resolveContentRef } from '@/lib/references';
import type { GitBookSiteContext } from '@v2/lib/context';
import { AnnouncementBanner } from './AnnouncementBanner';
/**
* Server-side component to resolve content refs and pass down to client-side component
*/
export async function Announcement(props: {
context: GitBookSiteContext;
}) {
const { context } = props;
const { customization } = context;
if (
!customization.announcement ||
!customization.announcement.enabled ||
!customization.announcement.message
) {
return null;
}
const resolvedContentRef = customization.announcement?.link
? await resolveContentRef(customization.announcement?.link?.to, context)
: null;
return (
<AnnouncementBanner
announcement={customization.announcement}
contentRef={resolvedContentRef}
/>
);
}
@@ -0,0 +1,140 @@
'use client';
import * as storage from '@/lib/local-storage';
import type { ResolvedContentRef } from '@/lib/references';
import { tcls } from '@/lib/tailwind';
import { type CustomizationAnnouncement, SiteInsightsLinkPosition } from '@gitbook/api';
import { Icon, type IconName } from '@gitbook/icons';
import { CONTAINER_STYLE } from '../layout';
import { Link, linkStyles } from '../primitives';
import { ANNOUNCEMENT_CSS_CLASS, ANNOUNCEMENT_STORAGE_KEY } from './constants';
/**
* Client-side component to enable closing the banner
*/
export function AnnouncementBanner(props: {
announcement: CustomizationAnnouncement;
contentRef: ResolvedContentRef | null;
}) {
const { announcement, contentRef } = props;
const hasLink = announcement.link && contentRef?.href;
const closeable = announcement.style !== 'danger';
const Tag = hasLink ? Link : 'div';
const style = BANNER_STYLES[announcement.style];
return (
<div className="announcement-banner theme-bold:bg-header-background pt-4 pb-2">
<div className="scroll-nojump">
<div className={tcls('relative', CONTAINER_STYLE)}>
<Tag
href={contentRef?.href ?? ''}
className={tcls(
'flex w-full items-start justify-center overflow-hidden rounded-md straight-corners:rounded-none px-4 py-3 text-neutral-strong text-sm theme-bold:ring-1 theme-gradient:ring-1 ring-inset transition-colors',
style.container,
closeable && 'pr-12',
hasLink && style.hover
)}
insights={
announcement.link
? {
type: 'link_click',
link: {
target: announcement.link.to,
position: SiteInsightsLinkPosition.Announcement,
},
}
: undefined
}
>
<Icon
icon={style.icon as IconName}
className={`mt-0.5 mr-3 size-4 shrink-0 ${style.iconColor}`}
/>
<div>
{announcement.message}
{hasLink ? (
<div className={tcls(linkStyles, style.link, 'ml-1 inline')}>
{contentRef?.icon ? (
<span className="mr-1 ml-2 *:inline">
{contentRef?.icon}
</span>
) : null}
{announcement.link?.title && (
<span className="mr-1">{announcement.link?.title}</span>
)}
<Icon
icon={
announcement.link?.to.kind === 'url'
? 'arrow-up-right'
: 'chevron-right'
}
className={tcls('mb-0.5 inline size-3')}
/>
</div>
) : null}
</div>
</Tag>
{closeable ? (
<button
className={`absolute top-0 right-4 mt-2 mr-2 rounded straight-corners:rounded-none p-1.5 transition-all hover:ring-1 sm:right-6 md:right-8 ${style.close}`}
type="button"
onClick={dismissAnnouncement}
>
<Icon icon="close" className="size-4" />
</button>
) : null}
</div>
</div>
</div>
);
}
/**
* Dismiss the announcement banner and store the dismissal state in local storage.
* @see AnnouncementScript
*/
function dismissAnnouncement() {
storage.setItem(ANNOUNCEMENT_STORAGE_KEY, {
visible: false,
at: Date.now(),
});
document.documentElement.classList.add(ANNOUNCEMENT_CSS_CLASS);
}
const BANNER_STYLES = {
info: {
container: 'bg-info ring-info-subtle',
hover: 'hover:bg-info-hover active:bg-info-active',
icon: 'circle-info',
iconColor: 'text-info-subtle',
close: 'hover:bg-tint-base hover:ring-info-subtle',
link: '',
},
warning: {
container: 'bg-warning decoration-warning/6 ring-warning-subtle',
hover: 'hover:bg-warning-hover',
icon: 'circle-exclamation',
iconColor: 'text-warning-subtle',
close: 'hover:bg-tint-base hover:ring-warning-subtle',
link: 'links-default:text-warning links-default:hover:text-warning-strong links-default:decoration-warning/6 links-accent:decoration-warning',
},
danger: {
container: 'bg-danger decoration-danger/6 ring-danger-subtle',
hover: 'hover:bg-danger-hover',
icon: 'triangle-exclamation',
iconColor: 'text-danger-subtle',
close: 'hover:bg-tint-base hover:ring-danger-subtle',
link: 'links-default:text-danger links-default:hover:text-danger-strong links-default:decoration-danger/6 links-accent:decoration-danger',
},
success: {
container: 'bg-success decoration-success/6 ring-success-subtle',
hover: 'hover:bg-success-hover',
icon: 'circle-check',
iconColor: 'text-success-subtle',
close: 'hover:bg-tint-base hover:ring-success-subtle',
link: 'links-default:text-success links-default:hover:text-success-strong links-default:decoration-success/6 links-accent:decoration-success',
},
};
@@ -0,0 +1,39 @@
'use client';
import * as React from 'react';
import {
ANNOUNCEMENT_CSS_CLASS,
ANNOUNCEMENT_DAYS_TILL_RESET,
ANNOUNCEMENT_STORAGE_KEY,
} from './constants';
import { checkStorageForDismissedScript } from './script';
/**
* Inject a script to read the local storage state for the announcement banner and apply the appropriate CSS class to the <html> element as early as possible.
* Bypasses react state to prevent flickering.
*/
export function AnnouncementDismissedScript() {
const scriptArgs = JSON.stringify([
ANNOUNCEMENT_STORAGE_KEY,
ANNOUNCEMENT_DAYS_TILL_RESET,
ANNOUNCEMENT_CSS_CLASS,
]).slice(1, -1);
// makes sure the banner dismissed state is kept when navigating between sites
React.useEffect(() => {
checkStorageForDismissedScript(
ANNOUNCEMENT_STORAGE_KEY,
ANNOUNCEMENT_DAYS_TILL_RESET,
ANNOUNCEMENT_CSS_CLASS
);
}, []);
return (
<script
suppressHydrationWarning
dangerouslySetInnerHTML={{
__html: `(${checkStorageForDismissedScript.toString()})(${scriptArgs})`,
}}
/>
);
}
@@ -0,0 +1,12 @@
/**
* The local storage key for the announcement banner.
*/
export const ANNOUNCEMENT_STORAGE_KEY = '@gitbook/announcement';
/**
* The CSS class to hide the announcement banner. Applies to the <html> element.
*/
export const ANNOUNCEMENT_CSS_CLASS = 'announcement-hidden';
/**
* The number of days until the announcement banner resets.
*/
export const ANNOUNCEMENT_DAYS_TILL_RESET = 7;
@@ -0,0 +1,2 @@
export * from './Announcement';
export * from './AnnouncementDismissedScript';
@@ -0,0 +1,34 @@
/**
* Read the local storage state for the announcement banner and apply the appropriate CSS class to the <html> element.
*
* NOTE: this script is stringified and run in the browser, so it must be self-contained and have syntax supported in all browsers.
*/
export function checkStorageForDismissedScript(
storageKey: string,
daysTillReset: number,
cssClass: string
) {
let showBanner = true;
try {
const announcementStateStr = window.localStorage.getItem(storageKey);
const announcementState = announcementStateStr
? JSON.parse(announcementStateStr)
: undefined;
if (announcementState && !announcementState.visible) {
const dismissedAt = announcementState.at;
const nowTime = new Date().getTime();
// Check if enough days have passed since dismissal
const daysSinceDismissal = Math.floor((nowTime - dismissedAt) / (1000 * 60 * 60 * 24));
if (daysSinceDismissal < daysTillReset) {
showBanner = false;
}
}
} catch {}
if (!showBanner) {
document.documentElement.classList.add(cssClass);
}
}
@@ -97,9 +97,8 @@ export function CookiesToast(props: { privacyPolicy?: string }) {
onClick={() => {
onUpdateState(true);
}}
>
{t(language, 'cookies_accept')}
</Button>
label={tString(language, 'cookies_accept')}
/>
<Button
variant="secondary"
size="small"
@@ -107,9 +106,8 @@ export function CookiesToast(props: { privacyPolicy?: string }) {
onClick={() => {
onUpdateState(false);
}}
>
{t(language, 'cookies_reject')}
</Button>
label={tString(language, 'cookies_reject')}
/>
</div>
</div>
);
@@ -104,7 +104,7 @@ const HINT_STYLES: {
'[&_.can-override-text]:text-neutral-strong',
],
container:
'bg-info border-info theme-muted-tint:bg-info-solid/2 theme-bold-tint:bg-info-solid/2',
'bg-info border-info theme-muted-tint:bg-info-solid/2 [html.sidebar-filled.theme-bold.tint_&]:bg-info-solid/2',
containerWithHeader: 'border-info-solid bg-info-subtle',
},
warning: {
@@ -1,19 +1,22 @@
import { type DocumentInlineLink, SiteInsightsLinkPosition } from '@gitbook/api';
import { resolveContentRef } from '@/lib/references';
import { Icon } from '@gitbook/icons';
import { StyledLink } from '../primitives';
import type { InlineProps } from './Inline';
import { InlineLinkTooltip } from './InlineLinkTooltip';
import { Inlines } from './Inlines';
export async function InlineLink(props: InlineProps<DocumentInlineLink>) {
const { inline, document, context, ancestorInlines } = props;
const resolved = context.contentContext
? await resolveContentRef(inline.data.ref, context.contentContext)
? await resolveContentRef(inline.data.ref, context.contentContext, {
resolveAnchorText: true,
})
: null;
if (!resolved) {
if (!context.contentContext || !resolved) {
return (
<span title="Broken link" className="underline">
<Inlines
@@ -25,24 +28,38 @@ export async function InlineLink(props: InlineProps<DocumentInlineLink>) {
</span>
);
}
const isExternal = inline.data.ref.kind === 'url';
return (
<StyledLink
href={resolved.href}
insights={{
type: 'link_click',
link: {
target: inline.data.ref,
position: SiteInsightsLinkPosition.Content,
},
}}
<InlineLinkTooltip
inline={inline}
document={document}
context={context}
ancestorInlines={ancestorInlines}
>
<Inlines
context={context}
document={document}
nodes={inline.nodes}
ancestorInlines={[...ancestorInlines, inline]}
/>
</StyledLink>
<StyledLink
href={resolved.href}
insights={{
type: 'link_click',
link: {
target: inline.data.ref,
position: SiteInsightsLinkPosition.Content,
},
}}
>
<Inlines
context={context}
document={document}
nodes={inline.nodes}
ancestorInlines={[...ancestorInlines, inline]}
/>
{isExternal ? (
<Icon
icon="arrow-up-right"
className="ml-0.5 inline size-3 links-accent:text-tint-subtle"
/>
) : null}
</StyledLink>
</InlineLinkTooltip>
);
}
@@ -0,0 +1,178 @@
import type { DocumentInlineLink } from '@gitbook/api';
import { resolveContentRef } from '@/lib/references';
import { tcls } from '@/lib/tailwind';
import { Icon } from '@gitbook/icons';
import * as Tooltip from '@radix-ui/react-tooltip';
import { Fragment } from 'react';
import { AIPageLinkSummary } from '../Adaptive/AIPageLinkSummary';
import { Button, StyledLink } from '../primitives';
import type { InlineProps } from './Inline';
import { Inlines } from './Inlines';
export async function InlineLinkTooltip(
props: InlineProps<DocumentInlineLink> & { children: React.ReactNode }
) {
const { inline, document, context, ancestorInlines, children } = props;
const resolved = context.contentContext
? await resolveContentRef(inline.data.ref, context.contentContext, {
resolveAnchorText: true,
})
: null;
if (!context.contentContext || !resolved) {
return (
<span title="Broken link" className="underline">
<Inlines
context={context}
document={document}
nodes={inline.nodes}
ancestorInlines={[...ancestorInlines, inline]}
/>
</span>
);
}
let breadcrumbs = resolved.ancestors;
const isExternal = inline.data.ref.kind === 'url';
const isSamePage = inline.data.ref.kind === 'anchor' && inline.data.ref.page === undefined;
if (isExternal) {
breadcrumbs = [
{
label: 'External link to',
},
];
}
if (isSamePage) {
breadcrumbs = [
{
label: 'Jump to section',
icon: <Icon icon="arrow-down-short-wide" className="size-3" />,
},
];
resolved.subText = undefined;
}
return (
<Tooltip.Provider delayDuration={200}>
<Tooltip.Root>
<Tooltip.Trigger asChild>{children}</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content className="z-40 w-screen max-w-md animate-present px-4 sm:w-auto">
<div className="overflow-hidden rounded-md straight-corners:rounded-none shadow-lg shadow-tint-12/4 ring-1 ring-tint-subtle dark:shadow-tint-1 ">
<div className="bg-tint-base p-4">
<div className={tcls('flex gap-4')}>
<div className="flex flex-col">
{breadcrumbs && breadcrumbs.length > 0 ? (
<div className="mb-1 flex grow flex-wrap items-center gap-x-2 gap-y-0.5 font-semibold text-tint text-xs uppercase leading-tight tracking-wide">
{breadcrumbs.map((crumb, index) => {
const Tag = crumb.href ? StyledLink : 'div';
return (
<Fragment key={crumb.label}>
{index !== 0 ? (
<Icon
icon="chevron-right"
className="size-3 text-tint-subtle"
/>
) : null}
<Tag
className={tcls(
'flex gap-1',
crumb.href &&
'links-default:text-tint no-underline hover:underline contrast-more:underline contrast-more:decoration-current'
)}
href={crumb.href ?? '#'}
>
{crumb.icon ? (
<span className="mt-0.5 text-tint-subtle empty:hidden">
{crumb.icon}
</span>
) : null}
{crumb.label}
</Tag>
</Fragment>
);
})}
</div>
) : null}
<div
className={tcls(
'flex gap-2 leading-snug',
isExternal && 'text-sm [overflow-wrap:anywhere]'
)}
>
{resolved.icon ? (
<div className="mt-1 text-tint-subtle empty:hidden">
{resolved.icon}
</div>
) : null}
<h5 className="font-semibold">{resolved.text}</h5>
</div>
</div>
{!isSamePage && resolved.href ? (
<Button
className={tcls(
'-mx-2 -my-2 ml-auto',
breadcrumbs?.length === 0
? null
: 'place-self-start'
)}
variant="blank"
href={resolved.href}
target="_blank"
label="Open in new tab"
size="small"
icon="arrow-up-right-from-square"
iconOnly={true}
/>
) : null}
</div>
{resolved.subText ? (
<p className="mt-1 text-sm text-tint">{resolved.subText}</p>
) : null}
</div>
{'customization' in context.contentContext &&
context.contentContext.customization.ai?.pageLinkSummaries.enabled &&
!isExternal &&
'page' in context.contentContext &&
inline.data.ref.kind === 'page' ? (
<div className="border-tint-subtle border-t bg-tint p-4">
<AIPageLinkSummary
currentPageId={context.contentContext.page.id}
currentSpaceId={context.contentContext.space.id}
currentPageTitle={context.contentContext.page.title}
targetPageId={
inline.data.ref.page ?? context.contentContext.page.id
}
targetSpaceId={
inline.data.ref.space ?? context.contentContext.space.id
}
linkTitle={inline.nodes
.map((node) => {
if (node.object === 'text') {
return node.leaves
.map((leaf) => leaf.text)
.join('');
}
return '';
})
.join('')}
linkPreview={`**${resolved.text}**: ${resolved.subText}`}
showTrademark={
context.contentContext.customization.trademark.enabled
}
/>
</div>
) : null}
</div>
<Tooltip.Arrow className="fill-tint-1" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
);
}
@@ -34,7 +34,11 @@ export async function IntegrationBlock(props: BlockProps<DocumentBlockIntegratio
dataFetcher.renderIntegrationUi({
integrationName: block.data.integration,
request: initialInput,
})
}),
// The API can respond with a 400 error if the integration is not installed
// and 404 if the integration is not found.
[404, 400]
);
if (!initialOutput || initialOutput.type === 'complete') {
return null;
@@ -77,7 +77,12 @@ async function OpenAPIOperationBody(props: BlockProps<AnyOpenAPIOperationsBlock>
ancestorBlocks={props.ancestorBlocks}
isEstimatedOffscreen={props.isEstimatedOffscreen}
context={props.context}
style={headingProps.deprecated ? 'line-through' : undefined}
style={tcls([
headingProps.deprecated ? 'line-through' : undefined,
headingProps.deprecated || !!headingProps.stability
? '[&>div]:mt-0'
: undefined,
])}
block={{
object: 'block',
key: `${block.key}-heading`,
@@ -50,6 +50,7 @@ async function OpenAPISchemasBody(props: BlockProps<OpenAPISchemasBlock>) {
return (
<BaseOpenAPISchemas
data={data}
grouped={block.data.grouped}
context={{
specUrl,
icons: {
@@ -20,10 +20,27 @@
@apply flex flex-col items-start justify-start gap-3;
}
.openapi-deprecated {
.openapi-summary-tags {
@apply flex flex-row gap-2 mt-[0.75em];
}
.openapi-deprecated,
.openapi-stability {
@apply py-0.5 px-1.5 min-w-[1.625rem] font-normal w-fit justify-center items-center ring-1 ring-inset ring-tint bg-tint rounded text-sm leading-[calc(max(1.20em,1.25rem))] before:!content-none after:!content-none;
}
.openapi-stability-alpha {
@apply text-amber-700 dark:text-amber-300 bg-amber-50 dark:bg-amber-900/6 ring-amber-500/5;
}
.openapi-stability-beta {
@apply text-blue-700 dark:text-blue-300 bg-blue-50 dark:bg-blue-900/6 ring-blue-500/5;
}
.openapi-stability-experimental {
@apply text-violet-700 dark:text-violet-300 bg-violet-50 dark:bg-violet-900/6 ring-violet-500/5;
}
.openapi-deprecated-sunset-date {
@apply font-semibold font-mono truncate;
}
@@ -131,7 +148,7 @@
/* Schema Structure */
.openapi-schema-root {
@apply mt-2;
/* unstyled */
}
.openapi-schema-properties {
@@ -209,6 +226,10 @@
@apply text-primary-subtle/9 text-[0.813rem];
}
.openapi-schema-writeonly {
@apply text-success dark:text-success-subtle/9 text-[0.813rem];
}
.openapi-schema-type {
@apply text-tint select-text text-[0.813rem] font-mono [word-spacing:-0.25rem];
}
@@ -240,11 +261,11 @@
/* Schema Enum */
.openapi-schema-enum {
@apply flex flex-row text-sm leading-relaxed flex-wrap text-tint;
@apply text-sm leading-relaxed max-w-full text-tint;
}
.openapi-schema-enum-value {
@apply text-sm;
@apply text-sm mr-1.5;
}
.openapi-schema-enum-value:first-child {
@@ -467,13 +488,9 @@
/* Common Elements */
.openapi-select {
@apply max-w-60 rounded font-mono text-xs leading-6 px-1 py-0.5 border border-tint-subtle bg-tint;
@apply max-w-60 rounded font-mono text-xs leading-6 px-1 py-0.5 truncate border border-tint-subtle bg-tint;
}
.openapi-select {
min-width: fit-content;
max-width: fit-content;
}
.openapi-select:focus {
width: auto;
}
@@ -655,7 +672,8 @@
@apply border border-tint-subtle rounded-lg;
}
.openapi-section-schemas > .openapi-section-body > .openapi-schema-properties > .openapi-schema {
.openapi-section-schemas > .openapi-section-body > .openapi-schema-properties > .openapi-schema,
.openapi-section-schemas > .openapi-section-body > .openapi-schema-root {
@apply p-2.5;
}
@@ -692,3 +710,7 @@
opacity: 0;
}
}
.openapi-copy-button {
@apply hover:brightness-95;
}
@@ -168,7 +168,7 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
key={index}
href={ref.href}
target="_blank"
style={['flex', 'flex-row', 'items-center', 'gap-2']}
className="flex flex-row items-center gap-2"
insights={
ref.file
? {
@@ -12,7 +12,7 @@ export function Tabs(props: BlockProps<DocumentBlockTabs>) {
const tabs: TabsItem[] = [];
const tabsBody: React.ReactNode[] = [];
block.nodes.forEach((tab) => {
block.nodes.forEach((tab, index) => {
tabs.push({
id: tab.meta?.id ?? tab.key!,
title: tab.data.title ?? '',
@@ -20,6 +20,7 @@ export function Tabs(props: BlockProps<DocumentBlockTabs>) {
tabsBody.push(
<Blocks
key={tab.key ?? index}
nodes={tab.nodes}
document={document}
ancestorBlocks={[...ancestorBlocks, block, tab]}
@@ -38,7 +38,7 @@ export function Header(props: { context: GitBookSiteContext; withTopHeader?: boo
'bg-tint-base/9',
'theme-muted:bg-tint-subtle/9',
'theme-bold-tint:bg-tint-subtle/9',
'[html.sidebar-filled.theme-bold.tint_&]:bg-tint-subtle/9',
'theme-gradient:bg-gradient-primary',
'theme-gradient-tint:bg-gradient-tint',
'contrast-more:bg-tint-base',
@@ -142,10 +142,9 @@ function HeaderItemButton(
position: SiteInsightsLinkPosition.Header,
},
}}
label={title}
{...rest}
>
{title}
</Button>
/>
);
}
@@ -63,7 +63,7 @@ export function SpacesDropdown(props: {
className
)}
>
<span className={tcls('truncate', 'grow')}>{siteSpace.title}</span>
<span className={tcls('line-clamp-1', 'grow')}>{siteSpace.title}</span>
<DropdownChevron />
</div>
)}
@@ -1,5 +1,4 @@
import {
type CustomizationSettings,
type Revision,
type RevisionPageDocument,
type RevisionPageGroup,
@@ -24,7 +23,7 @@ import { tString } from '@/intl/translate';
import { getPagePDFContainerId } from '@/lib/links';
import { resolvePageId } from '@/lib/pages';
import { tcls } from '@/lib/tailwind';
import { defaultCustomizationForSpace } from '@/lib/utils';
import { defaultCustomization } from '@/lib/utils';
import { type PDFSearchParams, getPDFSearchParams } from './urls';
import { PageControlButtons } from './PageControlButtons';
@@ -57,7 +56,7 @@ export async function PDFPage(props: {
const pdfParams = getPDFSearchParams(new URLSearchParams(searchParams));
const customization =
'customization' in baseContext ? baseContext.customization : defaultCustomizationForSpace();
'customization' in baseContext ? baseContext.customization : defaultCustomization();
const language = getSpaceLanguage(customization);
// Compute the pages to render
@@ -180,7 +179,7 @@ export async function PDFPage(props: {
async function PDFSpaceIntro(props: {
space: Space;
customization: CustomizationSettings | SiteCustomizationSettings;
customization: SiteCustomizationSettings;
}) {
const { space, customization } = props;
@@ -1,5 +1,6 @@
import { CustomizationRootLayout } from '@/components/RootLayout';
import { defaultCustomizationForSpace } from '@/lib/utils';
import { defaultCustomization } from '@/lib/utils';
import { CustomizationThemeMode } from '@gitbook/api';
import type { GitBookSiteContext, GitBookSpaceContext } from '@v2/lib/context';
/**
@@ -14,8 +15,9 @@ export async function PDFRootLayout(props: {
return (
<CustomizationRootLayout
customization={
'customization' in context ? context.customization : defaultCustomizationForSpace()
'customization' in context ? context.customization : defaultCustomization()
}
forcedTheme={CustomizationThemeMode.Light}
>
{children}
</CustomizationRootLayout>
@@ -5,7 +5,7 @@ import React from 'react';
import { useScrollActiveId } from '@/components/hooks';
import { Button } from '@/components/primitives';
import { t, useLanguage } from '@/intl/client';
import { t, tString, useLanguage } from '@/intl/client';
import { tcls } from '@/lib/tailwind';
import { type PDFSearchParams, getPDFURLSearchParams } from './urls';
@@ -58,9 +58,8 @@ export function PageControlButtons(props: {
only: true,
}).toString()}`}
variant="secondary"
>
{t(language, 'pdf_mode_only_page')}
</Button>
label={tString(language, 'pdf_mode_only_page')}
/>
)}
<Button
href={`?${getPDFURLSearchParams({
@@ -69,9 +68,8 @@ export function PageControlButtons(props: {
only: false,
}).toString()}`}
variant="secondary"
>
{t(language, 'pdf_mode_all')}
</Button>
label={tString(language, 'pdf_mode_all')}
/>
{trademark ? <div className={tcls('mt-5')}>{trademark}</div> : null}
</div>
@@ -13,7 +13,6 @@ import urlJoin from 'url-join';
import { getSpaceLanguage, t } from '@/intl/server';
import { getDocumentSections } from '@/lib/document-sections';
import { tcls } from '@/lib/tailwind';
import { Ad } from '../Ads';
import { getPDFURLSearchParams } from '../PDF';
import { PageFeedbackForm } from '../PageFeedback';
@@ -42,12 +41,14 @@ export function PageAside(props: {
limit: 100,
}).toString()}`
);
return (
<aside
className={tcls(
'group/aside',
'hidden',
'xl:flex',
'text-sm',
// 'page-no-toc:lg:flex',
'flex-col',
'basis-56',
@@ -92,143 +93,113 @@ export function PageAside(props: {
)}
>
{page.layout.outline ? (
<>
<div
className={tcls(
'overflow-y-auto',
'overflow-x-visible',
'flex',
'flex-col',
'shrink',
'pb-12',
'sticky',
'lg:top:0',
'site-header:lg:top-16',
'site-header-sections:lg:top-[6.75rem]',
'gap-2',
'pt-8',
'page-api-block:xl:max-2xl:py-0',
// Hide it for api page, until hovered
'page-api-block:xl:max-2xl:hidden',
'page-api-block:xl:max-2xl:group-hover/aside:flex'
)}
>
{document ? (
<React.Suspense fallback={null}>
<PageAsideSections document={document} context={context} />
</React.Suspense>
) : null}
<div
className={tcls(
'hidden',
'page-api-block:xl:max-2xl:flex',
'text-xs',
'tracking-wide',
'font-semibold',
'uppercase',
'flex-row',
'items-center',
'gap-2'
)}
>
<Icon icon="block-quote" className={tcls('size-3')} />
{t(language, 'on_this_page')}
<Icon
icon="chevron-down"
className={tcls(
'size-3',
'opacity-6',
'ml-auto',
'page-api-block:xl:max-2xl:group-hover/aside:hidden'
)}
/>
</div>
<div
className={tcls(
'overflow-y-auto',
'overflow-x-visible',
'flex',
'flex-col',
'shrink',
'pb-12',
'sticky',
'lg:top:0',
'site-header:lg:top-16',
'site-header-sections:lg:top-[6.75rem]',
'gap-6',
'pt-8',
'page-api-block:xl:max-2xl:py-0',
// Hide it for api page, until hovered
'page-api-block:xl:max-2xl:hidden',
'page-api-block:xl:max-2xl:group-hover/aside:flex'
'gap-3',
'sidebar-list-default:px-3',
'border-t',
'first:border-none',
'border-tint-subtle',
'py-4',
'first:pt-0',
'page-api-block:xl:max-2xl:px-3',
'empty:hidden'
)}
>
{document ? (
{withPageFeedback ? (
<React.Suspense fallback={null}>
<PageAsideSections document={document} context={context} />
<PageFeedbackForm pageId={page.id} className={tcls('mt-2')} />
</React.Suspense>
) : null}
<div
className={tcls(
'flex',
'flex-col',
'gap-3',
'sidebar-list-default:px-3',
'border-t',
'first:border-none',
'border-tint-subtle',
'py-4',
'first:pt-0',
'page-api-block:xl:max-2xl:px-3',
'empty:hidden'
)}
>
{withPageFeedback ? (
<React.Suspense fallback={null}>
<PageFeedbackForm pageId={page.id} className={tcls('mt-2')} />
</React.Suspense>
) : null}
{customization.git.showEditLink && space.gitSync?.url && page.git ? (
<div>
<a
href={urlJoin(space.gitSync.url, page.git.path)}
className={tcls(
'flex',
'flex-row',
'items-center',
'text-sm',
'hover:text-tint-strong',
'links-accent:hover:underline',
'links-accent:hover:underline-offset-4',
'links-accent:hover:decoration-[3px]',
'links-accent:hover:decoration-primary-subtle',
'py-2'
)}
>
<Icon
icon={
space.gitSync.installationProvider === 'gitlab'
? 'gitlab'
: 'github'
}
className={tcls('size-4', 'mr-1.5')}
/>
{t(language, 'edit_on_git', getGitSyncName(space))}
</a>
</div>
) : null}
{customization.pdf.enabled ? (
<div>
<a
href={pdfHref}
className={tcls(
'flex',
'flex-row',
'items-center',
'text-sm',
'hover:text-tint-strong',
'links-accent:hover:underline',
'links-accent:hover:underline-offset-4',
'links-accent:hover:decoration-[3px]',
'links-accent:hover:decoration-primary-subtle',
'py-2'
)}
>
<Icon
icon="file-pdf"
className={tcls('size-4', 'mr-1.5')}
/>
{t(language, 'pdf_download')}
</a>
</div>
) : null}
</div>
{customization.git.showEditLink && space.gitSync?.url && page.git ? (
<div>
<a
href={urlJoin(space.gitSync.url, page.git.path)}
className={tcls(
'flex',
'flex-row',
'items-center',
'text-sm',
'hover:text-tint-strong',
'links-accent:hover:underline',
'links-accent:hover:underline-offset-4',
'links-accent:hover:decoration-[3px]',
'links-accent:hover:decoration-primary-subtle',
'py-2'
)}
>
<Icon
icon={
space.gitSync.installationProvider === 'gitlab'
? 'gitlab'
: 'github'
}
className={tcls('size-4', 'mr-1.5')}
/>
{t(language, 'edit_on_git', getGitSyncName(space))}
</a>
</div>
) : null}
{customization.pdf.enabled ? (
<div>
<a
href={pdfHref}
className={tcls(
'flex',
'flex-row',
'items-center',
'text-sm',
'hover:text-tint-strong',
'links-accent:hover:underline',
'links-accent:hover:underline-offset-4',
'links-accent:hover:decoration-[3px]',
'links-accent:hover:decoration-primary-subtle',
'py-2'
)}
>
<Icon icon="file-pdf" className={tcls('size-4', 'mr-1.5')} />
{t(language, 'pdf_download')}
</a>
</div>
) : null}
</div>
</>
</div>
) : null}
<div
className={tcls(
'sticky bottom-0 z-10 mt-auto flex flex-col bg-tint-base theme-bold-tint:bg-tint-subtle theme-gradient-tint:bg-gradient-tint theme-gradient:bg-gradient-primary theme-muted:bg-tint-subtle pb-4 page-api-block:xl:max-2xl:hidden page-api-block:xl:max-2xl:pb-0 page-api-block:xl:max-2xl:group-hover/aside:flex',
'sticky bottom-0 z-10 mt-auto flex flex-col bg-tint-base theme-gradient-tint:bg-gradient-tint theme-gradient:bg-gradient-primary theme-muted:bg-tint-subtle pb-4 page-api-block:xl:max-2xl:hidden page-api-block:xl:max-2xl:pb-0 page-api-block:xl:max-2xl:group-hover/aside:flex [html.sidebar-filled.theme-bold.tint_&]:bg-tint-subtle',
'page-api-block:xl:max-2xl:bg-transparent'
)}
>
@@ -111,7 +111,7 @@ export function ScrollSectionsList(props: { sections: DocumentSection[] }) {
'hover:bg-primary-hover',
'theme-muted:hover:bg-primary-active',
'theme-bold-tint:hover:bg-primary-active',
'[html.sidebar-filled.theme-bold.tint_&]:hover:bg-primary-active',
'theme-gradient:hover:bg-primary-active',
'tint:font-semibold',
@@ -7,7 +7,6 @@ import { t } from '@/intl/translate';
import { hasFullWidthBlock, isNodeEmpty } from '@/lib/document';
import type { AncestorRevisionPage } from '@/lib/pages';
import { tcls } from '@/lib/tailwind';
import { DocumentView, DocumentViewSkeleton } from '../DocumentView';
import { TrackPageViewEvent } from '../Insights';
import { PageFeedbackForm } from '../PageFeedback';
@@ -35,7 +35,7 @@ export async function PageHeader(props: {
<li key={breadcrumb.id}>
<StyledLink
href={href}
style={tcls(
className={tcls(
'no-underline',
'hover:underline',
'text-xs',
@@ -106,9 +106,8 @@ export function PageFeedbackForm(props: {
<Button
size="small"
onClick={() => onSubmitComment(rating, comment)}
>
{t(languages, 'submit')}
</Button>
label={tString(languages, 'submit')}
/>
{comment.length > MAX_COMMENT_LENGTH * 0.8 ? (
<span
className={
@@ -2,9 +2,9 @@ import {
CustomizationCorners,
CustomizationHeaderPreset,
CustomizationIconsStyle,
type CustomizationSettings,
CustomizationSidebarBackgroundStyle,
CustomizationSidebarListStyle,
CustomizationThemeMode,
type CustomizationThemedColor,
type CustomizationTint,
type SiteCustomizationSettings,
@@ -34,16 +34,18 @@ import { ClientContexts } from './ClientContexts';
import '@gitbook/icons/style.css';
import './globals.css';
import { GITBOOK_FONTS_URL, GITBOOK_ICONS_TOKEN, GITBOOK_ICONS_URL } from '@v2/lib/env';
import { AnnouncementDismissedScript } from '../Announcement';
/**
* Layout shared between the content and the PDF renderer.
* It takes care of setting the theme and the language.
*/
export async function CustomizationRootLayout(props: {
customization: SiteCustomizationSettings | CustomizationSettings;
forcedTheme?: CustomizationThemeMode | null;
customization: SiteCustomizationSettings;
children: React.ReactNode;
}) {
const { customization, children } = props;
const { customization, forcedTheme, children } = props;
const language = getSpaceLanguage(customization);
const tintColor = getTintColor(customization);
@@ -85,7 +87,12 @@ export async function CustomizationRootLayout(props: {
'links' in customization.styling && `links-${customization.styling.links}`,
fontNotoColorEmoji.variable,
ibmPlexMono.variable,
fontData.type === 'default' ? fontData.variable : 'font-custom'
fontData.type === 'default' ? fontData.variable : 'font-custom',
// Set the dark/light class statically to avoid flashing and make it work when JS is disabled
(forcedTheme ?? customization.themes.default) === CustomizationThemeMode.Dark
? 'dark'
: ''
)}
>
<head>
@@ -96,6 +103,11 @@ export async function CustomizationRootLayout(props: {
{/* Inject custom font @font-face rules */}
{fontData.type === 'custom' ? <style>{fontData.fontFaceRules}</style> : null}
{/* Inject a script to detect if the announcmeent banner has been dismissed */}
{'announcement' in customization && customization.announcement?.enabled ? (
<AnnouncementDismissedScript />
) : null}
<style
nonce={
//Since I can't get the nonce to work for inline styles, we need to allow unsafe-inline
@@ -141,9 +153,9 @@ export async function CustomizationRootLayout(props: {
</head>
<body
className={tcls(
'[html.sidebar-filled.theme-bold.tint_&]:bg-tint-subtle',
'bg-tint-base',
'theme-muted:bg-tint-subtle',
'theme-bold-tint:bg-tint-subtle',
'theme-gradient:bg-gradient-primary',
'theme-gradient-tint:bg-gradient-tint'
@@ -175,7 +187,7 @@ export async function CustomizationRootLayout(props: {
* If the tint color is not set or it is a space customization, it will return the default color.
*/
function getTintColor(
customization: CustomizationSettings | SiteCustomizationSettings
customization: SiteCustomizationSettings
): CustomizationTint['color'] | undefined {
if ('tint' in customization.styling && customization.styling.tint) {
return {
@@ -222,7 +234,7 @@ function getTintMixColor(
* If it is a space customization, it will return the default styles.
*/
function getSidebarStyles(
customization: CustomizationSettings | SiteCustomizationSettings
customization: SiteCustomizationSettings
): SiteCustomizationSettings['styling']['sidebar'] {
if ('sidebar' in customization.styling) {
return {
@@ -242,7 +254,7 @@ function getSidebarStyles(
* If it is a space customization, it will return the default styles.
*/
function getSemanticColors(
customization: CustomizationSettings | SiteCustomizationSettings
customization: SiteCustomizationSettings
): Pick<
SiteCustomizationSettings['styling'],
'infoColor' | 'successColor' | 'warningColor' | 'dangerColor'
@@ -160,3 +160,7 @@ html {
html.dark {
color-scheme: dark light;
}
html.announcement-hidden .announcement-banner {
@apply hidden;
}
@@ -189,6 +189,9 @@ function SearchModalBody(
setSearchState((state) => (state ? { ...state, ask: true } : null));
};
// We trim the query to avoid invalidating the search when the user is typing between words.
const normalizedQuery = state.query.trim();
return (
<motion.div
transition={{
@@ -288,12 +291,14 @@ function SearchModalBody(
<SearchResults
ref={resultsRef}
global={isMultiVariants && state.global}
query={state.query}
query={normalizedQuery}
withAsk={withAsk}
onSwitchToAsk={onSwitchToAsk}
/>
) : null}
{state.query && state.ask && withAsk ? <SearchAskAnswer query={state.query} /> : null}
{normalizedQuery && state.ask && withAsk ? (
<SearchAskAnswer query={normalizedQuery} />
) : null}
</motion.div>
);
}
@@ -1,7 +1,6 @@
import { Icon } from '@gitbook/icons';
import React from 'react';
import { tcls } from '@/lib/tailwind';
import { Icon, type IconName } from '@gitbook/icons';
import React from 'react';
import { Link } from '../primitives';
import { HighlightQuery } from './HighlightQuery';
@@ -17,6 +16,14 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt
) {
const { query, item, active } = props;
const breadcrumbs =
item.breadcrumbs?.map((crumb) => (
<span key={crumb.label} className="flex items-center gap-1">
{crumb.icon ? <Icon className="size-3" icon={crumb.icon as IconName} /> : null}
{crumb.label}
</span>
)) ?? [];
return (
<Link
ref={ref}
@@ -38,6 +45,14 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt
? ['is-active', 'bg-primary', 'text-contrast-primary', 'hover:bg-primary-hover']
: null
)}
insights={{
type: 'search_open_result',
query,
result: {
pageId: item.pageId,
spaceId: item.spaceId,
},
}}
>
<div className="size-4">
<Icon
@@ -46,18 +61,44 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt
/>
</div>
<div className={tcls('flex', 'flex-col', 'w-full')}>
{item.spaceTitle ? (
{breadcrumbs.length > 0 ? (
<div
className={tcls(
'text-xs',
'opacity-6',
'contrast-more:opacity-11',
'font-normal',
'uppercase',
'tracking-wider',
'mb-1'
'mb-1',
'flex',
'flex-wrap',
'gap-x-2',
'gap-y-1',
'items-center'
)}
>
{item.spaceTitle}
{(breadcrumbs.length > 3
? [
...breadcrumbs.slice(0, 2),
<Icon key="ellipsis" icon="ellipsis-h" className="size-3" />,
...breadcrumbs.slice(-1),
]
: breadcrumbs
).map((crumb, index) => (
<>
{index !== 0 ? (
<Icon
key={`${crumb.key}-icon`}
icon="chevron-right"
className="size-3"
/>
) : null}
<span key={crumb.key} className="line-clamp-1">
{crumb}
</span>
</>
))}
</div>
) : null}
<HighlightQuery query={query} text={item.title} />
@@ -40,6 +40,14 @@ export const SearchSectionResultItem = React.forwardRef(function SearchSectionRe
'hover:bg-primary-hover',
]
)}
insights={{
type: 'search_open_result',
query,
result: {
pageId: item.pageId,
spaceId: item.spaceId,
},
}}
>
<div
className={tcls(
@@ -1,11 +1,17 @@
'use server';
import { resolvePageId } from '@/lib/pages';
import { findSiteSpaceById, getSiteStructureSections } from '@/lib/sites';
import { filterOutNullable } from '@/lib/typescript';
import { getV1BaseContext } from '@/lib/v1';
import type {
RevisionPage,
SearchAIAnswer,
SearchAIRecommendedQuestionStream,
SearchPageResult,
SiteSpace,
SearchSpaceResult,
SiteSection,
SiteSectionGroup,
Space,
} from '@gitbook/api';
import type { GitBookBaseContext, GitBookSiteContext } from '@v2/lib/context';
@@ -13,13 +19,9 @@ import { fetchServerActionSiteContext, getServerActionBaseContext } from '@v2/li
import { createStreamableValue } from 'ai/rsc';
import type * as React from 'react';
import { getAbsoluteHref } from '@/lib/links';
import { resolvePageId } from '@/lib/pages';
import { findSiteSpaceById } from '@/lib/sites';
import { filterOutNullable } from '@/lib/typescript';
import { getV1BaseContext } from '@/lib/v1';
import { joinPathWithBaseURL } from '@/lib/paths';
import { isV2 } from '@/lib/v2';
import type { IconName } from '@gitbook/icons';
import { throwIfDataError } from '@v2/lib/data';
import { getSiteURLDataFromMiddleware } from '@v2/lib/middleware';
import { DocumentView } from '../DocumentView';
@@ -30,18 +32,24 @@ export interface ComputedSectionResult {
type: 'section';
id: string;
title: string;
href: string;
body: string;
href: string;
pageId: string;
spaceId: string;
}
export interface ComputedPageResult {
type: 'page';
id: string;
title: string;
href: string;
/** When part of a multi-spaces search, the title of the space */
spaceTitle?: string;
pageId: string;
spaceId: string;
breadcrumbs?: Array<{ icon?: IconName; label: string }>;
}
export interface AskAnswerSource {
@@ -252,11 +260,27 @@ async function searchSiteContent(
return (
await Promise.all(
searchResults.map(async (spaceItem) => {
const sections = getSiteStructureSections(structure).flatMap((item) =>
item.object === 'site-section-group' ? [item, ...item.sections] : item
);
const siteSpace = findSiteSpaceById(structure, spaceItem.id);
const siteSection = sections.find(
(section) => section.id === siteSpace?.section
) as SiteSection;
const siteSectionGroup = siteSection?.sectionGroup
? sections.find((sectionGroup) => sectionGroup.id === siteSection.sectionGroup)
: null;
return Promise.all(
spaceItem.pages.map((item) =>
transformSitePageResult(item, siteSpace ?? undefined)
spaceItem.pages.map((pageItem) =>
transformSitePageResult(context, {
pageItem,
spaceItem,
space: siteSpace?.space,
spaceURL: siteSpace?.urls.published,
siteSection: siteSection ?? undefined,
siteSectionGroup: (siteSectionGroup as SiteSectionGroup) ?? undefined,
})
)
);
})
@@ -297,7 +321,7 @@ async function transformAnswer(
const spaceURL = siteSpace?.urls.published;
const href = spaceURL
? await getURLWithSections(page.page.path, spaceURL)
? joinPathWithBaseURL(spaceURL, page.page.path)
: context.linker.toPathForPage({
pages,
page: page.page,
@@ -330,55 +354,59 @@ async function transformAnswer(
};
}
async function transformSectionsAndPage(args: {
item: SearchPageResult;
space?: Space;
spaceURL?: string;
}): Promise<[ComputedPageResult, ComputedSectionResult[]]> {
const { item, space, spaceURL } = args;
const sections = await Promise.all(
item.sections?.map<Promise<ComputedSectionResult>>(async (section) => ({
type: 'section',
id: `${item.id}/${section.id}`,
title: section.title,
href: await getURLWithSections(section.path, spaceURL),
body: section.body,
})) ?? []
);
async function transformSitePageResult(
context: GitBookBaseContext,
args: {
pageItem: SearchPageResult;
spaceItem: SearchSpaceResult;
space?: Space;
spaceURL?: string;
siteSection?: SiteSection;
siteSectionGroup?: SiteSectionGroup;
}
): Promise<OrderedComputedResult[]> {
const { pageItem, spaceItem, space, spaceURL, siteSection, siteSectionGroup } = args;
const { linker } = context;
const page: ComputedPageResult = {
type: 'page',
id: item.id,
title: item.title,
href: await getURLWithSections(item.path, spaceURL),
spaceTitle: space?.title,
id: `${spaceItem.id}/${pageItem.id}`,
title: pageItem.title,
href: spaceURL
? linker.toLinkForContent(joinPathWithBaseURL(spaceURL, pageItem.path))
: linker.toPathInSpace(pageItem.path),
pageId: pageItem.id,
spaceId: spaceItem.id,
breadcrumbs: [
siteSectionGroup && {
icon: siteSectionGroup?.icon as IconName,
label: siteSectionGroup.title,
},
siteSection && {
icon: siteSection?.icon as IconName,
label: siteSection.title,
},
(!siteSection || siteSection?.siteSpaces.length > 1) && space
? {
label: space?.title,
}
: undefined,
].filter((item) => item !== undefined),
};
return [page, sections];
}
const pageSections = await Promise.all(
pageItem.sections?.map<Promise<ComputedSectionResult>>(async (section) => ({
type: 'section',
id: `${page.id}/${section.id}`,
title: section.title,
href: spaceURL
? linker.toLinkForContent(joinPathWithBaseURL(spaceURL, section.path))
: linker.toPathInSpace(pageItem.path),
body: section.body,
pageId: pageItem.id,
spaceId: spaceItem.id,
})) ?? []
);
async function transformSitePageResult(item: SearchPageResult, siteSpace?: SiteSpace) {
const [page, sections] = await transformSectionsAndPage({
item,
space: siteSpace?.space,
spaceURL: siteSpace?.urls.published,
});
return [page, ...sections];
}
// Resolve a relative path to an absolute URL
// if the search result is relative to another space, we use the space URL
async function getURLWithSections(path: string, spaceURL?: string) {
if (spaceURL) {
if (!spaceURL.endsWith('/')) {
spaceURL += '/';
}
if (path.startsWith('/')) {
path = path.slice(1);
}
return spaceURL + path;
}
return getAbsoluteHref(path);
return [page, ...pageSections];
}
@@ -32,7 +32,7 @@ export function SiteSectionList(props: { sections: ClientSiteSections; className
<nav
aria-label="Sections"
className={tcls(
'-mx-5 before:contents[] relative border-tint-subtle border-b from-transparent sidebar-filled:to-tint-subtle theme-bold-tint:to-tint-subtle theme-muted:to-tint-subtle to-tint-base text-sm text-tint before:pointer-events-none before:absolute before:right-2 before:bottom-0 before:left-0 before:h-12 before:bg-gradient-to-b [html.sidebar-filled.theme-bold.tint_&]:to-tint-base [html.sidebar-filled.theme-muted_&]:to-tint-base',
'-mx-5 before:contents[] relative border-tint-subtle border-b from-transparent sidebar-filled:to-tint-subtle theme-muted:to-tint-subtle to-tint-base text-sm text-tint before:pointer-events-none before:absolute before:right-2 before:bottom-0 before:left-0 before:h-12 before:bg-gradient-to-b [html.sidebar-filled.theme-bold.tint_&]:to-tint-base [html.sidebar-filled.theme-bold.tint_&]:to-tint-subtle [html.sidebar-filled.theme-muted_&]:to-tint-base',
className
)}
>
@@ -13,6 +13,7 @@ import { tcls } from '@/lib/tailwind';
import type { VisitorAuthClaims } from '@/lib/adaptive';
import { GITBOOK_API_PUBLIC_URL, GITBOOK_APP_URL } from '@v2/lib/env';
import { Announcement } from '../Announcement';
import { SpacesDropdown } from '../Header/SpacesDropdown';
import { InsightsProvider } from '../Insights';
import { SiteSectionList, encodeClientSiteSections } from '../SiteSections';
@@ -62,6 +63,7 @@ export function SpaceLayout(props: {
spaceId={context.space.id}
visitorAuthClaims={visitorAuthClaims}
>
<Announcement context={context} />
<Header withTopHeader={withTopHeader} context={context} />
<div className="scroll-nojump">
<div
@@ -40,7 +40,7 @@ export function PageGroupItem(props: {
'bg-tint-base',
'sidebar-filled:bg-tint-subtle',
'theme-muted:bg-tint-subtle',
'theme-bold-tint:bg-tint-subtle',
'[html.sidebar-filled.theme-bold.tint_&]:bg-tint-subtle',
'[html.sidebar-filled.theme-muted_&]:bg-tint-base',
'[html.sidebar-filled.theme-bold.tint_&]:bg-tint-base',
'[html.sidebar-default.theme-gradient_&]:bg-gradient-primary',
@@ -75,7 +75,7 @@ export function TableOfContents(props: {
'sidebar-filled:bg-tint-subtle',
'theme-muted:bg-tint-subtle',
'theme-bold-tint:bg-tint-subtle',
'[html.sidebar-filled.theme-bold.tint_&]:bg-tint-subtle',
'[html.sidebar-filled.theme-muted_&]:bg-tint-base',
'[html.sidebar-filled.theme-bold.tint_&]:bg-tint-base',
'page-no-toc:!bg-transparent',
@@ -1,5 +1,4 @@
import type {
CustomizationSettings,
SiteCustomizationSettings,
SiteInsightsTrademarkPlacement,
Space,
@@ -16,7 +15,7 @@ import { Link } from '../primitives';
*/
export function Trademark(props: {
space: Space;
customization: CustomizationSettings | SiteCustomizationSettings;
customization: SiteCustomizationSettings;
placement: SiteInsightsTrademarkPlacement;
}) {
return (
@@ -53,7 +52,7 @@ export function Trademark(props: {
'before:to-tint-base',
'sidebar-filled:before:to-tint-subtle',
'theme-muted:before:to-tint-subtle',
'theme-bold-tint:before:to-tint-subtle',
'[html.sidebar-filled.theme-bold.tint_&]:before:to-tint-subtle',
'[html.sidebar-filled.theme-muted_&]:before:to-tint-base',
'[html.sidebar-filled.theme-bold.tint_&]:before:to-tint-base',
'page-no-toc:before:!to-transparent'
@@ -69,7 +68,7 @@ export function Trademark(props: {
*/
export function TrademarkLink(props: {
space: Space;
customization: CustomizationSettings | SiteCustomizationSettings;
customization: SiteCustomizationSettings;
placement: SiteInsightsTrademarkPlacement;
}) {
const { space, customization, placement } = props;
@@ -79,7 +79,7 @@ function ThemeButton(props: {
active && [
'bg-primary',
'theme-muted:bg-primary-hover',
'theme-bold-tint:bg-primary-hover',
'[html.sidebar-filled.theme-bold.tint_&]:bg-primary-hover',
'hover:bg-primary',
'text-primary-strong',
'contrast-more:text-primary-strong',
@@ -1,62 +1,80 @@
'use client';
import type { HTMLAttributes } from 'react';
import type { HTMLAttributeAnchorTarget, HTMLAttributes } from 'react';
import { type ClassValue, tcls } from '@/lib/tailwind';
import { Icon, type IconName } from '@gitbook/icons';
import { Link, type LinkInsightsProps } from './Link';
type ButtonProps = {
href?: string;
variant?: 'primary' | 'secondary';
variant?: 'primary' | 'secondary' | 'blank';
icon?: IconName;
iconOnly?: boolean;
size?: 'default' | 'medium' | 'small';
className?: ClassValue;
label?: string;
} & LinkInsightsProps &
HTMLAttributes<HTMLElement>;
const variantClasses = {
primary: [
'bg-primary-solid',
'text-contrast-primary-solid',
'hover:bg-primary-solid-hover',
'hover:text-contrast-primary-solid-hover',
'ring-0',
'contrast-more:ring-1',
],
blank: [
'bg-transparent',
'text-tint',
'ring-0',
'shadow-none',
'hover:bg-primary-hover',
'hover:text-primary',
'hover:scale-1',
'hover:shadow-none',
'contrast-more:bg-tint-subtle',
],
secondary: [
'bg-tint',
'text-tint',
'hover:bg-tint-hover',
'hover:text-primary',
'contrast-more:bg-tint-subtle',
],
};
export function Button({
href,
children,
variant = 'primary',
size = 'default',
className,
insights,
target,
label,
icon,
iconOnly = false,
...rest
}: ButtonProps) {
const variantClasses =
variant === 'primary'
? //PRIMARY
[
'bg-primary-solid',
'text-contrast-primary-solid',
'hover:bg-primary-solid-hover',
'hover:text-contrast-primary-solid-hover',
'ring-0',
'contrast-more:ring-1',
]
: // SECONDARY
[
'bg-tint',
'text-tint',
'hover:bg-tint-hover',
'hover:text-primary',
'contrast-more:bg-tint-subtle',
];
}: ButtonProps & { target?: HTMLAttributeAnchorTarget }) {
const sizes = {
default: ['text-base', 'px-4', 'py-2'],
medium: ['text-sm', 'px-3', 'py-1.5'],
small: ['text-xs', 'px-3 py-2'],
small: ['text-xs', 'py-2', iconOnly ? 'px-2' : 'px-3'],
};
const sizeClasses = sizes[size] || sizes.default;
const domClassName = tcls(
'button',
'inline-block',
'inline-flex',
'items-center',
'gap-2',
'rounded-md',
'straight-corners:rounded-none',
'place-self-start',
// 'place-self-start',
'ring-1',
'ring-tint',
@@ -79,22 +97,31 @@ export function Button({
'grow-0',
'shrink-0',
'truncate',
variantClasses,
variantClasses[variant],
sizeClasses,
className
);
if (href) {
return (
<Link href={href} className={domClassName} insights={insights} {...rest}>
{children}
<Link
href={href}
className={domClassName}
insights={insights}
aria-label={label}
target={target}
{...rest}
>
{icon ? <Icon icon={icon} className={tcls('size-[1em]')} /> : null}
{iconOnly ? null : label}
</Link>
);
}
return (
<button type="button" className={domClassName} {...rest}>
{children}
<button type="button" className={domClassName} aria-label={label} {...rest}>
{icon ? <Icon icon={icon} className={tcls('size-[1em]')} /> : null}
{iconOnly ? null : label}
</button>
);
}
@@ -16,7 +16,8 @@ export type LinkInsightsProps = {
insights?:
| TrackEventInput<'ad_click'>
| TrackEventInput<'link_click'>
| TrackEventInput<'trademark_click'>;
| TrackEventInput<'trademark_click'>
| TrackEventInput<'search_open_result'>;
};
export type LinkProps = Omit<BaseLinkProps, 'href'> &
@@ -2,7 +2,10 @@ import type { SVGProps } from 'react';
import { tcls } from '@/lib/tailwind';
export const Loading = (props: Partial<SVGProps<SVGSVGElement>>) => {
export const Loading = ({
busy = true,
...props
}: { busy?: boolean } & SVGProps<SVGSVGElement>) => {
return (
<svg
width="100%"
@@ -14,7 +17,11 @@ export const Loading = (props: Partial<SVGProps<SVGSVGElement>>) => {
{...props}
>
<path
className={tcls('animate-[pathLoading_2s_ease_infinite_forwards]')}
className={tcls(
busy
? 'animate-[pathLoading_2s_ease_infinite_forwards]'
: 'animate-[pathLoading_2s_ease_forwards]'
)}
d="M6 59.5V56.291C6 45.8865 11.5194 36.263 20.5 31.0091V31.0091L60.9857 7.32407C63.4452 5.88525 66.4843 5.86317 68.9643 7.26611L116 33.8734L70.4183 60.2148C67.9468 61.6431 64.9014 61.6462 62.4269 60.223L29.9772 41.5592C19.3106 35.4242 6 43.1236 6 55.4288V64.8776C6 73.4486 10.5708 81.3691 17.9918 85.6575L54.59 106.807C62.0198 111.1 71.1766 111.1 78.6064 106.807L116.364 84.9874C120.074 82.8432 122.36 78.883 122.36 74.5975V59.2647C122.36 57.7248 120.692 56.7626 119.359 57.5331L72.6023 84.5529C68.8874 86.6996 64.309 86.6996 60.5941 84.5529L26 64.5617"
stroke="currentColor"
pathLength="100"
@@ -27,7 +34,8 @@ export const Loading = (props: Partial<SVGProps<SVGSVGElement>>) => {
d="M6 59.5V56.291C6 45.8865 11.5194 36.263 20.5 31.0091V31.0091L60.9857 7.32407C63.4452 5.88525 66.4843 5.86317 68.9643 7.26611L116 33.8734L70.4183 60.2148C67.9468 61.6431 64.9014 61.6462 62.4269 60.223L29.9772 41.5592C19.3106 35.4242 6 43.1236 6 55.4288V64.8776C6 73.4486 10.5708 81.3691 17.9918 85.6575L54.59 106.807C62.0198 111.1 71.1766 111.1 78.6064 106.807L116.364 84.9874C120.074 82.8432 122.36 78.883 122.36 74.5975V59.2647C122.36 57.7248 120.692 56.7626 119.359 57.5331L72.6023 84.5529C68.8874 86.6996 64.309 86.6996 60.5941 84.5529L26 64.5617"
stroke="currentColor"
pathLength="100"
strokeOpacity="0.24"
strokeOpacity={busy ? 0.24 : 1}
className="transition-opacity duration-1000"
fill="none"
strokeWidth="11"
strokeLinecap="round"
@@ -2,36 +2,34 @@ import { type ClassValue, tcls } from '@/lib/tailwind';
import { Link, type LinkProps } from '../primitives/Link';
export const linkStyles = [
'underline',
'decoration-[max(0.07em,1px)]', // Set the underline to be proportional to the font size, with a minimum. The default is too thin.
'underline-offset-2',
'links-accent:underline-offset-4',
'links-default:decoration-primary/6',
'links-default:text-primary-subtle',
'links-default:hover:text-primary-strong',
'links-default:contrast-more:text-primary',
'links-default:contrast-more:hover:text-primary-strong',
'links-accent:decoration-primary-subtle',
'links-accent:hover:decoration-[3px]',
'links-accent:hover:[text-decoration-skip-ink:none]',
'transition-all',
'duration-100',
];
/**
* Styled version of Link component.
*/
export function StyledLink(props: Omit<LinkProps, 'style'> & { style?: ClassValue }) {
const { style, ...rest } = props;
export function StyledLink(props: Omit<LinkProps, 'style'> & { className?: ClassValue }) {
const { className, ...rest } = props;
return (
<Link
{...rest}
className={tcls(
'underline',
'decoration-[max(0.07em,1px)]', // Set the underline to be proportional to the font size, with a minimum. The default is too thin.
'underline-offset-2',
'links-accent:underline-offset-4',
'links-default:decoration-primary/6',
'links-default:text-primary-subtle',
'links-default:hover:text-primary-strong',
'links-default:contrast-more:text-primary',
'links-default:contrast-more:hover:text-primary-strong',
'links-accent:decoration-primary-subtle',
'links-accent:hover:decoration-[3px]',
'links-accent:hover:[text-decoration-skip-ink:none]',
'transition-all',
'duration-100',
style
)}
>
<Link {...rest} className={tcls(linkStyles, className)}>
{props.children}
</Link>
);
@@ -6,6 +6,7 @@ import { generateFontFacesCSS, getFontSourcesToPreload } from './custom';
const TEST_FONTS: { [key in string]: CustomizationFontDefinition } = {
basic: {
id: 'open-sans',
custom: true,
fontFamily: 'Open Sans',
fontFaces: [
{
@@ -31,6 +32,7 @@ const TEST_FONTS: { [key in string]: CustomizationFontDefinition } = {
multiWeight: {
id: 'roboto',
custom: true,
fontFamily: 'Roboto',
fontFaces: [
{
@@ -83,6 +85,7 @@ const TEST_FONTS: { [key in string]: CustomizationFontDefinition } = {
multiSource: {
id: 'lato',
custom: true,
fontFamily: 'Lato',
fontFaces: [
{
@@ -100,6 +103,7 @@ const TEST_FONTS: { [key in string]: CustomizationFontDefinition } = {
missingFormat: {
id: 'source-sans',
custom: true,
fontFamily: 'Source Sans Pro',
fontFaces: [
{
@@ -117,12 +121,14 @@ const TEST_FONTS: { [key in string]: CustomizationFontDefinition } = {
empty: {
id: 'empty-font',
custom: true,
fontFamily: 'Empty Font',
fontFaces: [],
},
specialChars: {
id: 'special-font',
custom: true,
fontFamily: 'Special Font & Co.',
fontFaces: [
{
@@ -134,6 +140,7 @@ const TEST_FONTS: { [key in string]: CustomizationFontDefinition } = {
complex: {
id: 'complex-font',
custom: true,
fontFamily: 'Complex Font',
fontFaces: [
{
@@ -155,6 +162,7 @@ const TEST_FONTS: { [key in string]: CustomizationFontDefinition } = {
variousURLs: {
id: 'various-urls',
custom: true,
fontFamily: 'Various URLs Font',
fontFaces: [
{
+2 -4
View File
@@ -1,4 +1,4 @@
import type { CustomizationSettings, SiteCustomizationSettings } from '@gitbook/api';
import type { SiteCustomizationSettings } from '@gitbook/api';
import { type TranslationLanguage, languages } from './translations';
@@ -7,9 +7,7 @@ export * from './translate';
/**
* Create the translation context for a space to use in the server components.
*/
export function getSpaceLanguage(
customization: CustomizationSettings | SiteCustomizationSettings
): TranslationLanguage {
export function getSpaceLanguage(customization: SiteCustomizationSettings): TranslationLanguage {
const fallback = languages.en;
const { locale } = customization.internationalization;
+3 -2
View File
@@ -1,4 +1,5 @@
import type { PublishedSiteContent, SiteAPIToken } from '@gitbook/api';
import type { SiteAPIToken } from '@gitbook/api';
import type { SiteURLData } from '@v2/lib/context';
import { jwtDecode } from 'jwt-decode';
/**
@@ -9,7 +10,7 @@ export type VisitorAuthClaims = Record<string, any>;
/**
* Get the visitor auth claims from the API response obtained from `getPublishedContentByUrl`.
*/
export function getVisitorAuthClaims(siteData: PublishedSiteContent): VisitorAuthClaims {
export function getVisitorAuthClaims(siteData: SiteURLData): VisitorAuthClaims {
const { apiToken } = siteData;
return getVisitorAuthClaimsFromToken(jwtDecode<SiteAPIToken>(apiToken));
+1 -1
View File
@@ -104,7 +104,7 @@ export function resolvePrevNextPages(
}
/**
* Resolve a page to its canonical path.
* Resolve a page to its canonical path. The first page of the site will return "".
* The path will NOT start with "/".
*/
export function getPagePath(
+17
View File
@@ -5,6 +5,23 @@ export function joinPath(...parts: string[]): string {
return parts.join('/').replace(/\/+/g, '/');
}
/**
* Join a path with a base URL.
*/
export function joinPathWithBaseURL(baseURL: string, path: string): string {
if (!path) {
return baseURL;
}
if (!baseURL.endsWith('/')) {
baseURL += '/';
}
if (path.startsWith('/')) {
path = path.slice(1);
}
return baseURL + path;
}
/**
* Remove the trailing slash from a path.
*/
+29 -3
View File
@@ -19,6 +19,7 @@ import { getGitbookAppHref } from './links';
import { resolvePageId } from './pages';
import { findSiteSpaceById } from './sites';
import type { ClassValue } from './tailwind';
import { filterOutNullable } from './typescript';
export interface ResolvedContentRef {
/** Text to render in the content ref */
@@ -27,8 +28,12 @@ export interface ResolvedContentRef {
subText?: string;
/** Icon associated with it */
icon?: React.ReactNode;
/** ID of the content ref */
id?: string;
/** Emoji associated with the reference */
emoji?: string;
/** The content ref's ancestors */
ancestors?: { icon?: React.ReactNode; label: string; href?: string }[];
/** URL to open for the content ref */
href: string;
/** True if the content ref is active */
@@ -115,6 +120,14 @@ export async function resolveContentRef(
: resolvePageId(pages, contentRef.page);
const page = resolvePageResult?.page;
const ancestors =
resolvePageResult?.ancestors.map((ancestor) => ({
label: ancestor.title,
icon: <PageIcon page={ancestor} style={iconStyle} />,
href: resolveAsAbsoluteURL
? linker.toAbsoluteURL(linker.toPathForPage({ page: ancestor, pages }))
: linker.toPathForPage({ page: ancestor, pages }),
})) ?? [];
if (!page) {
return null;
}
@@ -125,10 +138,16 @@ export async function resolveContentRef(
let text = '';
let icon: React.ReactNode | undefined = undefined;
let emoji: string | undefined = undefined;
const href = linker.toPathForPage({ page, pages, anchor });
// Compute the text to display for the link
if (anchor) {
text = `#${anchor}`;
ancestors.push({
label: page.title,
icon: <PageIcon page={page} style={iconStyle} />,
href: resolveAsAbsoluteURL ? linker.toAbsoluteURL(href) : href,
});
if (resolveAnchorText) {
const document = await getPageDocument(dataFetcher, space, page);
@@ -151,13 +170,14 @@ export async function resolveContentRef(
icon = <PageIcon page={page} style={iconStyle} />;
}
const href = linker.toPathForPage({ page, pages, anchor });
return {
href: resolveAsAbsoluteURL ? linker.toAbsoluteURL(href) : href,
text,
subText: page.description,
ancestors: ancestors,
emoji,
icon,
id: page.id,
active: !anchor && page.id === activePage?.id,
};
}
@@ -346,6 +366,12 @@ async function resolveContentRefInSpace(
return {
...resolved,
subText: space.title,
ancestors: [
{
label: space.title,
href: baseURL.toString(),
},
...(resolved.ancestors ?? []),
].filter(filterOutNullable),
};
}
+32 -20
View File
@@ -1,22 +1,30 @@
import * as api from '@gitbook/api';
/**
* Return the customizations with the default values for a space.
* Return the default customization settings for a site.
*/
export function defaultCustomizationForSpace(): api.CustomizationSettings {
export function defaultCustomization(): api.SiteCustomizationSettings {
return {
internationalization: {
inherit: false,
locale: api.CustomizationLocale.En,
},
styling: {
primaryColor: {
dark: '#346DDB',
light: '#346DDB',
},
theme: api.CustomizationTheme.Clean,
primaryColor: { light: '#346DDB', dark: '#346DDB' },
infoColor: { light: '#787878', dark: '#787878' },
warningColor: { light: '#FE9A00', dark: '#FE9A00' },
dangerColor: { light: '#FB2C36', dark: '#FB2C36' },
successColor: { light: '#00C950', dark: '#00C950' },
corners: api.CustomizationCorners.Rounded,
font: api.CustomizationDefaultFont.Inter,
background: api.CustomizationBackground.Plain,
icons: api.CustomizationIconsStyle.Regular,
links: api.CustomizationLinksStyle.Default,
sidebar: {
background: api.CustomizationSidebarBackgroundStyle.Default,
list: api.CustomizationSidebarListStyle.Default,
},
search: api.CustomizationSearchStyle.Subtle,
},
internationalization: {
locale: api.CustomizationLocale.En,
},
favicon: {},
header: {
@@ -28,28 +36,32 @@ export function defaultCustomizationForSpace(): api.CustomizationSettings {
},
themes: {
default: api.CustomizationThemeMode.Light,
toggeable: false,
toggeable: true,
},
trademark: {
pdf: {
enabled: true,
},
feedback: {
enabled: false,
},
pdf: {
enabled: true,
},
aiSearch: {
enabled: true,
},
advancedCustomization: {
enabled: true,
},
git: {
showEditLink: false,
},
pagination: {
enabled: true,
},
privacyPolicy: {},
socialPreview: {},
git: {
showEditLink: false,
trademark: {
enabled: true,
},
inherit: false,
privacyPolicy: {
url: 'https://www.gitbook.com/privacy',
},
socialPreview: {},
};
}
+5 -1
View File
@@ -59,7 +59,7 @@ export async function getV1BaseContext(): Promise<GitBookBaseContext> {
const dataFetcher = await getDataFetcherV1();
const imageResizer = createImageResizer({
host,
imagesContextId: host,
// In V1, we always resize at the top level of the hostname, not relative to the content.
linker: createLinker({
host,
@@ -153,6 +153,10 @@ async function getDataFetcherV1(): Promise<GitBookDataFetcher> {
});
},
getRevisionPageMarkdown() {
throw new Error('Not implemented in v1');
},
getDocument(params) {
return wrapDataFetcherError(async () => {
const document = await getDocument(params.spaceId, params.documentId);
+1
View File
@@ -564,6 +564,7 @@ async function lookupSiteOrSpaceInMultiIdMode(
apiEndpoint,
contextId,
cookies,
canonicalUrl: url.toString(),
};
}
+6 -6
View File
@@ -17,9 +17,9 @@ export async function serveResizedImage(
request: Request,
requestOptions: {
/**
* The host to use for the image.
* The site identifier to use for verifying the image signature.
*/
host?: string;
imagesContextId?: string;
} = {}
) {
const requestURL = new URL(request.url);
@@ -45,8 +45,8 @@ export async function serveResizedImage(
}
// Verify the signature
const host =
requestOptions.host ??
const imagesContextId =
requestOptions.imagesContextId ??
request.headers.get('x-gitbook-host') ?? // Only for v1, to be removed
request.headers.get('x-forwarded-host') ??
request.headers.get('host') ??
@@ -54,13 +54,13 @@ export async function serveResizedImage(
const verified = await verifyImageSignature(
{
url,
host,
imagesContextId,
},
{ signature, version: signatureVersion }
);
if (!verified) {
return new Response(
`Invalid signature "${signature ?? ''}" (version ${signatureVersion}) for "${url}" on "${host}"`,
`Invalid signature "${signature ?? ''}" (version ${signatureVersion}) for "${url}" on identifier "${imagesContextId}"`,
{ status: 400 }
);
}
+43 -14
View File
@@ -1,19 +1,27 @@
import { joinPath } from '@/lib/paths';
import { getIndexablePages } from '@/lib/sitemap';
import { getSiteStructureSections } from '@/lib/sites';
import type { SiteSection, SiteSpace } from '@gitbook/api';
import { type GitBookSiteContext, checkIsRootSiteContext } from '@v2/lib/context';
import { throwIfDataError } from '@v2/lib/data';
import assertNever from 'assert-never';
import type { ListItem, Paragraph, Root, RootContent } from 'mdast';
import { toMarkdown } from 'mdast-util-to-markdown';
import { getPagePath } from '@/lib/pages';
import { joinPath } from '@/lib/paths';
import { getIndexablePages } from '@/lib/sitemap';
import { getSiteStructureSections } from '@/lib/sites';
import { type GitBookSiteContext, checkIsRootSiteContext } from '@v2/lib/context';
import { throwIfDataError } from '@v2/lib/data';
/**
* Generate a llms.txt file for the site.
*/
export async function serveLLMsTxt(context: GitBookSiteContext) {
export async function serveLLMsTxt(
context: GitBookSiteContext,
{
withMarkdownPages = false,
}: {
/**
* If true, a markdown extension will be added to the page path.
*/
withMarkdownPages?: boolean;
} = {}
) {
const { site } = context;
if (!checkIsRootSiteContext(context)) {
@@ -28,7 +36,7 @@ export async function serveLLMsTxt(context: GitBookSiteContext) {
depth: 1,
children: [{ type: 'text', value: site.title }],
},
...(await getNodesFromSiteStructure(context)),
...(await getNodesFromSiteStructure(context, { withMarkdownPages })),
],
};
@@ -47,15 +55,24 @@ export async function serveLLMsTxt(context: GitBookSiteContext) {
/**
* Get MDAST nodes from site structure.
*/
async function getNodesFromSiteStructure(context: GitBookSiteContext): Promise<RootContent[]> {
async function getNodesFromSiteStructure(
context: GitBookSiteContext,
options: {
withMarkdownPages: boolean;
}
): Promise<RootContent[]> {
switch (context.structure.type) {
case 'sections':
return getNodesFromSections(
context,
getSiteStructureSections(context.structure, { ignoreGroups: true })
getSiteStructureSections(context.structure, { ignoreGroups: true }),
{ withMarkdownPages: options.withMarkdownPages }
);
case 'siteSpaces':
return getNodesFromSiteSpaces(context, context.structure.structure, { heading: true });
return getNodesFromSiteSpaces(context, context.structure.structure, {
heading: true,
withMarkdownPages: options.withMarkdownPages,
});
default:
assertNever(context.structure);
}
@@ -66,12 +83,16 @@ async function getNodesFromSiteStructure(context: GitBookSiteContext): Promise<R
*/
async function getNodesFromSections(
context: GitBookSiteContext,
siteSections: SiteSection[]
siteSections: SiteSection[],
options: {
withMarkdownPages: boolean;
}
): Promise<RootContent[]> {
const all = await Promise.all(
siteSections.map(async (siteSection): Promise<RootContent[]> => {
const siteSpaceNodes = await getNodesFromSiteSpaces(context, siteSection.siteSpaces, {
heading: false,
withMarkdownPages: options.withMarkdownPages,
});
return [
{
@@ -97,6 +118,11 @@ async function getNodesFromSiteSpaces(
* Includes a heading for each site space.
*/
heading?: boolean;
/**
* If true, a markdown extension will be added to the page path.
*/
withMarkdownPages: boolean;
}
): Promise<RootContent[]> {
const { dataFetcher, linker } = context;
@@ -118,7 +144,10 @@ async function getNodesFromSiteSpaces(
const listChildren = await Promise.all(
pages.map(async ({ page }): Promise<ListItem> => {
const pageURL = new URL(siteSpaceUrl);
pageURL.pathname = joinPath(pageURL.pathname, getPagePath(rootPages, page));
pageURL.pathname = joinPath(pageURL.pathname, page.path);
if (options.withMarkdownPages) {
pageURL.pathname = `${pageURL.pathname}.md`;
}
const url = linker.toLinkForContent(pageURL.toString());
const children: Paragraph['children'] = [
@@ -0,0 +1,34 @@
import { resolvePagePath } from '@/lib/pages';
import { RevisionPageType } from '@gitbook/api';
import type { GitBookSiteContext } from '@v2/lib/context';
import { throwIfDataError } from '@v2/lib/data';
/**
* Generate a markdown version of a page.
*/
export async function servePageMarkdown(context: GitBookSiteContext, pagePath: string) {
const pageLookup = resolvePagePath(context.pages, pagePath);
if (!pageLookup) {
return new Response(`Page "${pagePath}" not found`, { status: 404 });
}
const { page } = pageLookup;
if (page.type !== RevisionPageType.Document) {
return new Response(`Page "${pagePath}" is not a document`, { status: 404 });
}
const markdown = await throwIfDataError(
context.dataFetcher.getRevisionPageMarkdown({
spaceId: context.space.id,
revisionId: context.revisionId,
pageId: page.id,
})
);
return new Response(markdown, {
headers: {
'Content-Type': 'text/markdown; charset=utf-8',
},
});
}
-2
View File
@@ -10,8 +10,6 @@ export async function serveRobotsTxt(context: GitBookSiteContext) {
const isRoot = checkIsRootSiteContext(context);
const lines = [
'User-agent: *',
// Disallow image resizing
'Disallow: /~gitbook/',
// Disallow dynamic routes / search queries
'Disallow: /*?',
...((await isSiteIndexable(context))
+2 -2
View File
@@ -295,7 +295,7 @@ const config: Config = {
),
},
animation: {
present: 'present .5s ease-out both',
present: 'present 200ms cubic-bezier(0.25, 1, 0.5, 1) both',
scaleIn: 'scaleIn 200ms ease',
scaleOut: 'scaleOut 200ms ease',
fadeIn: 'fadeIn 200ms ease forwards',
@@ -330,7 +330,7 @@ const config: Config = {
present: {
from: {
opacity: '0',
transform: 'translateY(2rem) scale(0.9)',
transform: 'translateY(1rem) scale(90%)',
},
to: {
opacity: '1',
+8
View File
@@ -1,5 +1,13 @@
# @gitbook/openapi-parser
## 2.1.2
### Patch Changes
- 7bb37c7: Move filterSelectedOpenAPISchemas to @gitbook/openapi-parser
- 5b1e01c: Support for x-stability property
- 813b2af: Support for x-enumDescriptions and x-gitbook-enum
## 2.1.1
### Patch Changes
+1 -1
View File
@@ -9,7 +9,7 @@
"default": "./dist/index.js"
}
},
"version": "2.1.1",
"version": "2.1.2",
"sideEffects": false,
"dependencies": {
"@scalar/openapi-parser": "^0.10.10",

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