diff --git a/package.json b/package.json
index 004c5aa..fcc8801 100644
--- a/package.json
+++ b/package.json
@@ -14,6 +14,7 @@
"@astrojs/check": "^0.9.4",
"@astrojs/cloudflare": "^12.0.1",
"@astrojs/react": "^4.1.0",
+ "@astrojs/rss": "^4.0.10",
"@astrojs/tailwind": "^5.1.2",
"@fontsource/bricolage-grotesque": "^5.1.0",
"@fortawesome/fontawesome-svg-core": "^6.7.1",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index eb3be09..1190372 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -17,6 +17,9 @@ importers:
'@astrojs/react':
specifier: ^4.1.0
version: 4.1.0(@types/node@22.10.2)(@types/react-dom@19.0.2(@types/react@19.0.1))(@types/react@19.0.1)(jiti@1.21.6)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(yaml@2.6.1)
+ '@astrojs/rss':
+ specifier: ^4.0.10
+ version: 4.0.10
'@astrojs/tailwind':
specifier: ^5.1.2
version: 5.1.3(astro@5.0.5(@types/node@22.10.2)(jiti@1.21.6)(rollup@4.28.1)(typescript@5.7.2)(yaml@2.6.1))(tailwindcss@3.4.16)
@@ -159,6 +162,9 @@ packages:
react: ^17.0.2 || ^18.0.0 || ^19.0.0
react-dom: ^17.0.2 || ^18.0.0 || ^19.0.0
+ '@astrojs/rss@4.0.10':
+ resolution: {integrity: sha512-2gFdHM763uUAySkdwPYrpi6dppOBJr9ddg5VbkKXctWze8d1JHgIBBY78zWIYs7KBJT58zxadsObVAVt55RDaw==}
+
'@astrojs/tailwind@5.1.3':
resolution: {integrity: sha512-XF7WhXRhqEHGvADqc0kDtF7Yv/g4wAWTaj91jBBTBaYnc4+MQLH94duFfFa4NlTkRG40VQd012eF3MhO3Kk+bg==}
peerDependencies:
@@ -1528,6 +1534,10 @@ packages:
fast-uri@3.0.3:
resolution: {integrity: sha512-aLrHthzCjH5He4Z2H9YZ+v6Ujb9ocRuW6ZzkJQOrTxleEijANq4v1TsaPaVG1PZcuurEzrLcWRyYBYXD5cEiaw==}
+ fast-xml-parser@4.5.1:
+ resolution: {integrity: sha512-y655CeyUQ+jj7KBbYMc4FG01V8ZQqjN+gDYGJ50RtfsUB8iG9AmwmwoAgeKLJdmueKKMrH1RJ7yXHTSoczdv5w==}
+ hasBin: true
+
fastq@1.17.1:
resolution: {integrity: sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==}
@@ -2532,6 +2542,9 @@ packages:
resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==}
engines: {node: '>=4'}
+ strnum@1.0.5:
+ resolution: {integrity: sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA==}
+
sucrase@3.35.0:
resolution: {integrity: sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==}
engines: {node: '>=16 || 14 >=14.17'}
@@ -3149,6 +3162,11 @@ snapshots:
- tsx
- yaml
+ '@astrojs/rss@4.0.10':
+ dependencies:
+ fast-xml-parser: 4.5.1
+ kleur: 4.1.5
+
'@astrojs/tailwind@5.1.3(astro@5.0.5(@types/node@22.10.2)(jiti@1.21.6)(rollup@4.28.1)(typescript@5.7.2)(yaml@2.6.1))(tailwindcss@3.4.16)':
dependencies:
astro: 5.0.5(@types/node@22.10.2)(jiti@1.21.6)(rollup@4.28.1)(typescript@5.7.2)(yaml@2.6.1)
@@ -4502,6 +4520,10 @@ snapshots:
fast-uri@3.0.3: {}
+ fast-xml-parser@4.5.1:
+ dependencies:
+ strnum: 1.0.5
+
fastq@1.17.1:
dependencies:
reusify: 1.0.4
@@ -5721,6 +5743,8 @@ snapshots:
strip-bom@3.0.0: {}
+ strnum@1.0.5: {}
+
sucrase@3.35.0:
dependencies:
'@jridgewell/gen-mapping': 0.3.5
diff --git a/src/layouts/Layout.astro b/src/layouts/Layout.astro
index 1d15274..d8da481 100644
--- a/src/layouts/Layout.astro
+++ b/src/layouts/Layout.astro
@@ -29,11 +29,12 @@ import Footer from "../components/Footer.astro";
-
-
-
+
+
+
+
-
+
diff --git a/src/pages/feed.xml.ts b/src/pages/feed.xml.ts
new file mode 100644
index 0000000..24cbe8b
--- /dev/null
+++ b/src/pages/feed.xml.ts
@@ -0,0 +1,142 @@
+import rss, { type RSSOptions } from '@astrojs/rss';
+import { releaseNotes } from '../release-notes';
+import type { ReleaseNote } from '../release-notes';
+
+/** The default number of entries to include in the RSS feed. */
+const RSS_ENTRY_LIMIT = 20;
+
+/**
+ * Handles the GET request for the `feed.xml` endpoint.
+ * @returns The RSS feed for the Zen Browser release notes.
+ */
+export function GET(context: any) {
+ // Just in case the release notes array is empty for whatever reason.
+ const latestDate = releaseNotes.length > 0
+ ? formatRssDate(releaseNotes[0].date)
+ : new Date();
+
+ const rssData: RSSOptions = {
+ title: "Zen Browser Release Notes",
+ description: "Release Notes for the Zen Browser",
+ site: context.url,
+ items: [],
+ customData: `
+ en
+ https://www.zen-browser.app/release-notes
+ Zen Browser © ${new Date().getFullYear()} - Made with ❤️ by the Zen team.
+ ${pubDate(latestDate)}
+
+ https://www.zen-browser.app/favicon.ico
+ Zen Browser
+ https://www.zen-browser.app
+
+ `
+ };
+
+ for (const releaseNote of releaseNotes.slice(0, RSS_ENTRY_LIMIT)) {
+ rssData.items.push({
+ title: `Release notes for version ${releaseNote.version}`,
+ link: `https://www.zen-browser.app/release-notes/${releaseNote.version}`,
+ pubDate: formatRssDate(releaseNote.date),
+ description: releaseNote.extra,
+ content: formatReleaseNote(releaseNote),
+ });
+ }
+
+ return rss(rssData);
+}
+
+/**
+ * Formats a date string in the format day/month/year.
+ *
+ * Note: If release notes change to ISO format, this will need to be updated.
+ * @param dateStr The date string to format.
+ * @returns The passed in date string as a Date object.
+ */
+function formatRssDate(dateStr: string) {
+ const splitDate = dateStr.split("/");
+ if (splitDate.length !== 3) {
+ throw new Error("Invalid date format");
+ }
+
+ const day = Number(splitDate[0]);
+ const month = Number(splitDate[1]) - 1;
+ const year = Number(splitDate[2]);
+ return new Date(year, month, day);
+}
+
+/**
+ * Formats the release note entry for use as the content of the RSS feed.
+ * @param releaseNote The release note to format.
+ * @returns The formatted release note as a HTML string.
+ */
+function formatReleaseNote(releaseNote: ReleaseNote) {
+ let content = `
+ If you encounter any issues, please report them on the issues page.
+ Thanks everyone for your feedback! ❤️
+
`;
+
+ if (releaseNote.image) {
+ content += `
`;
+ }
+
+ if (releaseNote.extra) {
+ content += `${releaseNote.extra.replace(/(\n)/g, "
")}
`;
+ }
+
+ content += addReleaseNoteSection("⚠️ Breaking changes", releaseNote.breakingChanges);
+ content += addReleaseNoteSection("✓ Fixes", releaseNote.fixes?.map(fixToReleaseNote));
+ content += addReleaseNoteSection("🖌 Theme Changes", releaseNote.themeChanges)
+ content += addReleaseNoteSection("⭐ Features", releaseNote.features);
+
+ return content;
+}
+
+function addReleaseNoteSection(title: string, items?: string[]): string {
+ if (!items) {
+ return "";
+ }
+
+ let content = `${title}
`;
+ content += ``;
+ for (const item of items) {
+ if (item && item.length > 0) {
+ content += `- ${item}
`;
+ }
+ }
+ content += `
`;
+ return content;
+}
+
+function fixToReleaseNote(fix?: Exclude[number]) {
+ if (!fix || !fix.description || fix.description.length === 0) {
+ return "";
+ }
+
+ let note = fix.description;
+ if (fix.issue) {
+ note += ` (#${fix.issue})`;
+ }
+ return note;
+}
+
+function pubDate(date?: Date) {
+ date ??= new Date();
+
+ const pieces = date.toString().split(' ');
+ const offsetTime = pieces[5].match(/[-+]\d{4}/);
+ const offset = (offsetTime) ? offsetTime : pieces[5];
+ const parts = [
+ pieces[0] + ',',
+ pieces[2],
+ pieces[1],
+ pieces[3],
+ pieces[4],
+ offset
+ ];
+
+ return parts.join(' ');
+}