Add RSS feed for pages with update blocks (#3824)

Co-authored-by: Samy Pessé <samypesse@gmail.com>
This commit is contained in:
Viktor Renkema
2025-11-26 20:34:25 +01:00
committed by GitHub
parent 5c14948cf3
commit 3553db0989
27 changed files with 379 additions and 39 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": minor
---
Add a RSS feed to all pages with update blocks.
+17 -1
View File
@@ -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",
@@ -206,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",
@@ -1905,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=="],
@@ -2003,6 +2005,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=="],
@@ -2733,12 +2737,16 @@
"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=="],
"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 +3035,12 @@
"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=="],
"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=="],
@@ -4111,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=="],
+5
View File
@@ -897,6 +897,11 @@ const testCases: TestsCase[] = [
url: 'blocks/cards',
fullPage: true,
},
{
name: 'Updates',
url: 'blocks/updates',
fullPage: true,
},
{
name: 'Math',
url: 'blocks/math',
+4 -2
View File
@@ -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",
@@ -104,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",
@@ -0,0 +1,12 @@
import { type RouteParams, getPagePathFromParams, getStaticSiteContext } from '@/app/utils';
import { servePageRSS } from '@/routes/rss';
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 servePageRSS(context, pathname);
}
@@ -5,6 +5,7 @@ import { type ClassValue, tcls } from '@/lib/tailwind';
import { getSpaceLanguage, tString } from '@/intl/server';
import { languages } from '@/intl/translations';
import { isHeadingBlock } from '@/lib/document';
import { Block, type BlockProps } from './Block';
import { Blocks } from './Blocks';
import { getBlockTextStyle } from './spacing';
@@ -18,7 +19,7 @@ export function Hint({
const hintStyle = HINT_STYLES[block.data.style] ?? HINT_STYLES.info;
const firstNode = block.nodes[0]!;
const firstLine = getBlockTextStyle(firstNode);
const hasHeading = ['heading-1', 'heading-2', 'heading-3'].includes(firstNode.type);
const hasHeading = isHeadingBlock(firstNode);
const language = contextProps.context.contentContext
? getSpaceLanguage(contextProps.context.contentContext)
@@ -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 (
<PageActionWrapper
type={type}
icon="rss"
label={tString(language, 'rss_feed')}
description={tString(language, 'open_rss_feed')}
href={url}
target="_blank"
/>
);
}
/**
* Action to copy a string to the clipboard.
*/
@@ -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<HTMLDivElement>(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
</React.Fragment>
) : null,
urls.editOnGit || urls.pdf ? (
urls.editOnGit || urls.pdf || urls.rss ? (
<React.Fragment key="editOnGit">
<DropdownMenuSeparator className="first:hidden" />
{urls.editOnGit ? (
@@ -137,6 +139,7 @@ function getPageDropdownActions(props: PageActionsDropdownProps): React.ReactNod
url={urls.editOnGit.url}
/>
) : null}
{urls.rss ? <ActionViewAsRSS url={urls.rss} type="dropdown-menu-item" /> : null}
{urls.pdf ? <ActionViewAsPDF url={urls.pdf} type="dropdown-menu-item" /> : null}
</React.Fragment>
) : 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 <ActionViewAsRSS url={urls.rss} type="button" />;
}
const assistant = assistants[0];
if (assistant) {
return <ActionOpenAssistant assistant={assistant} type="button" />;
@@ -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: {
<PageCover as="hero" page={page} cover={page.cover} context={context} />
) : null}
<PageHeader context={context} page={page} ancestors={ancestors} />
<PageHeader
context={context}
page={page}
ancestors={ancestors}
withRSSFeed={contentHasUpdates}
/>
{document && !isNodeEmpty(document) ? (
<React.Suspense
fallback={
@@ -1,6 +1,7 @@
import type { GitBookSiteContext } from '@/lib/context';
import type { AncestorRevisionPage } from '@/lib/pages';
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';
@@ -16,8 +17,9 @@ export async function PageHeader(props: {
context: GitBookSiteContext;
page: RevisionPageDocument;
ancestors: AncestorRevisionPage[];
withRSSFeed: boolean;
}) {
const { context, page, ancestors } = props;
const { context, page, ancestors, withRSSFeed } = props;
const { revision, linker } = context;
if (!page.layout.title && !page.layout.description) {
@@ -43,7 +45,7 @@ export async function PageHeader(props: {
// Show page actions if *any* of the actions are enabled
<PageActionsDropdown
siteTitle={context.site.title}
urls={getPageActionsURLs(context, page)}
urls={getPageActionsURLs({ context, page, withRSSFeed })}
actions={context.customization.pageActions}
className={tcls(
'float-right ml-4 xl:max-2xl:page-api-block:mr-62',
@@ -128,15 +130,21 @@ export async function PageHeader(props: {
/**
* Return the URLs for the page actions.
*/
function getPageActionsURLs(
context: GitBookSiteContext,
page: RevisionPageDocument
): PageActionsDropdownURLs {
function getPageActionsURLs({
context,
page,
withRSSFeed,
}: {
context: GitBookSiteContext;
page: RevisionPageDocument;
withRSSFeed: boolean;
}): PageActionsDropdownURLs {
const pagePath = context.linker.toPathForPage({ pages: context.revision.pages, page });
return {
html: context.linker.toAbsoluteURL(
context.linker.toPathForPage({ pages: context.revision.pages, page })
),
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 ? getPageRSSURL(context, page) : undefined,
editOnGit:
context.customization.git.showEditLink && context.space.gitSync?.url && page.git
? {
@@ -3,6 +3,7 @@ import { getDataOrNull, getPageDocument } from '@/lib/data';
import {
CustomizationHeaderPreset,
CustomizationThemeMode,
type RevisionPageDocument,
SiteInsightsDisplayContext,
type TranslationLanguage,
} from '@gitbook/api';
@@ -17,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';
@@ -155,8 +157,7 @@ export async function generateSitePageMetadata(props: SitePageProps): Promise<Me
}
const { page, ancestors } = pageTarget;
const { site, customization, revision, linker, imageResizer } = context;
const siteStructureTitle = getSiteStructureTitle(context);
const { customization, revision, linker, imageResizer } = context;
const canonical = (
pageMetaLinks?.canonical
@@ -193,20 +194,16 @@ export async function generateSitePageMetadata(props: SitePageProps): Promise<Me
);
return {
title: [
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(' | '),
title: getPageFullTitle(context, page),
description: page.description ?? '',
alternates: {
canonical,
languages: alternates?.languages,
types: {
'text/markdown': `${linker.toAbsoluteURL(linker.toPathInSpace(page.path))}.md`,
// We always reference the RSS feed even if the page doesn't have updates blocks,
// It might result in 404, but we can't know here if the page has updates blocks.
'application/rss+xml': [{ url: getPageRSSURL(context, page), title: 'RSS Feed' }],
// Currently it will output with an empty "type" like <link rel="alternate" href="..." type />
// 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
@@ -383,3 +380,20 @@ function shouldResolveMetaLinks(siteId: string): boolean {
return Math.abs(hash % 100) < META_LINKS_PERCENTAGE_ROLLOUT;
}
/**
* Get the <title> 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(' | ');
}
@@ -74,6 +74,8 @@ export const de = {
unexpected_error:
'Entschuldigung, ein unerwarteter Fehler ist aufgetreten. Bitte versuchen Sie es später erneut.',
unexpected_error_retry: 'Erneut versuchen',
rss_feed: 'RSS-Feed',
open_rss_feed: 'Feed für diese Seite abonnieren',
pdf_download: 'Als PDF exportieren',
pdf_goback: 'Zurück zum Inhalt',
pdf_print: 'Drucken oder als PDF speichern',
@@ -72,6 +72,8 @@ export const en = {
unexpected_error_title: 'An error occurred',
unexpected_error: 'Sorry, an unexpected error has occurred. Please try again later.',
unexpected_error_retry: 'Try again',
rss_feed: 'RSS feed',
open_rss_feed: 'Subscribe to the feed for this page',
pdf_download: 'Export as PDF',
pdf_goback: 'Go back to content',
pdf_print: 'Print or Save as PDF',
@@ -75,6 +75,8 @@ export const es: TranslationLanguage = {
unexpected_error:
'Lo sentimos, ha ocurrido un error inesperado. Por favor, intenta de nuevo más tarde.',
unexpected_error_retry: 'Reintentar',
rss_feed: 'Feed RSS',
open_rss_feed: 'Suscribirse al feed de esta página',
pdf_download: 'Exportar como PDF',
pdf_goback: 'Volver al contenido',
pdf_print: 'Imprimir o Guardar como PDF',
@@ -71,6 +71,8 @@ export const fr = {
unexpected_error_title: 'Erreur inattendue',
unexpected_error: 'Désolé, une erreur est survenue. Veuillez réessayer plus tard.',
unexpected_error_retry: 'Réessayer',
rss_feed: 'Flux RSS',
open_rss_feed: 'Sabonner au flux de cette page',
pdf_download: 'Exporter en PDF',
pdf_goback: 'Retourner au contenu',
pdf_print: 'Imprimer ou enregistrer en PDF',
@@ -74,6 +74,8 @@ export const it: TranslationLanguage = {
unexpected_error_title: 'Si è verificato un errore',
unexpected_error: 'Spiacenti, si è verificato un errore imprevisto. Riprova più tardi.',
unexpected_error_retry: 'Riprova',
rss_feed: 'Feed RSS',
open_rss_feed: 'Iscriviti al feed di questa pagina',
pdf_download: 'Esporta in PDF',
pdf_goback: 'Torna al contenuto',
pdf_print: 'Stampa o salva come PDF',
@@ -74,6 +74,8 @@ export const ja: TranslationLanguage = {
unexpected_error_title: 'エラーが発生しました',
unexpected_error: '申し訳ありません、予期せぬエラーが発生しました。もう一度お試しください。',
unexpected_error_retry: '再試行',
rss_feed: 'RSSフィード',
open_rss_feed: 'このページのフィードを購読する',
pdf_download: 'PDFとしてエクスポート',
pdf_goback: 'コンテンツに戻る',
pdf_print: '印刷するかPDFとして保存',
@@ -74,6 +74,8 @@ export const nl: TranslationLanguage = {
unexpected_error_title: 'Er is een fout opgetreden',
unexpected_error: 'Sorry, er is een onverwachte fout opgetreden. Probeer het later opnieuw.',
unexpected_error_retry: 'Opnieuw proberen',
rss_feed: 'RSS-feed',
open_rss_feed: 'Abonneer je op de feed van deze pagina',
pdf_download: 'Exporteer als PDF',
pdf_goback: 'Ga terug naar inhoud',
pdf_print: 'Print of opslaan als PDF',
@@ -75,6 +75,8 @@ export const no: TranslationLanguage = {
unexpected_error_title: 'En feil oppstod',
unexpected_error: 'Beklager, en uventet feil har oppstått. Vennligst prøv igjen senere.',
unexpected_error_retry: 'Prøv igjen',
rss_feed: 'RSS-feed',
open_rss_feed: 'Abonner på feeden til denne siden',
pdf_download: 'Eksporter som PDF',
pdf_goback: 'Gå tilbake til innhold',
pdf_print: 'Skriv ut eller lagre som PDF',
@@ -73,6 +73,8 @@ export const pt_br = {
unexpected_error:
'Desculpe, aconteceu um erro inesperado. Por favor tente novamente mais tarde.',
unexpected_error_retry: ' Tentar novamente',
rss_feed: 'Feed RSS',
open_rss_feed: 'Assinar o feed desta página',
pdf_download: 'Exportar como PDF',
pdf_goback: 'Voltar ao conteúdo',
pdf_print: 'Imprimir ou salvar como PDF',
@@ -72,6 +72,8 @@ export const ru = {
unexpected_error_title: 'Произошла ошибка',
unexpected_error: 'Извините, произошла непредвиденная ошибка. Пожалуйста, попробуйте позже.',
unexpected_error_retry: 'Попробуйте снова',
rss_feed: 'RSS-лента',
open_rss_feed: 'Подписаться на ленту этой страницы',
pdf_download: 'Экспортировать как PDF',
pdf_goback: 'Вернуться к материалу',
pdf_print: 'Напечатать или сохранить как PDF',
@@ -72,6 +72,8 @@ export const zh: TranslationLanguage = {
unexpected_error_title: '发生错误',
unexpected_error: '抱歉,发生了意外的错误。请稍后再试。',
unexpected_error_retry: '重试',
rss_feed: 'RSS 源',
open_rss_feed: '订阅此页面的 RSS 源',
pdf_download: '导出为 PDF',
pdf_goback: '返回内容',
pdf_print: '打印或另存为 PDF',
+79 -4
View File
@@ -1,6 +1,7 @@
import { Emoji } from '@/components/primitives';
import type {
DocumentBlock,
DocumentBlockHeading,
DocumentFragment,
DocumentInline,
DocumentText,
@@ -20,15 +21,29 @@ export interface DocumentSection {
* Check if the document contains one block that should be rendered in full-width mode.
*/
export function hasFullWidthBlock(document: JSONDocument): boolean {
for (const node of document.nodes) {
if (node.data && 'fullWidth' in node.data && node.data.fullWidth) {
return hasTopLevelBlock(document, (block) => {
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;
}
@@ -201,6 +216,66 @@ export function getBlockById(document: JSONDocument, id: string): DocumentBlock
});
}
/**
* Get all block by a type in the document.
*/
export function getBlocksByType<Type extends DocumentBlock['type']>(
document: JSONDocument,
type: Type
): Extract<DocumentBlock, { type: Type }>[] {
return findBlocks(document, (block): block is Extract<DocumentBlock, { type: Type }> => {
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<Block extends DocumentBlock>(
container: JSONDocument | DocumentBlock | DocumentFragment,
test: (block: DocumentBlock) => block is Block
): Block[] {
if (!('nodes' in container)) {
return [];
}
const results: Block[] = [];
for (const block of container.nodes) {
if (block.object !== 'block') {
continue;
}
if (test(block)) {
results.push(block);
}
if (block.object === 'block' && 'nodes' in block) {
const result = findBlocks<Block>(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<Block>(fragment, test);
if (result.length > 0) {
results.push(...result);
}
}
}
}
return results;
}
/**
* Find a block by a predicate in the document.
*/
+1 -1
View File
@@ -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[];
+24 -6
View File
@@ -560,6 +560,11 @@ function getSiteURLFromRequest(request: NextRequest): URLWithMode | null {
return null;
}
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*))?$/;
/**
* Encode path in a site content.
* Special paths are not encoded and passed to be handled by the route handlers.
@@ -574,26 +579,35 @@ function encodePathInSiteContent(rawPathname: string): {
return { 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 {
pathname: `~gitbook/rss/${encodePagePath(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)}`,
pathname: `~gitbook/markdown/${encodePagePath(pagePathWithoutMD)}`,
// The markdown content is always static and doesn't depend on the dynamic parameter (customization, theme, etc)
routeType: 'static',
};
}
// 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] || '/')}`,
pathname: `~gitbook/embed/page/${encodePagePath(embedPage[1])}`,
};
}
@@ -615,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.
*/
+104
View File
@@ -0,0 +1,104 @@
import { getPageFullTitle } from '@/components/SitePage';
import type { GitBookSiteContext } from '@/lib/context';
import { getPageDocument } from '@/lib/data/pages';
import { getBlocksByType, getNodeText, isHeadingBlock } from '@/lib/document';
import { resolvePagePathDocumentOrGroup } from '@/lib/pages';
import { joinPath } from '@/lib/paths';
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.
*/
export async function servePageRSS(
context: GitBookSiteContext,
inputPagePath: string
): Promise<Response> {
const pageLookup = resolvePagePathDocumentOrGroup(context.revision.pages, inputPagePath);
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 notFoundResponse('Page is empty');
}
const updatesBlocks = getBlocksByType(document, 'updates');
if (updatesBlocks.length === 0) {
return notFoundResponse('No updates found in page');
}
// Get page URL
const pagePath = context.linker.toPathForPage({ pages: context.revision.pages, page });
const pageURL = context.linker.toAbsoluteURL(pagePath);
const rssURL = getPageRSSURL(context, page);
const docsURL = context.linker.toAbsoluteURL(context.linker.toPathInSite('/'));
// Create RSS feed
const feed = new Feed({
id: page.id,
title: getPageFullTitle(context, page),
description: page.description || `Updates feed for ${page.title}`,
link: pageURL,
copyright: `Copyright ${new Date().getFullYear()}`,
updated: new Date(page.updatedAt ?? page.createdAt ?? Date.now()),
feedLinks: {
rss: rssURL,
},
docs: docsURL,
});
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;
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: new Date(update.data.date),
});
});
});
return new Response(feed.rss2(), {
headers: {
'Content-Type': 'application/rss+xml; charset=utf-8',
},
});
}
function notFoundResponse(message: string) {
return new Response(message, {
status: 404,
headers: {
'Content-Type': 'text/plain; charset=utf-8',
},
});
}
+22
View File
@@ -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/rss.xml'
);
const feed = await parser.parseURL(feedURL);
expect(feed.title).toBe('Updates | E2E Tests 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/rss.xml'
);
const response = await fetch(feedURL);
expect(response.status).toBe(404);
});