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
96 changes: 96 additions & 0 deletions frontend/docs/lib/feeds/changelog.ts
Original file line number Diff line number Diff line change
@@ -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);
}
47 changes: 47 additions & 0 deletions frontend/docs/lib/feeds/cookbooks.ts
Original file line number Diff line number Diff line change
@@ -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<string, MetaEntry>)) {
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);
}
16 changes: 16 additions & 0 deletions frontend/docs/lib/feeds/registry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type { FeedContext } from "@/lib/feeds/rss";
import { buildChangelogFeed } from "./changelog";
import { buildCookbooksFeed } from "./cookbooks";


export const FEEDS: Record<string, (ctx: FeedContext) => 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,
};
76 changes: 76 additions & 0 deletions frontend/docs/lib/feeds/rss.ts
Original file line number Diff line number Diff line change
@@ -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 <media:thumbnail>. */
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}

function renderItem(item: FeedItem): string {
const fields = [
`<title>${xml(item.title)}</title>`,
`<link>${xml(item.link)}</link>`,
item.description && `<description>${xml(item.description)}</description>`,
item.pubDate && `<pubDate>${item.pubDate}</pubDate>`,
`<guid isPermaLink="true">${xml(item.link)}</guid>`,
item.thumbnail &&
`<media:thumbnail url="${xml(item.thumbnail)}" height="600" width="900"/>`,
].filter(Boolean);
return `<item>\n ${fields.join("\n ")}\n </item>`;
}

export function renderRSS(channel: Channel, items: FeedItem[]): string {
const optional = [
channel.lastBuildDate &&
`<lastBuildDate>${channel.lastBuildDate}</lastBuildDate>`,
channel.logo &&
`<image>
<url>${xml(channel.logo)}</url>
<title>${xml(channel.title)}</title>
<link>${xml(channel.link)}</link>
</image>`,
].filter(Boolean);

return `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"
xmlns:atom="http://www.w3.org/2005/Atom"
xmlns:media="http://search.yahoo.com/mrss/">
<channel>
<title>${xml(channel.title)}</title>
<link>${xml(channel.link)}</link>
<description>${xml(channel.description)}</description>
<atom:link href="${xml(channel.self)}" rel="self" type="application/rss+xml"/>
<language>${channel.language}</language>
${optional.join("\n ")}
${items.map(renderItem).join("\n ")}
</channel>
</rss>`;
}
6 changes: 6 additions & 0 deletions frontend/docs/next.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 ---
Expand Down
35 changes: 35 additions & 0 deletions frontend/docs/pages/api/feeds/[feed].ts
Original file line number Diff line number Diff line change
@@ -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" });
}
}
33 changes: 32 additions & 1 deletion frontend/docs/theme.config.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => (
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
<path d="M22.106 5.68L12.5.135a.998.998 0 00-.998 0L1.893 5.68a.84.84 0 00-.419.726v11.186c0 .3.16.577.42.727l9.607 5.547a.999.999 0 00.998 0l9.608-5.547a.84.84 0 00.42-.727V6.407a.84.84 0 00-.42-.726zm-.603 1.176L12.228 22.92c-.063.108-.228.064-.228-.061V12.34a.59.59 0 00-.295-.51l-9.11-5.26c-.107-.062-.063-.228.062-.228h18.55c.264 0 .428.286.296.514z" />
Expand All @@ -39,6 +49,14 @@ const MarkdownIcon = () => (
</svg>
);

const RssIcon = () => (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 11a9 9 0 0 1 9 9" />
<path d="M4 4a16 16 0 0 1 16 16" />
<circle cx="5" cy="19" r="1" />
</svg>
);

const CopyIcon = () => (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
Expand Down Expand Up @@ -124,7 +142,7 @@ const config = {
width="120"
height="35"
fill="none"
// preserveAspectRatio="xMidYMid meet"
// preserveAspectRatio="xMidYMid meet"
>
<path
fill="var(--brand)"
Expand All @@ -143,13 +161,17 @@ 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` : "";

return (
<>
<title>{title ? `${title} - ${fallbackTitle}` : fallbackTitle}</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/png" href="/favicon.ico" />
<link rel="alternate" type="text/markdown" href={llmsMarkdownHref} />
<link rel="prefetch" href={router.basePath ? `${router.basePath}/llms-search-index.json` : "/llms-search-index.json"} />
{hasRssFeed && <link rel="alternate" type="application/rss+xml" href={rssFeedHref}/>}
</>
);
},
Expand All @@ -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",
Expand All @@ -195,6 +220,12 @@ const config = {
<MarkdownIcon />
<span className="page-action-label">View as MD</span>
</a>
{hasRssFeed && (
<a href={rssFeedHref} target="_blank" rel="noopener noreferrer" style={pageLinkStyle} onClick={() => posthog.capture("docs_rss_click", { feed: rssFeedHref, page: pathname })} title="Subscribe via RSS">
<RssIcon />
<span className="page-action-label">RSS</span>
</a>
)}
<LanguageSelectorButton />
</div>
{children}
Expand Down
Loading