mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-10-12 07:58:49 +00:00
Merge remote-tracking branch 'origin/main' into peter/rnd-13071-search-space-scope
This commit is contained in:
@@ -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