diff --git a/astro.config.mjs b/astro.config.mjs
@@ -1,5 +1,6 @@
// @ts-check
import { defineConfig } from 'astro/config';
+import sitemap from '@astrojs/sitemap';
import { satteri } from '@astrojs/markdown-satteri';
import satteriFigure from './src/lib/satteri-figure.mjs';
import satteriProse from './src/lib/satteri-prose.mjs';
@@ -89,6 +90,10 @@ const codeBlock = {
export default defineConfig({
site: 'https://blog.sqzer.com',
+ /* The Hugo site this replaced shipped a sitemap and the migration lost it. robots.txt
+ names it, so it has to exist: a robots line pointing at a 404 is the same defect as
+ the <head> feed link that pointed at nothing. */
+ integrations: [sitemap()],
output: 'static',
// Every canonical URL ends in a trailing slash; the directory format emits dist/<path>/index.html.
trailingSlash: 'always',
diff --git a/package-lock.json b/package-lock.json
@@ -9,6 +9,8 @@
"astro": "^7.3.3"
},
"devDependencies": {
+ "@astrojs/rss": "^4.0.19",
+ "@astrojs/sitemap": "^3.7.4",
"mermaid": "12.0.0"
},
"engines": {
@@ -274,6 +276,29 @@
"node": ">=22.12.0"
}
},
+ "node_modules/@astrojs/rss": {
+ "version": "4.0.19",
+ "resolved": "https://registry.npmjs.org/@astrojs/rss/-/rss-4.0.19.tgz",
+ "integrity": "sha512-e+z5wYeYtffQdHQO8c2tkSd2JEBdAuRXJV4ZEU5IxkYeE6e39woDd7nw1PH1Kk2tEYNCYuKdylnnbhGmt61awA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fast-xml-parser": "^5.5.7",
+ "piccolore": "^0.1.3",
+ "zod": "^4.3.6"
+ }
+ },
+ "node_modules/@astrojs/sitemap": {
+ "version": "3.7.4",
+ "resolved": "https://registry.npmjs.org/@astrojs/sitemap/-/sitemap-3.7.4.tgz",
+ "integrity": "sha512-LbKNC24bdUWcQf/pThB6qLlSqHojxGjZDURIzFocY8rlWnAn2t74nnhnK6S5x0NHriHoAduLEpVjRykmeGiVvA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "sitemap": "^9.0.0",
+ "zod": "^4.3.6"
+ }
+ },
"node_modules/@astrojs/telemetry": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/@astrojs/telemetry/-/telemetry-3.3.3.tgz",
@@ -1648,6 +1673,19 @@
"@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4"
}
},
+ "node_modules/@nodable/entities": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-3.0.0.tgz",
+ "integrity": "sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/nodable"
+ }
+ ],
+ "license": "MIT"
+ },
"node_modules/@oslojs/encoding": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@oslojs/encoding/-/encoding-1.1.0.tgz",
@@ -2370,6 +2408,26 @@
"@types/unist": "*"
}
},
+ "node_modules/@types/node": {
+ "version": "24.13.6",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.6.tgz",
+ "integrity": "sha512-SGrw/h3KPFshy3OE6ZL53LMBG5vGQQ8/gIpiqz/kRZhPJ7HgwCEs8LBuNtWLa8dvGZVpSF7+Bf+c11HUrCb/yg==",
+ "devOptional": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~7.18.0"
+ }
+ },
+ "node_modules/@types/sax": {
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/@types/sax/-/sax-1.2.7.tgz",
+ "integrity": "sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
@@ -2438,6 +2496,26 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
+ "node_modules/anynum": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz",
+ "integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/NaturalIntelligence"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/arg": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
+ "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
@@ -3601,6 +3679,47 @@
"fast-string-width": "^3.0.2"
}
},
+ "node_modules/fast-xml-builder": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.3.1.tgz",
+ "integrity": "sha512-pIM/1n3ntFXKYrUZwW7QCK0gAW7XY+wzj1YMIV3tLDvPj/V+zTGJK5e3/4WJfwj0qWw2ElNXiTixda/R+3YSug==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/NaturalIntelligence"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "path-expression-matcher": "^1.6.2",
+ "xml-naming": "^0.3.0"
+ }
+ },
+ "node_modules/fast-xml-parser": {
+ "version": "5.11.1",
+ "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.11.1.tgz",
+ "integrity": "sha512-TBw6K/fxoQGGjCmZDw9w/ZwP3uDcnTM4YH/g+PFRWr8sbe5idXtxNN6vITh4+1ruCZaho6uBFurElsA7F0zzgw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/NaturalIntelligence"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@nodable/entities": "^3.0.0",
+ "fast-xml-builder": "^1.2.0",
+ "is-unsafe": "^2.0.0",
+ "path-expression-matcher": "^1.6.2",
+ "strnum": "^2.4.2",
+ "xml-naming": "^0.3.0"
+ },
+ "bin": {
+ "fxparser": "src/cli/cli.js"
+ }
+ },
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -3844,6 +3963,19 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/is-unsafe": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.2.tgz",
+ "integrity": "sha512-HgbIHPBH0KHHCcjLfGsCvhtPTVxjaAZlXjwdz7/GQC40SjSe4sfQsar8J5VFo8JOSbarkpV0OLG95bbaNd9aAQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/NaturalIntelligence"
+ }
+ ],
+ "license": "MIT"
+ },
"node_modules/js-yaml": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
@@ -4558,6 +4690,22 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/path-expression-matcher": {
+ "version": "1.6.2",
+ "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz",
+ "integrity": "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/NaturalIntelligence"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
"node_modules/piccolore": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/piccolore/-/piccolore-0.1.3.tgz",
@@ -4910,6 +5058,26 @@
"integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==",
"license": "MIT"
},
+ "node_modules/sitemap": {
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/sitemap/-/sitemap-9.0.1.tgz",
+ "integrity": "sha512-S6hzjGJSG3d6if0YoF5kTyeRJvia6FSTBroE5fQ0bu1QNxyJqhhinfUsXi9fH3MgtXODWvwo2BDyQSnhPQ88uQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "^24.9.2",
+ "@types/sax": "^1.2.1",
+ "arg": "^5.0.0",
+ "sax": "^1.4.1"
+ },
+ "bin": {
+ "sitemap": "dist/esm/cli.js"
+ },
+ "engines": {
+ "node": ">=20.19.5",
+ "npm": ">=10.8.2"
+ }
+ },
"node_modules/smol-toml": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.8.0.tgz",
@@ -4955,6 +5123,22 @@
"url": "https://github.com/sponsors/wooorm"
}
},
+ "node_modules/strnum": {
+ "version": "2.4.2",
+ "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.2.tgz",
+ "integrity": "sha512-rDG3Ah4TV0k1hWvLSzkZtMmLN9+eS+h3knq4MP6A42Y3Yh5qGNnOUs1jJkoSr8FG5dsL28c7KgkIBzSEykqtuw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/NaturalIntelligence"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "anynum": "^1.0.1"
+ }
+ },
"node_modules/stylis": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/stylis/-/stylis-4.4.0.tgz",
@@ -5091,6 +5275,13 @@
"node": ">=22.19.0"
}
},
+ "node_modules/undici-types": {
+ "version": "7.18.2",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
+ "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
+ "devOptional": true,
+ "license": "MIT"
+ },
"node_modules/unified": {
"version": "11.0.5",
"resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
@@ -5435,6 +5626,22 @@
}
}
},
+ "node_modules/xml-naming": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz",
+ "integrity": "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/NaturalIntelligence"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
"node_modules/xxhash-wasm": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/xxhash-wasm/-/xxhash-wasm-1.1.0.tgz",
diff --git a/package.json b/package.json
@@ -17,6 +17,8 @@
"astro": "^7.3.3"
},
"devDependencies": {
+ "@astrojs/rss": "^4.0.19",
+ "@astrojs/sitemap": "^3.7.4",
"mermaid": "12.0.0"
}
}
diff --git a/src/pages/index.xml.ts b/src/pages/index.xml.ts
@@ -0,0 +1,46 @@
+import rss from '@astrojs/rss';
+import { getCollection } from 'astro:content';
+import type { APIContext } from 'astro';
+import { byHugoOrder, postUrl } from '../lib/order';
+import { excerpt } from '../lib/prose';
+
+/**
+ * The feed, at the one address the site has always claimed.
+ *
+ * Every page's <head> has carried `<link rel="alternate" type="application/rss+xml"
+ * href="/index.xml">` since the migration, and nothing answered it — a reader who
+ * subscribed got a 404 from a link the site itself printed. This is that file.
+ *
+ * One feed, not one per section: the section feeds the Hugo site shipped were dropped
+ * deliberately, and a blog that publishes into a single stream has nothing to split.
+ *
+ * Ordering and addresses come from src/lib/order.ts rather than being restated here, so
+ * the feed cannot drift from the Writing index. Descriptions reuse the same excerpt()
+ * the page's own <meta name="description"> uses, which means a post that gains a `deck`
+ * improves both at once.
+ *
+ * Escaping is @astrojs/rss's, not ours. The corpus is full of the characters that break
+ * hand-rolled XML: ampersands in shell pipelines, angle brackets in log output, quotes
+ * and em-dashes in Korean prose.
+ */
+export async function GET(context: APIContext) {
+ const posts = (await getCollection('writing'))
+ .filter((p) => !p.data.draft)
+ .sort(byHugoOrder);
+
+ return rss({
+ title: 'sqzer',
+ description: 'Notes on systems, networks, and the things around them.',
+ // context.site is astro.config.mjs's `site`. Required: every link in a feed is absolute.
+ site: context.site!,
+ items: posts.map((p) => ({
+ title: p.data.title,
+ // Dates are stored YYYY-MM-DD and read as UTC everywhere on this site; parsing them
+ // in local time moves half the corpus back a day.
+ pubDate: new Date(`${p.data.date}T00:00:00Z`),
+ description: p.data.deck ?? excerpt(p.body ?? ''),
+ link: postUrl(p),
+ })),
+ customData: '<language>en</language>',
+ });
+}
diff --git a/src/pages/robots.txt.ts b/src/pages/robots.txt.ts
@@ -0,0 +1,21 @@
+import type { APIContext } from 'astro';
+
+/**
+ * Written as an endpoint rather than dropped in public/ so the sitemap line is built from
+ * `site` in astro.config.mjs. A robots.txt that names a hard-coded host is one rename away
+ * from pointing at someone else's sitemap, and this domain has already moved once.
+ *
+ * Everything is allowed: the whole site is public and there is nothing here a crawler
+ * should be kept out of. The file exists for the sitemap line, not to forbid anything.
+ */
+export function GET(context: APIContext) {
+ const body = [
+ 'User-agent: *',
+ 'Allow: /',
+ '',
+ `Sitemap: ${new URL('sitemap-index.xml', context.site).href}`,
+ '',
+ ].join('\n');
+
+ return new Response(body, { headers: { 'Content-Type': 'text/plain; charset=utf-8' } });
+}
|