mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-10-07 22:02:23 +00:00
Merge remote-tracking branch 'origin/main' into peter/rnd-13071-search-space-scope
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Track server-side Insights events (markdown, llms.txt, RSS, ask) with the site's public URL instead of the proxy URL.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@gitbook/react-openapi": patch
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix OpenAPI schemas that reference themselves through a nullable union rendering forever when expanded, which crashed PDF exports.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix previewed customization and theme overrides being lost when navigating between pages inside the site preview. The preview cookie was scoped to the site's canonical base path instead of the preview route it is actually served under, so the browser dropped it on the next in-preview navigation.
|
||||
@@ -1,6 +1,8 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
|
||||
import { getPreviewRequestIdentifier, isPreviewRequest } from './preview';
|
||||
import type { PublishedSiteContent } from '@gitbook/api';
|
||||
|
||||
import { getPreviewCookieResponse, getPreviewRequestIdentifier, isPreviewRequest } from './preview';
|
||||
|
||||
describe('isPreviewRequest', () => {
|
||||
it('should return true for preview requests', () => {
|
||||
@@ -23,3 +25,43 @@ describe('getPreviewRequestIdentifier', () => {
|
||||
expect(getPreviewRequestIdentifier(previewRequestURL)).toBe('site_foo');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPreviewCookieResponse', () => {
|
||||
// The site's canonical base path — deliberately different from the preview route prefix so the
|
||||
// test fails if the cookie is scoped to it instead of the preview route.
|
||||
const siteURLData = { siteBasePath: '/docs/' } as unknown as PublishedSiteContent;
|
||||
|
||||
it('scopes the cookie to the preview route prefix in url-host mode, not the site base path', () => {
|
||||
const cookie = getPreviewCookieResponse({
|
||||
name: 'gitbook-customization',
|
||||
value: 'v',
|
||||
mode: 'url-host',
|
||||
siteRequestURL: new URL('https://sites.gitbook.com/preview/site_foo/hello/world'),
|
||||
siteURLData,
|
||||
});
|
||||
expect(cookie.options?.path).toBe('/preview/site_foo');
|
||||
expect(cookie.options?.sameSite).toBe('lax');
|
||||
});
|
||||
|
||||
it('scopes the cookie to the proxied preview route prefix in url mode', () => {
|
||||
const cookie = getPreviewCookieResponse({
|
||||
name: 'gitbook-customization',
|
||||
value: 'v',
|
||||
mode: 'url',
|
||||
siteRequestURL: new URL('https://sites.gitbook.com/preview/site_foo/hello/world'),
|
||||
siteURLData,
|
||||
});
|
||||
expect(cookie.options?.path).toBe('/url/sites.gitbook.com/preview/site_foo');
|
||||
});
|
||||
|
||||
it('falls back to the site base path for a non-preview url-host request', () => {
|
||||
const cookie = getPreviewCookieResponse({
|
||||
name: 'gitbook-customization',
|
||||
value: 'v',
|
||||
mode: 'url-host',
|
||||
siteRequestURL: new URL('https://example.com/docs/hello/world'),
|
||||
siteURLData,
|
||||
});
|
||||
expect(cookie.options?.path).toBe('/docs/');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -51,8 +51,21 @@ export function getPreviewCookieResponse(args: {
|
||||
gitbookPreviewBaseURL.host + gitbookPreviewBaseURL.pathname.replace(/\/$/, '');
|
||||
return `/url/${gitbookPreviewHostPath}/${getPreviewRequestIdentifier(siteRequestURL)}`;
|
||||
}
|
||||
case 'url-host':
|
||||
return siteURLData.siteBasePath;
|
||||
case 'url-host': {
|
||||
// The whole site preview (all variants/sections/pages) is served under the preview
|
||||
// route prefix `<previewBasePath>/<siteId>` (e.g. `/preview/<siteId>`). Scope the
|
||||
// cookie to that prefix, derived from the request, so it is resent on every
|
||||
// in-preview navigation. The resolved `siteBasePath` is the site's canonical base
|
||||
// path and does not prefix the preview route, so relying on it makes the browser
|
||||
// drop the cookie on the next page — the actual cause of the override being lost.
|
||||
// Mirrors getVisitorAuthBasePath, which uses the request identifier for proxy
|
||||
// requests. Fall back to `siteBasePath` for any preview not served under the route.
|
||||
if (!isPreviewRequest(siteRequestURL)) {
|
||||
return siteURLData.siteBasePath;
|
||||
}
|
||||
const gitbookPreviewBaseURL = new URL(GITBOOK_PREVIEW_BASE_URL);
|
||||
return `${gitbookPreviewBaseURL.pathname.replace(/\/$/, '')}/${getPreviewRequestIdentifier(siteRequestURL)}`;
|
||||
}
|
||||
default:
|
||||
assertNever(mode);
|
||||
}
|
||||
|
||||
@@ -508,7 +508,8 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
|
||||
siteId: siteURLData.site,
|
||||
events,
|
||||
request: {
|
||||
url: siteRequestURL.toString(),
|
||||
// A proxied site's request URL is the proxy's (`/sites/site_xxx/...`), not its public one.
|
||||
url: siteCanonicalURL.toString(),
|
||||
headers: requestHeaders,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { renderToReadableStream } from 'react-dom/server';
|
||||
|
||||
import { parseOpenAPI } from '@gitbook/openapi-parser';
|
||||
|
||||
import type { OpenAPIClientContext } from './context';
|
||||
import { OpenAPIRootSchema } from './OpenAPISchemaServer';
|
||||
import { resolveOpenAPISchemas } from './schemas/resolveOpenAPISchemas';
|
||||
import { translations } from './translations';
|
||||
|
||||
const context: OpenAPIClientContext = {
|
||||
translation: translations.en,
|
||||
icons: {
|
||||
chevronDown: null,
|
||||
chevronRight: null,
|
||||
plus: null,
|
||||
copy: null,
|
||||
check: null,
|
||||
lock: null,
|
||||
mcp: null,
|
||||
hashtag: null,
|
||||
},
|
||||
expandAllModelSections: true,
|
||||
scalarRuntimeURL: '',
|
||||
$$isClientContext$$: true,
|
||||
};
|
||||
|
||||
describe('OpenAPIRootSchema', () => {
|
||||
it('should stop at a self-reference behind a nullable union when expanded', async () => {
|
||||
const { filesystem } = await parseOpenAPI({
|
||||
value: JSON.stringify({
|
||||
openapi: '3.1.0',
|
||||
info: { title: 'Test', version: '1.0.0' },
|
||||
paths: {},
|
||||
components: {
|
||||
schemas: {
|
||||
FilterClause: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
AND: {
|
||||
anyOf: [
|
||||
{
|
||||
type: 'array',
|
||||
items: { $ref: '#/components/schemas/FilterClause' },
|
||||
},
|
||||
{ type: 'null' },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
rootURL: 'memory://spec.json',
|
||||
});
|
||||
const resolved = await resolveOpenAPISchemas(filesystem, { schemas: ['FilterClause'] });
|
||||
const schema = resolved?.schemas[0]?.schema;
|
||||
if (!schema) {
|
||||
throw new Error('FilterClause not resolved');
|
||||
}
|
||||
|
||||
const stream = await renderToReadableStream(
|
||||
<OpenAPIRootSchema schema={schema} context={context} />
|
||||
);
|
||||
const html = await new Response(stream).text();
|
||||
|
||||
expect(html).toContain('AND');
|
||||
});
|
||||
});
|
||||
@@ -190,6 +190,14 @@ describe('extractNonNullTypes', () => {
|
||||
});
|
||||
|
||||
describe('normalizeNullableUnion', () => {
|
||||
it('should return the same object for the same input', () => {
|
||||
const schema: OpenAPIV3_1.SchemaObject = {
|
||||
anyOf: [{ type: 'array', items: { type: 'string' } }, { type: 'null' }],
|
||||
};
|
||||
|
||||
expect(normalizeNullableUnion(schema)).toBe(normalizeNullableUnion(schema));
|
||||
});
|
||||
|
||||
it('should collapse anyOf with a single non-null member into a nullable schema', () => {
|
||||
const schema: OpenAPIV3_1.SchemaObject = {
|
||||
anyOf: [{ type: 'string' }, { type: 'null' }],
|
||||
|
||||
@@ -287,6 +287,10 @@ function isNullSchema(schema: OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject
|
||||
return type === 'null';
|
||||
}
|
||||
|
||||
// Circular-ref tracking compares schemas by identity: a fresh copy on every render lets a
|
||||
// self-referencing nullable property (`anyOf: [{ items: Self }, null]`) recurse forever when expanded.
|
||||
const normalizedNullableUnions = new WeakMap<OpenAPIV3.SchemaObject, OpenAPIV3.SchemaObject>();
|
||||
|
||||
/**
|
||||
* Normalize the OpenAPI 3.1+ idiom of expressing nullability through `anyOf`/`oneOf`
|
||||
* (e.g. `anyOf: [{ type: 'string' }, { type: 'null' }]`) into a regular nullable schema,
|
||||
@@ -301,7 +305,17 @@ export function normalizeNullableUnion(
|
||||
schema: OpenAPIV3.SchemaObject | OpenAPIV3_1.SchemaObject
|
||||
): OpenAPIV3.SchemaObject {
|
||||
const typed = schema as OpenAPIV3.SchemaObject;
|
||||
const cached = normalizedNullableUnions.get(typed);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const normalized = normalizeNullableUnionUncached(typed);
|
||||
normalizedNullableUnions.set(typed, normalized);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeNullableUnionUncached(typed: OpenAPIV3.SchemaObject): OpenAPIV3.SchemaObject {
|
||||
const isAnyOf = Array.isArray(typed.anyOf);
|
||||
const isOneOf = !isAnyOf && Array.isArray(typed.oneOf);
|
||||
if (!isAnyOf && !isOneOf) {
|
||||
|
||||
Reference in New Issue
Block a user