blog.git

blog

~ Personal research notes. blog.sqzer.com
git clone https://github.com/debuman/blog.git
commit 3dc63b8daffe31d8b85da7d85d48669289707a9d
parent de512a179fa3a71df2129ca36072693316f0795c
Author: sqzer <110329244+sqzer-x@users.noreply.github.com>
Date: Wed, 23 Sep 2026 17:23:43 +0900

feat: a post template every field can be left blank in

Writing a post meant remembering which keys exist: the 30 posts in the corpus carry
title, date and tags, and nothing else, because the other four fields were easy to forget
and the template offered no help. The template now ships every field the schema has,
blank, so writing a post is filling in what you have something to say about.

Blank had to be made safe first. Three fields rejected it: a bare `type:`, `draft:` or
`tags:` is YAML null, and the enum, the boolean and the array default all refuse null, so
copying the old template and clearing its values broke the build. One wrapper now absorbs
the three spellings of nothing — absent, null and empty string — for every optional field,
replacing four hand-rolled variations of the same idea.

Blank also means invisible: a blank deck draws no deck paragraph, and the description
falls back to the post's opening sentence. Verified on a post copied from the template
with only title and date filled in.

Two pre-chosen values are gone from the template. `draft: true` was the dangerous one —
copy it, write a post, build clean, and the post is nowhere on the site. `date:` ships
blank because an inherited date is a wrong date, and the year segment of the URL comes
from it.

The field guide moved into the template's body rather than sitting as YAML comments: a
comment in front matter is copied into every post made from the template and stays there,
while the body is overwritten by the first sentence typed.

The gate learned the failures an author actually hits. It parses the front matter as YAML
now instead of only pattern-matching it, so a quote typed inside the template's `deck: ""`
fails at the file with a line number rather than somewhere inside Astro with a message
about a collection entry. An unquoted `#` silently truncating a value is warned about by
comparing what was typed against what parsed. Whole-line and trailing comments are allowed
in the template, which is the one file whose job is to explain itself, and a value left in
it is a warning rather than a failed deploy.

Also fixed on the way: JSON.stringify does not escape `<`, so a deck containing
`</script>` closed the JSON-LD element early and the rest of the object was parsed as
HTML. Now that deck is a field authors fill in freely, that is a string they can type.

Diffstat:
Mcontent/_template-code.md | 4++--
Mcontent/_template.md | 52++++++++++++++++++++++++++++++++++++++++++++++++----
Mpackage-lock.json | 54++++++++++++++++++++++++++++++++++++++++++++++++++----
Mpackage.json | 1+
Mscripts/check-content.mjs | 168++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Msrc/content.config.ts | 38++++++++++++++++++++++++++++----------
Msrc/pages/writing/[year]/[slug].astro | 15+++++++++++++--
7 files changed, 300 insertion(+), 32 deletion(-)

diff --git a/content/_template-code.md b/content/_template-code.md
@@ -5,8 +5,8 @@ name: ""
 url: ""
 # Position in the list, ascending. Numbered in tens in content/code, so a new project
 # goes between 30 and 40 as `order: 35` and nothing else has to be renumbered.
-order: 0
-draft: true
+order:
+draft:
 ---
 
 One sentence saying what it does. This body IS the description the Code page prints.
diff --git a/content/_template.md b/content/_template.md
@@ -1,11 +1,55 @@
 ---
 title: ""
 titleKo: ""
+date:
 deck: ""
-date: 2026-01-01
-type: essay
+type:
 tags: []
-draft: true
+draft:
 ---
 
