mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-12 05:48:57 +00:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b647538e41 | |||
| e50797a5c1 | |||
| 5b1e01c786 | |||
| 813b2af06b | |||
| 6f71da88d8 | |||
| fa91eb7eaf | |||
| c756761591 | |||
| 434af90366 | |||
| 0f41e194cb | |||
| bd353489e9 | |||
| 77fd393f15 | |||
| 8701b5e078 | |||
| 61db166f92 | |||
| 0d9b9cea0f | |||
| 7bb37c7f0a | |||
| 5b2bf828e3 | |||
| c85d3f9752 | |||
| 4d7088bbf0 | |||
| dd6ab6044c |
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Fix OpenAPI tabs indicator overflow
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@gitbook/react-openapi': patch
|
||||
---
|
||||
|
||||
Fix XML in code sample
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix favicon not being displayed in Google because `robots.txt` was preventing the indexation of the image route
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Disallow crawling by web-robots of search/ask URLs
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@gitbook/react-openapi': patch
|
||||
---
|
||||
|
||||
Safe parse OpenAPI JSON schema
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
'@gitbook/react-openapi': patch
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Fix multiple request examples selector not showing
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": minor
|
||||
---
|
||||
|
||||
Support site announcement banner
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix a crash when a page contains a block of an integration that is no longer installed
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix flash when loading sites with dark mode as default theme
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
"@gitbook/react-openapi": patch
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix spec properties rendering and missing keys
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@gitbook/react-openapi': patch
|
||||
---
|
||||
|
||||
Replace $ref with $reference in json-decycle
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Trim the search query to avoid showing a loading state when typing
|
||||
@@ -74,7 +74,6 @@
|
||||
"bun-types": "^1.1.20",
|
||||
"classnames": "^2.5.1",
|
||||
"framer-motion": "^10.16.14",
|
||||
"holy-loader": "^2.3.13",
|
||||
"js-cookie": "^3.0.5",
|
||||
"jsontoxml": "^1.0.1",
|
||||
"jwt-decode": "^4.0.0",
|
||||
@@ -263,7 +262,7 @@
|
||||
},
|
||||
"overrides": {
|
||||
"@codemirror/state": "6.4.1",
|
||||
"@gitbook/api": "0.102.0",
|
||||
"@gitbook/api": "0.106.0",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
},
|
||||
@@ -624,7 +623,7 @@
|
||||
|
||||
"@fortawesome/fontawesome-svg-core": ["@fortawesome/fontawesome-svg-core@6.6.0", "", { "dependencies": { "@fortawesome/fontawesome-common-types": "6.6.0" } }, "sha512-KHwPkCk6oRT4HADE7smhfsKudt9N/9lm6EJ5BVg0tD1yPA5hht837fB87F8pn15D8JfTqQOjhKTktwmLMiD7Kg=="],
|
||||
|
||||
"@gitbook/api": ["@gitbook/api@0.102.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-9wPr5kyCHhTTwkaCYEMT4q3JjEl1q9mytgvpeusmeREfG4E0RZHYPEL8bmumO7T9wH/0vHjpfW2LSTUUs60AwQ=="],
|
||||
"@gitbook/api": ["@gitbook/api@0.106.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-2qA/w18JwHe2fwR2A45q1pEdCZArxqUloegfNibu0xngDhea+iKTXSBrN94wD6Lwkh7cqBp9MvtxC+YMz3hx1g=="],
|
||||
|
||||
"@gitbook/cache-do": ["@gitbook/cache-do@workspace:packages/cache-do"],
|
||||
|
||||
@@ -1958,8 +1957,6 @@
|
||||
|
||||
"highlightjs-vue": ["highlightjs-vue@1.0.0", "", {}, "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA=="],
|
||||
|
||||
"holy-loader": ["holy-loader@2.3.13", "", { "peerDependencies": { "react": ">= 16.0.0" } }, "sha512-SnQ06sfMgbhiRGkHZ0RFMbexSpZN3i6tgItt3q8GWNba3mOdsq1RNJXkoqsWkqPIAmyroj2/xIOHu1hCFnjkBQ=="],
|
||||
|
||||
"hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="],
|
||||
|
||||
"hookified": ["hookified@1.8.1", "", {}, "sha512-GrO2l93P8xCWBSTBX9l2BxI78VU/MAAYag+pG8curS3aBGy0++ZlxrQ7PdUOUVMbn5BwkGb6+eRrnf43ipnFEA=="],
|
||||
|
||||
+1
-1
@@ -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.106.0"
|
||||
},
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "gitbook-v2",
|
||||
"version": "0.2.2",
|
||||
"version": "0.2.3",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"next": "^15.2.3",
|
||||
|
||||
-12
@@ -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);
|
||||
}
|
||||
-12
@@ -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);
|
||||
}
|
||||
-12
@@ -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);
|
||||
}
|
||||
-12
@@ -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);
|
||||
}
|
||||
+1
-1
@@ -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 });
|
||||
}
|
||||
|
||||
+11
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,24 +198,43 @@ 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,
|
||||
// The pathname is passed as the next segment of the route and should not cause this segment to change
|
||||
// based on the page being visited
|
||||
pathname: '<DO_NOT_USE>',
|
||||
})
|
||||
rison.encode(
|
||||
// rison can't encode undefined values
|
||||
Object.fromEntries(
|
||||
Object.entries(stableSiteURLData).filter(
|
||||
([_, v]) => typeof v !== 'undefined'
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
pathname,
|
||||
].join('/');
|
||||
@@ -233,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
|
||||
);
|
||||
@@ -389,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' };
|
||||
|
||||
@@ -1,5 +1,44 @@
|
||||
# gitbook
|
||||
|
||||
## 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
|
||||
|
||||
@@ -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/',
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gitbook",
|
||||
"version": "0.8.2",
|
||||
"version": "0.9.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "env-cmd --silent -f ../../.env.local next dev",
|
||||
@@ -39,7 +39,6 @@
|
||||
"bun-types": "^1.1.20",
|
||||
"classnames": "^2.5.1",
|
||||
"framer-motion": "^10.16.14",
|
||||
"holy-loader": "^2.3.13",
|
||||
"js-cookie": "^3.0.5",
|
||||
"jsontoxml": "^1.0.1",
|
||||
"jwt-decode": "^4.0.0",
|
||||
|
||||
@@ -3,11 +3,10 @@
|
||||
import * as storage from '@/lib/local-storage';
|
||||
import type { ResolvedContentRef } from '@/lib/references';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import type { CustomizationAnnouncement } from '@gitbook/api';
|
||||
import { type CustomizationAnnouncement, SiteInsightsLinkPosition } from '@gitbook/api';
|
||||
import { Icon, type IconName } from '@gitbook/icons';
|
||||
import Link from 'next/link';
|
||||
import { CONTAINER_STYLE } from '../layout';
|
||||
import { linkStyles } from '../primitives';
|
||||
import { Link, linkStyles } from '../primitives';
|
||||
import { ANNOUNCEMENT_CSS_CLASS, ANNOUNCEMENT_STORAGE_KEY } from './constants';
|
||||
|
||||
/**
|
||||
@@ -26,52 +25,67 @@ export function AnnouncementBanner(props: {
|
||||
const style = BANNER_STYLES[announcement.style];
|
||||
|
||||
return (
|
||||
<div className="announcement-banner scroll-nojump theme-bold:bg-header-background pt-4 pb-2">
|
||||
<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
|
||||
)}
|
||||
>
|
||||
<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}
|
||||
<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="close" className="size-4" />
|
||||
</button>
|
||||
) : null}
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import {
|
||||
ANNOUNCEMENT_CSS_CLASS,
|
||||
ANNOUNCEMENT_DAYS_TILL_RESET,
|
||||
@@ -18,6 +19,15 @@ export function AnnouncementDismissedScript() {
|
||||
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
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -20,10 +20,31 @@
|
||||
@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-stable {
|
||||
@apply text-green-600 dark:text-green-300 bg-green-50 dark:bg-green-900/6 ring-green-500/5;
|
||||
}
|
||||
|
||||
.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 +152,7 @@
|
||||
|
||||
/* Schema Structure */
|
||||
.openapi-schema-root {
|
||||
@apply mt-2;
|
||||
/* unstyled */
|
||||
}
|
||||
|
||||
.openapi-schema-properties {
|
||||
@@ -209,6 +230,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,7 +265,11 @@
|
||||
|
||||
/* Schema Enum */
|
||||
.openapi-schema-enum {
|
||||
@apply flex flex-row text-sm leading-relaxed flex-wrap text-tint;
|
||||
@apply flex flex-row text-sm leading-relaxed gap-2 flex-wrap text-tint;
|
||||
}
|
||||
|
||||
.openapi-schema-enum-list {
|
||||
@apply flex flex-row gap-1.5 items-center;
|
||||
}
|
||||
|
||||
.openapi-schema-enum-value {
|
||||
@@ -651,7 +680,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;
|
||||
}
|
||||
|
||||
@@ -688,3 +718,7 @@
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.openapi-copy-button {
|
||||
@apply hover:brightness-95;
|
||||
}
|
||||
|
||||
@@ -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]}
|
||||
|
||||
@@ -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,6 @@
|
||||
import { CustomizationRootLayout } from '@/components/RootLayout';
|
||||
import { defaultCustomization } from '@/lib/utils';
|
||||
import { CustomizationThemeMode } from '@gitbook/api';
|
||||
import type { GitBookSiteContext, GitBookSpaceContext } from '@v2/lib/context';
|
||||
|
||||
/**
|
||||
@@ -16,6 +17,7 @@ export async function PDFRootLayout(props: {
|
||||
customization={
|
||||
'customization' in context ? context.customization : defaultCustomization()
|
||||
}
|
||||
forcedTheme={CustomizationThemeMode.Light}
|
||||
>
|
||||
{children}
|
||||
</CustomizationRootLayout>
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
hexToRgb,
|
||||
} from '@gitbook/colors';
|
||||
import { IconStyle, IconsProvider } from '@gitbook/icons';
|
||||
import HolyLoader from 'holy-loader';
|
||||
import * as ReactDOM from 'react-dom';
|
||||
|
||||
import { getFontData } from '@/fonts';
|
||||
@@ -29,6 +28,7 @@ import { fontNotoColorEmoji, ibmPlexMono } from '@/fonts/default';
|
||||
import { getSpaceLanguage } from '@/intl/server';
|
||||
import { getAssetURL } from '@/lib/assets';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { ClientContexts } from './ClientContexts';
|
||||
|
||||
import '@gitbook/icons/style.css';
|
||||
@@ -151,7 +151,6 @@ export async function CustomizationRootLayout(props: {
|
||||
}
|
||||
`}</style>
|
||||
</head>
|
||||
<HolyLoader color="rgb(var(--primary-9))" height={1} />
|
||||
<body
|
||||
className={tcls(
|
||||
'bg-tint-base',
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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'> &
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -564,6 +564,7 @@ async function lookupSiteOrSpaceInMultiIdMode(
|
||||
apiEndpoint,
|
||||
contextId,
|
||||
cookies,
|
||||
canonicalUrl: url.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -6,4 +6,5 @@ export type * from '@scalar/openapi-types';
|
||||
export * from './error';
|
||||
export * from './helpers/shouldIgnoreEntity';
|
||||
export * from './traverse';
|
||||
export * from './schemas';
|
||||
export type * from './types';
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { OpenAPIV3, OpenAPIV3_1 } from '@scalar/openapi-types';
|
||||
import { shouldIgnoreEntity } from './helpers/shouldIgnoreEntity';
|
||||
import type { OpenAPISchema } from './types';
|
||||
|
||||
/**
|
||||
* Extract selected schemas from the OpenAPI document.
|
||||
*/
|
||||
export function filterSelectedOpenAPISchemas(
|
||||
schema: OpenAPIV3.Document | OpenAPIV3_1.Document,
|
||||
selectedSchemas: string[]
|
||||
): OpenAPISchema[] {
|
||||
const componentsSchemas = schema.components?.schemas ?? {};
|
||||
|
||||
// Preserve the order of the selected schemas
|
||||
return selectedSchemas
|
||||
.map((name) => {
|
||||
const schema = componentsSchemas[name];
|
||||
if (schema && !shouldIgnoreEntity(schema)) {
|
||||
return {
|
||||
name,
|
||||
schema,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((schema): schema is OpenAPISchema => !!schema);
|
||||
}
|
||||
@@ -47,8 +47,31 @@ export interface OpenAPICustomOperationProperties {
|
||||
* Description in Document format.
|
||||
*/
|
||||
'x-gitbook-description-document'?: object;
|
||||
|
||||
/**
|
||||
* Enums with name and description
|
||||
*/
|
||||
'x-enumDescriptions'?: object;
|
||||
|
||||
/**
|
||||
* Enums with name and description
|
||||
*/
|
||||
'x-gitbook-enum'?: {
|
||||
[key: string]: {
|
||||
description?: string;
|
||||
name?: string;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Stability of the operation.
|
||||
* @enum 'experimental' | 'alpha' | 'beta' | 'stable'
|
||||
*/
|
||||
'x-stability'?: OpenAPIStability;
|
||||
}
|
||||
|
||||
export type OpenAPIStability = 'experimental' | 'alpha' | 'beta' | 'stable';
|
||||
|
||||
/**
|
||||
* Custom code samples that can be defined at the operation level.
|
||||
* It follows the spec defined by Redocly.
|
||||
@@ -79,3 +102,8 @@ export type FilesystemEntry<T extends AnyObject> = {
|
||||
filename: string;
|
||||
specification: T;
|
||||
};
|
||||
|
||||
export type OpenAPISchema = {
|
||||
name: string;
|
||||
schema: OpenAPIV3.SchemaObject;
|
||||
};
|
||||
|
||||
@@ -1,5 +1,24 @@
|
||||
# @gitbook/react-openapi
|
||||
|
||||
## 1.1.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- bd35348: Fix missing alternative schemas
|
||||
- ae78fc5: Fix XML in code sample
|
||||
- 7bb37c7: Move filterSelectedOpenAPISchemas to @gitbook/openapi-parser
|
||||
- 373183a: Safe parse OpenAPI JSON schema
|
||||
- 1505ddb: Fix multiple request examples selector not showing
|
||||
- 61db166: Add OpenAPI write-only indicator
|
||||
- 5b1e01c: Support for x-stability property
|
||||
- cd99ed5: Fix spec properties rendering and missing keys
|
||||
- 813b2af: Support for x-enumDescriptions and x-gitbook-enum
|
||||
- a25fded: Replace $ref with $reference in json-decycle
|
||||
- Updated dependencies [7bb37c7]
|
||||
- Updated dependencies [5b1e01c]
|
||||
- Updated dependencies [813b2af]
|
||||
- @gitbook/openapi-parser@2.1.2
|
||||
|
||||
## 1.1.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"version": "1.1.6",
|
||||
"version": "1.1.7",
|
||||
"sideEffects": false,
|
||||
"dependencies": {
|
||||
"@gitbook/openapi-parser": "workspace:*",
|
||||
|
||||
@@ -6,10 +6,16 @@ import { Button, type ButtonProps, Tooltip, TooltipTrigger } from 'react-aria-co
|
||||
export function OpenAPICopyButton(
|
||||
props: ButtonProps & {
|
||||
value: string;
|
||||
children: React.ReactNode;
|
||||
label?: string;
|
||||
/**
|
||||
* Whether to show a tooltip.
|
||||
* @default true
|
||||
*/
|
||||
withTooltip?: boolean;
|
||||
}
|
||||
) {
|
||||
const { value } = props;
|
||||
const { children, onPress, className } = props;
|
||||
const { value, label, children, onPress, className, withTooltip = true } = props;
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
@@ -21,12 +27,19 @@ export function OpenAPICopyButton(
|
||||
|
||||
setTimeout(() => {
|
||||
setCopied(false);
|
||||
setIsOpen(false);
|
||||
}, 2000);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<TooltipTrigger isOpen={isOpen} onOpenChange={setIsOpen} closeDelay={200} delay={200}>
|
||||
<TooltipTrigger
|
||||
isOpen={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
isDisabled={!withTooltip}
|
||||
closeDelay={200}
|
||||
delay={200}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
preventFocusOnPress
|
||||
@@ -47,7 +60,7 @@ export function OpenAPICopyButton(
|
||||
offset={4}
|
||||
className="openapi-tooltip"
|
||||
>
|
||||
{copied ? 'Copied' : 'Copy to clipboard'}{' '}
|
||||
{copied ? 'Copied' : label || 'Copy to clipboard'}
|
||||
</Tooltip>
|
||||
</TooltipTrigger>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import clsx from 'clsx';
|
||||
|
||||
import type { OpenAPICustomOperationProperties, OpenAPIV3 } from '@gitbook/openapi-parser';
|
||||
import type {
|
||||
OpenAPICustomOperationProperties,
|
||||
OpenAPIStability,
|
||||
OpenAPIV3,
|
||||
} from '@gitbook/openapi-parser';
|
||||
import { Markdown } from './Markdown';
|
||||
import { OpenAPICodeSample } from './OpenAPICodeSample';
|
||||
import { OpenAPIPath } from './OpenAPIPath';
|
||||
@@ -29,14 +33,24 @@ export function OpenAPIOperation(props: {
|
||||
return (
|
||||
<div className={clsx('openapi-operation', className)}>
|
||||
<div className="openapi-summary" id={operation.summary ? undefined : context.id}>
|
||||
{(operation.deprecated || operation['x-stability']) && (
|
||||
<div className="openapi-summary-tags">
|
||||
{operation.deprecated && (
|
||||
<div className="openapi-deprecated">Deprecated</div>
|
||||
)}
|
||||
{operation['x-stability'] && (
|
||||
<OpenAPIOperationStability stability={operation['x-stability']} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{operation.summary
|
||||
? context.renderHeading({
|
||||
deprecated: operation.deprecated ?? false,
|
||||
stability: operation['x-stability'],
|
||||
title: operation.summary,
|
||||
})
|
||||
: null}
|
||||
<OpenAPIPath data={data} context={context} />
|
||||
{operation.deprecated && <div className="openapi-deprecated">Deprecated</div>}
|
||||
</div>
|
||||
<div className="openapi-columns">
|
||||
<div className="openapi-column-spec">
|
||||
@@ -89,3 +103,26 @@ function OpenAPIOperationDescription(props: {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const stabilityEnum = {
|
||||
experimental: 'Experimental',
|
||||
alpha: 'Alpha',
|
||||
beta: 'Beta',
|
||||
stable: 'Stable',
|
||||
} as const;
|
||||
|
||||
function OpenAPIOperationStability(props: { stability: OpenAPIStability }) {
|
||||
const { stability } = props;
|
||||
|
||||
const foundStability = stabilityEnum[stability];
|
||||
|
||||
if (!foundStability) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`openapi-stability openapi-stability-${foundStability.toLowerCase()}`}>
|
||||
{foundStability}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
// This component does not use any client feature but we don't want to
|
||||
// render it server-side because it has recursion.
|
||||
|
||||
import type { OpenAPIV3 } from '@gitbook/openapi-parser';
|
||||
import type { OpenAPICustomOperationProperties, OpenAPIV3 } from '@gitbook/openapi-parser';
|
||||
import { useId } from 'react';
|
||||
|
||||
import clsx from 'clsx';
|
||||
import { Markdown } from './Markdown';
|
||||
import { OpenAPICopyButton } from './OpenAPICopyButton';
|
||||
import { OpenAPIDisclosure } from './OpenAPIDisclosure';
|
||||
import { OpenAPISchemaName } from './OpenAPISchemaName';
|
||||
import { retrocycle } from './decycle';
|
||||
@@ -50,7 +51,7 @@ function OpenAPISchemaProperty(props: {
|
||||
circularRefs.set(schema, id);
|
||||
|
||||
const properties = getSchemaProperties(schema);
|
||||
if (properties) {
|
||||
if (properties?.length) {
|
||||
return (
|
||||
<OpenAPIDisclosure context={context} label={getDisclosureLabel(schema)}>
|
||||
<OpenAPISchemaProperties
|
||||
@@ -236,20 +237,59 @@ function OpenAPISchemaCircularRef(props: { id: string; schema: OpenAPIV3.SchemaO
|
||||
/**
|
||||
* Render the enum value for a schema.
|
||||
*/
|
||||
function OpenAPISchemaEnum(props: { enumValues: any[] }) {
|
||||
const { enumValues } = props;
|
||||
function OpenAPISchemaEnum(props: {
|
||||
schema: OpenAPIV3.SchemaObject & OpenAPICustomOperationProperties;
|
||||
}) {
|
||||
const { schema } = props;
|
||||
|
||||
const enumValues = (() => {
|
||||
// Render x-gitbook-enum first, as it has a different format
|
||||
if (schema['x-gitbook-enum']) {
|
||||
return Object.entries(schema['x-gitbook-enum']).map(([name, { description }]) => {
|
||||
return {
|
||||
value: name,
|
||||
description,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
if (schema['x-enumDescriptions']) {
|
||||
return Object.entries(schema['x-enumDescriptions']).map(([value, description]) => {
|
||||
return {
|
||||
value,
|
||||
description,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return schema.enum?.map((value) => {
|
||||
return {
|
||||
value,
|
||||
description: undefined,
|
||||
};
|
||||
});
|
||||
})();
|
||||
|
||||
if (!enumValues?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="openapi-schema-enum">
|
||||
<span>
|
||||
Options:{' '}
|
||||
{enumValues.map((value, index) => (
|
||||
<span>Available options:</span>
|
||||
<div className="openapi-schema-enum-list">
|
||||
{enumValues.map((item, index) => (
|
||||
<span key={index} className="openapi-schema-enum-value">
|
||||
<code>{`${value}`}</code>
|
||||
{index < enumValues.length - 1 ? ', ' : ''}
|
||||
<OpenAPICopyButton
|
||||
value={item.value}
|
||||
label={item.description}
|
||||
withTooltip={!!item.description}
|
||||
>
|
||||
<code>{`${item.value}`}</code>
|
||||
</OpenAPICopyButton>
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -294,9 +334,7 @@ function OpenAPISchemaPresentation(props: { property: OpenAPISchemaPropertyEntry
|
||||
Pattern: <code>{schema.pattern}</code>
|
||||
</div>
|
||||
) : null}
|
||||
{schema.enum && schema.enum.length > 0 ? (
|
||||
<OpenAPISchemaEnum enumValues={schema.enum} />
|
||||
) : null}
|
||||
<OpenAPISchemaEnum schema={schema} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -421,7 +459,7 @@ function getSchemaTitle(schema: OpenAPIV3.SchemaObject): string {
|
||||
// Otherwise try to infer a nice title
|
||||
let type = 'any';
|
||||
|
||||
if (schema.enum) {
|
||||
if (schema.enum || schema['x-enumDescriptions'] || schema['x-gitbook-enum']) {
|
||||
type = `${schema.type} · enum`;
|
||||
// check array AND schema.items as this is sometimes null despite what the type indicates
|
||||
} else if (schema.type === 'array' && !!schema.items) {
|
||||
|
||||
@@ -32,6 +32,9 @@ export function OpenAPISchemaName(props: OpenAPISchemaNameProps) {
|
||||
) : null}
|
||||
</span>
|
||||
{schema?.readOnly ? <span className="openapi-schema-readonly">read-only</span> : null}
|
||||
{schema?.writeOnly ? (
|
||||
<span className="openapi-schema-writeonly">write-only</span>
|
||||
) : null}
|
||||
{required ? (
|
||||
<span className="openapi-schema-required">required</span>
|
||||
) : (
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import {
|
||||
type Filesystem,
|
||||
type OpenAPIV3,
|
||||
type OpenAPIV3_1,
|
||||
type OpenAPIV3xDocument,
|
||||
shouldIgnoreEntity,
|
||||
} from '@gitbook/openapi-parser';
|
||||
import type { Filesystem, OpenAPIV3xDocument } from '@gitbook/openapi-parser';
|
||||
import { filterSelectedOpenAPISchemas } from '@gitbook/openapi-parser';
|
||||
import { dereferenceFilesystem } from '../dereference';
|
||||
import type { OpenAPISchema, OpenAPISchemasData } from '../types';
|
||||
import type { OpenAPISchemasData } from '../types';
|
||||
|
||||
//!!TODO: We should return only the schemas that are used in the block. Still a WIP awaiting future work.
|
||||
|
||||
@@ -32,26 +27,3 @@ export async function resolveOpenAPISchemas(
|
||||
|
||||
return { schemas };
|
||||
}
|
||||
/**
|
||||
* Extract selected schemas from the OpenAPI document.
|
||||
*/
|
||||
export function filterSelectedOpenAPISchemas(
|
||||
schema: OpenAPIV3.Document | OpenAPIV3_1.Document,
|
||||
selectedSchemas: string[]
|
||||
): OpenAPISchema[] {
|
||||
const componentsSchemas = schema.components?.schemas ?? {};
|
||||
|
||||
// Preserve the order of the selected schemas
|
||||
return selectedSchemas
|
||||
.map((name) => {
|
||||
const schema = componentsSchemas[name];
|
||||
if (schema && !shouldIgnoreEntity(schema)) {
|
||||
return {
|
||||
name,
|
||||
schema,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((schema): schema is OpenAPISchema => !!schema);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type {
|
||||
OpenAPICustomOperationProperties,
|
||||
OpenAPICustomSpecProperties,
|
||||
OpenAPISchema,
|
||||
OpenAPIV3,
|
||||
} from '@gitbook/openapi-parser';
|
||||
|
||||
@@ -12,7 +13,11 @@ export interface OpenAPIContextProps extends OpenAPIClientContext {
|
||||
/**
|
||||
* Render the heading of the operation.
|
||||
*/
|
||||
renderHeading: (props: { deprecated: boolean; title: string }) => React.ReactNode;
|
||||
renderHeading: (props: {
|
||||
deprecated: boolean;
|
||||
title: string;
|
||||
stability?: string;
|
||||
}) => React.ReactNode;
|
||||
/**
|
||||
* Render the document of the operation.
|
||||
*/
|
||||
@@ -56,11 +61,6 @@ export interface OpenAPIOperationData extends OpenAPICustomSpecProperties {
|
||||
securities: [string, OpenAPIV3.SecuritySchemeObject][];
|
||||
}
|
||||
|
||||
export type OpenAPISchema = {
|
||||
name: string;
|
||||
schema: OpenAPIV3.SchemaObject;
|
||||
};
|
||||
|
||||
export interface OpenAPISchemasData {
|
||||
/** Components schemas to be used for schemas */
|
||||
schemas: OpenAPISchema[];
|
||||
|
||||
Reference in New Issue
Block a user