mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-22 02:23:30 +00:00
Add RSS feed for pages with update blocks (#3824)
Co-authored-by: Samy Pessé <samypesse@gmail.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": minor
|
||||
---
|
||||
|
||||
Add a RSS feed to all pages with update blocks.
|
||||
@@ -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=="],
|
||||
|
||||
@@ -897,6 +897,11 @@ const testCases: TestsCase[] = [
|
||||
url: 'blocks/cards',
|
||||
fullPage: true,
|
||||
},
|
||||
{
|
||||
name: 'Updates',
|
||||
url: 'blocks/updates',
|
||||
fullPage: true,
|
||||
},
|
||||
{
|
||||
name: 'Math',
|
||||
url: 'blocks/math',
|
||||
|
||||
@@ -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",
|
||||
|
||||
+12
@@ -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: 'S’abonner 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',
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user