-Body starts here.
+Copy this file to `content/writing/<slug>.md`, fill the front matter in from the top, and
+replace everything below with the post. Keep it out of `content/writing/` while it is a
+template: the collection loads every `.md` in that directory, and a blank has no date.
+
+The filename is the address. A file named `tso.md` dated 2026-03-04 is published at
+`/writing/2026/tso/`, so the slug is worth choosing before the first save and is awkward
+to change afterwards — the old URL does not redirect.
+
+Leave any line you have nothing to say on exactly as it ships. Blank does not mean an
+empty element on the page: a blank field parses to nothing at all, and every page tests
+for a value before it draws anything, so a blank `deck:` produces no deck paragraph
+rather than an empty one. Only `title` and `date` have to be filled in.
+
+**title** — required, and the string the post is known by everywhere: the heading, the
+entry on /writing/, the browser tab, the feed item, the search result. Quoted because a
+title containing `:` or opening with `#` breaks an unquoted YAML value.
+
+**titleKo** — the original Korean title of a post that now carries an English one. No page
+prints it today; it is kept so a rename does not lose the first title. Blank: nothing.
+
+**date** — required, `YYYY-MM-DD`. It orders the whole site and supplies the year segment
+of the URL, so changing it later moves the post's address. It ships blank on purpose: a
+date inherited from a template is the day the template was written, and that wrong date
+would be baked into the URL. Blank stops the build with `date is not YYYY-MM-DD: (none)`.
+
+**deck** — the line under the title, set in the display face, saying what the post is. It
+is also the page's `<meta name="description">` and the post's description in the RSS feed.
+Blank: no deck is drawn, and the description falls back to the opening sentence of the
+post itself.
+
+**type** — `essay` or `research`, and nothing else; any other word stops the build. It is
+published as a `data-type` attribute on the entry in /writing/, which is what a later
+split into sub-views would key on. Blank: the attribute is omitted and the post is
+untyped, which is what every post on the site is today.
+
+**tags** — a list, written `tags: ["dns", "network"]`. The brackets ship filled in because
+they are the shape: a bare word after the colon is a string, not a list, and is rejected.
+Nothing on the site renders tags yet, so they are for the archive rather than the page.
+Blank or `[]`: no tag list, and the content gate prints a warning naming the post.
+
+**draft** — `true` withholds the post: it is dropped from /writing/ and from the feed, and
+no page is generated for it, so its URL 404s. `false` publishes, and so does a blank line;
+`yes` and `no` are words here, not booleans, and stop the build.
+It ships blank rather than `true` because a template that hides whatever is written from
+it produces a post that reads as finished, builds clean, and is nowhere on the site.
diff --git a/package-lock.json b/package-lock.json
@@ -11,6 +11,7 @@
       "devDependencies": {
         "@astrojs/rss": "^4.0.19",
         "@astrojs/sitemap": "^3.7.4",
+        "js-yaml": "^5.4.2",
         "mermaid": "12.0.0"
       },
       "engines": {
@@ -252,6 +253,28 @@
         "unified": "^11.0.5"
       }
     },
+    "node_modules/@astrojs/internal-helpers/node_modules/js-yaml": {
+      "version": "4.3.2",
+      "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
+      "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/puzrin"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/nodeca"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "argparse": "^2.0.1"
+      },
+      "bin": {
+        "js-yaml": "bin/js-yaml.js"
+      }
+    },
     "node_modules/@astrojs/markdown-satteri": {
       "version": "0.4.1",
       "resolved": "https://registry.npmjs.org/@astrojs/markdown-satteri/-/markdown-satteri-0.4.1.tgz",
@@ -2615,6 +2638,28 @@
         }
       }
     },
