From f14056ba80e87d5a13c1c2361cfaca1943aa7b20 Mon Sep 17 00:00:00 2001 From: Viktor Renkema Date: Tue, 25 Nov 2025 18:17:09 +0100 Subject: [PATCH 01/14] Add route for updates RSS feed --- bun.lock | 7 + packages/gitbook/package.json | 3 +- .../~gitbook/rss/[pagePath]/route.ts | 23 ++ packages/gitbook/src/middleware.ts | 48 +++- packages/gitbook/src/routes/rssPage.ts | 228 ++++++++++++++++++ 5 files changed, 303 insertions(+), 6 deletions(-) create mode 100644 packages/gitbook/src/app/sites/static/[mode]/[siteURL]/[siteData]/~gitbook/rss/[pagePath]/route.ts create mode 100644 packages/gitbook/src/routes/rssPage.ts diff --git a/bun.lock b/bun.lock index 37511d4070..474cc04810 100644 --- a/bun.lock +++ b/bun.lock @@ -143,6 +143,7 @@ "classnames": "catalog:", "direction": "^2.0.1", "event-iterator": "^2.0.0", + "feed": "^5.1.0", "image-size": "^2.0.2", "js-cookie": "^3.0.5", "jsontoxml": "^1.0.1", @@ -2003,6 +2004,8 @@ "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + "feed": ["feed@5.1.0", "", { "dependencies": { "xml-js": "^1.6.11" } }, "sha512-qGNhgYygnefSkAHHrNHqC7p3R8J0/xQDS/cYUud8er/qD9EFGWyCdUDfULHTJQN1d3H3WprzVwMc9MfB4J50Wg=="], + "file-entry-cache": ["file-entry-cache@10.0.7", "", { "dependencies": { "flat-cache": "^6.1.7" } }, "sha512-txsf5fu3anp2ff3+gOJJzRImtrtm/oa9tYLN0iTuINZ++EyVR/nRrg2fKYwvG/pXDofcrvvb0scEbX3NyW/COw=="], "file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="], @@ -2739,6 +2742,8 @@ "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + "sax": ["sax@1.4.3", "", {}, "sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ=="], + "scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="], "secure-json-parse": ["secure-json-parse@2.7.0", "", {}, "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw=="], @@ -3027,6 +3032,8 @@ "xdg-portable": ["xdg-portable@7.3.0", "", { "dependencies": { "os-paths": "^4.0.1" } }, "sha512-sqMMuL1rc0FmMBOzCpd0yuy9trqF2yTTVe+E9ogwCSWQCdDEtQUwrZPT6AxqtsFGRNxycgncbP/xmOOSPw5ZUw=="], + "xml-js": ["xml-js@1.6.11", "", { "dependencies": { "sax": "^1.2.4" }, "bin": { "xml-js": "./bin/cli.js" } }, "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g=="], + "xxhash-wasm": ["xxhash-wasm@1.1.0", "", {}, "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA=="], "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], diff --git a/packages/gitbook/package.json b/packages/gitbook/package.json index e99583a26c..e24b8f7caf 100644 --- a/packages/gitbook/package.json +++ b/packages/gitbook/package.json @@ -8,9 +8,9 @@ "@gitbook/browser-types": "workspace:*", "@gitbook/cache-tags": "workspace:*", "@gitbook/colors": "workspace:*", + "@gitbook/embed": "workspace:*", "@gitbook/emoji-codepoints": "workspace:*", "@gitbook/expr": "workspace:*", - "@gitbook/embed": "workspace:*", "@gitbook/fonts": "workspace:*", "@gitbook/icons": "workspace:*", "@gitbook/openapi-parser": "workspace:*", @@ -36,6 +36,7 @@ "classnames": "catalog:", "direction": "^2.0.1", "event-iterator": "^2.0.0", + "feed": "^5.1.0", "image-size": "^2.0.2", "js-cookie": "^3.0.5", "jsontoxml": "^1.0.1", diff --git a/packages/gitbook/src/app/sites/static/[mode]/[siteURL]/[siteData]/~gitbook/rss/[pagePath]/route.ts b/packages/gitbook/src/app/sites/static/[mode]/[siteURL]/[siteData]/~gitbook/rss/[pagePath]/route.ts new file mode 100644 index 0000000000..bc27b0cee3 --- /dev/null +++ b/packages/gitbook/src/app/sites/static/[mode]/[siteURL]/[siteData]/~gitbook/rss/[pagePath]/route.ts @@ -0,0 +1,23 @@ +import { type RouteParams, getPagePathFromParams, getStaticSiteContext } from '@/app/utils'; +import { servePageRSS } from '@/routes/rssPage'; +import type { NextRequest } from 'next/server'; + +export const dynamic = 'force-static'; + +export async function GET(_request: NextRequest, { params }: { params: Promise }) { + const { context } = await getStaticSiteContext(await params); + const pathname = getPagePathFromParams(await params); + + const rssResponse = await servePageRSS(context, pathname); + + if (!rssResponse) { + return new Response('RSS feed not found', { + status: 404, + headers: { + 'Content-Type': 'text/plain; charset=utf-8', + }, + }); + } + + return rssResponse; +} diff --git a/packages/gitbook/src/middleware.ts b/packages/gitbook/src/middleware.ts index 915f2cc700..7c13aaaaf2 100644 --- a/packages/gitbook/src/middleware.ts +++ b/packages/gitbook/src/middleware.ts @@ -15,7 +15,7 @@ import { import { isGitBookAssetsHostURL, isGitBookHostURL } from '@/lib/env'; import { getImageResizingContextId } from '@/lib/images'; import { MiddlewareHeaders } from '@/lib/middleware'; -import { removeLeadingSlash, removeTrailingSlash } from '@/lib/paths'; +import { removeLeadingSlash, removeTrailingSlash, withLeadingSlash } from '@/lib/paths'; import { type ResponseCookies, getPathScopedCookieName, @@ -172,10 +172,16 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) { // request.headers.delete('x-gitbook-disable-tracking'); + // For feed requests, strip `/feed.xml` from the lookup URL so we're still targeting the actual page for content resolution. + const rssSuffixMatch = siteRequestURL.pathname.match(/^(.*)\/feed\.xml$/); + const siteRequestURLForLookup = rssSuffixMatch?.[1] + ? new URL(siteRequestURL.toString().replace(/feed\.xml$/, '')) + : siteRequestURL; + const withAPIToken = async (apiToken: string | null) => { const siteURLData = await throwIfDataError( lookupPublishedContentByUrl({ - url: siteRequestURL.toString(), + url: siteRequestURLForLookup.toString(), visitorPayload: { jwtToken: visitorToken?.token ?? undefined, unsignedClaims, @@ -359,10 +365,31 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) { requestHeaders.set('origin', request.nextUrl.origin); const siteURLWithoutProtocol = `${siteCanonicalURL.host}${siteURLData.basePath}`; - const { pathname, routeType: routeTypeFromPathname } = encodePathInSiteContent( - siteURLData.pathname + const basePathNormalized = withLeadingSlash( + removeTrailingSlash(siteURLData.basePath || '') ); - routeType = routeTypeFromPathname ?? routeType; + const lookupPathname = siteRequestURLForLookup.pathname; + let pagePathRelative = + siteURLData.pathname && siteURLData.pathname.length > 0 + ? siteURLData.pathname + : lookupPathname; + if (lookupPathname.startsWith(basePathNormalized)) { + pagePathRelative = lookupPathname.slice(basePathNormalized.length) || '/'; + } + + let pathForEncoding = pagePathRelative; + let forcedRouteType: 'static' | undefined; + + if (rssSuffixMatch) { + const normalizedPagePath = + pagePathRelative === '/' ? '%2F' : removeLeadingSlash(pagePathRelative); + pathForEncoding = `~gitbook/rss/${normalizedPagePath}`; + forcedRouteType = 'static'; + } + + const { pathname, routeType: routeTypeFromPathname } = + encodePathInSiteContent(pathForEncoding); + routeType = forcedRouteType ?? routeTypeFromPathname ?? routeType; const route = [ 'sites', @@ -574,6 +601,17 @@ function encodePathInSiteContent(rawPathname: string): { return { pathname }; } + // RSS feeds: either `/.../feed.xml` (external) or already `~gitbook/rss/...`. + const rssPath = + (!pathname.startsWith('~gitbook/') && pathname.match(/^(.*)\/feed\.xml$/)?.[1]) || + pathname.match(/^~gitbook\/rss\/(.+)$/)?.[1]; + if (rssPath !== undefined) { + return { + pathname: `~gitbook/rss/${encodeURIComponent(rssPath)}`, + routeType: 'static', + }; + } + // If the pathname is a markdown file, we rewrite it to ~gitbook/markdown/:pathname if (pathname.match(/\.md$/)) { const pagePathWithoutMD = pathname.slice(0, -3); diff --git a/packages/gitbook/src/routes/rssPage.ts b/packages/gitbook/src/routes/rssPage.ts new file mode 100644 index 0000000000..7ee58e8dca --- /dev/null +++ b/packages/gitbook/src/routes/rssPage.ts @@ -0,0 +1,228 @@ +import type { GitBookSiteContext } from '@/lib/context'; +import { getPageDocument } from '@/lib/data/pages'; +import { getNodeText } from '@/lib/document'; +import { resolvePagePathDocumentOrGroup } from '@/lib/pages'; +import { removeLeadingSlash } from '@/lib/paths'; +import type { + DocumentBlock, + DocumentBlockHeading, + DocumentBlockUpdate, + DocumentBlockUpdates, + JSONDocument, +} from '@gitbook/api'; +import { RevisionPageType } from '@gitbook/api'; +import { Feed } from 'feed'; + +/** + * Find all Updates blocks in a document and extract their Update entries. + */ +function findUpdatesInDocument(document: JSONDocument): Array<{ + updatesBlock: DocumentBlockUpdates; + updateEntries: DocumentBlockUpdate[]; +}> { + const results: Array<{ + updatesBlock: DocumentBlockUpdates; + updateEntries: DocumentBlockUpdate[]; + }> = []; + + function traverse(nodes: DocumentBlock[], depth = 0) { + for (const node of nodes) { + if (node.type === 'updates') { + const updatesBlock = node as DocumentBlockUpdates; + + const updateEntries = updatesBlock.nodes.filter( + (child): child is DocumentBlockUpdate => child.type === 'update' + ); + + if (updateEntries.length > 0) { + results.push({ updatesBlock, updateEntries }); + } + } + // Only traverse child nodes if they are blocks (not inlines or text) + if ('nodes' in node && Array.isArray(node.nodes) && node.object === 'block') { + // Filter to only blocks before recursing + const childBlocks: DocumentBlock[] = []; + for (const child of node.nodes) { + if (child.object === 'block') { + childBlocks.push(child); + } + } + if (childBlocks.length > 0) { + traverse(childBlocks, depth + 1); + } + } + } + } + + traverse(document.nodes); + return results; +} + +/** + * Convert an Update block's content to markdown-like text. + * This extracts text content from all nodes in the update. + */ +function getUpdateContent(update: DocumentBlockUpdate): string { + return getNodeText(update).trim(); +} + +function slugifyHeading(text: string): string { + return text + .trim() + .toLowerCase() + .replace(/[^a-z0-9\s-]/g, '') + .replace(/\s+/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, ''); +} + +function getHeadingInfo(update: DocumentBlockUpdate): { + title?: string; + anchorId?: string; +} { + for (const node of update.nodes ?? []) { + if (node.object !== 'block') { + continue; + } + if (!node.type.startsWith('heading')) { + continue; + } + + const title = getNodeText(node).trim(); + const anchorId = + (isHeadingBlock(node) && node.meta?.id) || (title ? slugifyHeading(title) : undefined); + return { + title: title || undefined, + anchorId: anchorId || undefined, + }; + } + + return {}; +} + +function isHeadingBlock(block: DocumentBlock): block is DocumentBlockHeading { + return block.type.startsWith('heading'); +} + +/** + * Generate an RSS feed from Updates blocks in a page. + * Returns null if no Updates blocks are found. + */ +export async function servePageRSS( + context: GitBookSiteContext, + pagePath: string +): Promise { + const normalizedPath = pagePath === '/' ? '' : removeLeadingSlash(pagePath); + const pageLookup = resolvePagePathDocumentOrGroup(context.revision.pages, normalizedPath); + + if (!pageLookup || pageLookup.page.type !== RevisionPageType.Document) { + return null; + } + + const { page } = pageLookup; + + const document = await getPageDocument(context, page); + + if (!document) { + return null; + } + + const updatesData = findUpdatesInDocument(document); + + // Collect all update entries from all Updates blocks + const allUpdates: Array<{ + update: DocumentBlockUpdate; + date: Date; + content: string; + }> = []; + + for (const { updateEntries } of updatesData) { + for (const update of updateEntries) { + const dateStr = update.data?.date; + + if (!dateStr) { + continue; + } + + const date = new Date(dateStr); + if (Number.isNaN(date.getTime())) { + continue; + } + + const content = getUpdateContent(update); + + if (!content) { + continue; + } + + allUpdates.push({ update, date, content }); + } + } + + if (allUpdates.length === 0) { + return null; + } + + // Sort updates by date (newest first) + allUpdates.sort((a, b) => b.date.getTime() - a.date.getTime()); + + // Get page URL + const pagePathForLink = context.linker.toPathForPage({ pages: context.revision.pages, page }); + const pageURL = context.linker.toAbsoluteURL(pagePathForLink); + + // Get feed URL - exposed as `/feed.xml` + const normalizedPagePath = + pagePathForLink.endsWith('/') && pagePathForLink !== '/' + ? pagePathForLink.slice(0, -1) + : pagePathForLink; + const rssPath = `${normalizedPagePath}/feed.xml`; + const rssURL = context.linker.toAbsoluteURL(rssPath); + const docsURL = context.linker.toAbsoluteURL( + context.linker.siteBasePath || context.linker.spaceBasePath + ); + + // Create RSS feed + const feed = new Feed({ + title: page.title, + description: page.description || `Updates feed for ${page.title}`, + id: pageURL, + link: pageURL, + copyright: `Copyright ${new Date().getFullYear()}`, + updated: allUpdates[0]?.date ?? new Date(), + feedLinks: { + rss: rssURL, + }, + docs: docsURL, + }); + + // Add update entries as feed items + for (const { update, date, content } of allUpdates) { + // Create the anchor link for the update + const headingInfo = getHeadingInfo(update); + const anchorId = headingInfo.anchorId; + const itemLink = `${pageURL}#${anchorId}`; + + // Extract title from first line or heading, fallback to date + const firstLine = content.split('\n')[0]?.trim() || ''; + const title = + headingInfo.title && headingInfo.title.length > 0 + ? headingInfo.title + : firstLine.length > 0 && firstLine.length < 100 + ? firstLine + : `Update - ${date.toLocaleDateString()}`; + + feed.addItem({ + title: title, + id: itemLink, + link: itemLink, + content, + date: date, + }); + } + + return new Response(feed.rss2(), { + headers: { + 'Content-Type': 'application/rss+xml; charset=utf-8', + }, + }); +} From aa6530294ded7c92434dea08838392c8e60b3c79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samy=20Pess=C3=A9?= Date: Wed, 26 Nov 2025 10:29:55 +0100 Subject: [PATCH 02/14] Simplify --- .../~gitbook/rss/[pagePath]/route.ts | 13 +- .../src/components/DocumentView/Hint.tsx | 3 +- packages/gitbook/src/lib/document.tsx | 62 +++++ packages/gitbook/src/routes/rssPage.ts | 228 ++++-------------- 4 files changed, 113 insertions(+), 193 deletions(-) diff --git a/packages/gitbook/src/app/sites/static/[mode]/[siteURL]/[siteData]/~gitbook/rss/[pagePath]/route.ts b/packages/gitbook/src/app/sites/static/[mode]/[siteURL]/[siteData]/~gitbook/rss/[pagePath]/route.ts index bc27b0cee3..3c86c8695a 100644 --- a/packages/gitbook/src/app/sites/static/[mode]/[siteURL]/[siteData]/~gitbook/rss/[pagePath]/route.ts +++ b/packages/gitbook/src/app/sites/static/[mode]/[siteURL]/[siteData]/~gitbook/rss/[pagePath]/route.ts @@ -8,16 +8,5 @@ export async function GET(_request: NextRequest, { params }: { params: Promise( + document: JSONDocument, + type: Type +): Extract[] { + return findBlocks(document, (block) => { + return block.type === type; + }); +} + +/** + * Check if a block is a heading block. + */ +export function isHeadingBlock(block: DocumentBlock): block is DocumentBlockHeading { + return ['heading-1', 'heading-2', 'heading-3'].includes(block.type); +} + +/** + * Find all blocks by a predicate in the document. + */ +function findBlocks( + container: JSONDocument | DocumentBlock | DocumentFragment, + test: (block: DocumentBlock) => boolean +): Block[] { + if (!('nodes' in container)) { + return []; + } + + const results: Block[] = []; + for (const block of container.nodes) { + if (block.object !== 'block') { + continue; + } + + if (test(block)) { + // @ts-expect-error - we know that the block is of type Block + results.push(block); + } + + if (block.object === 'block' && 'nodes' in block) { + const result = findBlocks(block, test); + if (result.length > 0) { + results.push(...result); + } + } + + if (block.object === 'block' && 'fragments' in block) { + for (const fragment of block.fragments) { + const result = findBlocks(fragment, test); + if (result.length > 0) { + results.push(...result); + } + } + } + } + + return results; +} + /** * Find a block by a predicate in the document. */ diff --git a/packages/gitbook/src/routes/rssPage.ts b/packages/gitbook/src/routes/rssPage.ts index 7ee58e8dca..ce6e5312c2 100644 --- a/packages/gitbook/src/routes/rssPage.ts +++ b/packages/gitbook/src/routes/rssPage.ts @@ -1,224 +1,83 @@ import type { GitBookSiteContext } from '@/lib/context'; import { getPageDocument } from '@/lib/data/pages'; -import { getNodeText } from '@/lib/document'; +import { getBlocksByType, getNodeText, isHeadingBlock } from '@/lib/document'; import { resolvePagePathDocumentOrGroup } from '@/lib/pages'; -import { removeLeadingSlash } from '@/lib/paths'; -import type { - DocumentBlock, - DocumentBlockHeading, - DocumentBlockUpdate, - DocumentBlockUpdates, - JSONDocument, -} from '@gitbook/api'; +import { joinPath } from '@/lib/paths'; import { RevisionPageType } from '@gitbook/api'; import { Feed } from 'feed'; -/** - * Find all Updates blocks in a document and extract their Update entries. - */ -function findUpdatesInDocument(document: JSONDocument): Array<{ - updatesBlock: DocumentBlockUpdates; - updateEntries: DocumentBlockUpdate[]; -}> { - const results: Array<{ - updatesBlock: DocumentBlockUpdates; - updateEntries: DocumentBlockUpdate[]; - }> = []; - - function traverse(nodes: DocumentBlock[], depth = 0) { - for (const node of nodes) { - if (node.type === 'updates') { - const updatesBlock = node as DocumentBlockUpdates; - - const updateEntries = updatesBlock.nodes.filter( - (child): child is DocumentBlockUpdate => child.type === 'update' - ); - - if (updateEntries.length > 0) { - results.push({ updatesBlock, updateEntries }); - } - } - // Only traverse child nodes if they are blocks (not inlines or text) - if ('nodes' in node && Array.isArray(node.nodes) && node.object === 'block') { - // Filter to only blocks before recursing - const childBlocks: DocumentBlock[] = []; - for (const child of node.nodes) { - if (child.object === 'block') { - childBlocks.push(child); - } - } - if (childBlocks.length > 0) { - traverse(childBlocks, depth + 1); - } - } - } - } - - traverse(document.nodes); - return results; -} - -/** - * Convert an Update block's content to markdown-like text. - * This extracts text content from all nodes in the update. - */ -function getUpdateContent(update: DocumentBlockUpdate): string { - return getNodeText(update).trim(); -} - -function slugifyHeading(text: string): string { - return text - .trim() - .toLowerCase() - .replace(/[^a-z0-9\s-]/g, '') - .replace(/\s+/g, '-') - .replace(/-+/g, '-') - .replace(/^-|-$/g, ''); -} - -function getHeadingInfo(update: DocumentBlockUpdate): { - title?: string; - anchorId?: string; -} { - for (const node of update.nodes ?? []) { - if (node.object !== 'block') { - continue; - } - if (!node.type.startsWith('heading')) { - continue; - } - - const title = getNodeText(node).trim(); - const anchorId = - (isHeadingBlock(node) && node.meta?.id) || (title ? slugifyHeading(title) : undefined); - return { - title: title || undefined, - anchorId: anchorId || undefined, - }; - } - - return {}; -} - -function isHeadingBlock(block: DocumentBlock): block is DocumentBlockHeading { - return block.type.startsWith('heading'); -} - /** * Generate an RSS feed from Updates blocks in a page. - * Returns null if no Updates blocks are found. */ export async function servePageRSS( context: GitBookSiteContext, - pagePath: string + inputPagePath: string ): Promise { - const normalizedPath = pagePath === '/' ? '' : removeLeadingSlash(pagePath); - const pageLookup = resolvePagePathDocumentOrGroup(context.revision.pages, normalizedPath); + const pageLookup = resolvePagePathDocumentOrGroup(context.revision.pages, inputPagePath); - if (!pageLookup || pageLookup.page.type !== RevisionPageType.Document) { - return null; + if (!pageLookup) { + return notFoundResponse('Page not found'); + } + if (pageLookup.page.type !== RevisionPageType.Document) { + return notFoundResponse('Page is not a document'); } const { page } = pageLookup; - const document = await getPageDocument(context, page); - if (!document) { - return null; + return notFoundResponse('Page is empty'); } - const updatesData = findUpdatesInDocument(document); - - // Collect all update entries from all Updates blocks - const allUpdates: Array<{ - update: DocumentBlockUpdate; - date: Date; - content: string; - }> = []; - - for (const { updateEntries } of updatesData) { - for (const update of updateEntries) { - const dateStr = update.data?.date; - - if (!dateStr) { - continue; - } - - const date = new Date(dateStr); - if (Number.isNaN(date.getTime())) { - continue; - } - - const content = getUpdateContent(update); - - if (!content) { - continue; - } - - allUpdates.push({ update, date, content }); - } - } + const updatesBlocks = getBlocksByType(document, 'updates'); - if (allUpdates.length === 0) { - return null; + if (updatesBlocks.length === 0) { + return notFoundResponse('No updates found in page'); } - // Sort updates by date (newest first) - allUpdates.sort((a, b) => b.date.getTime() - a.date.getTime()); - // Get page URL - const pagePathForLink = context.linker.toPathForPage({ pages: context.revision.pages, page }); - const pageURL = context.linker.toAbsoluteURL(pagePathForLink); - - // Get feed URL - exposed as `/feed.xml` - const normalizedPagePath = - pagePathForLink.endsWith('/') && pagePathForLink !== '/' - ? pagePathForLink.slice(0, -1) - : pagePathForLink; - const rssPath = `${normalizedPagePath}/feed.xml`; + const pagePath = context.linker.toPathForPage({ pages: context.revision.pages, page }); + const pageURL = context.linker.toAbsoluteURL(pagePath); + const rssPath = joinPath(pagePath, 'feed.xml'); const rssURL = context.linker.toAbsoluteURL(rssPath); - const docsURL = context.linker.toAbsoluteURL( - context.linker.siteBasePath || context.linker.spaceBasePath - ); + const docsURL = context.linker.toAbsoluteURL(context.linker.toPathInSite('/')); // Create RSS feed const feed = new Feed({ + id: page.id, title: page.title, description: page.description || `Updates feed for ${page.title}`, - id: pageURL, link: pageURL, copyright: `Copyright ${new Date().getFullYear()}`, - updated: allUpdates[0]?.date ?? new Date(), + updated: new Date(page.updatedAt ?? page.createdAt ?? Date.now()), feedLinks: { rss: rssURL, }, docs: docsURL, }); - // Add update entries as feed items - for (const { update, date, content } of allUpdates) { - // Create the anchor link for the update - const headingInfo = getHeadingInfo(update); - const anchorId = headingInfo.anchorId; - const itemLink = `${pageURL}#${anchorId}`; + updatesBlocks.forEach((updatesBlock) => { + updatesBlock.nodes.forEach((update) => { + const heading = update.nodes[0]; + if (!heading || !isHeadingBlock(heading)) { + return; + } + + const title = getNodeText(heading).trim(); + const anchorId = heading.meta?.id; + const itemLink = anchorId ? `${pageURL}#${anchorId}` : pageURL; - // Extract title from first line or heading, fallback to date - const firstLine = content.split('\n')[0]?.trim() || ''; - const title = - headingInfo.title && headingInfo.title.length > 0 - ? headingInfo.title - : firstLine.length > 0 && firstLine.length < 100 - ? firstLine - : `Update - ${date.toLocaleDateString()}`; + const contentNodes = update.nodes.slice(1); + const content = contentNodes.map((node) => getNodeText(node)).join('\n\n'); - feed.addItem({ - title: title, - id: itemLink, - link: itemLink, - content, - date: date, + feed.addItem({ + title: title, + id: itemLink, + link: itemLink, + content, + date: new Date(update.data.date), + }); }); - } + }); return new Response(feed.rss2(), { headers: { @@ -226,3 +85,12 @@ export async function servePageRSS( }, }); } + +function notFoundResponse(message: string) { + return new Response(message, { + status: 404, + headers: { + 'Content-Type': 'text/plain; charset=utf-8', + }, + }); +} From d66f9b8b5fbbbbfc90ebabab9efbde1cf9d04233 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samy=20Pess=C3=A9?= Date: Wed, 26 Nov 2025 10:32:20 +0100 Subject: [PATCH 03/14] Revert middleware --- packages/gitbook/src/middleware.ts | 48 ++++-------------------------- 1 file changed, 5 insertions(+), 43 deletions(-) diff --git a/packages/gitbook/src/middleware.ts b/packages/gitbook/src/middleware.ts index b22d19c66a..a6920e490a 100644 --- a/packages/gitbook/src/middleware.ts +++ b/packages/gitbook/src/middleware.ts @@ -15,7 +15,7 @@ import { import { isGitBookAssetsHostURL, isGitBookHostURL } from '@/lib/env'; import { getImageResizingContextId } from '@/lib/images'; import { MiddlewareHeaders } from '@/lib/middleware'; -import { removeLeadingSlash, removeTrailingSlash, withLeadingSlash } from '@/lib/paths'; +import { removeLeadingSlash, removeTrailingSlash } from '@/lib/paths'; import { type ResponseCookies, getPathScopedCookieName, @@ -172,16 +172,10 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) { // request.headers.delete('x-gitbook-disable-tracking'); - // For feed requests, strip `/feed.xml` from the lookup URL so we're still targeting the actual page for content resolution. - const rssSuffixMatch = siteRequestURL.pathname.match(/^(.*)\/feed\.xml$/); - const siteRequestURLForLookup = rssSuffixMatch?.[1] - ? new URL(siteRequestURL.toString().replace(/feed\.xml$/, '')) - : siteRequestURL; - const withAPIToken = async (apiToken: string | null) => { const siteURLData = await throwIfDataError( lookupPublishedContentByUrl({ - url: siteRequestURLForLookup.toString(), + url: siteRequestURL.toString(), visitorPayload: { jwtToken: visitorToken?.token ?? undefined, unsignedClaims, @@ -365,31 +359,10 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) { requestHeaders.set('origin', request.nextUrl.origin); const siteURLWithoutProtocol = `${siteCanonicalURL.host}${siteURLData.basePath}`; - const basePathNormalized = withLeadingSlash( - removeTrailingSlash(siteURLData.basePath || '') + const { pathname, routeType: routeTypeFromPathname } = encodePathInSiteContent( + siteURLData.pathname ); - const lookupPathname = siteRequestURLForLookup.pathname; - let pagePathRelative = - siteURLData.pathname && siteURLData.pathname.length > 0 - ? siteURLData.pathname - : lookupPathname; - if (lookupPathname.startsWith(basePathNormalized)) { - pagePathRelative = lookupPathname.slice(basePathNormalized.length) || '/'; - } - - let pathForEncoding = pagePathRelative; - let forcedRouteType: 'static' | undefined; - - if (rssSuffixMatch) { - const normalizedPagePath = - pagePathRelative === '/' ? '%2F' : removeLeadingSlash(pagePathRelative); - pathForEncoding = `~gitbook/rss/${normalizedPagePath}`; - forcedRouteType = 'static'; - } - - const { pathname, routeType: routeTypeFromPathname } = - encodePathInSiteContent(pathForEncoding); - routeType = forcedRouteType ?? routeTypeFromPathname ?? routeType; + routeType = routeTypeFromPathname ?? routeType; const route = [ 'sites', @@ -601,17 +574,6 @@ function encodePathInSiteContent(rawPathname: string): { return { pathname }; } - // RSS feeds: either `/.../feed.xml` (external) or already `~gitbook/rss/...`. - const rssPath = - (!pathname.startsWith('~gitbook/') && pathname.match(/^(.*)\/feed\.xml$/)?.[1]) || - pathname.match(/^~gitbook\/rss\/(.+)$/)?.[1]; - if (rssPath !== undefined) { - return { - pathname: `~gitbook/rss/${encodeURIComponent(rssPath)}`, - routeType: 'static', - }; - } - // If the pathname is a markdown file, we rewrite it to ~gitbook/markdown/:pathname if (pathname.match(/\.md$/)) { const pagePathWithoutMD = pathname.slice(0, -3); From b2fdd65aa310b91ec2629ee9ab952ddcf1c9a358 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samy=20Pess=C3=A9?= Date: Wed, 26 Nov 2025 10:52:03 +0100 Subject: [PATCH 04/14] Match it in the middleware --- packages/gitbook/src/middleware.ts | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/packages/gitbook/src/middleware.ts b/packages/gitbook/src/middleware.ts index a6920e490a..e5637a8791 100644 --- a/packages/gitbook/src/middleware.ts +++ b/packages/gitbook/src/middleware.ts @@ -560,6 +560,11 @@ function getSiteURLFromRequest(request: NextRequest): URLWithMode | null { return null; } +const RSS_PATH_REGEX = /^((\S+)\/)?feed\.xml$/; +const MARKDOWN_PATH_REGEX = /\.md$/; +const LLMS_FULL_PATH_REGEX = /^llms-full\.txt\/\d+$/; +const EMBED_PAGE_PATH_REGEX = /^~gitbook\/embed\/page(\/(\S*))?$/; + /** * Encode path in a site content. * Special paths are not encoded and passed to be handled by the route handlers. @@ -574,8 +579,17 @@ function encodePathInSiteContent(rawPathname: string): { return { pathname }; } + // If the pathname is a RSS feed (/.../feed.xml), we rewrite it to ~gitbook/rss/:pathname + const rssMatch = pathname.match(RSS_PATH_REGEX); + if (rssMatch) { + return { + pathname: `~gitbook/rss/${encodeURIComponent(rssMatch[2] ?? '')}`, + routeType: 'static', + }; + } + // If the pathname is a markdown file, we rewrite it to ~gitbook/markdown/:pathname - if (pathname.match(/\.md$/)) { + if (pathname.match(MARKDOWN_PATH_REGEX)) { const pagePathWithoutMD = pathname.slice(0, -3); return { pathname: `~gitbook/markdown/${encodeURIComponent(pagePathWithoutMD)}`, @@ -585,12 +599,12 @@ function encodePathInSiteContent(rawPathname: string): { } // We skip encoding for paginated llms-full.txt pages (i.e. llms-full.txt/100) - if (pathname.match(/^llms-full\.txt\/\d+$/)) { + if (pathname.match(LLMS_FULL_PATH_REGEX)) { return { pathname, routeType: 'static' }; } // If the pathname is an embedded page - const embedPage = pathname.match(/^~gitbook\/embed\/page(\/(\S*))?$/); + const embedPage = pathname.match(EMBED_PAGE_PATH_REGEX); if (embedPage) { return { pathname: `~gitbook/embed/page/${encodeURIComponent(embedPage[1] || '/')}`, From fe087e79ba30a7f5d02e307faba39ff16f37bbe8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samy=20Pess=C3=A9?= Date: Wed, 26 Nov 2025 11:02:54 +0100 Subject: [PATCH 05/14] Add some tests --- bun.lock | 11 ++++++- packages/gitbook/package.json | 3 +- .../~gitbook/rss/[pagePath]/route.ts | 2 +- .../src/components/SitePage/SitePage.tsx | 30 ++++++++++++------- .../gitbook/src/routes/{rssPage.ts => rss.ts} | 3 +- packages/gitbook/tests/rss.test.ts | 22 ++++++++++++++ 6 files changed, 57 insertions(+), 14 deletions(-) rename packages/gitbook/src/routes/{rssPage.ts => rss.ts} (96%) create mode 100644 packages/gitbook/tests/rss.test.ts diff --git a/bun.lock b/bun.lock index ff71fa560b..2f67c7d8cb 100644 --- a/bun.lock +++ b/bun.lock @@ -207,6 +207,7 @@ "env-cmd": "^10.1.0", "jsonwebtoken": "^9.0.2", "postcss": "^8", + "rss-parser": "^3.13.0", "stylelint": "^16.16.0", "tailwindcss": "^4.1.11", "ts-essentials": "^10.0.1", @@ -1906,7 +1907,7 @@ "enquirer": ["enquirer@2.4.1", "", { "dependencies": { "ansi-colors": "^4.1.1", "strip-ansi": "^6.0.1" } }, "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ=="], - "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + "entities": ["entities@2.2.0", "", {}, "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A=="], "env-cmd": ["env-cmd@10.1.0", "", { "dependencies": { "commander": "^4.0.0", "cross-spawn": "^7.0.0" }, "bin": { "env-cmd": "bin/env-cmd.js" } }, "sha512-mMdWTT9XKN7yNth/6N6g2GuKuJTsKMDHlQFUDacb/heQRRWOTIZ42t1rMHnQu4jYxU1ajdTeJM+9eEETlqToMA=="], @@ -2736,6 +2737,8 @@ "router": ["router@2.0.0", "", { "dependencies": { "array-flatten": "3.0.0", "is-promise": "4.0.0", "methods": "~1.1.2", "parseurl": "~1.3.3", "path-to-regexp": "^8.0.0", "setprototypeof": "1.2.0", "utils-merge": "1.0.1" } }, "sha512-dIM5zVoG8xhC6rnSN8uoAgFARwTE7BQs8YwHEvK0VCmfxQXMaOuA1uiR1IPwsW7JyK5iTt7Od/TC9StasS2NPQ=="], + "rss-parser": ["rss-parser@3.13.0", "", { "dependencies": { "entities": "^2.0.3", "xml2js": "^0.5.0" } }, "sha512-7jWUBV5yGN3rqMMj7CZufl/291QAhvrrGpDNE4k/02ZchL0npisiYYqULF71jCEKoIiHvK/Q2e6IkDwPziT7+w=="], + "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], @@ -3034,6 +3037,10 @@ "xml-js": ["xml-js@1.6.11", "", { "dependencies": { "sax": "^1.2.4" }, "bin": { "xml-js": "./bin/cli.js" } }, "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g=="], + "xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="], + + "xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="], + "xxhash-wasm": ["xxhash-wasm@1.1.0", "", {}, "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA=="], "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], @@ -4118,6 +4125,8 @@ "globby/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + "htmlparser2/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + "https-proxy-agent/debug": ["debug@4.4.0", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA=="], "import-fresh/resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], diff --git a/packages/gitbook/package.json b/packages/gitbook/package.json index e24b8f7caf..d7e63777ac 100644 --- a/packages/gitbook/package.json +++ b/packages/gitbook/package.json @@ -105,7 +105,8 @@ "ts-essentials": "^10.0.1", "typescript": "catalog:", "vercel": "^39.3.0", - "wrangler": "^4.43.0" + "wrangler": "^4.43.0", + "rss-parser": "^3.13.0" }, "scripts": { "generate": "./scripts/generate.sh", diff --git a/packages/gitbook/src/app/sites/static/[mode]/[siteURL]/[siteData]/~gitbook/rss/[pagePath]/route.ts b/packages/gitbook/src/app/sites/static/[mode]/[siteURL]/[siteData]/~gitbook/rss/[pagePath]/route.ts index 3c86c8695a..9499facf79 100644 --- a/packages/gitbook/src/app/sites/static/[mode]/[siteURL]/[siteData]/~gitbook/rss/[pagePath]/route.ts +++ b/packages/gitbook/src/app/sites/static/[mode]/[siteURL]/[siteData]/~gitbook/rss/[pagePath]/route.ts @@ -1,5 +1,5 @@ import { type RouteParams, getPagePathFromParams, getStaticSiteContext } from '@/app/utils'; -import { servePageRSS } from '@/routes/rssPage'; +import { servePageRSS } from '@/routes/rss'; import type { NextRequest } from 'next/server'; export const dynamic = 'force-static'; diff --git a/packages/gitbook/src/components/SitePage/SitePage.tsx b/packages/gitbook/src/components/SitePage/SitePage.tsx index a321ca000c..e8bbbd6ac6 100644 --- a/packages/gitbook/src/components/SitePage/SitePage.tsx +++ b/packages/gitbook/src/components/SitePage/SitePage.tsx @@ -3,6 +3,7 @@ import { getDataOrNull, getPageDocument } from '@/lib/data'; import { CustomizationHeaderPreset, CustomizationThemeMode, + type RevisionPageDocument, SiteInsightsDisplayContext, type TranslationLanguage, } from '@gitbook/api'; @@ -155,8 +156,7 @@ export async function generateSitePageMetadata(props: SitePageProps): Promise for a page. + */ +export function getPageFullTitle(context: GitBookSiteContext, page: RevisionPageDocument) { + const { site } = context; + const siteStructureTitle = getSiteStructureTitle(context); + + return [ + page.title, + // Prevent duplicate titles by comparing against the page title. + page.title !== siteStructureTitle ? siteStructureTitle : null, // The first page of a section is often the same as the section title, so we don't need to show it. + page.title !== site.title ? site.title : null, // The site title can also be the same as the site title on the site's landing page. + ] + .filter(Boolean) + .join(' | '); +} diff --git a/packages/gitbook/src/routes/rssPage.ts b/packages/gitbook/src/routes/rss.ts similarity index 96% rename from packages/gitbook/src/routes/rssPage.ts rename to packages/gitbook/src/routes/rss.ts index ce6e5312c2..366a93d8c2 100644 --- a/packages/gitbook/src/routes/rssPage.ts +++ b/packages/gitbook/src/routes/rss.ts @@ -1,3 +1,4 @@ +import { getPageFullTitle } from '@/components/SitePage'; import type { GitBookSiteContext } from '@/lib/context'; import { getPageDocument } from '@/lib/data/pages'; import { getBlocksByType, getNodeText, isHeadingBlock } from '@/lib/document'; @@ -44,7 +45,7 @@ export async function servePageRSS( // Create RSS feed const feed = new Feed({ id: page.id, - title: page.title, + title: getPageFullTitle(context, page), description: page.description || `Updates feed for ${page.title}`, link: pageURL, copyright: `Copyright ${new Date().getFullYear()}`, diff --git a/packages/gitbook/tests/rss.test.ts b/packages/gitbook/tests/rss.test.ts new file mode 100644 index 0000000000..f6d42ec9ec --- /dev/null +++ b/packages/gitbook/tests/rss.test.ts @@ -0,0 +1,22 @@ +import { expect, it } from 'bun:test'; +import Parser from 'rss-parser'; +import { getContentTestURL } from './utils'; + +it('should expose a RSS feed for a page with updates', async () => { + const parser = new Parser(); + const feedURL = getContentTestURL( + 'https://gitbook.gitbook.io/test-gitbook-open/blocks/updates/feed.xml' + ); + const feed = await parser.parseURL(feedURL); + + expect(feed.title).toBe('Updates | Test GitBook Open'); + expect(feed.items.length).toBe(4); +}); + +it('should not expose a RSS feed for a page without updates', async () => { + const feedURL = getContentTestURL( + 'https://gitbook.gitbook.io/test-gitbook-open/text-page/feed.xml' + ); + const response = await fetch(feedURL); + expect(response.status).toBe(404); +}); From 65ff95cff08c27d8cc10f8b4cf8e6145bd324482 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samy=20Pess=C3=A9?= Date: Wed, 26 Nov 2025 11:03:34 +0100 Subject: [PATCH 06/14] Add e2e tests for updates --- packages/gitbook/e2e/internal.spec.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/gitbook/e2e/internal.spec.ts b/packages/gitbook/e2e/internal.spec.ts index d4b33458c2..bc86eb7188 100644 --- a/packages/gitbook/e2e/internal.spec.ts +++ b/packages/gitbook/e2e/internal.spec.ts @@ -880,6 +880,11 @@ const testCases: TestsCase[] = [ url: 'blocks/cards', fullPage: true, }, + { + name: 'Updates', + url: 'blocks/updates', + fullPage: true, + }, { name: 'Math', url: 'blocks/math', From dcd2e7f9447a40aba2b48e755a8a5c96f108c5e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samy=20Pess=C3=A9?= Date: Wed, 26 Nov 2025 11:49:31 +0100 Subject: [PATCH 07/14] Show dropdown action for RSS --- .../components/PageActions/PageActions.tsx | 19 ++++++++++++++ .../PageActions/PageActionsDropdown.tsx | 13 +++++++--- .../src/components/PageBody/PageBody.tsx | 14 ++++++++-- .../src/components/PageBody/PageHeader.tsx | 26 ++++++++++++------- packages/gitbook/src/intl/translations/de.ts | 2 ++ packages/gitbook/src/intl/translations/en.ts | 2 ++ packages/gitbook/src/intl/translations/es.ts | 2 ++ packages/gitbook/src/intl/translations/fr.ts | 2 ++ packages/gitbook/src/intl/translations/it.ts | 2 ++ packages/gitbook/src/intl/translations/ja.ts | 2 ++ packages/gitbook/src/intl/translations/nl.ts | 2 ++ packages/gitbook/src/intl/translations/no.ts | 2 ++ .../gitbook/src/intl/translations/pt-br.ts | 2 ++ packages/gitbook/src/intl/translations/ru.ts | 2 ++ packages/gitbook/src/intl/translations/zh.ts | 2 ++ packages/gitbook/src/lib/document.tsx | 22 +++++++++++++--- packages/gitbook/src/lib/links.ts | 2 +- 17 files changed, 99 insertions(+), 19 deletions(-) diff --git a/packages/gitbook/src/components/PageActions/PageActions.tsx b/packages/gitbook/src/components/PageActions/PageActions.tsx index e536c9604b..35cff4a8ec 100644 --- a/packages/gitbook/src/components/PageActions/PageActions.tsx +++ b/packages/gitbook/src/components/PageActions/PageActions.tsx @@ -289,6 +289,25 @@ export function ActionViewAsPDF(props: { url: string; type: PageActionType }) { ); } +/** + * Action to view the page as an RSS feed. + */ +export function ActionViewAsRSS(props: { url: string; type: PageActionType }) { + const { url, type } = props; + const language = useLanguage(); + + return ( + + ); +} + /** * Action to copy a string to the clipboard. */ diff --git a/packages/gitbook/src/components/PageActions/PageActionsDropdown.tsx b/packages/gitbook/src/components/PageActions/PageActionsDropdown.tsx index b39df21115..d0ab2c69fa 100644 --- a/packages/gitbook/src/components/PageActions/PageActionsDropdown.tsx +++ b/packages/gitbook/src/components/PageActions/PageActionsDropdown.tsx @@ -16,6 +16,7 @@ import { ActionOpenMCP, ActionViewAsMarkdown, ActionViewAsPDF, + ActionViewAsRSS, } from './PageActions'; export type PageActionsDropdownURLs = { @@ -23,6 +24,7 @@ export type PageActionsDropdownURLs = { markdown: string; mcp?: string; pdf?: string; + rss?: string; editOnGit?: { provider: GitSyncState['installationProvider']; url: string; @@ -43,7 +45,7 @@ export function PageActionsDropdown(props: PageActionsDropdownProps) { const ref = useRef(null); const language = useLanguage(); - const defaultAction = getPageDefaultAction(props); + const defaultAction = usePageDefaultAction(props); const dropdownActions = getPageDropdownActions(props); return defaultAction || dropdownActions.length > 0 ? ( @@ -127,7 +129,7 @@ function getPageDropdownActions(props: PageActionsDropdownProps): React.ReactNod ) : null, - urls.editOnGit || urls.pdf ? ( + urls.editOnGit || urls.pdf || urls.rss ? ( {urls.editOnGit ? ( @@ -137,6 +139,7 @@ function getPageDropdownActions(props: PageActionsDropdownProps): React.ReactNod url={urls.editOnGit.url} /> ) : null} + {urls.rss ? : null} {urls.pdf ? : null} ) : null, @@ -146,12 +149,16 @@ function getPageDropdownActions(props: PageActionsDropdownProps): React.ReactNod /** * A default action shown as a quick-access button beside the dropdown menu */ -function getPageDefaultAction(props: PageActionsDropdownProps) { +function usePageDefaultAction(props: PageActionsDropdownProps) { const { urls, actions } = props; const assistants = useAI().assistants.filter( (assistant) => assistant.ui === true && assistant.pageAction ); + if (urls.rss) { + return ; + } + const assistant = assistants[0]; if (assistant) { return ; diff --git a/packages/gitbook/src/components/PageBody/PageBody.tsx b/packages/gitbook/src/components/PageBody/PageBody.tsx index 15e129c9ac..94435f5cf8 100644 --- a/packages/gitbook/src/components/PageBody/PageBody.tsx +++ b/packages/gitbook/src/components/PageBody/PageBody.tsx @@ -4,7 +4,7 @@ import React from 'react'; import { getSpaceLanguage } from '@/intl/server'; import { t } from '@/intl/translate'; -import { hasFullWidthBlock, hasMoreThan, isNodeEmpty } from '@/lib/document'; +import { hasFullWidthBlock, hasMoreThan, hasTopLevelBlock, isNodeEmpty } from '@/lib/document'; import type { AncestorRevisionPage } from '@/lib/pages'; import { tcls } from '@/lib/tailwind'; import { DocumentView, DocumentViewSkeleton } from '../DocumentView'; @@ -33,6 +33,11 @@ export function PageBody(props: { const contentFullWidth = document ? hasFullWidthBlock(document) : false; + // Update blocks can only be at the top level of the document, so we optimize the check. + const contentHasUpdates = document + ? hasTopLevelBlock(document, (block) => block.type === 'updates') + : false; + // Render link previews only if there are less than LINK_PREVIEW_MAX_COUNT links in the document. const withLinkPreviews = document ? !hasMoreThan( @@ -72,7 +77,12 @@ export function PageBody(props: { ) : null} - + {document && !isNodeEmpty(document) ? ( { + if (block.data && 'fullWidth' in block.data && block.data.fullWidth) { return true; } - if (node.type === 'swagger' || node.type === 'openapi-operation') { + if (block.type === 'swagger' || block.type === 'openapi-operation') { return true; } - } + return false; + }); +} +/** + * Check if a top level block matches a predicate. + */ +export function hasTopLevelBlock( + document: JSONDocument, + predicate: (block: DocumentBlock) => boolean +): boolean { + for (const node of document.nodes) { + if (node.object === 'block' && predicate(node)) { + return true; + } + } return false; } diff --git a/packages/gitbook/src/lib/links.ts b/packages/gitbook/src/lib/links.ts index 81a7d1ddc9..b3fb942ea0 100644 --- a/packages/gitbook/src/lib/links.ts +++ b/packages/gitbook/src/lib/links.ts @@ -37,7 +37,7 @@ export interface GitBookLinker { /** * Generate an absolute path for a page in the current content. - * The result should NOT be passed to `toPathInContent`. + * The result should NOT be passed to `toPathInSpace`. */ toPathForPage(input: { pages: RevisionPage[]; From 8f6907c14049baf6bb92ec296a79658666448a1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samy=20Pess=C3=A9?= Date: Wed, 26 Nov 2025 11:50:12 +0100 Subject: [PATCH 08/14] Rename to rss.xml --- packages/gitbook/src/components/PageBody/PageHeader.tsx | 2 +- packages/gitbook/src/middleware.ts | 4 ++-- packages/gitbook/src/routes/rss.ts | 2 +- packages/gitbook/tests/rss.test.ts | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/gitbook/src/components/PageBody/PageHeader.tsx b/packages/gitbook/src/components/PageBody/PageHeader.tsx index 6d577cc8df..eb216dc40f 100644 --- a/packages/gitbook/src/components/PageBody/PageHeader.tsx +++ b/packages/gitbook/src/components/PageBody/PageHeader.tsx @@ -144,7 +144,7 @@ function getPageActionsURLs({ html: context.linker.toAbsoluteURL(pagePath), // For the markdown URL, we use the page.path to ensure it works for the default page. markdown: `${context.linker.toAbsoluteURL(context.linker.toPathInSpace(page.path))}.md`, - rss: withRSSFeed ? context.linker.toAbsoluteURL(joinPath(pagePath, 'feed.xml')) : undefined, + rss: withRSSFeed ? context.linker.toAbsoluteURL(joinPath(pagePath, 'rss.xml')) : undefined, editOnGit: context.customization.git.showEditLink && context.space.gitSync?.url && page.git ? { diff --git a/packages/gitbook/src/middleware.ts b/packages/gitbook/src/middleware.ts index e5637a8791..09b44f9f45 100644 --- a/packages/gitbook/src/middleware.ts +++ b/packages/gitbook/src/middleware.ts @@ -560,7 +560,7 @@ function getSiteURLFromRequest(request: NextRequest): URLWithMode | null { return null; } -const RSS_PATH_REGEX = /^((\S+)\/)?feed\.xml$/; +const RSS_PATH_REGEX = /^((\S+)\/)?rss\.xml$/; const MARKDOWN_PATH_REGEX = /\.md$/; const LLMS_FULL_PATH_REGEX = /^llms-full\.txt\/\d+$/; const EMBED_PAGE_PATH_REGEX = /^~gitbook\/embed\/page(\/(\S*))?$/; @@ -579,7 +579,7 @@ function encodePathInSiteContent(rawPathname: string): { return { pathname }; } - // If the pathname is a RSS feed (/.../feed.xml), we rewrite it to ~gitbook/rss/:pathname + // If the pathname is a RSS feed (/.../rss.xml), we rewrite it to ~gitbook/rss/:pathname const rssMatch = pathname.match(RSS_PATH_REGEX); if (rssMatch) { return { diff --git a/packages/gitbook/src/routes/rss.ts b/packages/gitbook/src/routes/rss.ts index 366a93d8c2..c852418072 100644 --- a/packages/gitbook/src/routes/rss.ts +++ b/packages/gitbook/src/routes/rss.ts @@ -38,7 +38,7 @@ export async function servePageRSS( // Get page URL const pagePath = context.linker.toPathForPage({ pages: context.revision.pages, page }); const pageURL = context.linker.toAbsoluteURL(pagePath); - const rssPath = joinPath(pagePath, 'feed.xml'); + const rssPath = joinPath(pagePath, 'rss.xml'); const rssURL = context.linker.toAbsoluteURL(rssPath); const docsURL = context.linker.toAbsoluteURL(context.linker.toPathInSite('/')); diff --git a/packages/gitbook/tests/rss.test.ts b/packages/gitbook/tests/rss.test.ts index f6d42ec9ec..7172b4ae7e 100644 --- a/packages/gitbook/tests/rss.test.ts +++ b/packages/gitbook/tests/rss.test.ts @@ -5,7 +5,7 @@ import { getContentTestURL } from './utils'; it('should expose a RSS feed for a page with updates', async () => { const parser = new Parser(); const feedURL = getContentTestURL( - 'https://gitbook.gitbook.io/test-gitbook-open/blocks/updates/feed.xml' + 'https://gitbook.gitbook.io/test-gitbook-open/blocks/updates/rss.xml' ); const feed = await parser.parseURL(feedURL); @@ -15,7 +15,7 @@ it('should expose a RSS feed for a page with updates', async () => { it('should not expose a RSS feed for a page without updates', async () => { const feedURL = getContentTestURL( - 'https://gitbook.gitbook.io/test-gitbook-open/text-page/feed.xml' + 'https://gitbook.gitbook.io/test-gitbook-open/text-page/rss.xml' ); const response = await fetch(feedURL); expect(response.status).toBe(404); From c9f1fbdb8f0f23939d2429cd183e6463bc5d0242 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samy=20Pess=C3=A9?= Date: Wed, 26 Nov 2025 12:00:45 +0100 Subject: [PATCH 09/14] Add metadata on page --- .../gitbook/src/components/PageBody/PageHeader.tsx | 4 ++-- .../gitbook/src/components/SitePage/SitePage.tsx | 4 ++++ packages/gitbook/src/routes/rss.ts | 13 ++++++++++--- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/packages/gitbook/src/components/PageBody/PageHeader.tsx b/packages/gitbook/src/components/PageBody/PageHeader.tsx index eb216dc40f..a4cb9218cf 100644 --- a/packages/gitbook/src/components/PageBody/PageHeader.tsx +++ b/packages/gitbook/src/components/PageBody/PageHeader.tsx @@ -1,7 +1,7 @@ import type { GitBookSiteContext } from '@/lib/context'; import type { AncestorRevisionPage } from '@/lib/pages'; -import { joinPath } from '@/lib/paths'; import { tcls } from '@/lib/tailwind'; +import { getPageRSSURL } from '@/routes/rss'; import { type RevisionPageDocument, SiteVisibility } from '@gitbook/api'; import { Icon } from '@gitbook/icons'; import urlJoin from 'url-join'; @@ -144,7 +144,7 @@ function getPageActionsURLs({ html: context.linker.toAbsoluteURL(pagePath), // For the markdown URL, we use the page.path to ensure it works for the default page. markdown: `${context.linker.toAbsoluteURL(context.linker.toPathInSpace(page.path))}.md`, - rss: withRSSFeed ? context.linker.toAbsoluteURL(joinPath(pagePath, 'rss.xml')) : undefined, + rss: withRSSFeed ? getPageRSSURL(context, page) : undefined, editOnGit: context.customization.git.showEditLink && context.space.gitSync?.url && page.git ? { diff --git a/packages/gitbook/src/components/SitePage/SitePage.tsx b/packages/gitbook/src/components/SitePage/SitePage.tsx index e8bbbd6ac6..e90adb51ea 100644 --- a/packages/gitbook/src/components/SitePage/SitePage.tsx +++ b/packages/gitbook/src/components/SitePage/SitePage.tsx @@ -18,6 +18,7 @@ import { isPageIndexable, isSiteIndexable } from '@/lib/seo'; import { getResizedImageURL } from '@/lib/images'; import { resolveContentRef } from '@/lib/references'; import { tcls } from '@/lib/tailwind'; +import { getPageRSSURL } from '@/routes/rss'; import { PageContextProvider } from '../PageContext'; import { PageClientLayout } from './PageClientLayout'; import { type PagePathParams, fetchPageData, getPathnameParam } from './fetch'; @@ -200,6 +201,9 @@ export async function generateSitePageMetadata(props: SitePageProps): Promise // Team at Vercel is aware of this and will ensure it will be omitted when the value is empty in future versions of Next.js // https://gitbook.slack.com/archives/C04K6MV5W1K/p1763034072958419?thread_ts=1762937203.511629&cid=C04K6MV5W1K diff --git a/packages/gitbook/src/routes/rss.ts b/packages/gitbook/src/routes/rss.ts index c852418072..ffd8ebc641 100644 --- a/packages/gitbook/src/routes/rss.ts +++ b/packages/gitbook/src/routes/rss.ts @@ -4,9 +4,17 @@ import { getPageDocument } from '@/lib/data/pages'; import { getBlocksByType, getNodeText, isHeadingBlock } from '@/lib/document'; import { resolvePagePathDocumentOrGroup } from '@/lib/pages'; import { joinPath } from '@/lib/paths'; -import { RevisionPageType } from '@gitbook/api'; +import { type RevisionPageDocument, RevisionPageType } from '@gitbook/api'; import { Feed } from 'feed'; +/** + * Get the URL of a RSS feed for a page. + */ +export function getPageRSSURL(context: GitBookSiteContext, page: RevisionPageDocument): string { + const pagePath = context.linker.toPathForPage({ pages: context.revision.pages, page }); + return context.linker.toAbsoluteURL(joinPath(pagePath, 'rss.xml')); +} + /** * Generate an RSS feed from Updates blocks in a page. */ @@ -38,8 +46,7 @@ export async function servePageRSS( // Get page URL const pagePath = context.linker.toPathForPage({ pages: context.revision.pages, page }); const pageURL = context.linker.toAbsoluteURL(pagePath); - const rssPath = joinPath(pagePath, 'rss.xml'); - const rssURL = context.linker.toAbsoluteURL(rssPath); + const rssURL = getPageRSSURL(context, page); const docsURL = context.linker.toAbsoluteURL(context.linker.toPathInSite('/')); // Create RSS feed From add63da0b1d4e0f1615df51929f07e085b4b9868 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samy=20Pess=C3=A9?= Date: Wed, 26 Nov 2025 12:02:10 +0100 Subject: [PATCH 10/14] Changeset --- .changeset/tender-windows-double.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/tender-windows-double.md diff --git a/.changeset/tender-windows-double.md b/.changeset/tender-windows-double.md new file mode 100644 index 0000000000..c68e0f0129 --- /dev/null +++ b/.changeset/tender-windows-double.md @@ -0,0 +1,5 @@ +--- +"gitbook": minor +--- + +Add a RSS feed to all pages with update blocks. From 637c8a8dd7854785494e6b8db822d5bf829f5d9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samy=20Pess=C3=A9?= Date: Wed, 26 Nov 2025 12:36:27 +0100 Subject: [PATCH 11/14] Fix type --- packages/gitbook/src/routes/rss.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/gitbook/src/routes/rss.ts b/packages/gitbook/src/routes/rss.ts index ffd8ebc641..d51c1e8cf9 100644 --- a/packages/gitbook/src/routes/rss.ts +++ b/packages/gitbook/src/routes/rss.ts @@ -21,7 +21,7 @@ export function getPageRSSURL(context: GitBookSiteContext, page: RevisionPageDoc export async function servePageRSS( context: GitBookSiteContext, inputPagePath: string -): Promise { +): Promise { const pageLookup = resolvePagePathDocumentOrGroup(context.revision.pages, inputPagePath); if (!pageLookup) { From 9d52270bdfaca2543627ec93ed2c64bcbe973961 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samy=20Pess=C3=A9?= Date: Wed, 26 Nov 2025 12:39:27 +0100 Subject: [PATCH 12/14] Simplify --- packages/gitbook/src/middleware.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/gitbook/src/middleware.ts b/packages/gitbook/src/middleware.ts index 09b44f9f45..979276e22a 100644 --- a/packages/gitbook/src/middleware.ts +++ b/packages/gitbook/src/middleware.ts @@ -583,7 +583,7 @@ function encodePathInSiteContent(rawPathname: string): { const rssMatch = pathname.match(RSS_PATH_REGEX); if (rssMatch) { return { - pathname: `~gitbook/rss/${encodeURIComponent(rssMatch[2] ?? '')}`, + pathname: `~gitbook/rss/${encodePagePath(rssMatch[2])}`, routeType: 'static', }; } @@ -592,7 +592,7 @@ function encodePathInSiteContent(rawPathname: string): { if (pathname.match(MARKDOWN_PATH_REGEX)) { const pagePathWithoutMD = pathname.slice(0, -3); return { - pathname: `~gitbook/markdown/${encodeURIComponent(pagePathWithoutMD)}`, + pathname: `~gitbook/markdown/${encodePagePath(pagePathWithoutMD)}`, // The markdown content is always static and doesn't depend on the dynamic parameter (customization, theme, etc) routeType: 'static', }; @@ -607,7 +607,7 @@ function encodePathInSiteContent(rawPathname: string): { const embedPage = pathname.match(EMBED_PAGE_PATH_REGEX); if (embedPage) { return { - pathname: `~gitbook/embed/page/${encodeURIComponent(embedPage[1] || '/')}`, + pathname: `~gitbook/embed/page/${encodePagePath(embedPage[1])}`, }; } @@ -629,10 +629,14 @@ function encodePathInSiteContent(rawPathname: string): { // PDF routes are always dynamic as they depend on the search params. return { pathname, routeType: 'dynamic' }; default: - return { pathname: encodeURIComponent(pathname || '/') }; + return { pathname: encodePagePath(pathname) }; } } +function encodePagePath(path: string | undefined): string { + return encodeURIComponent(path || '/'); +} + /** * Append all the query params from a URL to another URL. */ From 3503806c1dbca165b07382d9aae8ba276009a82d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samy=20Pess=C3=A9?= Date: Wed, 26 Nov 2025 12:50:53 +0100 Subject: [PATCH 13/14] Fix rss test --- packages/gitbook/tests/rss.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/gitbook/tests/rss.test.ts b/packages/gitbook/tests/rss.test.ts index 7172b4ae7e..580c7fd438 100644 --- a/packages/gitbook/tests/rss.test.ts +++ b/packages/gitbook/tests/rss.test.ts @@ -9,7 +9,7 @@ it('should expose a RSS feed for a page with updates', async () => { ); const feed = await parser.parseURL(feedURL); - expect(feed.title).toBe('Updates | Test GitBook Open'); + expect(feed.title).toBe('Updates | E2E Tests GitBook Open'); expect(feed.items.length).toBe(4); }); From 71908063871ca0f62c55d0119265f2a5ef4069e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samy=20Pess=C3=A9?= Date: Wed, 26 Nov 2025 12:54:43 +0100 Subject: [PATCH 14/14] Fix TS --- packages/gitbook/src/lib/document.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/gitbook/src/lib/document.tsx b/packages/gitbook/src/lib/document.tsx index 049eeed6b3..31833ff1bc 100644 --- a/packages/gitbook/src/lib/document.tsx +++ b/packages/gitbook/src/lib/document.tsx @@ -223,7 +223,7 @@ export function getBlocksByType( document: JSONDocument, type: Type ): Extract[] { - return findBlocks(document, (block) => { + return findBlocks(document, (block): block is Extract => { return block.type === type; }); } @@ -240,7 +240,7 @@ export function isHeadingBlock(block: DocumentBlock): block is DocumentBlockHead */ function findBlocks( container: JSONDocument | DocumentBlock | DocumentFragment, - test: (block: DocumentBlock) => boolean + test: (block: DocumentBlock) => block is Block ): Block[] { if (!('nodes' in container)) { return []; @@ -253,7 +253,6 @@ function findBlocks( } if (test(block)) { - // @ts-expect-error - we know that the block is of type Block results.push(block); }