blog.git

blog

~ Personal research notes. blog.sqzer.com
git clone https://github.com/debuman/blog.git
commit 37e7f817d75c1833dd543062132b38e4dc5c4de5
parent 14e9108794c651cb5789d7d6226cc2664742f568
Author: sqzer <110329244+sqzer-x@users.noreply.github.com>
Date: Fri, 25 Sep 2026 19:42:14 +0900

fix: refuse file names, dates and case clashes the build mishandles

Three inputs passed the gate and then broke the site without an error:
- A file name with #, %, ? or a space. `#` dropped the post from every
page, the feed and the sitemap; `%` shipped an address GitHub Pages
answers with 400. File names are now held to letters in any script,
digits, - and _, for posts and code entries alike.
- A date that matches YYYY-MM-DD but does not exist. 2025-02-30 was
published on March 2, and 2025-12-32 under the next year's URL.
- Two files whose names differ only in letter case. They share one
collection id and the loader keeps one. They cannot coexist on
Windows, but a commit from Linux or the web editor can add the second.

Tested on fixtures, the clash in a case-sensitive directory; the 30
posts and 7 code entries pass unchanged.

Diffstat:
Mscripts/check-content.mjs | 31+++++++++++++++++++++++++++++++
1 file changed, 31 insertion(+), 0 deletion(-)

diff --git a/scripts/check-content.mjs b/scripts/check-content.mjs
@@ -48,6 +48,29 @@ const asciiLower = (s) => s.replace(/[A-Z]/g, (c) => String.fromCharCode(c.charC
 /** The year segment already keeps slugs away from sub-view names; this is a second line. */
 const RESERVED = new Set(['essay', 'research', 'video', 'slides', 'podcast', 'index', 'page', 'tags', 'feed', 'about']);
 
+/* A file name is a URL segment (writing) or an entry id (code), so it is held to what
+   survives as one: letters in any script, digits, - and _. Measured on a build: `#` in
+   the name dropped the post from every page, the feed and the sitemap with exit 0; `%`
+   shipped a URL GitHub Pages answers with 400; a space shipped a raw space in href. */
+const FILENAME = /^[\p{L}\p{N}][\p{L}\p{N}_-]*$/u;
+const FILENAME_RULE = 'file name may use only letters, digits, - and _ (it becomes the address; #, %, ? or a space breaks or drops the page)';
+
+/* The collection id is the file name with ASCII lowercased (src/content.config.ts), so
+   Arp.md and arp.md are one id and the loader keeps only one of them. The two cannot sit
+   side by side on Windows, but a commit made on Linux or in the web editor can add the
+   second, and the build would then drop a post without saying which. */
+const idClash = (ids, id, rel) => {
+  if (ids.has(id)) err(rel, `has the same id as ${ids.get(id)} ("${id}"): the names differ only in letter case, and one of the two would not be built`);
+  else ids.set(id, rel);
+};
+
+/* 2025-02-30 matches YYYY-MM-DD, and the loader rolls it over to March 2 and publishes it
+   there; 2025-12-32 rolls into the next year's URL, which then never moves back. */
+const realDate = (d) => {
+  const t = new Date(`${d}T00:00:00Z`);
+  return !Number.isNaN(t.getTime()) && t.toISOString().slice(0, 10) === d;
+};
+
 /* ── Images ──────────────────────────────────────────────────────────────────
    Every /uploads/ path a document references must exist in public/uploads, spelled
    exactly as on disk. Pages serves a case-sensitive file system, while existsSync on
@@ -147,6 +170,7 @@ const files = [];
 if (files.length === 0) err('(all)', 'no markdown found under content — the checkout may have failed');
 
 const seen = new Map();
+const writingIds = new Map();
 for (const abs of files) {
   const rel = path.relative(SRC, abs).split(path.sep).join('/');
   const raw = await read(abs);
@@ -199,10 +223,13 @@ for (const abs of files) {
   const date = get('date');
   if (!title) err(rel, 'missing title');
   if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) err(rel, `date is not YYYY-MM-DD: ${date || '(none)'}`);
+  else if (!realDate(date)) err(rel, `date is not a real calendar date: ${date}`);
 
   const slug = asciiLower(path.basename(abs, '.md'));
+  if (!FILENAME.test(path.basename(abs, '.md'))) err(rel, FILENAME_RULE);
   if (RESERVED.has(slug)) err(rel, `slug collides with a reserved name: ${slug}`);
   if (slug !== slug.normalize('NFC')) err(rel, 'filename is not NFC-normalised (decomposed Hangul jamo)');
+  idClash(writingIds, slug, rel);
 
   const url = `/writing/${date.slice(0, 4)}/${slug}/`;
   if (seen.has(url)) err(rel, `URL collision: ${url} (already taken by ${seen.get(url)})`);
@@ -333,8 +360,12 @@ const isProjectUrl = (v) => {
 };
 
 const orders = new Map();
+const codeIds = new Map();
 for (const abs of codeFiles) {
   const rel = path.relative(SRC, abs).split(path.sep).join('/');
+  const base = path.basename(abs, '.md');
+  if (!FILENAME.test(base)) err(rel, FILENAME_RULE);
+  idClash(codeIds, asciiLower(base), rel);
   const raw = await read(abs);
   const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(raw);
   if (!m) { err(rel, 'missing front matter'); continue; }