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>
|