diff --git a/docs/install/release-notes/index.mdx b/docs/install/release-notes/index.mdx
index 6145072f..e768ff99 100644
--- a/docs/install/release-notes/index.mdx
+++ b/docs/install/release-notes/index.mdx
@@ -10,3 +10,5 @@ description: |-
- [1.1.1](/docs/install/release-notes/1-1-1) – Released on February 13, 2025
- [1.1.0](/docs/install/release-notes/1-1-0) – Released on January 30, 2025
- [1.0.1](/docs/install/release-notes/1-0-1) – Released on December 31, 2024
+
+You can also subscribe to the [JSON feed](/docs/install/release-notes/feed.json).
diff --git a/package-lock.json b/package-lock.json
index 982d8e1f..31d5f351 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10,6 +10,7 @@
"dependencies": {
"@r4ai/remark-callout": "^0.6.2",
"classnames": "^2.5.1",
+ "feed": "^5.1.0",
"gray-matter": "^4.0.3",
"klaw-sync": "^6.0.0",
"lucide-react": "^0.424.0",
@@ -21,6 +22,7 @@
"react-dom": "^18",
"react-intersection-observer": "^9.14.0",
"rehype-highlight": "^7.0.1",
+ "rehype-stringify": "^10.0.1",
"remark-gfm": "^4.0.0",
"slugify": "^1.6.6",
"xml2js": "^0.6.2",
@@ -3448,6 +3450,19 @@
"reusify": "^1.0.4"
}
},
+ "node_modules/feed": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/feed/-/feed-5.1.0.tgz",
+ "integrity": "sha512-qGNhgYygnefSkAHHrNHqC7p3R8J0/xQDS/cYUud8er/qD9EFGWyCdUDfULHTJQN1d3H3WprzVwMc9MfB4J50Wg==",
+ "license": "MIT",
+ "dependencies": {
+ "xml-js": "^1.6.11"
+ },
+ "engines": {
+ "node": ">=20",
+ "pnpm": ">=10"
+ }
+ },
"node_modules/file-entry-cache": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
@@ -3922,6 +3937,39 @@
"url": "https://opencollective.com/unified"
}
},
+ "node_modules/hast-util-to-html": {
+ "version": "9.0.5",
+ "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz",
+ "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "@types/unist": "^3.0.0",
+ "ccount": "^2.0.0",
+ "comma-separated-tokens": "^2.0.0",
+ "hast-util-whitespace": "^3.0.0",
+ "html-void-elements": "^3.0.0",
+ "mdast-util-to-hast": "^13.0.0",
+ "property-information": "^7.0.0",
+ "space-separated-tokens": "^2.0.0",
+ "stringify-entities": "^4.0.0",
+ "zwitch": "^2.0.4"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/hast-util-to-html/node_modules/property-information": {
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz",
+ "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/hast-util-to-jsx-runtime": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.0.tgz",
@@ -3989,6 +4037,16 @@
"url": "https://opencollective.com/unified"
}
},
+ "node_modules/html-void-elements": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz",
+ "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/ignore": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz",
@@ -7084,6 +7142,21 @@
"url": "https://opencollective.com/unified"
}
},
+ "node_modules/rehype-stringify": {
+ "version": "10.0.1",
+ "resolved": "https://registry.npmjs.org/rehype-stringify/-/rehype-stringify-10.0.1.tgz",
+ "integrity": "sha512-k9ecfXHmIPuFVI61B9DeLPN0qFHfawM6RsuX48hoqlaKSF61RskNjSm1lI8PhBEM0MRdLxVVm4WmTqJQccH9mA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "hast-util-to-html": "^9.0.0",
+ "unified": "^11.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
"node_modules/remark-gfm": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.0.tgz",
@@ -8440,6 +8513,18 @@
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"dev": true
},
+ "node_modules/xml-js": {
+ "version": "1.6.11",
+ "resolved": "https://registry.npmjs.org/xml-js/-/xml-js-1.6.11.tgz",
+ "integrity": "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==",
+ "license": "MIT",
+ "dependencies": {
+ "sax": "^1.2.4"
+ },
+ "bin": {
+ "xml-js": "bin/cli.js"
+ }
+ },
"node_modules/xml2js": {
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz",
diff --git a/package.json b/package.json
index 418907ab..20f2bef3 100644
--- a/package.json
+++ b/package.json
@@ -11,6 +11,7 @@
"dependencies": {
"@r4ai/remark-callout": "^0.6.2",
"classnames": "^2.5.1",
+ "feed": "^5.1.0",
"gray-matter": "^4.0.3",
"klaw-sync": "^6.0.0",
"lucide-react": "^0.424.0",
@@ -22,6 +23,7 @@
"react-dom": "^18",
"react-intersection-observer": "^9.14.0",
"rehype-highlight": "^7.0.1",
+ "rehype-stringify": "^10.0.1",
"remark-gfm": "^4.0.0",
"slugify": "^1.6.6",
"xml2js": "^0.6.2",
diff --git a/src/app/docs/install/release-notes/feed.json/route.ts b/src/app/docs/install/release-notes/feed.json/route.ts
new file mode 100644
index 00000000..0509b06e
--- /dev/null
+++ b/src/app/docs/install/release-notes/feed.json/route.ts
@@ -0,0 +1,12 @@
+import { NextResponse } from "next/server";
+import { generateFeed } from "@/lib/generate-feed";
+
+export async function GET() {
+ const feed = await generateFeed();
+
+ return new NextResponse(feed, {
+ headers: {
+ "Content-Type": "application/feed+json",
+ },
+ });
+}
diff --git a/src/layouts/root-layout/index.tsx b/src/layouts/root-layout/index.tsx
index a4506941..b1b4b93d 100644
--- a/src/layouts/root-layout/index.tsx
+++ b/src/layouts/root-layout/index.tsx
@@ -42,6 +42,12 @@ export default function RootLayout({
+
{
+ const result = await unified()
+ .use(remarkParse)
+ .use(remarkRehype)
+ .use(rehypeStringify)
+ .process(md);
+
+ return result.toString();
+}
+
+export async function generateFeed(): Promise {
+ const releaseNotesUrl = new URL(RELEASE_NOTES_DIRECTORY, BASE_URL).href;
+ const feedUrl = new URL(FEED_FILENAME, releaseNotesUrl).href;
+ const currentYear = new Date().getFullYear();
+
+ const feed = new Feed({
+ title: "Ghostty Release Notes",
+ description: "Release notes for Ghostty",
+ id: feedUrl,
+ link: releaseNotesUrl,
+ feedLinks: {
+ json: feedUrl,
+ },
+ favicon: new URL("favicon.ico", BASE_URL).href,
+ copyright: `© ${currentYear} Mitchell Hashimoto`,
+ });
+
+ const releaseNotesDir = path.join(process.cwd(), RELEASE_NOTES_DIRECTORY);
+ const filenames = (await fs.readdir(releaseNotesDir, { withFileTypes: true }))
+ .filter((dirent) =>
+ dirent.isFile() && dirent.name !== "index.mdx" &&
+ dirent.name.endsWith(".mdx")
+ )
+ .map((dirent) => dirent.name)
+ .toReversed();
+
+ for (const filename of filenames) {
+ const filePath = path.join(RELEASE_NOTES_DIRECTORY, filename);
+
+ const { data, content } = matter.read(filePath);
+ const contentHtml = await mdToHtml(content);
+
+ const slug = filename.replace(".mdx", "");
+
+ const fileUrl = new URL(slug, releaseNotesUrl).href;
+ const dateString = data.description?.match(
+ /released on ([A-Za-z]+ \d+, \d{4})/i,
+ )?.[1];
+ const date = dateString ? new Date(dateString) : new Date();
+
+ feed.addItem({
+ title: data.title,
+ id: fileUrl,
+ link: fileUrl,
+ description: data.description,
+ content: contentHtml,
+ date,
+ });
+ }
+
+ return feed.json1();
+}