diff --git a/eslint.config.mjs b/eslint.config.mjs
index 0552609c2126..7fc04bdf812c 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -8,6 +8,7 @@ export default defineConfig([
globalIgnores([
"**/dist/",
"**/examples/",
+ "**/printable.mdx",
"src/content/loaders/_*.mdx",
"src/content/plugins/_*.mdx",
"src/content/contribute/Governance-*.mdx",
diff --git a/package.json b/package.json
index 2472d23c3e13..f15aaab9cb58 100644
--- a/package.json
+++ b/package.json
@@ -36,7 +36,7 @@
"fetch-all": "run-s fetch-repos fetch",
"prebuild": "npm run clean",
"build": "run-s fetch-repos fetch:governance fetch content && webpack --config webpack.prod.mjs --config-node-env production && run-s printable content && webpack --config webpack.ssg.mjs --config-node-env production --env ssg",
- "postbuild": "npm run sitemap",
+ "postbuild": "npm run sitemap && npm run rss",
"build-test": "npm run build && http-server --port 4200 dist/",
"serve-dist": "http-server --port 4200 dist/",
"test": "npm run lint",
@@ -47,6 +47,7 @@
"lint:prose": "vale --config='.vale.ini' src/content",
"lint:links": "hyperlink -c 8 --root dist -r dist/index.html --canonicalroot https://webpack.js.org/ --internal --skip /plugins/extract-text-webpack-plugin/ --skip /printable --skip /contribute/Governance --skip https:// --skip http:// --skip sw.js --skip /vendor > internal-links.tap; cat internal-links.tap | tap-spot",
"sitemap": "cd dist && sitemap-static --ignore-file=../sitemap-ignore.json --pretty --prefix=https://webpack.js.org/ > sitemap.xml",
+ "rss": "node src/scripts/generate-rss.mjs",
"serve": "npm run build && sirv start ./dist --port 4000",
"preprintable": "npm run clean-printable",
"printable": "node ./src/scripts/concatenate-docs.mjs",
diff --git a/src/components/Site/Site.jsx b/src/components/Site/Site.jsx
index 523a9d4742c7..2736a344aad6 100644
--- a/src/components/Site/Site.jsx
+++ b/src/components/Site/Site.jsx
@@ -199,6 +199,12 @@ function Site(props) {
location.pathname,
)}`}
/>
+
diff --git a/src/scripts/generate-rss.mjs b/src/scripts/generate-rss.mjs
new file mode 100644
index 000000000000..f3cbe5501248
--- /dev/null
+++ b/src/scripts/generate-rss.mjs
@@ -0,0 +1,107 @@
+import fs from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const ROOT = path.resolve(__dirname, "../..");
+const BASE_URL = "https://webpack.js.org";
+
+function escapeXml(text) {
+ return String(text)
+ .replaceAll("&", "&")
+ .replaceAll("<", "<")
+ .replaceAll(">", ">")
+ .replaceAll('"', """)
+ .replaceAll("'", "'");
+}
+
+function extractPubDate(node) {
+ const name = node.name || "";
+
+ const filenameDateMatch = name.match(/^(\d{4}-\d{2}-\d{2})-/);
+ if (filenameDateMatch) {
+ const date = new Date(filenameDateMatch[1]);
+ if (!Number.isNaN(date.getTime())) return date;
+ }
+
+ const title = node.title || "";
+ const titleDateMatch = title.match(/\((\d{4}-\d{2}-\d{2})\)/);
+ if (titleDateMatch) {
+ const date = new Date(titleDateMatch[1]);
+ if (!Number.isNaN(date.getTime())) return date;
+ }
+
+ const filePath = path.resolve(ROOT, node.path);
+ try {
+ const stat = fs.statSync(filePath);
+ return stat.mtime;
+ } catch {
+ return new Date();
+ }
+}
+
+function formatRfc2822(date) {
+ return date.toUTCString();
+}
+
+function main() {
+ const contentPath = path.resolve(ROOT, "src/_content.json");
+ const contentTree = JSON.parse(fs.readFileSync(contentPath, "utf8"));
+
+ const blogSection = contentTree.children?.find(
+ (child) => child.name === "blog" && child.type === "directory",
+ );
+
+ if (!blogSection?.children) {
+ throw new Error("generate-rss: blog section not found in content tree");
+ }
+
+ const posts = blogSection.children
+ .filter(
+ (child) =>
+ child.type === "file" &&
+ (child.extension === ".mdx" || child.extension === ".md") &&
+ child.name !== "index" &&
+ child.name !== "printable" &&
+ child.url !== "/blog/",
+ )
+ .map((node) => ({
+ title: node.title || "Untitled",
+ link: `${BASE_URL}${node.url}`,
+ pubDate: extractPubDate(node),
+ description: node.description || node.title || "Untitled",
+ }))
+ .toSorted((a, b) => b.pubDate.getTime() - a.pubDate.getTime());
+
+ const lastBuildDate = formatRfc2822(new Date());
+
+ const xml = `
+
+
+ webpack Blog
+ ${BASE_URL}/blog/
+ Announcements and updates from the webpack team
+ en
+ ${escapeXml(lastBuildDate)}
+
+${posts
+ .map(
+ (post) => ` -
+ ${escapeXml(post.title)}
+ ${escapeXml(post.link)}
+ ${escapeXml(post.description)}
+ ${escapeXml(formatRfc2822(post.pubDate))}
+ ${escapeXml(post.link)}
+
`,
+ )
+ .join("\n")}
+
+
+`;
+
+ const distPath = path.resolve(ROOT, "dist/feed.xml");
+ fs.writeFileSync(distPath, xml, "utf8");
+ console.log(`Successfully generated RSS feed at ${distPath}`);
+}
+
+main();