diff --git a/frontend/docs/lib/feeds/changelog.ts b/frontend/docs/lib/feeds/changelog.ts new file mode 100644 index 0000000000..d49311efe1 --- /dev/null +++ b/frontend/docs/lib/feeds/changelog.ts @@ -0,0 +1,96 @@ +import { renderRSS, type FeedItem, type Channel, FeedContext } from "@/lib/feeds/rss"; +import fs from "node:fs"; +import path from "node:path"; + +const RELEASE_HEADING = /^## (v\d+\.\d+\.\d+(?:-[\w.]+)?) - (\d{4}-\d{2}-\d{2})\s*$/; +const MAX_ITEMS_PER_FEED = 20; + +interface Release { + version: string; + date: string; + body: string +} + +export interface ChangelogSource { + label: string; + pageSlug: string; +} + +function slug(heading: string): string { + return heading.toLowerCase().replace(/[^a-z0-9 -]/g, "").replace(/ /g, "-"); +} + +function extractDescription(body: string): string { + // allows us to extract the prose from under the release header + // up-to (but not including) the next header encountered. + const paragraph = body + .split(/\n\s*\n/) + .map((p) => p.trim()) + .find((p) => p && !/^[#\-`]/.test(p)) ?? ""; + + return paragraph + .replace(/\[([^\]]+)\]\([^)]*\)/g, "$1") + .replace(/[`*_]/g, "") + .replace(/\s+/g, " ") + .trim(); +} + + +function parseMarkdown(md: string): Release[] { + const out: Release[] = []; + let buf: string[] = []; + + const flush = () => { + if (out.length) out[out.length - 1].body = buf.join("\n").trim(); + buf = []; + }; + + for (const line of md.split("\n")) { + if (out.length >= MAX_ITEMS_PER_FEED) { + break + } + + const match = line.match(RELEASE_HEADING); + if (match) { + flush(); + out.push({ version: match[1], date: match[2], body: "" }); + continue + } + + buf.push(line); + + } + flush(); + + return out; +} + +export function buildChangelogFeed( + { site, feedUrl }: FeedContext, + { label, pageSlug }: ChangelogSource, +): string { + const page = `${site}/reference/changelog/${pageSlug}`; + const source = path.join(process.cwd(), "pages/reference/changelog", `${pageSlug}.mdx`); + + const items: FeedItem[] = parseMarkdown(fs.readFileSync(source, "utf-8")).map( + ({ version, date, body }) => ({ + title: `Hatchet ${label} ${version}`, + description: extractDescription(body), + link: `${page}#${slug(`${version} - ${date}`)}`, + image: `${site}/og.png`, + pubDate: new Date(`${date}T00:00:00Z`).toUTCString(), + }), + ); + + const feed: Channel = { + title: `Hatchet ${label} Changelog`, + link: page, + self: feedUrl, + description: `Release notes for Hatchet ${label}`, + language: "en", + logo: `${site}/logo.png`, + lastBuildDate: items[0]?.pubDate, + } + + return renderRSS(feed, items); +} diff --git a/frontend/docs/lib/feeds/cookbooks.ts b/frontend/docs/lib/feeds/cookbooks.ts new file mode 100644 index 0000000000..42661b8559 --- /dev/null +++ b/frontend/docs/lib/feeds/cookbooks.ts @@ -0,0 +1,47 @@ +import { renderRSS, type FeedItem, type Channel, FeedContext } from "@/lib/feeds/rss"; +import meta from "@/pages/cookbooks/_meta.js"; + +interface Cookbook { + slug: string; + title: string; + section: string; +} + +type MetaEntry = string | { title?: string; type?: string }; + +function extractCookbooks(): Cookbook[] { + const out: Cookbook[] = []; + let section = ""; + + for (const [key, value] of Object.entries(meta as Record)) { + if (typeof value === "object" && value.type === "separator") { + section = value.title ?? ""; + } else if (key !== "index") { + const title = typeof value === "string" ? value : value.title ?? key; + out.push({ slug: key, title, section }); + } + } + + return out; +} + +export function buildCookbooksFeed({ site, feedUrl }: FeedContext): string { + const page = `${site}/cookbooks`; + + const items: FeedItem[] = extractCookbooks().map((c) => ({ + title: c.section ? `${c.section}: ${c.title}` : c.title, + link: `${page}/${c.slug}`, + image: `${site}/og.png`, + })); + + const feed: Channel = { + title: "Hatchet Cookbooks", + link: page, + self: feedUrl, + description: "Guides and recipes for building with Hatchet", + language: "en", + logo: `${site}/logo.png`, + } + + return renderRSS(feed, items); +} diff --git a/frontend/docs/lib/feeds/registry.ts b/frontend/docs/lib/feeds/registry.ts new file mode 100644 index 0000000000..3232cbc9e4 --- /dev/null +++ b/frontend/docs/lib/feeds/registry.ts @@ -0,0 +1,16 @@ +import type { FeedContext } from "@/lib/feeds/rss"; +import { buildChangelogFeed } from "./changelog"; +import { buildCookbooksFeed } from "./cookbooks"; + + +export const FEEDS: Record string> = { + platform: (ctx) => + buildChangelogFeed(ctx, { label: "Platform", pageSlug: "platform" }), + python: (ctx) => + buildChangelogFeed(ctx, { label: "Python SDK", pageSlug: "python" }), + typescript: (ctx) => + buildChangelogFeed(ctx, { label: "TypeScript SDK", pageSlug: "typescript" }), + ruby: (ctx) => + buildChangelogFeed(ctx, { label: "Ruby SDK", pageSlug: "ruby" }), + cookbooks: buildCookbooksFeed, +}; diff --git a/frontend/docs/lib/feeds/rss.ts b/frontend/docs/lib/feeds/rss.ts new file mode 100644 index 0000000000..76d5029480 --- /dev/null +++ b/frontend/docs/lib/feeds/rss.ts @@ -0,0 +1,76 @@ + +export interface FeedContext { + site: string; + feedUrl: string; +} + +export interface Channel { + title: string; + link: string; + self: string; + description: string; + language: string; + logo?: string; + lastBuildDate?: string; +} + +export interface FeedItem { + title: string; + link: string; + description?: string; + /** RFC-822 date. */ + pubDate?: string; + /** Thumbnail URL, rendered as . */ + thumbnail?: string; +} + +/** Escape XML special characters so titles/URLs with `&`, `<`, etc. don't break the feed. */ +function xml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function renderItem(item: FeedItem): string { + const fields = [ + `${xml(item.title)}`, + `${xml(item.link)}`, + item.description && `${xml(item.description)}`, + item.pubDate && `${item.pubDate}`, + `${xml(item.link)}`, + item.thumbnail && + ``, + ].filter(Boolean); + return `\n ${fields.join("\n ")}\n `; +} + +export function renderRSS(channel: Channel, items: FeedItem[]): string { + const optional = [ + channel.lastBuildDate && + `${channel.lastBuildDate}`, + channel.logo && + ` + ${xml(channel.logo)} + ${xml(channel.title)} + ${xml(channel.link)} + `, + ].filter(Boolean); + + return ` + + + ${xml(channel.title)} + ${xml(channel.link)} + ${xml(channel.description)} + + ${channel.language} + ${optional.join("\n ")} + ${items.map(renderItem).join("\n ")} + +`; +} diff --git a/frontend/docs/next.config.mjs b/frontend/docs/next.config.mjs index caa843567b..ab8780c773 100644 --- a/frontend/docs/next.config.mjs +++ b/frontend/docs/next.config.mjs @@ -29,6 +29,12 @@ const nextConfig = { images: { unoptimized: true, }, + async rewrites() { + return [ + { source: "/reference/changelog/:component/feed.xml", destination: "/api/feeds/:component" }, + { source: "/cookbooks/feed.xml", destination: "/api/feeds/cookbooks" }, + ] + }, async redirects() { return [ // --- New site: section index redirects --- diff --git a/frontend/docs/pages/api/feeds/[feed].ts b/frontend/docs/pages/api/feeds/[feed].ts new file mode 100644 index 0000000000..49542a022b --- /dev/null +++ b/frontend/docs/pages/api/feeds/[feed].ts @@ -0,0 +1,35 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { FEEDS } from "@/lib/feeds/registry"; + +const SITE = "https://docs.hatchet.run"; + +export default function handler( + req: NextApiRequest, + res: NextApiResponse, +): void { + if (req.method !== "GET") { + res.status(405).json({ error: "Method not allowed" }); + return; + } + + const { feed } = req.query; + const buildFeed = typeof feed === "string" ? FEEDS[feed] : undefined; + if (!buildFeed) { + res.status(404).json({ error: `Unknown feed: ${feed}` }); + return; + } + + // TODO(gregfurman): Ensure CDN doesn't cache XML feed for too long. + try { + const xml = buildFeed({ site: SITE, feedUrl: `${SITE}/api/feeds/${feed}` }); + res.setHeader("Content-Type", "application/rss+xml; charset=utf-8"); + res.setHeader( + "Cache-Control", + "public, s-maxage=3600, stale-while-revalidate=86400", + ); + res.send(xml); + } catch (error) { + console.error(`Failed to build ${feed} feed:`, error); + res.status(500).json({ error: "Failed to build feed" }); + } +} diff --git a/frontend/docs/theme.config.tsx b/frontend/docs/theme.config.tsx index 0f34d28822..fee883bc06 100644 --- a/frontend/docs/theme.config.tsx +++ b/frontend/docs/theme.config.tsx @@ -17,6 +17,16 @@ function safeBase64Encode(str: string): string { return ""; } +// TODO(gregfurman): While it's unlikely these items will change, ensuring they're aligned +// with the actual paths/slugs would be prudent. +const RSS_FEED_PATHS = [ + "reference/changelog/platform", + "reference/changelog/python", + "reference/changelog/typescript", + "reference/changelog/ruby", + "cookbooks", +]; + const CursorIcon = () => ( @@ -39,6 +49,14 @@ const MarkdownIcon = () => ( ); +const RssIcon = () => ( + + + + + +); + const CopyIcon = () => ( @@ -124,7 +142,7 @@ const config = { width="120" height="35" fill="none" - // preserveAspectRatio="xMidYMid meet" + // preserveAspectRatio="xMidYMid meet" > {title ? `${title} - ${fallbackTitle}` : fallbackTitle} @@ -150,6 +171,7 @@ const config = { + {hasRssFeed && } ); }, @@ -173,6 +195,9 @@ const config = { const base = router.basePath ? router.basePath.replace(/\/$/, "") : ""; const llmsMarkdownHref = `${base}/llms/${pathname}.md`; + const hasRssFeed = RSS_FEED_PATHS.includes(pathname); + const rssFeedHref = hasRssFeed ? `${base}/${pathname}/feed.xml` : ""; + const mcpUrl = `${origin}/api/mcp`; const cursorConfig = JSON.stringify({ command: "npx", @@ -195,6 +220,12 @@ const config = { View as MD + {hasRssFeed && ( + posthog.capture("docs_rss_click", { feed: rssFeedHref, page: pathname })} title="Subscribe via RSS"> + + RSS + + )} {children}