diff --git a/@api/page-rss-feed.get.ts b/@api/page-rss-feed.get.ts new file mode 100644 index 00000000..43ba6132 --- /dev/null +++ b/@api/page-rss-feed.get.ts @@ -0,0 +1,280 @@ +import type { ApiFunctionsContext } from '@redocly/config'; +import { escapeXml, formatRssDate } from '../@theme/utils/rss'; +import crypto from 'crypto'; +// @ts-ignore +import pagesConfig from './page-rss-pages.yaml'; + +interface PageConfig { + slug: string; + title?: string; +} + +interface PagesConfig { + pages: PageConfig[]; +} + +interface PageData { + props?: { + frontmatter?: { + title?: string; + description?: string; + [key: string]: unknown; + }; + seo?: { + title?: string; + description?: string; + [key: string]: unknown; + }; + ast?: unknown; + lastModified?: number | string; + [key: string]: unknown; + }; + [key: string]: unknown; +} + +interface PageChangeRecord { + hash: string; + timestamp: number; + pageTitle: string; + pageUrl: string; +} + +async function calculateHash(pageData: PageData): Promise { + const dataToHash = pageData?.props?.ast || pageData?.props || pageData; + const dataString = JSON.stringify(dataToHash); + + return crypto.createHash('sha1').update(dataString).digest('base64').substring(0, 16); +} + +function getPageTitle(pageData: PageData, slug: string): string { + return ( + pageData?.props?.seo?.title || + pageData?.props?.frontmatter?.title || + slug.split('/').filter(Boolean).pop() || + 'Untitled Page' + ); +} + +function buildRssItem(record: PageChangeRecord, baseUrl: string): string { + const title = `${escapeXml(record.pageTitle)} was updated`; + const link = `${baseUrl}${record.pageUrl}`; + const guid = `${link}#${record.hash}`; + const pubDate = formatRssDate(record.timestamp); + const description = `${escapeXml(record.pageTitle)} was updated at ${new Date(record.timestamp).toLocaleString()}`; + + return ` + + ${title} + ${escapeXml(link)} + ${escapeXml(guid)} + ${pubDate} + ${escapeXml(description)} + + `; +} + +async function fetchPageData(baseUrl: string, pageSlug: string): Promise { + const pageDataPath = pageSlug.replace(/^\//, '').replace(/\/$/, ''); + const pageDataUrl = `${baseUrl}/page-data/${pageDataPath}/data.json`; + + try { + const pageDataResponse = await fetch(pageDataUrl); + + if (!pageDataResponse.ok) { + throw new Error(`Page not found: ${pageDataResponse.status} ${pageDataResponse.statusText} at ${pageDataUrl}`); + } + + return await pageDataResponse.json(); + } catch (error) { + if (error instanceof Error && error.message.includes('Page not found')) { + throw error; + } + throw new Error(`Failed to fetch page data from ${pageDataUrl}: ${error instanceof Error ? error.message : 'Unknown error'}`); + } +} + +async function updatePageChanges( + kv: any, + pageDataPath: string, + currentHash: string, + pageTitle: string, + pageUrl: string, + pageLastModified?: number | string +): Promise { + const lastHashKey = ['pageRss', 'pages', pageDataPath, 'lastHash']; + const lastHashRecord = await kv.get(lastHashKey); + const lastHash = lastHashRecord?.value; + + if (lastHash === currentHash) { + return; + } + + const changesPrefix = ['pageRss', 'changes', pageDataPath]; + const existingChanges = await kv.list( + { prefix: changesPrefix }, + { limit: 100 } + ) as { items: Array<{ value: PageChangeRecord }> }; + + const hashAlreadyExists = existingChanges.items.some( + (item) => item.value?.hash === currentHash + ); + + if (!hashAlreadyExists) { + let timestamp: number; + if (pageLastModified) { + timestamp = typeof pageLastModified === 'string' + ? new Date(pageLastModified).getTime() + : pageLastModified; + } else { + timestamp = Date.now(); + } + + const changeRecord: PageChangeRecord = { + hash: currentHash, + timestamp, + pageTitle, + pageUrl, + }; + + const recordKey = [ + 'pageRss', + 'changes', + pageDataPath, + timestamp.toString().padStart(20, '0'), + currentHash, + ]; + + await kv.set(recordKey, changeRecord); + } + + await kv.set(lastHashKey, currentHash); +} + +async function getChangeRecords( + kv: any, + pageDataPath: string, + currentHash: string, + pageTitle: string, + pageUrl: string +): Promise { + const changesPrefix = ['pageRss', 'changes', pageDataPath]; + const changesResult = await kv.list( + { prefix: changesPrefix }, + { limit: 50, reverse: true } + ); + + if (changesResult.items.length > 0) { + return changesResult.items.map((item) => item.value); + } + + return [ + { + hash: currentHash, + timestamp: Date.now(), + pageTitle, + pageUrl, + }, + ]; +} + +function buildRssFeed( + pageTitle: string, + pageUrl: string, + baseUrl: string, + apiPath: string, + pageSlug: string, + records: PageChangeRecord[] +): string { + const rssItems = records.map((record) => buildRssItem(record, baseUrl)).join(''); + + const feedUrl = `${baseUrl}${apiPath}?page=${encodeURIComponent(pageSlug)}`; + + return ` + + + ${escapeXml(pageTitle)} - Update Feed + ${escapeXml(`${baseUrl}${pageUrl}`)} + RSS feed for changes to ${escapeXml(pageTitle)} + en-us + ${formatRssDate(Date.now())} + + ${rssItems} + +`; +} + +function getPageSlug(context: ApiFunctionsContext, config: PagesConfig): string { + const pageSlug = context.query.page as string | undefined; + + if (pageSlug) { + const isValidPage = config.pages.some((p) => p.slug === pageSlug); + if (!isValidPage) { + throw new Error(`Page '${pageSlug}' is not in the allowed pages list`); + } + return pageSlug; + } + + if (config.pages.length === 0) { + throw new Error('No pages configured in page-rss-pages.yaml'); + } + + return config.pages[0].slug; +} + +export default async function pageRssFeedHandler( + request: Request, + context: ApiFunctionsContext +) { + try { + const config = pagesConfig as PagesConfig; + const pageSlug = getPageSlug(context, config); + + // @ts-ignore - getKv may not be in type definitions yet but exists at runtime + const kv = await context.getKv(); + const url = new URL(request.url); + const baseUrl = `${url.protocol}//${url.host}`; + const apiPath = url.pathname; + + const pageData = await fetchPageData(baseUrl, pageSlug); + const pageDataPath = pageSlug.replace(/^\//, '').replace(/\/$/, ''); + + const currentHash = await calculateHash(pageData); + const pageTitle = getPageTitle(pageData, pageSlug); + const pageLastModified = pageData?.props?.lastModified; + + await updatePageChanges(kv, pageDataPath, currentHash, pageTitle, pageSlug, pageLastModified); + const records = await getChangeRecords(kv, pageDataPath, currentHash, pageTitle, pageSlug); + + const rssXml = buildRssFeed(pageTitle, pageSlug, baseUrl, apiPath, pageSlug, records); + + return new Response(rssXml, { + status: 200, + headers: { + 'Content-Type': 'application/rss+xml; charset=utf-8', + 'Cache-Control': 'no-store, no-cache, must-revalidate', + Pragma: 'no-cache', + Expires: '0', + }, + }); + } catch (error: any) { + if (error?.message?.includes('Page not found') || error?.message?.includes('404')) { + return context.status(404).json({ + error: 'Page not found', + message: error.message || 'Could not fetch page data', + }); + } + + if (error?.message?.includes('not in the allowed pages list')) { + return context.status(400).json({ + error: 'Invalid page', + message: error.message, + availablePages: (pagesConfig as PagesConfig).pages.map((p) => p.slug), + }); + } + + return context.status(500).json({ + error: 'Internal server error', + message: error?.message || 'Failed to generate RSS feed', + }); + } +} diff --git a/@api/page-rss-pages.yaml b/@api/page-rss-pages.yaml new file mode 100644 index 00000000..008a8182 --- /dev/null +++ b/@api/page-rss-pages.yaml @@ -0,0 +1,201 @@ +pages: + - slug: '/docs' + title: 'docs' + - slug: '/billing/signup' + title: 'signup' + - slug: '/billing/w-9' + title: 'w-9' + - slug: '/code-of-conduct' + title: 'code-of-conduct' + - slug: '/gsod-casestudy' + title: 'gsod-casestudy' + - slug: '/gsod-2022' + title: 'gsod-2022' + - slug: '/gsod' + title: 'gsod' + - slug: '/cookie-notice' + title: 'cookie-notice' + - slug: '/policies/pandemic-plan' + title: 'pandemic-plan' + - slug: '/dpa' + title: 'dpa' + - slug: '/legal/privacy-notice-2020-07-18' + title: 'privacy-notice-2020-07-18' + - slug: '/privacy-notice' + title: 'privacy-notice' + - slug: '/sla-2021-07-18' + title: 'sla-2021-07-18' + - slug: '/sla' + title: 'sla' + - slug: '/legal/subscription-agreement-2021-11-11' + title: 'subscription-agreement-2021-11-11' + - slug: '/subscription-agreement-2024-01-26' + title: 'subscription-agreement-2024-01-26' + - slug: '/subscription-agreement' + title: 'subscription-agreement' + - slug: '/vulnerability-disclosure-policy' + title: 'vulnerability-disclosure-policy' + - slug: '/product-timelines' + title: 'product-timelines' + - slug: '/products' + title: 'products' + - slug: '/resources' + title: 'resources' + - slug: '/roadmap' + title: 'roadmap' + - slug: '/sub-processors' + title: 'sub-processors' + - slug: '/learn/ai-for-docs/ai-modern-api-docs' + title: 'ai-modern-api-docs' + - slug: '/learn/ai-for-docs/ai-reviews' + title: 'ai-reviews' + - slug: '/learn/ai-for-docs/ai-usability-testing' + title: 'ai-usability-testing' + - slug: '/learn/arazzo/arazzo-basics' + title: 'arazzo-basics' + - slug: '/learn/arazzo/arazzo-walkthrough' + title: 'arazzo-walkthrough' + - slug: '/learn/arazzo/documenting-api-workflows' + title: 'documenting-api-workflows' + - slug: '/learn/arazzo/documenting-multiple-apis-using-arazzo' + title: 'documenting-multiple-apis-using-arazzo' + - slug: '/learn/arazzo/linting-arazzo-workflows' + title: 'linting-arazzo-workflows' + - slug: '/learn/arazzo/source-descriptions-and-refs' + title: 'source-descriptions-and-refs' + - slug: '/learn/arazzo/success-criteria-and-failure-handling' + title: 'success-criteria-and-failure-handling' + - slug: '/learn/arazzo/testing-arazzo-workflows' + title: 'testing-arazzo-workflows' + - slug: '/learn/arazzo/understanding-workflows-and-steps' + title: 'understanding-workflows-and-steps' + - slug: '/learn/arazzo/what-is-arazzo' + title: 'what-is-arazzo' + - slug: '/learn/arazzo/why-arazzo-matters' + title: 'why-arazzo-matters' + - slug: '/learn/markdoc/debug-markdoc-variables' + title: 'debug-markdoc-variables' + - slug: '/learn/markdoc/evaluating-markdoc' + title: 'evaluating-markdoc' + - slug: '/learn/markdoc' + title: 'markdoc' + - slug: '/learn/markdoc/write-with-markdoc' + title: 'write-with-markdoc' + - slug: '/learn/openapi/all-of' + title: 'all-of' + - slug: '/learn/openapi/any-of-one-of' + title: 'any-of-one-of' + - slug: '/learn/openapi/discriminator' + title: 'discriminator' + - slug: '/learn/openapi/learning-openapi' + title: 'learning-openapi' + - slug: '/learn/openapi/multi-file-definitions' + title: 'multi-file-definitions' + - slug: '/learn/openapi/openapi-decisions' + title: 'openapi-decisions' + - slug: '/learn/openapi/ref-guide' + title: 'ref-guide' + - slug: '/learn/security/api-input-validation-injection-prevention' + title: 'api-input-validation-injection-prevention' + - slug: '/learn/security/api-rate-limiting-abuse-prevention' + title: 'api-rate-limiting-abuse-prevention' + - slug: '/learn/security/api-tls-encryption-https-best-practices' + title: 'api-tls-encryption-https-best-practices' + - slug: '/learn/security/authentication-authorization-openapi' + title: 'authentication-authorization-openapi' + - slug: '/learn/security' + title: 'security' + - slug: '/learn/testing/contract-testing-101' + title: 'contract-testing-101' + - slug: '/learn/testing' + title: 'testing' + - slug: '/learn/testing/tools-for-api-testing-in-2025' + title: 'tools-for-api-testing-in-2025' + - slug: '/learn/yaml/blocks-and-flows' + title: 'blocks-and-flows' + - slug: '/learn/yaml/documents-comments' + title: 'documents-comments' + - slug: '/learn/yaml' + title: 'yaml' + - slug: '/learn/yaml/maps' + title: 'maps' + - slug: '/learn/yaml/scalars' + title: 'scalars' + - slug: '/learn/yaml/sequences' + title: 'sequences' + - slug: '/learn/yaml/troubleshooting' + title: 'troubleshooting' + - slug: '/learn/yaml/yaml-or-json' + title: 'yaml-or-json' + - slug: '/learn/openapi/openapi-visual-reference/contributing' + title: 'contributing' + - slug: '/learn/openapi/openapi-visual-reference/array' + title: 'array' + - slug: '/learn/openapi/openapi-visual-reference/boolean' + title: 'boolean' + - slug: '/learn/openapi/openapi-visual-reference/callbacks' + title: 'callbacks' + - slug: '/learn/openapi/openapi-visual-reference/components' + title: 'components' + - slug: '/learn/openapi/openapi-visual-reference/contact' + title: 'contact' + - slug: '/learn/openapi/openapi-visual-reference/discriminator' + title: 'discriminator' + - slug: '/learn/openapi/openapi-visual-reference/encoding' + title: 'encoding' + - slug: '/learn/openapi/openapi-visual-reference/example' + title: 'example' + - slug: '/learn/openapi/openapi-visual-reference/examples' + title: 'examples' + - slug: '/learn/openapi/openapi-visual-reference/external-docs' + title: 'external-docs' + - slug: '/learn/openapi/openapi-visual-reference/header' + title: 'header' + - slug: '/learn/openapi/openapi-visual-reference' + title: 'openapi-visual-reference' + - slug: '/learn/openapi/openapi-visual-reference/info' + title: 'info' + - slug: '/learn/openapi/openapi-visual-reference/integer' + title: 'integer' + - slug: '/learn/openapi/openapi-visual-reference/license' + title: 'license' + - slug: '/learn/openapi/openapi-visual-reference/links' + title: 'links' + - slug: '/learn/openapi/openapi-visual-reference/media-type' + title: 'media-type' + - slug: '/learn/openapi/openapi-visual-reference/named-path-items' + title: 'named-path-items' + - slug: '/learn/openapi/openapi-visual-reference/named-request-bodies' + title: 'named-request-bodies' + - slug: '/learn/openapi/openapi-visual-reference/named-responses' + title: 'named-responses' + - slug: '/learn/openapi/openapi-visual-reference/null' + title: 'null' + - slug: '/learn/openapi/openapi-visual-reference/number' + title: 'number' + - slug: '/learn/openapi/openapi-visual-reference/oauth-flows' + title: 'oauth-flows' + - slug: '/learn/openapi/openapi-visual-reference/object' + title: 'object' + - slug: '/learn/openapi/openapi-visual-reference/openapi-node-types' + title: 'openapi-node-types' + - slug: '/learn/openapi/openapi-visual-reference/openapi-1' + title: 'openapi-1' + - slug: '/learn/openapi/openapi-visual-reference/operation' + title: 'operation' + - slug: '/learn/openapi/openapi-visual-reference/parameter' + title: 'parameter' + - slug: '/learn/openapi/openapi-visual-reference/parameters' + title: 'parameters' + - slug: '/learn/openapi/openapi-visual-reference/path-item' + title: 'path-item' + - slug: '/learn/openapi/openapi-visual-reference/paths' + title: 'paths' + - slug: '/learn/openapi/openapi-visual-reference/reference' + title: 'reference' + - slug: '/learn/openapi/openapi-visual-reference/request-body' + title: 'request-body' + - slug: '/learn/openapi/openapi-visual-reference/response' + title: 'response' + - slug: '/learn/openapi/openapi-visual-reference/responses' + title: 'responses' diff --git a/package-lock.json b/package-lock.json index 945000c2..bfbc9cee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "UNLICENSED", "dependencies": { "@redocly/marketing-pages": "0.1.43", - "@redocly/realm": "0.128.0", + "@redocly/realm": "next", "buffer": "^6.0.3", "highlight-words-core": "^1.2.3", "path": "^0.12.7", diff --git a/package.json b/package.json index c597d02a..b7e25bcd 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "license": "UNLICENSED", "dependencies": { "@redocly/marketing-pages": "0.1.43", - "@redocly/realm": "0.128.0", + "@redocly/realm": "next", "buffer": "^6.0.3", "highlight-words-core": "^1.2.3", "path": "^0.12.7",