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. diff --git a/bun.lock b/bun.lock index f61bbef285..2f67c7d8cb 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", @@ -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=="], diff --git a/packages/gitbook/e2e/internal.spec.ts b/packages/gitbook/e2e/internal.spec.ts index 4cb95e2b45..53903e5e18 100644 --- a/packages/gitbook/e2e/internal.spec.ts +++ b/packages/gitbook/e2e/internal.spec.ts @@ -897,6 +897,11 @@ const testCases: TestsCase[] = [ url: 'blocks/cards', fullPage: true, }, + { + name: 'Updates', + url: 'blocks/updates', + fullPage: true, + }, { name: 'Math', url: 'blocks/math', diff --git a/packages/gitbook/package.json b/packages/gitbook/package.json index e99583a26c..d7e63777ac 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", @@ -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", 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..9499facf79 --- /dev/null +++ b/packages/gitbook/src/app/sites/static/[mode]/[siteURL]/[siteData]/~gitbook/rss/[pagePath]/route.ts @@ -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 }) { + const { context } = await getStaticSiteContext(await params); + const pathname = getPagePathFromParams(await params); + + return servePageRSS(context, pathname); +} diff --git a/packages/gitbook/src/components/DocumentView/Hint.tsx b/packages/gitbook/src/components/DocumentView/Hint.tsx index 21272489a7..fc65638e2b 100644 --- a/packages/gitbook/src/components/DocumentView/Hint.tsx +++ b/packages/gitbook/src/components/DocumentView/Hint.tsx @@ -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) 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) ? ( // 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 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/intl/translations/de.ts b/packages/gitbook/src/intl/translations/de.ts index 6b90a9b1e1..82c15626e8 100644 --- a/packages/gitbook/src/intl/translations/de.ts +++ b/packages/gitbook/src/intl/translations/de.ts @@ -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', diff --git a/packages/gitbook/src/intl/translations/en.ts b/packages/gitbook/src/intl/translations/en.ts index d06e39a9c3..95f8608373 100644 --- a/packages/gitbook/src/intl/translations/en.ts +++ b/packages/gitbook/src/intl/translations/en.ts @@ -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', diff --git a/packages/gitbook/src/intl/translations/es.ts b/packages/gitbook/src/intl/translations/es.ts index 8f95415e25..f1d456bf35 100644 --- a/packages/gitbook/src/intl/translations/es.ts +++ b/packages/gitbook/src/intl/translations/es.ts @@ -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', diff --git a/packages/gitbook/src/intl/translations/fr.ts b/packages/gitbook/src/intl/translations/fr.ts index 9548630d95..34be244271 100644 --- a/packages/gitbook/src/intl/translations/fr.ts +++ b/packages/gitbook/src/intl/translations/fr.ts @@ -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: 'S’abonner au flux de cette page', pdf_download: 'Exporter en PDF', pdf_goback: 'Retourner au contenu', pdf_print: 'Imprimer ou enregistrer en PDF', diff --git a/packages/gitbook/src/intl/translations/it.ts b/packages/gitbook/src/intl/translations/it.ts index 704697a2d2..700963aec6 100644 --- a/packages/gitbook/src/intl/translations/it.ts +++ b/packages/gitbook/src/intl/translations/it.ts @@ -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', diff --git a/packages/gitbook/src/intl/translations/ja.ts b/packages/gitbook/src/intl/translations/ja.ts index 0c32e87699..e0aee3dc40 100644 --- a/packages/gitbook/src/intl/translations/ja.ts +++ b/packages/gitbook/src/intl/translations/ja.ts @@ -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として保存', diff --git a/packages/gitbook/src/intl/translations/nl.ts b/packages/gitbook/src/intl/translations/nl.ts index 77147647fb..cced889adf 100644 --- a/packages/gitbook/src/intl/translations/nl.ts +++ b/packages/gitbook/src/intl/translations/nl.ts @@ -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', diff --git a/packages/gitbook/src/intl/translations/no.ts b/packages/gitbook/src/intl/translations/no.ts index a067599f3d..1057ea120e 100644 --- a/packages/gitbook/src/intl/translations/no.ts +++ b/packages/gitbook/src/intl/translations/no.ts @@ -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', diff --git a/packages/gitbook/src/intl/translations/pt-br.ts b/packages/gitbook/src/intl/translations/pt-br.ts index 66c478b8d9..e2f4b2faf2 100644 --- a/packages/gitbook/src/intl/translations/pt-br.ts +++ b/packages/gitbook/src/intl/translations/pt-br.ts @@ -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', diff --git a/packages/gitbook/src/intl/translations/ru.ts b/packages/gitbook/src/intl/translations/ru.ts index 583d60e12d..106e9c41c1 100644 --- a/packages/gitbook/src/intl/translations/ru.ts +++ b/packages/gitbook/src/intl/translations/ru.ts @@ -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', diff --git a/packages/gitbook/src/intl/translations/zh.ts b/packages/gitbook/src/intl/translations/zh.ts index a6ad3baf57..c79f343136 100644 --- a/packages/gitbook/src/intl/translations/zh.ts +++ b/packages/gitbook/src/intl/translations/zh.ts @@ -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', diff --git a/packages/gitbook/src/lib/document.tsx b/packages/gitbook/src/lib/document.tsx index cf12d71e96..31833ff1bc 100644 --- a/packages/gitbook/src/lib/document.tsx +++ b/packages/gitbook/src/lib/document.tsx @@ -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. */ 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[]; diff --git a/packages/gitbook/src/middleware.ts b/packages/gitbook/src/middleware.ts index a6920e490a..979276e22a 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+)\/)?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. */ diff --git a/packages/gitbook/src/routes/rss.ts b/packages/gitbook/src/routes/rss.ts new file mode 100644 index 0000000000..d51c1e8cf9 --- /dev/null +++ b/packages/gitbook/src/routes/rss.ts @@ -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', + }, + }); +} diff --git a/packages/gitbook/tests/rss.test.ts b/packages/gitbook/tests/rss.test.ts new file mode 100644 index 0000000000..580c7fd438 --- /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/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); +});