Compare commits

...

7 Commits

Author SHA1 Message Date
Peter White 27441c0605 Drop ref memoization 2026-09-07 15:34:54 +02:00
Peter White dd1ca93837 Bump @gitbook/api to 0.200.0 2026-09-07 15:27:23 +02:00
Peter White 08e61c89f0 Read the permanent flag from the typed site redirect 2026-09-07 14:11:44 +02:00
Peter White 587398ddd4 Merge branch 'main' into peter/rnd-12709-support-permanent-308-site-redirects 2026-09-07 12:25:33 +02:00
Peter White 321b06d873 fix: keep permanent redirects temporary in site previews and for draft redirects 2026-09-07 12:14:36 +02:00
Peter White 50eee8fec9 fix: read isLoggedInVisitor from the site context 2026-09-07 10:26:52 +02:00
Peter White ec709c3d99 feat: serve permanent site redirects 2026-09-07 09:25:03 +02:00
5 changed files with 115 additions and 27 deletions
+2 -2
View File
@@ -354,7 +354,7 @@
},
"catalog": {
"@base-ui/react": "^1.7.0",
"@gitbook/api": "0.199.0",
"@gitbook/api": "0.200.0",
"@scalar/api-client-react": "^1.3.46",
"@tsconfig/node20": "^20.1.6",
"@tsconfig/strictest": "^2.0.6",
@@ -726,7 +726,7 @@
"@fortawesome/fontawesome-svg-core": ["@fortawesome/fontawesome-svg-core@7.2.0", "", { "dependencies": { "@fortawesome/fontawesome-common-types": "7.2.0" } }, "sha512-6639htZMjEkwskf3J+e6/iar+4cTNM9qhoWuRfj9F3eJD6r7iCzV1SWnQr2Mdv0QT0suuqU8BoJCZUyCtP9R4Q=="],
"@gitbook/api": ["@gitbook/api@0.199.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-yLxkSTXlGk7jbtThV2vpnTfqZTq6yLgMJt6jhDGqOmP0LsYAVYYtg8NPf2tXp37aF46m3Z0D908wfSQyu374cg=="],
"@gitbook/api": ["@gitbook/api@0.200.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-JgPosRwabDqw8FzTpSZdEWCUdwUxO2vIHJH9A8CXYXBmubjjj/Ft01qnzbL2T5q1CSLSQKe9PvW9t5V+R/8iJA=="],
"@gitbook/browser-types": ["@gitbook/browser-types@workspace:packages/browser-types"],
+1 -1
View File
@@ -48,7 +48,7 @@
"@tsconfig/strictest": "^2.0.6",
"@tsconfig/node20": "^20.1.6",
"@base-ui/react": "^1.7.0",
"@gitbook/api": "0.199.0",
"@gitbook/api": "0.200.0",
"@scalar/api-client-react": "^1.3.46",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
@@ -1,4 +1,4 @@
import { redirect } from 'next/navigation';
import { permanentRedirect, redirect } from 'next/navigation';
import {
SITE_REDIRECT_SOURCE_PATH_MAX_LENGTH,
@@ -89,7 +89,21 @@ async function resolvePage(context: GitBookSiteContext, params: PagePathParams |
})
));
if (resolvedSiteRedirect) {
return redirect(linker.toLinkForContent(resolvedSiteRedirect.target));
const destination = linker.toLinkForContent(resolvedSiteRedirect.target);
const isPublicLiveContext =
!shareKey &&
!context.changeRequest &&
!context.preview &&
context.revisionId === context.space.revision &&
!context.isLoggedInVisitor;
if (
resolvedSiteRedirect.redirect?.permanent &&
!resolvedSiteRedirect.redirect.draft &&
isPublicLiveContext
) {
return permanentRedirect(destination);
}
return redirect(destination);
}
}
+6
View File
@@ -201,6 +201,9 @@ export type GitBookSiteContext = GitBookSpaceContext & {
/** Whether the request included a visitor token. */
isLoggedInVisitor: boolean;
/** Whether the site is rendered from a preview URL. */
preview: boolean;
/** Whether to display agent instructions in the markdown output. Defaults to true when undefined. */
displayAgentInstructions?: boolean;
@@ -287,6 +290,7 @@ export async function fetchSiteContextByURLLookup(
isFallback: data.isFallback ?? false,
noIndexSearch: data.noIndexSearch ?? false,
isLoggedInVisitor: data.isLoggedInVisitor ?? false,
preview: data.preview ?? false,
displayAgentInstructions: data.displayAgentInstructions,
isAiAgent: data.isAiAgent,
});
@@ -310,6 +314,7 @@ export async function fetchSiteContextByIds(
isFallback: boolean;
noIndexSearch: boolean;
isLoggedInVisitor: boolean;
preview: boolean;
displayAgentInstructions?: boolean;
isAiAgent?: boolean;
}
@@ -439,6 +444,7 @@ export async function fetchSiteContextByIds(
isFallback: ids.isFallback,
noIndexSearch: ids.noIndexSearch,
isLoggedInVisitor: ids.isLoggedInVisitor,
preview: ids.preview,
displayAgentInstructions: ids.displayAgentInstructions,
isAiAgent: ids.isAiAgent,
};
+90 -22
View File
@@ -219,7 +219,8 @@ async function renderGroupPageMarkdown(args: {
/**
* Re-writes URLs in a markdown content:
* -
* - stable content refs (`/pages/:id`, `/spaces/:id/pages/:id`, `/files/:id`...) in links,
* images, definitions and in the `href`/`src` of raw HTML blocks are resolved to site URLs.
* - the URL of every relative <a> link so it is expressed from the site-root.
*/
async function rewriteMarkdownLinks(
@@ -246,30 +247,13 @@ async function rewriteMarkdownLinks(
pending.push(
(async () => {
const resolved = await resolveContentRef(contentRef, context);
if (resolved?.href) {
node.url = resolved.href;
} else {
// We use an absolute URL so that crawler don't follow it.
node.url = `broken://${original.startsWith('/') ? original.slice(1) : original}`;
}
node.url = resolved?.href ?? toBrokenURL(original);
if (isMention) {
// Replace the text for mentions as otherwise it contains the raw ref
if (resolved) {
node.children = [
{
type: 'text',
value: resolved.text,
},
];
} else {
node.children = [
{
type: 'text',
value: 'Broken mention',
},
];
}
node.children = [
{ type: 'text', value: resolved?.text ?? 'Broken mention' },
];
node.title = undefined;
}
})()
@@ -288,6 +272,20 @@ async function rewriteMarkdownLinks(
}
});
visit(tree, 'image', (node: Image) => {
pending.push(rewriteNodeURL(context, node));
});
visit(tree, 'definition', (node: Definition) => {
pending.push(rewriteNodeURL(context, node));
});
// Blocks markdown cannot express (tables, cards, figures...) are emitted as raw HTML,
// with the same stable refs in their anchors and images.
visit(tree, 'html', (node: Html) => {
pending.push(rewriteHTMLRefs(context, node));
});
if (pending.length > 0) {
await Promise.all(pending);
}
@@ -295,6 +293,76 @@ async function rewriteMarkdownLinks(
return tree;
}
/**
* Resolve a URL if it is a stable content ref. Returns null for anything else
* (external URLs, anchors, plain paths) so the caller leaves it untouched.
*/
async function resolveRefURL(
context: GitBookAnyContext,
url: string
): Promise<{ url: string; text: string | null } | null> {
if (checkIsExternalURL(url) || checkIsAnchor(url)) {
return null;
}
const contentRef = resolveStringContentRef(url);
if (!contentRef) {
return null;
}
const resolved = await resolveContentRef(contentRef, context);
return { url: resolved?.href ?? toBrokenURL(url), text: resolved?.text ?? null };
}
async function rewriteNodeURL(context: GitBookAnyContext, node: Image | Definition) {
const resolved = await resolveRefURL(context, node.url);
if (resolved) {
node.url = resolved.url;
}
}
async function rewriteHTMLRefs(context: GitBookAnyContext, node: Html): Promise<void> {
node.value = await replaceAsync(node.value, HTML_ANCHOR_RE, async (match) => {
const [full, before = '', href = '', after = '', text = ''] = match;
const resolved = await resolveRefURL(context, href);
if (!resolved) {
return full;
}
// The API emits the raw ref as the text; swap it for the resolved title.
const content = text === href ? escapeHTML(resolved.text ?? 'Broken link') : text;
return `<a${before}href="${escapeHTML(resolved.url)}"${after}>${content}</a>`;
});
node.value = await replaceAsync(node.value, HTML_SRC_RE, async (match) => {
const [full, src = ''] = match;
const resolved = await resolveRefURL(context, src);
return resolved ? `src="${escapeHTML(resolved.url)}"` : full;
});
}
async function replaceAsync(
value: string,
re: RegExp,
replacer: (match: RegExpMatchArray) => Promise<string>
): Promise<string> {
const replacements = await Promise.all(Array.from(value.matchAll(re), replacer));
let index = 0;
return value.replace(re, () => replacements[index++]!);
}
/**
* Use an absolute URL so that crawlers don't follow it.
*/
function toBrokenURL(original: string): string {
return `broken://${original.startsWith('/') ? original.slice(1) : original}`;
}
function escapeHTML(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
function isMentionLike(node: Link) {
if (node.title === 'mention') {
return true;