+    "node_modules/astro/node_modules/js-yaml": {
+      "version": "4.3.2",
+      "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
+      "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/puzrin"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/nodeca"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "argparse": "^2.0.1"
+      },
+      "bin": {
+        "js-yaml": "bin/js-yaml.js"
+      }
+    },
     "node_modules/axobject-query": {
       "version": "4.1.0",
       "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
@@ -3977,9 +4022,10 @@
       "license": "MIT"
     },
     "node_modules/js-yaml": {
-      "version": "4.3.2",
-      "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
-      "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==",
+      "version": "5.4.2",
+      "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.4.2.tgz",
+      "integrity": "sha512-m+aqu+LwO1O6sIopafj8HUVl5aawITwZQe/yHpMCKjaWBaA/d07B/QdMb3529REftiU+RMMHL3Vlsw3hON7vWg==",
+      "dev": true,
       "funding": [
         {
           "type": "github",
@@ -3995,7 +4041,7 @@
         "argparse": "^2.0.1"
       },
       "bin": {
-        "js-yaml": "bin/js-yaml.js"
+        "js-yaml": "bin/js-yaml.mjs"
       }
     },
     "node_modules/jsonc-parser": {
diff --git a/package.json b/package.json
@@ -19,6 +19,7 @@
   "devDependencies": {
     "@astrojs/rss": "^4.0.19",
     "@astrojs/sitemap": "^3.7.4",
+    "js-yaml": "^5.4.2",
     "mermaid": "12.0.0"
   }
 }
diff --git a/scripts/check-content.mjs b/scripts/check-content.mjs
@@ -10,6 +10,7 @@
 import { readdir, readFile } from 'node:fs/promises';
 import { existsSync } from 'node:fs';
 import path from 'node:path';
+import { load as parseYaml } from 'js-yaml';
 
 const SRC = process.env.CONTENT_DIR ?? '.';
 const STRICT = new Set((process.env.CONTENT_STRICT ?? 'urls,schema').split(',').map((s) => s.trim()));
@@ -20,6 +21,19 @@ const err = (f, m) => errors.push(`${f}: ${m}`);
 const warn = (f, m) => warns.push(`${f}: ${m}`);
 const req = (k, f, m) => (on(k) ? err(f, m) : warn(f, m));
 
+/**
+ * Read a content file, dropping a leading UTF-8 BOM.
+ *
+ * Windows editors write one by default — PowerShell's own `Out-File -Encoding utf8` does —
+ * and it lands in front of the opening `---`. Astro strips it and parses the file normally,
+ * so a post carrying one builds and publishes correctly. Without this, every front-matter
+ * regex below misses on the very first character and the gate stops the build with
+ * `missing front matter` for a file whose front matter is plainly there: a message that
+ * states the opposite of what is true, on a file the build would have accepted. The gate is
+ * here to agree with the build, not to be stricter than it over an invisible byte.
+ */
+const read = async (p) => (await readFile(p, 'utf8')).replace(/^\uFEFF/, '');
+
 const asciiLower = (s) => s.replace(/[A-Z]/g, (c) => String.fromCharCode(c.charCodeAt(0) + 32));
 /** 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']);
@@ -27,19 +41,68 @@ const RESERVED = new Set(['essay', 'research', 'video', 'slides', 'podcast', 'in
 const files = [];
 {
   const d = path.join(SRC, 'content', 'writing');
+  /* Underscore files are deliberately NOT skipped, because Astro does not skip them: the
+     glob loader in src/content.config.ts matches `*.md`, so a post parked as `_wip.md` is
+     loaded, schema-checked and built into a page like any other. Skipping it here only
+     meant the gate reported "0 errors — passed" and the build then died on
+     InvalidContentEntryDataError, which names no field and points at line 0. Checking it
+     costs nothing — there are no underscore files in this directory, and both templates
+     live at content/ top level, outside this glob — and it puts the URL-collision and
+     reserved-slug rules onto files that really do become URLs. The supported way to park
+     a post is `draft: true`, which every page and the feed honour. */
   if (existsSync(d))
-    for (const f of await readdir(d)) if (f.endsWith('.md') && !f.startsWith('_')) files.push(path.join(d, f));
+    for (const f of await readdir(d)) if (f.endsWith('.md')) files.push(path.join(d, f));
 }
 if (files.length === 0) err('(all)', 'no markdown found under content — the checkout may have failed');
 
 const seen = new Map();
 for (const abs of files) {
   const rel = path.relative(SRC, abs).split(path.sep).join('/');
-  const raw = await readFile(abs, 'utf8');
+  const raw = await read(abs);
   const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(raw);
   if (!m) { err(rel, 'missing front matter'); continue; }
   const fm = m[1];
-  const get = (k) => (new RegExp(`^${k}:\s*(.*)$`, 'm').exec(fm)?.[1] ?? '').trim().replace(/^["']|["']$/g, '');
+
+  /* Parse the front matter for real before reading it with regexes.
+     The checks below read values by pattern, which answers "what did the author type" but
+     never "is this YAML at all". That gap has one very likely victim: the template ships
+     `deck: ""`, and typing a quoted phrase inside those quotes gives
+     `deck: "Ratios: what's a "good" one?"`, which is not valid YAML. The regexes see a
+     non-empty deck and pass it; the build then fails elsewhere with a message about a
+     collection entry, naming neither the file nor the quote. Parsing here fails at the
+     file, with a line number, before Astro is reached. */
+  let parsed = null;
+  try {
+    parsed = parseYaml(fm);
+    if (parsed !== null && typeof parsed !== 'object') throw new Error('front matter is not a set of key: value fields');
+  } catch (e) {
+    const where = e && e.mark && e.mark.line != null ? ` (front matter line ${e.mark.line + 1})` : '';
+    err(rel, `front matter is not valid YAML${where}: ${String(e.message).split('\n')[0]}`);
+    err(rel, '  a quote inside a quoted value has to be escaped: write the whole value in single quotes, or double the inner ones');
+    continue;
+  }
+
+  /* An unquoted `#` opens a YAML comment, so `deck: Ratios of what # and why` keeps only
+     "Ratios of what" and loses the rest on the page, in the meta description and in the
+     feed. Nothing downstream can tell that from a deck that was always short, so the one
+     place it can be noticed is here, by comparing what was typed against what parsed. */
+  for (const [k, v] of Object.entries(parsed || {})) {
+    if (typeof v !== 'string') continue;
+    const typed = (new RegExp('^' + k + ':[ \\t]*(.*)$', 'm').exec(fm) || [])[1] || '';
+    const t = typed.trim();
+    if (t.includes('#') && !t.startsWith('"') && !t.startsWith("'") && v.length < t.length - 1)
+      warn(rel, `${k} is cut short at a "#": YAML read it as ${JSON.stringify(v)} — quote the whole value to keep the rest`);
+  }
+  /* `[ \\t]*`, and neither `\s*` nor the literal `s*` this used to be.
+     `\s` inside a template literal is not an escape sequence — the backslash is dropped
+     and the class becomes a literal `s*`, which works on every value that does not begin
+     with an `s` immediately after the colon. Writing it `\\s*` fixes that and breaks
+     something worse: `\s` matches newlines, so on a key with nothing after it the class
+     runs past the end of the line and `(.*)` captures the NEXT key's line. A blank `deck:`
+     then reads as `type:` — non-empty, and the missing-deck warning silently stops firing.
+     Now that blank values are the template's normal state, that is the case to get right.
+     A YAML key is separated from its value by spaces or tabs, on its own line. */
+  const get = (k) => (new RegExp(`^${k}:[ \\t]*(.*)$`, 'm').exec(fm)?.[1] ?? '').trim().replace(/^["']|["']$/g, '');
 
   const title = get('title');
   const date = get('date');
@@ -54,9 +117,23 @@ for (const abs of files) {
   if (seen.has(url)) err(rel, `URL collision: ${url} (already taken by ${seen.get(url)})`);
   else seen.set(url, rel);
 
-  // Decks and tags are still being backfilled, so these are staged rather than hard errors.
+  /* Decks and tags are still being backfilled, so these are staged rather than hard
+     errors — and they have to stay staged now that content/_template.md ships both of
+     them blank, or copying the template would produce a post the gate refuses.
+
+     Each counts three spellings of nothing as the same thing: the key absent, the key
+     with nothing after it (YAML null, which is what the template ships) and an explicitly
+     empty value. The schema absorbs all three to the same parsed value, so a gate that
+     told them apart would be reporting on punctuation rather than on content. */
   if (!get('deck')) req('deck', rel, 'deck is empty');
-  if (/^tags:\s*\[\s*\]\s*$/m.test(fm)) req('tags', rel, 'tags is empty');
+
+  /* Tags have two YAML spellings and only one of them is on the key's own line:
+     `tags: [a, b]` is visible to get(), a block sequence puts its items on the lines
+     below. Looking only for `tags: []`, as this did, meant a post carrying no `tags:` key
+     at all passed the check in silence — the absent case, not a rare one here. */
+  const tagsInline = /^tags:[ \t]*(.*)$/m.exec(fm);
+  const tagsBlock = /^tags:[ \t]*\r?\n[ \t]*-[ \t]*\S/m.test(fm);
+  if (!tagsBlock && !(tagsInline && /[^\s[\],]/.test(tagsInline[1]))) req('tags', rel, 'tags is empty');
 
   // Every image the prose references must exist.
   for (const im of raw.matchAll(/\/uploads\/([^\s"')\]]+)/g)) {
@@ -65,6 +142,73 @@ for (const abs of files) {
   }
 }
 
+/* ────────────────────────────────────────────────────────────────────────────
+   content/_template.md — the blank every post is copied from.
+
+   It is checked, but NOT as a post, and the `_` skip in the loop above stays exactly as
+   it is. Every rule up there is a rule about a finished document — a title, a real date,
+   a slug that does not collide with another post's URL — and a template that had to
+   satisfy them would have to carry a title and a date, which is the one thing a blank
+   cannot do. Running the post rules over it would mean the template can only exist in a
+   state that is already a post.
+
+   What is worth checking is whether it is still a usable blank, and that is three
+   questions the post rules never ask. Is it well-formed, i.e. one `key: value` line per
+   field. Does it offer every field the schema has — a field added to the schema and not
+   to the template is a field no post will ever carry, because the template is where post
+   front matter comes from, and nothing else would ever report that. And is every value
+   actually blank: this file shipped `draft: true` and `date: 2026-01-01` until recently,
+   which meant copying it produced a post silently dated to the template's own birthday
+   and silently withheld from the site.
+
+   The file is optional the way content/code is: absent, this block does nothing, so a
+   checkout without it fails no differently than before.
+   ──────────────────────────────────────────────────────────────────────────── */
+
+/* The field list of the `writing` collection in src/content.config.ts, duplicated here on
+   purpose and for the same reason isProjectUrl is duplicated below — this script runs in
+   `prebuild`, before Astro has parsed anything. Keep the two in step. */
+const WRITING_FIELDS = ['title', 'titleKo', 'date', 'deck', 'type', 'tags', 'draft'];
+
+{
+  const abs = path.join(SRC, 'content', '_template.md');
+  const rel = 'content/_template.md';
+  if (existsSync(abs)) {
+    const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(await read(abs));
+    if (!m) err(rel, 'missing front matter — the template is what post front matter is copied from');
+    else {
+      const fields = new Map();
+      let shaped = true;
+      for (const line of m[1].split(/\r?\n/)) {
+        /* A whole-line comment is allowed here. This is the one file whose job is to
+           explain itself, and forbidding the normal way to annotate YAML would be a strange
+           rule for it — content/_template-code.md already annotates itself that way. */
+        if (!line.trim() || line.trim().startsWith('#')) continue;
+        const kv = /^([A-Za-z][A-Za-z0-9_]*):[ \t]*(.*)$/.exec(line);
+        if (!kv) { err(rel, `front matter is not one "key: value" line per field: ${line.trim()}`); shaped = false; break; }
+        /* A trailing comment is not a value. `date:   # YYYY-MM-DD` is a blank field with a
+           note beside it, and calling that filled would push the author into deleting the
+           hints this file exists to carry. */
+        fields.set(kv[1], kv[2].replace(/(^|\s)#.*$/, '').trim());
+      }
+      if (shaped) {
+        const missing = WRITING_FIELDS.filter((k) => !fields.has(k));
+        const extra = [...fields.keys()].filter((k) => !WRITING_FIELDS.includes(k));
+        /* Blank, in the three spellings the schema absorbs, plus the empty list `tags`
+           carries so that its shape is visible to whoever fills it in. */
+        const filled = [...fields].filter(([, v]) => !/^(|""|''|\[\s*\])$/.test(v)).map(([k]) => k);
+        if (missing.length) err(rel, `front matter is missing ${missing.join(', ')} — every field the schema offers belongs here, blank`);
+        if (extra.length) err(rel, `front matter offers ${extra.join(', ')}, which the writing schema does not have`);
+        /* A warning, not an error. A value left here is inherited unread by every post
+           copied from the template — which is how `draft: true` once produced posts that
+           built clean and were nowhere on the site — but it is also how an author keeps a
+           default they actually want. Say it on every build; do not stop the deploy. */
+        if (filled.length) warn(rel, `a value is left in ${filled.join(', ')} — every post copied from this template inherits it unread`);
+      }
+    }
+  }
+}
+
 /* ────────────────────────────────────────────────────────────────────────────
    content/code — the Code index.
 
@@ -84,8 +228,10 @@ for (const abs of files) {
 const codeFiles = [];
 {
   const d = path.join(SRC, 'content', 'code');
+  /* Not skipping underscore files, for the reason the writing loop above spells out: the
+     code collection's loader is the same `*.md` glob, so it loads them too. */
   if (existsSync(d))
-    for (const f of await readdir(d)) if (f.endsWith('.md') && !f.startsWith('_')) codeFiles.push(path.join(d, f));
+    for (const f of await readdir(d)) if (f.endsWith('.md')) codeFiles.push(path.join(d, f));
 }
 
 /** The same test src/content.config.ts applies, so the two cannot disagree about a link. */
@@ -102,13 +248,15 @@ const isProjectUrl = (v) => {
 const orders = new Map();
 for (const abs of codeFiles) {
   const rel = path.relative(SRC, abs).split(path.sep).join('/');
-  const raw = await readFile(abs, 'utf8');
+  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; }
   const [, fm, body] = m;
-  /* Local, and `\\s` rather than `\s`: inside a template literal `\s` is not an escape
-     sequence, so the backslash is dropped and the class becomes a literal `s*`. */
-  const get = (k) => (new RegExp(`^${k}:\\s*(.*)$`, 'm').exec(fm)?.[1] ?? '').trim().replace(/^["']|["']$/g, '');
+  /* Local, and `[ \\t]*` for the reason the writing loop's copy spells out: `\s` in a
+     template literal degrades to a literal `s`, and `\\s` matches newlines, so a key with
+     nothing after it captures the next key's line — a blank `name:` would read as `url:`
+     and the missing-name error would never fire. */
+  const get = (k) => (new RegExp(`^${k}:[ \\t]*(.*)$`, 'm').exec(fm)?.[1] ?? '').trim().replace(/^["']|["']$/g, '');
 
   if (!get('name')) err(rel, 'missing name');
 
diff --git a/src/content.config.ts b/src/content.config.ts
@@ -13,11 +13,26 @@ const dateString = z.union([z.string(), z.date()]).transform((v) =>
   typeof v === 'string' ? v.slice(0, 10) : v.toISOString().slice(0, 10),
 );
 
-/** An empty `deck:` parses as null, which fails validation unless absorbed to undefined. */
-const optText = z.preprocess(
-  (v) => (v === null || v === '' ? undefined : v),
-  z.string().optional(),
-);
+/**
+ * YAML reads a key with nothing after it — `deck:` — as null, and null satisfies neither
+ * `.optional()`, which admits undefined and not null, nor `.default([])`, which fires on
+ * undefined only. Left alone, every optional field rejects the blank line while accepting
+ * the key being absent altogether, which is exactly backwards for a site whose posts are
+ * copied from a template of blanks.
+ *
+ * So each optional field is wrapped here, and both spellings of "nothing" — bare null and
+ * the quoted empty string `deck: ""` — are absorbed to undefined before the field's own
+ * schema sees the value. The field schema is still the one that runs, so `type: novel` and
+ * `tags: security` are rejected as loudly as before; only emptiness is reinterpreted.
+ *
+ * This is what content/_template.md stands on: a line the author leaves blank parses to
+ * undefined, and undefined is what every page already tests for before it prints anything.
+ */
+const blankable = <T extends z.ZodType>(field: T) =>
+  z.preprocess((v) => (v === null || v === '' ? undefined : v), field);
+
+/** Optional free text. Absent, `deck:` and `deck: ""` all arrive as undefined. */
+const optText = blankable(z.string().optional());
 
 const slugOf = ({ entry }: { entry: string }) =>
   asciiLower(entry.replace(/^.*\//, '').replace(/\.md$/, ''));
@@ -31,10 +46,12 @@ const writing = defineCollection({
     date: dateString,
     /** In an index without images the deck carries the entry. May be empty until backfilled. */
     deck: optText,
-    /** Sub-views split on this value rather than on a URL segment. */
-    type: z.enum(['essay', 'research']).optional(),
-    tags: z.array(z.string()).default([]),
-    draft: z.boolean().optional(),
+    /** Sub-views split on this value rather than on a URL segment. Untyped is the norm. */
+    type: blankable(z.enum(['essay', 'research']).optional()),
+    /** A blank `tags:` and `tags: []` mean the same thing, and neither prints a tag list. */
+    tags: blankable(z.array(z.string()).default([])),
+    /** Blank publishes. Only an explicit `draft: true` withholds a post. */
+    draft: blankable(z.boolean().optional()),
   }),
 });
 
@@ -89,7 +106,8 @@ const code = defineCollection({
     name: z.string(),
     url: projectUrl,
     order: z.number().int(),
-    draft: z.boolean().optional(),
+    /** Blankable for the same reason the writing fields are: `draft:` is null, not false. */
+    draft: blankable(z.boolean().optional()),
   }),
   /*
    * No `description` field. The one-sentence description is the markdown BODY of each
diff --git a/src/pages/writing/[year]/[slug].astro b/src/pages/writing/[year]/[slug].astro
@@ -164,7 +164,13 @@ const ld = {
   headline: post.data.title,
   datePublished: post.data.date,
   inLanguage: lang,
-  description,
+  /* Omitted rather than emitted empty. `description` is '' when the deck is blank AND
+     excerpt() finds no paragraph to fall back on — a post copied from the template whose
+     body has not been written yet. Both other sinks already drop it in that case:
+     Base.astro guards <meta name="description"> behind a truth test, and @astrojs/rss
+     omits the element. This key was the last place a blank field still put an empty
+     value into the page, asserting that the post has an empty description. */
+  ...(description ? { description } : {}),
   mainEntityOfPage: new URL(postUrl(post), Astro.site).href,
 };
 ---
@@ -273,7 +279,12 @@ const ld = {
     </footer>
   </article>
   </div>
-  <script type="application/ld+json" set:html={JSON.stringify(ld)} />
+  {/* JSON.stringify does not escape `<`, so a deck or a title containing `</script>` closes
+      this element early and the rest of the object is parsed as HTML. The title is the
+      author's and the deck is about to become a field they fill in freely, so this is a
+      string they can type, not a string only the build produces. `<` is escaped as its
+      < form, which is the same character to a JSON parser and inert to an HTML one. */}
+  <script type="application/ld+json" set:html={JSON.stringify(ld).replace(/</g, '\\u003c')} />
 </Base>
 
 <script>