Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/tender-windows-double.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"gitbook": minor
---

Add a RSS feed to all pages with update blocks.
18 changes: 17 additions & 1 deletion bun.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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=="],

Expand Down Expand Up @@ -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=="],
Expand Down Expand Up @@ -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=="],
Expand Down Expand Up @@ -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=="],
Expand Down Expand Up @@ -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=="],
Expand Down
5 changes: 5 additions & 0 deletions packages/gitbook/e2e/internal.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -897,6 +897,11 @@ const testCases: TestsCase[] = [
url: 'blocks/cards',
fullPage: true,
},
{
name: 'Updates',
url: 'blocks/updates',
fullPage: true,
},
{
name: 'Math',
url: 'blocks/math',
Expand Down
6 changes: 4 additions & 2 deletions packages/gitbook/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*",
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
3 changes: 2 additions & 1 deletion packages/gitbook/src/components/DocumentView/Hint.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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)
Expand Down
19 changes: 19 additions & 0 deletions packages/gitbook/src/components/PageActions/PageActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,15 @@ import {
ActionOpenMCP,
ActionViewAsMarkdown,
ActionViewAsPDF,
ActionViewAsRSS,
} from './PageActions';

export type PageActionsDropdownURLs = {
html: string;
markdown: string;
mcp?: string;
pdf?: string;
rss?: string;
editOnGit?: {
provider: GitSyncState['installationProvider'];
url: string;
Expand All @@ -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 ? (
Expand Down Expand Up @@ -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 ? (
Expand All @@ -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,
Expand All @@ -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" />;
Expand Down
14 changes: 12 additions & 2 deletions packages/gitbook/src/components/PageBody/PageBody.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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={
Expand Down
26 changes: 17 additions & 9 deletions packages/gitbook/src/components/PageBody/PageHeader.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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) {
Expand All @@ -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',
Expand Down Expand Up @@ -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
? {
Expand Down
34 changes: 24 additions & 10 deletions packages/gitbook/src/components/SitePage/SitePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { getDataOrNull, getPageDocument } from '@/lib/data';
import {
CustomizationHeaderPreset,
CustomizationThemeMode,
type RevisionPageDocument,
SiteInsightsDisplayContext,
type TranslationLanguage,
} from '@gitbook/api';
Expand All @@ -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';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(' | ');
}
Loading