@wxxb789/anc 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +295 -0
  3. package/astro.config.mjs +255 -0
  4. package/bin/anc.mjs +798 -0
  5. package/package.json +93 -0
  6. package/public/_headers +45 -0
  7. package/public/favicon.ico +0 -0
  8. package/public/favicon.svg +9 -0
  9. package/public/fonts/Temml.woff2 +0 -0
  10. package/scripts/build-snapshot.js +69 -0
  11. package/scripts/build-wasm.js +67 -0
  12. package/scripts/build-workspace.js +50 -0
  13. package/scripts/copy-snapshot.js +48 -0
  14. package/scripts/copy-wasm.js +57 -0
  15. package/scripts/emit-redirects.js +64 -0
  16. package/scripts/generate-corpus.js +421 -0
  17. package/scripts/init-repository.js +249 -0
  18. package/scripts/load-config.js +975 -0
  19. package/scripts/markdown-to-artifact.js +1286 -0
  20. package/scripts/preview-site.js +364 -0
  21. package/scripts/publish-set-review.js +157 -0
  22. package/scripts/resolve-links.js +640 -0
  23. package/scripts/run-pagefind.js +125 -0
  24. package/scripts/scan-residue.js +1016 -0
  25. package/scripts/scan-secrets.js +268 -0
  26. package/scripts/seed-gitignore.js +410 -0
  27. package/scripts/snapshot-rows.js +205 -0
  28. package/scripts/validate-content.js +95 -0
  29. package/scripts/vendor-provenance.js +307 -0
  30. package/scripts/verify-output-inventory.js +307 -0
  31. package/scripts/write-report.js +351 -0
  32. package/scripts/write-snapshot.js +136 -0
  33. package/src/components/CollectionExplorer.astro +149 -0
  34. package/src/components/FacetIndex.astro +45 -0
  35. package/src/components/LinkedNotes.astro +94 -0
  36. package/src/components/NoteGraph.astro +434 -0
  37. package/src/components/NoteList.astro +54 -0
  38. package/src/components/SiteMetadata.astro +143 -0
  39. package/src/components/TableOfContents.astro +69 -0
  40. package/src/components/TagBrowser.astro +70 -0
  41. package/src/layouts/Layout.astro +305 -0
  42. package/src/lib/artifact-source.js +72 -0
  43. package/src/lib/build-bindings.js +11 -0
  44. package/src/lib/collection-navigation.js +145 -0
  45. package/src/lib/content.js +92 -0
  46. package/src/lib/diagram-mode.js +18 -0
  47. package/src/lib/graph-layout.js +219 -0
  48. package/src/lib/graph-selection.js +84 -0
  49. package/src/lib/graph.js +42 -0
  50. package/src/lib/link-resolution.js +389 -0
  51. package/src/lib/markdown.js +1469 -0
  52. package/src/lib/math-mode.js +31 -0
  53. package/src/lib/math-output.js +131 -0
  54. package/src/lib/math.js +165 -0
  55. package/src/lib/mermaid-environment.js +475 -0
  56. package/src/lib/mermaid-render.js +769 -0
  57. package/src/lib/preview-model.js +96 -0
  58. package/src/lib/relations.js +242 -0
  59. package/src/lib/rendered-marker.js +55 -0
  60. package/src/lib/route-path.js +192 -0
  61. package/src/lib/routes.js +342 -0
  62. package/src/lib/schema.js +578 -0
  63. package/src/lib/site.js +578 -0
  64. package/src/lib/snapshot-contract.js +216 -0
  65. package/src/lib/snapshot-operations.js +147 -0
  66. package/src/lib/snapshot-queries.js +216 -0
  67. package/src/lib/snapshot-reader.js +210 -0
  68. package/src/lib/snapshot.js +233 -0
  69. package/src/lib/sqlite-warning.js +22 -0
  70. package/src/lib/sqlite.js +14 -0
  71. package/src/lib/tag-browser-model.js +18 -0
  72. package/src/lib/translations.js +533 -0
  73. package/src/lib/wasm-asset.js +47 -0
  74. package/src/lib/worker-protocol.js +93 -0
  75. package/src/pages/404.astro +35 -0
  76. package/src/pages/about.astro +100 -0
  77. package/src/pages/collections/[slug].astro +36 -0
  78. package/src/pages/collections/index.astro +33 -0
  79. package/src/pages/graph.astro +64 -0
  80. package/src/pages/index.astro +33 -0
  81. package/src/pages/notes/[slug].astro +509 -0
  82. package/src/pages/privacy.astro +121 -0
  83. package/src/pages/private.astro +59 -0
  84. package/src/pages/recent.astro +33 -0
  85. package/src/pages/robots.txt.js +15 -0
  86. package/src/pages/rss.xml.js +21 -0
  87. package/src/pages/sitemap.xml.js +20 -0
  88. package/src/pages/tags/[tag].astro +56 -0
  89. package/src/pages/tags/index.astro +37 -0
  90. package/src/scripts/code-copy.js +45 -0
  91. package/src/scripts/diagram-disabled.js +16 -0
  92. package/src/scripts/diagram.js +322 -0
  93. package/src/scripts/graph-client.js +222 -0
  94. package/src/scripts/graph-draw.js +166 -0
  95. package/src/scripts/link-preview.js +334 -0
  96. package/src/scripts/math-disabled.js +18 -0
  97. package/src/scripts/math.js +133 -0
  98. package/src/scripts/preferences.js +123 -0
  99. package/src/scripts/search-dialog.js +651 -0
  100. package/src/scripts/snapshot-client.js +157 -0
  101. package/src/scripts/snapshot-worker.js +341 -0
  102. package/src/scripts/tag-browser.js +153 -0
  103. package/src/scripts/theme-init.js +35 -0
  104. package/src/scripts/toc-active.js +38 -0
  105. package/src/styles/code.css +152 -0
  106. package/src/styles/diagram.css +149 -0
  107. package/src/styles/global.css +1739 -0
  108. package/src/styles/graph.css +317 -0
  109. package/src/styles/math.css +376 -0
  110. package/src/styles/tokens.css +171 -0
  111. package/tsconfig.json +25 -0
@@ -0,0 +1,975 @@
1
+ /**
2
+ * The user's configuration file, and the one place its defaults are written
3
+ * down.
4
+ *
5
+ * A stranger runs this tool on their own notes repository. Everything here is
6
+ * therefore judged by one question — *what happens to something you typed
7
+ * wrong* — because the alternative is a user who believes they configured an
8
+ * exclusion and did not, and whose next push publishes the note they withheld.
9
+ *
10
+ * ## The format: YAML, and the measurement that chose it
11
+ *
12
+ * Four candidates, each measured on this host (Node 24.18.1, `yaml` 2.9.0)
13
+ * rather than ranked by taste.
14
+ *
15
+ * **A `.ts` module — the plan's choice at §4.1 — is rejected on three
16
+ * measurements, and the third is disqualifying.**
17
+ *
18
+ * 1. It loads in every repository shape, as the plan says: measured, a
19
+ * `publish.config.ts` importing a type from an unresolvable specifier loads
20
+ * with no `package.json`, with a CommonJS one, and with `"type": "module"`.
21
+ * So the plan's own supporting measurement reproduces. The problems are
22
+ * elsewhere.
23
+ * 2. **It prints the user's absolute path to stderr, and the print cannot be
24
+ * intercepted.** Measured, in the middle case above — a notes repository
25
+ * holding a `package.json` with no `"type"`, which is what `npm i` for a
26
+ * local preview leaves behind — Node emits
27
+ * `[MODULE_TYPELESS_PACKAGE_JSON] Warning: Module type of
28
+ * file:///…/publish.config.ts is not specified`, containing the user's own
29
+ * directory. A `process.on('warning')` listener saw **zero** warnings while
30
+ * stderr carried it, so it is not interceptable; only the process-wide
31
+ * `NODE_NO_WARNINGS=1` suppresses it, which silences every other warning
32
+ * too. TK-25 §2.3 forbids any discovered filesystem path on that stream, and
33
+ * a format whose mere loading violates it cannot be made to comply.
34
+ * 3. **Validating it is not possible, because the value can change after it is
35
+ * validated.** Measured: a config whose `exclude` is a getter returned
36
+ * `['drafts/**']` to the validator and `['nothing-at-all/**']` to the next
37
+ * reader. A `Proxy` reports whatever key set it likes to `Object.keys`. So
38
+ * the unknown-key check — the thing this ticket exists to provide — is
39
+ * advisory against a hostile or merely clever file, and the failure is
40
+ * silent in the publishing direction. Executing the config also means an
41
+ * arbitrary throw: measured, a config calling `readFileSync` on a missing
42
+ * path produced `ENOENT: … open 'Q:\etc\hostname'`, a host path in a message
43
+ * nothing here composed.
44
+ *
45
+ * **TOML is rejected on cost.** Node has no TOML parser — measured,
46
+ * `import('node:toml')` fails `ERR_UNKNOWN_BUILTIN_MODULE` — so it is a new
47
+ * dependency, and the ticket's rule is that a new dependency must be shown to
48
+ * beat what is already installed. It does not: `yaml` is already a declared
49
+ * dependency, added by TK-26 for frontmatter, and carries comments too.
50
+ *
51
+ * **JSON is rejected on comments.** This file's main content is a list of
52
+ * exclusion patterns, which is exactly the content a user wants to annotate
53
+ * (`# not ready yet`), and JSON has no way to. Two lesser measurements agree:
54
+ * `JSON.parse` silently keeps the *last* of two duplicate keys — so a user who
55
+ * writes `exclude` twice loses the first list with no signal — and its parse
56
+ * error quotes the file's own bytes back (`Unexpected token ']', ..."ts/**",\n
57
+ * ]\n}" is not valid JSON`), which puts pattern text on a stream TK-25 §2.3
58
+ * bans it from.
59
+ *
60
+ * **YAML wins, and the trade is real rather than nominal.** A user editing
61
+ * notes already writes YAML in their frontmatter, `yaml` is already installed,
62
+ * comments work, and a duplicate key is a *loud* `DUPLICATE_KEY` error rather
63
+ * than a silent overwrite. What it costs is that YAML's plain scalars are a
64
+ * language, and a glob pattern is not written in it. Measured over 34 realistic
65
+ * patterns written unquoted:
66
+ *
67
+ * ```
68
+ * "*.md" ReferenceError: Unresolved alias "!README.md" -> ""
69
+ * "{a,b}/**" UNEXPECTED_TOKEN "&draft/**" -> null
70
+ * "@work/**" BAD_SCALAR_START "- dash/**" -> ["dash/**"]
71
+ * ```
72
+ *
73
+ * Three of those are rewritten with no error at all, and the middle one is the
74
+ * worst available: `!README.md` is the exact spelling `docs/plans/…-plan.md`
75
+ * §2.2 gives for re-including the repository README, and plain YAML turns it
76
+ * into the empty string.
77
+ *
78
+ * **So the checks below are not defensive tidying; they are what makes the
79
+ * format safe.** With every parse *warning* promoted to a failure, every member
80
+ * required to be a non-empty untrimmed string, and every eaten `!` tag refused,
81
+ * the same 34 patterns measure **21 exact, 13 loud, and zero silent** unquoted,
82
+ * and **34 exact, zero loud, zero silent** quoted. Zero silent is the property
83
+ * the format was chosen for.
84
+ *
85
+ * Which of the three mechanisms catches which case was **measured**, after a
86
+ * mutation removed the warning promotion and every gate stayed green — the
87
+ * cases it alone catches are a discarded tag *outside* `exclude`, where the
88
+ * surviving value is a valid string and no other check can see that anything
89
+ * happened. {@link CORRUPTION_GATE} records the split.
90
+ *
91
+ * ## What a message may carry
92
+ *
93
+ * TK-25 §2.1 admits a *config location* — filename, 1-indexed line, array index
94
+ * — and a literal of this tool's own source, and admits no other user-derived
95
+ * string. Two consequences, and the split between them is the whole rule:
96
+ *
97
+ * - **A known key is a literal of this file's own table**, so `exclude` and
98
+ * `origin` are printed in full.
99
+ * - **An unknown key is user-authored text**, and `clients/acme/2026-renewal.md`
100
+ * is a valid YAML key. So a misspelling is reported by *line*, with the
101
+ * suggestion drawn from {@link KNOWN_KEYS}, and its bytes go to the report's
102
+ * private half. The user opens their own file at that line and reads what
103
+ * they wrote there — which is strictly more actionable than the key alone,
104
+ * since the line is where the fix goes.
105
+ *
106
+ * A *value* is never echoed for the same reason; what is printed is the type
107
+ * that was found, which is a closed set of literals.
108
+ */
109
+ import { existsSync, readFileSync } from 'node:fs';
110
+ import { join } from 'node:path';
111
+ import { LineCounter, parseAllDocuments } from 'yaml';
112
+ import { FIELD_LIMITS, isLanguageTag } from "../src/lib/schema.js";
113
+ import { BuildFailure } from "./write-report.js";
114
+ /**
115
+ * The one filename, at the root of the content directory.
116
+ *
117
+ * **One name and no search order**, per plan §4.1: a search order is a place
118
+ * for a file to be silently not found. What the plan does not say, and what
119
+ * {@link NEAR_MISS_NAMES} adds, is that the same argument condemns silence
120
+ * about a *near* name — a user who wrote `publish.config.yml` has configured
121
+ * nothing and been told nothing, which is the failure mode this whole module
122
+ * exists to prevent, one level up from a misspelled key.
123
+ *
124
+ * **At the content directory rather than the invocation directory**, because
125
+ * the patterns inside it are matched against content-directory-relative paths
126
+ * (`scripts/markdown-to-artifact.ts`). Anchoring the file where its own
127
+ * patterns are anchored means `drafts/**` is relative to the directory the file
128
+ * sits in, which is the only reading a user has to be told once.
129
+ *
130
+ * The stem carries no project name — the same decision as TK-25's
131
+ * `publish-report/` — and it matches the `CONFIG_LOCATION` shape
132
+ * `scripts/markdown-to-artifact.ts` requires before it will put a caller's
133
+ * source label on a stream.
134
+ */
135
+ export const CONFIG_FILENAME = 'publish.config.yaml';
136
+ /**
137
+ * Spellings that are not the filename, and that mean the user meant it.
138
+ *
139
+ * Each is refused by name rather than accepted, because accepting them is a
140
+ * search order. The TypeScript spelling is on the list because it is the one
141
+ * `docs/plans/ssg-generalisation-plan.md` §4.1 names, so it is what a reader of
142
+ * the plan — or of anything derived from it — will write first.
143
+ *
144
+ * Every entry is a literal of this file's own source, so printing one discloses
145
+ * nothing; that is what lets the refusal name the file it found.
146
+ *
147
+ * **Built by joining a stem to an extension rather than written out, and that
148
+ * is a packaging constraint rather than a style.** `scripts/compile-package.ts`
149
+ * rewrites every quoted `….ts` string naming a real file of ours to `.js` on
150
+ * the way into the tarball, and `tests/packaging.test.ts` then fails on any
151
+ * `.ts` string that survives. Both are right to: an unrewritten specifier is a
152
+ * broken packaged build. But these are *the user's* filenames, not modules —
153
+ * there is nothing to rewrite, and a quoted TypeScript spelling here is
154
+ * indistinguishable from a stale import to a tool reading strings. Measured:
155
+ * the gate failed on exactly that, once for the array entry and a second time
156
+ * for a quoted mention of it inside this very comment, since the gate scans
157
+ * bytes rather than syntax. Joining the parts keeps the value identical and the
158
+ * hazard absent, and nothing in this file quotes the spelling any more.
159
+ */
160
+ export const NEAR_MISS_NAMES = [
161
+ ...['yml', 'json', 'ts', 'js', 'mjs', 'toml'].map((extension) => `publish.config.${extension}`),
162
+ ...['yaml', 'yml', 'json'].map((extension) => `publish.${extension}`),
163
+ // Measured as silently ignored before they were listed: a hyphen for the dot,
164
+ // an abbreviated stem, and the bare noun are each a spelling a user reaches
165
+ // for first, and each configured nothing while looking configured.
166
+ ...['yaml', 'yml'].flatMap((extension) => [`publish-config.${extension}`, `publish.conf.${extension}`]),
167
+ ];
168
+ /** Every key the file may carry. The order is the order they are documented in. */
169
+ const KNOWN_KEYS = ['title', 'origin', 'exclude', 'language'];
170
+ /**
171
+ * The site name a repository with no configuration gets.
172
+ *
173
+ * Deliberately not `src/lib/site.ts`'s `SITE_NAME`. That value is this
174
+ * project's own name, and a stranger's first build must not put it in their
175
+ * browser tab — which is the residue plan decision D2 exists to remove. A
176
+ * generic noun is legible, obviously a placeholder, and belongs to nobody.
177
+ */
178
+ export const DEFAULT_TITLE = 'Notes';
179
+ /**
180
+ * Every default, in one object, so "what does a repository with no config get"
181
+ * is answered by reading one declaration.
182
+ *
183
+ * `origin` is absent by design; see {@link LoadedConfig}.
184
+ *
185
+ * Frozen, and {@link freshDefaults} is what callers actually receive. A spread
186
+ * of this object is a *shallow* copy, so every caller would share one `exclude`
187
+ * array — measured, pushing to the array returned by one `loadConfig` changed
188
+ * what the next one returned. `readonly string[]` is a compile-time claim only,
189
+ * and `exclusionOptions` hands the array straight to `discover`.
190
+ */
191
+ export const DEFAULTS = Object.freeze({
192
+ title: DEFAULT_TITLE,
193
+ origin: undefined,
194
+ exclude: Object.freeze([]),
195
+ language: undefined,
196
+ });
197
+ /** The defaults, as a value a caller may keep and a later caller cannot see. */
198
+ function freshDefaults() {
199
+ return { ...DEFAULTS, exclude: [] };
200
+ }
201
+ /**
202
+ * An object with no prototype surprises, which is what a config table must be.
203
+ *
204
+ * The same shape as `src/lib/schema.ts`'s `isPlainObject`, plus the prototype
205
+ * test that `Set` and `Map` need — see the call site for the measurement.
206
+ */
207
+ function isPlainObject(value) {
208
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
209
+ return false;
210
+ const prototype = Object.getPrototypeOf(value);
211
+ return prototype === Object.prototype || prototype === null;
212
+ }
213
+ /**
214
+ * The patterns a plain YAML document rewrites without saying so, with what it
215
+ * makes of them.
216
+ *
217
+ * Exported so the gate that proves the loader refuses them is written against
218
+ * the measurement that motivated it rather than against a rediscovered example.
219
+ *
220
+ * **Which mechanism catches which is the difference between a gate and a
221
+ * coincidence, so it is recorded rather than assumed.** It had to be: a
222
+ * mutation that removed the warning promotion left every gate green, because
223
+ * the members it then admitted were all caught by something else.
224
+ *
225
+ * ```
226
+ * "!README.md" TAG_RESOLVE_FAILED -> "" emptiness check too
227
+ * "!private drafts/**" TAG_RESOLVE_FAILED -> "drafts/**" promotion ONLY
228
+ * "&draft/**" no warning -> null type check only
229
+ * "- dash/**" no warning -> ["dash/**"] type check only
230
+ * "! README.md" no warning -> "README.md" tag rule ONLY
231
+ * ```
232
+ *
233
+ * The second and last rows are each the sole case of their mechanism, and the
234
+ * difference between them is worth reading twice: a *named* tag is reported as
235
+ * `'!private'` and the non-specific one as `'!'`, so the tag rule sees only the
236
+ * second and the promotion is what refuses the first. A rule written against
237
+ * `tag.startsWith('!')` would collapse them and lose the distinction the two
238
+ * gates rest on.
239
+ *
240
+ * The last row is the worst of the five. `! README.md` — a negation written
241
+ * with the space a human naturally puts after punctuation — parses to
242
+ * `README.md` with no error and no warning, and that is not a corrupted pattern
243
+ * but an **inverted** one: measured, `!README.md` re-includes the repository
244
+ * README and `README.md` excludes it. The user asked for a file back and
245
+ * silently told the tool to withhold it. Nothing about the parsed *value* can
246
+ * see it; {@link taggedExcludeMembers} reads the node's tag instead.
247
+ *
248
+ * Note what this list does **not** cover, and why the promotion has a gate of
249
+ * its own beyond the row above: a discarded tag on `title` or `origin` leaves a
250
+ * valid string that no check on the value can question, and outside `exclude`
251
+ * the tag rule does not look.
252
+ */
253
+ export const CORRUPTION_GATE = [
254
+ '!README.md',
255
+ '!private drafts/**',
256
+ '&draft/**',
257
+ '- dash/**',
258
+ '! README.md',
259
+ ];
260
+ /** What was found where a type was expected. A closed set of literals. */
261
+ function typeName(value) {
262
+ if (value === null)
263
+ return 'null';
264
+ if (Array.isArray(value))
265
+ return 'a list';
266
+ if (typeof value === 'string')
267
+ return 'a string';
268
+ if (typeof value === 'number')
269
+ return 'a number';
270
+ if (typeof value === 'boolean')
271
+ return 'a boolean';
272
+ if (typeof value === 'object')
273
+ return 'a table';
274
+ return 'nothing';
275
+ }
276
+ /**
277
+ * How far apart two strings are, capped at the only distances worth reporting.
278
+ *
279
+ * The two-row form rather than a full matrix, because the matrix is the version
280
+ * that gets written wrong. Used only to choose a suggestion out of
281
+ * {@link KNOWN_KEYS}, so its output never reaches a message.
282
+ */
283
+ function editDistance(from, to) {
284
+ let previous = Array.from({ length: to.length + 1 }, (_, index) => index);
285
+ for (let i = 0; i < from.length; i += 1) {
286
+ const current = [i + 1];
287
+ for (let j = 0; j < to.length; j += 1) {
288
+ current.push(Math.min((previous[j + 1] ?? 0) + 1, (current[j] ?? 0) + 1, (previous[j] ?? 0) + (from[i] === to[j] ? 0 : 1)));
289
+ }
290
+ previous = current;
291
+ }
292
+ return previous[to.length] ?? to.length;
293
+ }
294
+ /**
295
+ * The known key a misspelling most likely meant, or `undefined`.
296
+ *
297
+ * Two rules, because the two common mistakes have different shapes.
298
+ * Containment catches an *elaboration* — `siteTitle` for `title`, `excludes`
299
+ * for `exclude` — which is four edits away and would never clear a distance
300
+ * threshold. Distance catches a *typo*: `exclud`, `orgin`, `titel`.
301
+ *
302
+ * Compared on a folded form so that `Origin` and `EXCLUDE` are recognised;
303
+ * the file's keys are lowercase and a user who capitalised one has still made
304
+ * a spelling mistake rather than named something else.
305
+ *
306
+ * The containment rule needs a length floor, because `known.includes(folded)`
307
+ * is true of any substring: measured, a key of `e` was suggested as `title`,
308
+ * `x` as `exclude`, and `o` as `origin`. A confidently wrong suggestion tells
309
+ * the user to rename a key they did not mean to write, which is worse than
310
+ * listing the three.
311
+ */
312
+ function suggestionFor(key) {
313
+ const folded = key.toLowerCase().replace(/[^a-z0-9]/g, '');
314
+ if (folded === '')
315
+ return undefined;
316
+ for (const known of KNOWN_KEYS) {
317
+ if (folded.length >= 3 && (folded.includes(known) || known.includes(folded)))
318
+ return known;
319
+ }
320
+ for (const known of KNOWN_KEYS) {
321
+ if (editDistance(folded, known) <= 2)
322
+ return known;
323
+ }
324
+ return undefined;
325
+ }
326
+ /**
327
+ * How many issues one refusal prints before it stops enumerating.
328
+ *
329
+ * Measured: a list of 5,000 wrong-typed members composes a 303,940-character
330
+ * message. Nothing in it is a disclosure, but a stream nobody can read is a
331
+ * diagnostic nobody uses, and the remainder is reported as a count — which is a
332
+ * class-A integer and so costs nothing.
333
+ */
334
+ const MAX_REPORTED_ISSUES = 50;
335
+ /**
336
+ * Every issue found in one file, thrown as one failure.
337
+ *
338
+ * The shape mirrors `src/lib/schema.ts`'s `ContentValidationError` — the file,
339
+ * a count, then one indented line per issue — rather than inventing a second
340
+ * way for this project to reject malformed input. Accumulating rather than
341
+ * throwing on the first is the same decision for the same reason: a user
342
+ * correcting three mistakes one build at a time is three builds.
343
+ *
344
+ * A {@link BuildFailure} rather than a new class, so the message is printable:
345
+ * `bin/anc.mjs` prints only errors composed under the
346
+ * disclosure rule, and everything else becomes `build failed`. The private half
347
+ * carries what the public half may not — and the private half is **not**
348
+ * truncated, because the file it goes to is where a user reads the rest.
349
+ */
350
+ function refuse(issues, details) {
351
+ const shown = issues.slice(0, MAX_REPORTED_ISSUES);
352
+ const remainder = issues.length - shown.length;
353
+ throw new BuildFailure('config-invalid', `${CONFIG_FILENAME}: ${issues.length} configuration violation${issues.length === 1 ? '' : 's'}\n` +
354
+ shown.map((issue) => ` - ${issue}`).join('\n') +
355
+ (remainder === 0 ? '' : `\n … and ${remainder} more`), details.length === 0 ? issues.join('; ') : details.join('; '));
356
+ }
357
+ /**
358
+ * An absolute `http(s)` origin and nothing more.
359
+ *
360
+ * The path check is the one that is not obvious and is the one that matters.
361
+ * `src/lib/site.ts` forms every canonical URL, feed id, and sitemap `<loc>` as
362
+ * `new URL(route, site)` where `route` is root-absolute — so a configured
363
+ * `https://example.com/notes/` has its `/notes/` **discarded**, and the site
364
+ * builds cleanly while every public URL in it is wrong. Astro spells that
365
+ * arrangement `base:`, which is a separate option and a separate decision; what
366
+ * this module must not do is accept a value that produces a valid, wrong site.
367
+ *
368
+ * A non-`http` scheme is refused for the same class of reason: a canonical link
369
+ * is dereferenced by a crawler.
370
+ */
371
+ function checkOrigin(value, line, issues, details) {
372
+ const at = line === undefined ? 'origin' : `origin (line ${line})`;
373
+ if (typeof value !== 'string') {
374
+ issues.push(`${at}: must be a string holding an absolute URL, found ${typeName(value)}`);
375
+ details.push(`origin = ${JSON.stringify(value)}`);
376
+ return;
377
+ }
378
+ // Before `new URL`, because `URL` *tolerates* surrounding whitespace and a
379
+ // tab inside the host — measured, `" https://example.com/ "` and
380
+ // `"https://exa\tmple.com/"` both parse and both pass every check below, and
381
+ // the raw string is what reaches `site:`. Refusing rather than trimming, for
382
+ // the reason `checkExclude` refuses a padded pattern: a silent rewrite is the
383
+ // failure mode this module exists to prevent.
384
+ if (value !== value.trim() || /\s/.test(value)) {
385
+ issues.push(`${at}: must not contain whitespace`);
386
+ details.push(`origin = ${JSON.stringify(value)}`);
387
+ return;
388
+ }
389
+ let url;
390
+ try {
391
+ url = new URL(value);
392
+ }
393
+ catch {
394
+ // The value is not echoed: an origin is not itself sensitive, but a
395
+ // *mistyped* one is whatever the user's editor left in the buffer, and this
396
+ // reaches a workflow log. The line number is where the fix goes.
397
+ issues.push(`${at}: must be an absolute URL with a scheme, such as https://notes.example.com/`);
398
+ details.push(`origin = ${JSON.stringify(value)}`);
399
+ return;
400
+ }
401
+ if (url.protocol !== 'https:' && url.protocol !== 'http:') {
402
+ issues.push(`${at}: must use the https or http scheme`);
403
+ details.push(`origin = ${JSON.stringify(value)}`);
404
+ return;
405
+ }
406
+ // Credentials in the origin are the sharpest case of "builds cleanly and is
407
+ // wrong": measured, `https://alice:s3cret@example.com/` passes every other
408
+ // check, and `new URL('/notes/a/', origin)` then puts the password into every
409
+ // canonical link, feed id, and sitemap entry of the published site.
410
+ if (url.username !== '' || url.password !== '') {
411
+ issues.push(`${at}: must not carry a username or password — they would appear in every published URL`);
412
+ // Deliberately not the value, which is the one string here that really is a
413
+ // secret. The line is enough to find it.
414
+ details.push('origin carried credentials');
415
+ return;
416
+ }
417
+ if (url.pathname !== '/' || url.search !== '' || url.hash !== '') {
418
+ issues.push(`${at}: must be a bare origin with no path, query, or fragment — every ` +
419
+ 'canonical URL, feed id, and sitemap entry is resolved against it as a ' +
420
+ 'root-absolute route, so a path here is silently discarded');
421
+ details.push(`origin = ${JSON.stringify(value)}`);
422
+ }
423
+ }
424
+ /** Whether a validated origin names only the current host rather than a public server. */
425
+ export function isLoopbackOrigin(origin) {
426
+ let hostname;
427
+ try {
428
+ hostname = new URL(origin).hostname.toLowerCase().replace(/^\[|\]$/g, '');
429
+ }
430
+ catch {
431
+ return true;
432
+ }
433
+ if (hostname === 'localhost' || hostname.endsWith('.localhost'))
434
+ return true;
435
+ if (hostname === '0.0.0.0' || hostname.startsWith('127.'))
436
+ return true;
437
+ if (hostname === '::' || hostname === '::1')
438
+ return true;
439
+ if (!hostname.startsWith('::ffff:'))
440
+ return false;
441
+ const mapped = hostname.slice('::ffff:'.length);
442
+ if (mapped === '0.0.0.0' || mapped.startsWith('127.') || mapped === '0:0')
443
+ return true;
444
+ const first = Number.parseInt(mapped.split(':')[0] ?? '', 16);
445
+ return Number.isFinite(first) && first >= 0x7f00 && first <= 0x7fff;
446
+ }
447
+ /**
448
+ * A non-empty site title carrying nothing XML cannot represent.
449
+ *
450
+ * The control-character rule is not belt-and-braces. `src/lib/site.ts` records
451
+ * that XML 1.0's `Char` production admits no escape for a C0 control, so a
452
+ * title carrying one produces a feed no reader can parse — and that the content
453
+ * contract checks `title` for emptiness and privacy markers but *not* for these
454
+ * characters. This is the site's own title rather than a note's, so it is the
455
+ * one such string this module can close.
456
+ */
457
+ function checkTitle(value, line, issues, details) {
458
+ const at = line === undefined ? 'title' : `title (line ${line})`;
459
+ if (typeof value !== 'string') {
460
+ issues.push(`${at}: must be a string, found ${typeName(value)}`);
461
+ details.push(`title = ${JSON.stringify(value)}`);
462
+ return;
463
+ }
464
+ if (value.trim() === '') {
465
+ issues.push(`${at}: must not be empty`);
466
+ return;
467
+ }
468
+ // Refused rather than trimmed, which is the same rule `checkExclude` applies
469
+ // to a padded pattern. YAML preserves the padding a user quoted deliberately,
470
+ // and quietly discarding it is a silent rewrite — the thing this module is
471
+ // built to prevent, applied inconsistently if it happened here.
472
+ if (value !== value.trim()) {
473
+ issues.push(`${at}: must not have leading or trailing whitespace`);
474
+ details.push(`title = ${JSON.stringify(value)}`);
475
+ return;
476
+ }
477
+ // oxlint-disable-next-line no-control-regex -- refusing them is the point
478
+ if (/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/.test(value)) {
479
+ issues.push(`${at}: must not contain a control character — XML has no escape for one, so the feed would not parse`);
480
+ details.push(`title = ${JSON.stringify(value)}`);
481
+ }
482
+ }
483
+ /**
484
+ * A BCP 47 tag, validated exactly as a note's own `language:` is.
485
+ *
486
+ * The same shape (`isLanguageTag`) and the same 35-character registry ceiling
487
+ * the content contract applies, so a value accepted here is one every consumer
488
+ * of a note's language already accepts. Refused rather than trimmed or
489
+ * case-folded, for the reason {@link checkTitle} gives.
490
+ */
491
+ function checkLanguage(value, line, issues, details) {
492
+ const at = line === undefined ? 'language' : `language (line ${line})`;
493
+ if (typeof value !== 'string') {
494
+ issues.push(`${at}: must be a string holding a BCP 47 language tag, found ${typeName(value)}`);
495
+ details.push(`language = ${JSON.stringify(value)}`);
496
+ return;
497
+ }
498
+ if (!isLanguageTag(value) || value.length > FIELD_LIMITS.strings.language) {
499
+ issues.push(`${at}: must be a BCP 47 language tag such as en, zh-CN, or zh-Hans-CN`);
500
+ details.push(`language = ${JSON.stringify(value)}`);
501
+ }
502
+ }
503
+ /**
504
+ * A list of gitignore-style patterns, every member checked.
505
+ *
506
+ * The backslash rule is a cross-platform trap `scripts/markdown-to-artifact.ts`
507
+ * measured and this is the only place that can name it: `matchesGlob` is
508
+ * separator-literal, so `drafts\**` written by a Windows user matches nothing.
509
+ * Left to itself that surfaces as TK-26's zero-match failure, which is loud but
510
+ * names a typo the user cannot see — the pattern looks right on their machine.
511
+ * Refusing it here says what is actually wrong.
512
+ *
513
+ * The duplicate rule is here for the same reason and is the one case TK-26's
514
+ * zero-match check structurally cannot reach. Measured: `exclude: ['draft.md',
515
+ * 'draft.md']` builds cleanly, because `excludes()` records a hit for *every*
516
+ * pattern that matched, so a duplicate is never idle. A repeated pattern is
517
+ * always either redundant or — far more likely in a list a user has edited over
518
+ * months — a line they meant to change and copied instead, which reads as a
519
+ * configured rule and is not one.
520
+ *
521
+ * **`tagged` marks the members YAML read a `!` tag on**, which is the one rule
522
+ * that cannot be written against the parsed value: a re-include whose `!` was
523
+ * eaten arrives as an ordinary string. See {@link taggedExcludeMembers}.
524
+ *
525
+ * **`memberLines` is per member, not per list.** A coordinate is only worth
526
+ * printing if it points at the thing that is wrong.
527
+ */
528
+ function checkExclude(value, line, memberLines, tagged, issues, details) {
529
+ const at = line === undefined ? 'exclude' : `exclude (line ${line})`;
530
+ if (!Array.isArray(value)) {
531
+ issues.push(`${at}: must be a list of strings, found ${typeName(value)}`);
532
+ details.push(`exclude = ${JSON.stringify(value)}`);
533
+ return;
534
+ }
535
+ /** Pattern to the index that claimed it, so a duplicate can name the original. */
536
+ const seen = new Map();
537
+ for (const [index, member] of value.entries()) {
538
+ // The index, never the pattern: TK-25 §2.3 forbids a pattern's text on this
539
+ // surface, because a gitignore-style pattern may be a bare path and echoing
540
+ // it re-admits the exact string the exclusion existed to withhold. The user
541
+ // opens their own file and reads their own pattern at that index.
542
+ const memberLine = memberLines[index];
543
+ const where = `exclude[${index}]${memberLine === undefined ? '' : ` (line ${memberLine})`}`;
544
+ if (typeof member !== 'string') {
545
+ issues.push(`${where}: must be a string, found ${typeName(member)}`);
546
+ details.push(`exclude[${index}] = ${JSON.stringify(member)}`);
547
+ continue;
548
+ }
549
+ if (member.trim() === '') {
550
+ issues.push(`${where}: must not be empty`);
551
+ continue;
552
+ }
553
+ if (member !== member.trim()) {
554
+ issues.push(`${where}: must not have leading or trailing whitespace`);
555
+ details.push(`exclude[${index}] = ${JSON.stringify(member)}`);
556
+ continue;
557
+ }
558
+ if (member.includes('\\')) {
559
+ issues.push(`${where}: must use "/" separators on every platform — a backslash is ` +
560
+ 'matched literally, so this pattern would match nothing');
561
+ details.push(`exclude[${index}] = ${JSON.stringify(member)}`);
562
+ continue;
563
+ }
564
+ const original = seen.get(member);
565
+ if (original !== undefined) {
566
+ // Two coordinates and no text, which is the whole message a user needs:
567
+ // they open their own file and see the same line twice.
568
+ issues.push(`${where}: repeats exclude[${original}] — a duplicate pattern is dead config`);
569
+ details.push(`exclude[${index}] = ${JSON.stringify(member)} duplicates exclude[${original}]`);
570
+ continue;
571
+ }
572
+ seen.set(member, index);
573
+ // The inversion, and the one refusal here that prevents a file being
574
+ // *withheld* rather than published.
575
+ if (tagged[index] === true && !member.startsWith('!')) {
576
+ issues.push(`${where}: a re-include must be written as "!pattern" in quotes, with no ` +
577
+ 'space after the "!" — unquoted, YAML reads it as a tag and discards it, ' +
578
+ 'which turns a rule that keeps a file into one that removes it');
579
+ details.push(`exclude[${index}] = ${JSON.stringify(member)} carried an eaten "!" tag`);
580
+ }
581
+ }
582
+ }
583
+ /**
584
+ * Whether a YAML node carries the non-specific `!` tag, following an alias.
585
+ *
586
+ * The one signal that something the user wrote was discarded. `! README.md`
587
+ * parses to `README.md` with no error and no warning — and inside `exclude`
588
+ * that is not a corrupted pattern but an inverted one, since `!README.md`
589
+ * re-includes the repository README while `README.md` excludes it. So the user
590
+ * wrote "give me this file back", the loader received "withhold it", and every
591
+ * check on the parsed value sees an ordinary string.
592
+ *
593
+ * **The alias hop, and what it is and is not doing.** Measured:
594
+ * `title: &r ! README.md` with `exclude: [*r]` reaches this function as an
595
+ * `Alias` node carrying no tag of its own — the tag lives on the anchored
596
+ * scalar — so a check on `.tag` alone answers `false` for it. `resolve` follows
597
+ * the alias.
598
+ *
599
+ * Recorded honestly, because the mutation that removes the hop stays **green**:
600
+ * every site an anchor can be defined at is already checked by a caller, so I
601
+ * could not construct a document that only the hop refuses. What I tried — an
602
+ * anchor on `title`, on an `exclude` member, on an unknown key, and aliased in
603
+ * both block and flow style — is the search, not a proof there is none. The hop
604
+ * stays because this function must be right about the node it is handed rather
605
+ * than right only while its callers happen to cover the anchor sites, which is
606
+ * the rule `src/lib/routes.ts` states for the same reason.
607
+ *
608
+ * `=== '!'` exactly, never `startsWith('!')`. A *named* tag such as
609
+ * `!private drafts/**` reports `'!private'` and is refused by the warning
610
+ * promotion in {@link parseConfig} instead; widening this test would collapse
611
+ * two mechanisms into one and leave the promotion with no gate of its own.
612
+ */
613
+ function carriesEatenTag(node, document) {
614
+ if (node === null || typeof node !== 'object')
615
+ return false;
616
+ const direct = node.tag;
617
+ if (direct === '!')
618
+ return true;
619
+ const resolve = node.resolve;
620
+ if (typeof resolve !== 'function')
621
+ return false;
622
+ const target = resolve.call(node, document);
623
+ return target !== null && typeof target === 'object' && target.tag === '!';
624
+ }
625
+ /**
626
+ * Which `exclude` members carry an eaten `!`.
627
+ *
628
+ * **Read off the node, and the first version read the source line instead —
629
+ * which was wrong in four measured ways.** Recovering the text after the `-` on
630
+ * the member's own line missed a flow sequence (`exclude: [! README.md]`,
631
+ * accepted), missed a member indented onto the next line, missed **every member
632
+ * of a CRLF file** — the ordinary case on Windows, where splitting on a bare
633
+ * newline leaves a `\r` that an unanchored `$` will not match — and *falsely
634
+ * refused* the legitimate `!!str "a/**"`, whose line also opens with a `!`. So
635
+ * the rule was off exactly where it was most needed and on where it was not.
636
+ *
637
+ * The tag is the thing itself rather than a proxy for it: measured, `"!README.md"`
638
+ * carries no tag, `!!str` carries `tag:yaml.org,2002:str`, and CRLF changes
639
+ * neither the tag nor the line numbers.
640
+ */
641
+ function taggedExcludeMembers(document) {
642
+ const sequence = document.get('exclude', true);
643
+ const items = sequence !== null && typeof sequence === 'object' && 'items' in sequence ? sequence.items : undefined;
644
+ if (!Array.isArray(items))
645
+ return [];
646
+ return items.map((item) => carriesEatenTag(item, document));
647
+ }
648
+ /**
649
+ * The 1-indexed line each `exclude` member was written on.
650
+ *
651
+ * A member's issue must point at the member, not at the `exclude:` key. The
652
+ * first version passed one line for the whole list, so on a nine-line config
653
+ * the fourth member's error read `exclude[3] (line 4)` while line 4 was
654
+ * `exclude:` — and a *config location* that is wrong is worse than none, since
655
+ * the entire disclosure design rests on the user opening their own file at that
656
+ * line and reading their own text there.
657
+ */
658
+ function excludeLines(document, lineCounter) {
659
+ const sequence = document.get('exclude', true);
660
+ const items = sequence !== null && typeof sequence === 'object' && 'items' in sequence ? sequence.items : undefined;
661
+ if (!Array.isArray(items))
662
+ return [];
663
+ return items.map((item) => {
664
+ const range = item.range;
665
+ if (!Array.isArray(range) || typeof range[0] !== 'number')
666
+ return undefined;
667
+ return lineCounter.linePos(range[0]).line;
668
+ });
669
+ }
670
+ /**
671
+ * Parse one configuration file and apply the defaults it did not override.
672
+ *
673
+ * Separated from {@link loadConfig} so the whole validator is reachable from a
674
+ * string, which is what lets a gate state a hostile document inline instead of
675
+ * writing it to a temporary directory first.
676
+ *
677
+ * **Every warning is a failure, and that is the load-bearing line.** `yaml`
678
+ * reports an unresolvable tag as a *warning* and hands back a document whose
679
+ * value has been quietly replaced — measured, `- !README.md` yields `''` with
680
+ * `doc.errors` empty and `doc.warnings` holding one `TAG_RESOLVE_FAILED`. A
681
+ * loader that reads only `errors` publishes the file the user meant to withhold
682
+ * and reports success.
683
+ *
684
+ * **`logLevel: 'silent'` is a disclosure fix, not a tidiness one.** `yaml`
685
+ * writes some diagnostics through `process.emitWarning` rather than collecting
686
+ * them, and measured, one of them prints the user's own config key:
687
+ * `Warning: Keys with collection values will be stringified … "[ clients/acme/…`.
688
+ * That is a user-authored string on stderr from a call nothing here composed,
689
+ * emitted before this function can reject anything, and a `process.on('warning')`
690
+ * listener does not see it. Silencing the logger suppresses it entirely, and —
691
+ * measured — leaves `doc.warnings` populated, so the promotion below is
692
+ * unaffected.
693
+ */
694
+ export function parseConfig(text) {
695
+ const issues = [];
696
+ const details = [];
697
+ // A leading BOM is what Notepad writes, and it is not YAML. Stripped rather
698
+ // than refused: the user did not type it and cannot see it.
699
+ //
700
+ // CRLF is deliberately *not* normalised here, and the earlier version that
701
+ // did is worth recording. It was added to repair the inversion rule, which
702
+ // then recovered each pattern from the text of its source line and so broke
703
+ // on the carriage return a bare newline split leaves behind. The rule now
704
+ // reads the node's tag instead, and measured, `yaml` handles CRLF itself: the
705
+ // tag, the line numbers, and every scalar — plain, quoted, literal, folded —
706
+ // are identical under both line endings, and no carriage return reaches a
707
+ // value. So the normalisation became a line no mutation could turn red, which
708
+ // is the kind of code that outlives the reason for it.
709
+ const source = text.replace(/^/, "");
710
+ const lineCounter = new LineCounter();
711
+ let documents;
712
+ try {
713
+ // `parseAllDocuments` rather than `parseDocument`, because the latter
714
+ // returns the *first* document and says nothing about the rest. Measured:
715
+ // `title: One\n---\nexclude:\n - "drafts/**"\n` parsed to `{title: 'One'}`
716
+ // with `errors` and `warnings` both empty — the user's entire exclusion list
717
+ // discarded with no error and no line number. That is the same failure class
718
+ // as the `!!set` document below, and worse, because what is dropped is the
719
+ // half that withholds files.
720
+ documents = parseAllDocuments(source, { lineCounter, logLevel: 'silent' });
721
+ }
722
+ catch (error) {
723
+ // A throw rather than a collected error is a `yaml` internal, so its message
724
+ // has opaque provenance and goes to the private half only.
725
+ throw new BuildFailure('config-unparsable', `${CONFIG_FILENAME}: is not valid YAML`, error instanceof Error ? error.message : String(error));
726
+ }
727
+ const lineOf = (offset) => offset === undefined ? undefined : lineCounter.linePos(offset).line;
728
+ // Measured, an empty input yields zero documents rather than one empty one.
729
+ if (documents.length === 0)
730
+ return freshDefaults();
731
+ if (documents.length > 1) {
732
+ const second = documents[1]?.range?.[0];
733
+ const line = lineOf(typeof second === 'number' ? second : undefined);
734
+ refuse([
735
+ `${line === undefined ? 'the document' : `line ${line}`}: a second document begins here, ` +
736
+ 'and only the first is read — remove the "---" separator and the documents after it',
737
+ ], [`${documents.length} documents in one file`]);
738
+ }
739
+ const document = documents[0];
740
+ for (const problem of [...document.errors, ...document.warnings]) {
741
+ // The code is `yaml`'s own closed set of identifiers, and the position is a
742
+ // config location — both admissible. The message is not: it quotes the
743
+ // offending source line back, which for this file is pattern text.
744
+ const line = problem.linePos?.[0]?.line;
745
+ issues.push(`${line === undefined ? 'the document' : `line ${line}`}: ${problem.code}`);
746
+ details.push(problem.message);
747
+ }
748
+ if (issues.length > 0)
749
+ refuse(issues, details);
750
+ // `toJS` is what resolves aliases, and an unresolvable one throws from inside
751
+ // it rather than being collected — so it runs after the promotion above has
752
+ // already refused every document that could reach that state, and is guarded
753
+ // regardless because a throw here would otherwise escape as a library string.
754
+ let value;
755
+ try {
756
+ value = document.toJS({ logLevel: 'silent' });
757
+ }
758
+ catch (error) {
759
+ throw new BuildFailure('config-unparsable', `${CONFIG_FILENAME}: is not valid YAML`, error instanceof Error ? error.message : String(error));
760
+ }
761
+ // An empty file is a user who created the file and has not filled it in, which
762
+ // is what the defaults are for. A file whose top level is a list or a string
763
+ // is a structural mistake, and silently defaulting it would discard whatever
764
+ // they did write.
765
+ if (value === null || value === undefined)
766
+ return freshDefaults();
767
+ // **A plain object, not merely an object**, and the difference is a whole
768
+ // configuration silently discarded. Measured: `!!set` and `!!omap` at the
769
+ // document level make `toJS` return a `Set` and a `Map`, both of which pass a
770
+ // bare `typeof === 'object' && !Array.isArray`, and `Object.keys` on either is
771
+ // empty — so the unknown-key loop finds nothing, no key is ever read, and the
772
+ // user's entire file is replaced by the defaults with no error and no line
773
+ // number. That is this module's own headline failure mode one level above the
774
+ // misspelled key it was built to catch.
775
+ if (!isPlainObject(value)) {
776
+ refuse([`the document: must be a table of keys, found ${typeName(value)}`], []);
777
+ }
778
+ const table = value;
779
+ /** The 1-indexed line a key was written on, for the message to point at. */
780
+ const lines = new Map();
781
+ /** Keys whose *value* carried a tag YAML discarded. */
782
+ const eatenTags = new Set();
783
+ const contents = document.contents;
784
+ const items = contents !== null && typeof contents === 'object' && 'items' in contents ? contents.items : undefined;
785
+ if (Array.isArray(items)) {
786
+ for (const item of items) {
787
+ const key = item.key;
788
+ if (key === null || typeof key !== 'object')
789
+ continue;
790
+ const range = key.range;
791
+ const name = key.value;
792
+ if (typeof name !== 'string' || !Array.isArray(range))
793
+ continue;
794
+ const line = lineOf(typeof range[0] === 'number' ? range[0] : undefined);
795
+ if (line !== undefined)
796
+ lines.set(name, line);
797
+ // The same eaten-tag rule the exclusion list gets, applied to every key.
798
+ // Measured: `title: ! My Notes` and `origin: ! https://example.com/` were
799
+ // accepted with the tag discarded and no warning — the user wrote
800
+ // something the parser did not understand and got a build that ignored
801
+ // it. `exclude`'s members are checked separately, per member.
802
+ if (name !== 'exclude' && carriesEatenTag(item.value, document)) {
803
+ eatenTags.add(name);
804
+ }
805
+ }
806
+ }
807
+ for (const key of eatenTags) {
808
+ const line = lines.get(key);
809
+ issues.push(`${line === undefined ? 'a key' : `line ${line}`}: the value carries a "!" tag, which YAML ` +
810
+ 'discards — remove it, or quote the value if the "!" is part of it');
811
+ details.push(`${key} carried an eaten "!" tag`);
812
+ }
813
+ for (const key of Object.keys(table)) {
814
+ if (KNOWN_KEYS.includes(key))
815
+ continue;
816
+ // The line, never the key. A known key is a literal of this file's table and
817
+ // is printed in full below; an unknown one is user-authored text, and
818
+ // `clients/acme/2026-renewal.md:` is a valid YAML key. The suggestion comes
819
+ // out of the table, so it discloses nothing and tells the user which key
820
+ // they meant — which is the half the line number cannot supply.
821
+ const line = lines.get(key);
822
+ const suggestion = suggestionFor(key);
823
+ issues.push(`${line === undefined ? 'a key' : `line ${line}`}: unknown key is not allowed` +
824
+ (suggestion === undefined
825
+ ? `. The keys are ${KNOWN_KEYS.join(', ')}`
826
+ : `. Did you mean "${suggestion}"?`));
827
+ details.push(`unknown key ${JSON.stringify(key)}`);
828
+ }
829
+ if ('title' in table)
830
+ checkTitle(table['title'], lines.get('title'), issues, details);
831
+ if ('origin' in table)
832
+ checkOrigin(table['origin'], lines.get('origin'), issues, details);
833
+ if ('language' in table)
834
+ checkLanguage(table['language'], lines.get('language'), issues, details);
835
+ if ('exclude' in table) {
836
+ checkExclude(table['exclude'], lines.get('exclude'), excludeLines(document, lineCounter), taggedExcludeMembers(document), issues, details);
837
+ }
838
+ if (issues.length > 0)
839
+ refuse(issues, details);
840
+ return {
841
+ // No `.trim()`: `checkTitle` has already refused a padded value, so trimming
842
+ // here would be a rewrite that can never fire, and a reader would have to
843
+ // check whether it could.
844
+ title: 'title' in table ? table['title'] : DEFAULTS.title,
845
+ origin: 'origin' in table ? table['origin'] : DEFAULTS.origin,
846
+ exclude: 'exclude' in table ? [...table['exclude']] : [],
847
+ language: 'language' in table ? table['language'] : DEFAULTS.language,
848
+ };
849
+ }
850
+ /**
851
+ * The configuration for a build rooted at `directory`, or the defaults.
852
+ *
853
+ * **Absent must work**, and it is the ordinary case rather than a degradation:
854
+ * this tool publishes by default, so a stranger who has written notes and
855
+ * nothing else gets a complete site. What they do not get is an origin — see
856
+ * {@link LoadedConfig} — because the fallback for that belongs to the one place
857
+ * the origin is written down.
858
+ *
859
+ * `directory` being `undefined` means the caller has no user directory to
860
+ * consult at all, which is this repository building itself. Same answer,
861
+ * reached without a filesystem call.
862
+ */
863
+ export function loadConfig(directory) {
864
+ if (directory === undefined)
865
+ return freshDefaults();
866
+ const path = join(directory, CONFIG_FILENAME);
867
+ if (!existsSync(path)) {
868
+ for (const name of NEAR_MISS_NAMES) {
869
+ if (!existsSync(join(directory, name)))
870
+ continue;
871
+ // Both names are literals of this file's own source, so the refusal can
872
+ // name them. Refusing rather than reading it: accepting a second spelling
873
+ // is a search order, and a search order is a place for the *next* file to
874
+ // be silently not found.
875
+ throw new BuildFailure('config-misnamed', `found ${name}, but the configuration file is ${CONFIG_FILENAME}. ` +
876
+ 'Rename it — a file under any other name is not read, and would ' +
877
+ 'leave you believing you had configured something.', `${name} present, ${CONFIG_FILENAME} absent, in the content directory`);
878
+ }
879
+ return freshDefaults();
880
+ }
881
+ let text;
882
+ try {
883
+ text = readFileSync(path, 'utf8');
884
+ }
885
+ catch (error) {
886
+ // The path is in the message `readFileSync` composed, and nothing here
887
+ // composed that message. It goes to the private half.
888
+ throw new BuildFailure('config-unreadable', `${CONFIG_FILENAME}: exists but could not be read`, error instanceof Error ? error.message : String(error));
889
+ }
890
+ return parseConfig(text);
891
+ }
892
+ /**
893
+ * The exclusion argument `discover()` takes, built from a loaded config.
894
+ *
895
+ * It also carries the configured site `language`, because `discover()` is
896
+ * where an undeclared note's effective language is decided: one options object
897
+ * keeps every caller of `discover()` (build and review) passing the same
898
+ * configuration rather than threading a second argument through each.
899
+ *
900
+ * The return type is imported from the producer rather than restated, so the
901
+ * handoff is checked by `astro check` instead of by a comment: if TK-26's
902
+ * `ExclusionOptions` ever gains a field or changes one, this stops compiling.
903
+ * That is the whole of the "accepted without adaptation" criterion, held at the
904
+ * type level; the gate that calls `discover()` with it holds the other half at
905
+ * run time.
906
+ *
907
+ * `excludeSource` is {@link CONFIG_FILENAME}, which is the shape the producer's
908
+ * `CONFIG_LOCATION` allowlist requires before it will put a source label on a
909
+ * stream — so a zero-match failure names this file rather than falling back to
910
+ * the anonymous literal.
911
+ *
912
+ * **Nothing in the shipped binary calls this yet, and a reader must not assume
913
+ * otherwise.** `bin/anc.mjs` calls `discover(contentDirectory)`
914
+ * with no options, so measured end to end today, a repository whose
915
+ * `publish.config.yaml` excludes `drafts/**` builds and **publishes the drafts**
916
+ * — the only file dropped is the config itself, as `not-markdown`. The same run
917
+ * ignores the configured `title`. That is TK-32's wiring, one line at each call
918
+ * site, and it is stated here rather than left implied because the gate in
919
+ * `tests/config.test.ts` calls `discover` directly and reads as end-to-end
920
+ * evidence when it is not.
921
+ */
922
+ export function exclusionOptions(config) {
923
+ return {
924
+ exclude: config.exclude,
925
+ excludeSource: CONFIG_FILENAME,
926
+ ...(config.language === undefined ? {} : { language: config.language }),
927
+ };
928
+ }
929
+ /**
930
+ * The environment variable naming the directory holding the user's config.
931
+ *
932
+ * The seam, and the reason it is one. `bin/anc.mjs` changes the
933
+ * working directory to this package's root before Astro is started, so by the
934
+ * time `astro.config.mjs` is evaluated the user's own directory is no longer
935
+ * reachable from `process.cwd()`. `CONTENT_ARTIFACT` already crosses that
936
+ * boundary the same way and for the same reason.
937
+ *
938
+ * **Nothing sets it yet, and that is deliberate rather than unfinished.**
939
+ * Setting it is one line in the CLI, which TK-32 owns along with the rest of the
940
+ * end-to-end wiring; this ticket owns the loader and the config's arrival in
941
+ * `astro.config.mjs`. Until then a packaged build falls back to the shipped
942
+ * default origin, which is the placeholder `astro.config.mjs` documents as safe.
943
+ */
944
+ export const CONFIG_DIRECTORY_VARIABLE = 'PUBLISH_CONFIG_DIR';
945
+ /**
946
+ * The configuration an Astro build should use, resolved from the environment.
947
+ *
948
+ * `||` rather than `??`, and `src/lib/artifact-source.ts` documents the same
949
+ * choice for the same measured reason: an *empty* environment variable is how a
950
+ * shell unsets one for a single command, and `??` would treat `''` as a
951
+ * directory — making `join('', 'publish.config.yaml')` a relative path resolved
952
+ * against wherever the process happens to be standing.
953
+ */
954
+ export function configForBuild() {
955
+ const config = loadConfig(process.env[CONFIG_DIRECTORY_VARIABLE] || process.cwd());
956
+ // The configured site language crosses into the page modules the way the
957
+ // title does — an environment variable, because `src/lib/` cannot import
958
+ // this module (see `SITE_TITLE_VARIABLE` in `src/lib/site.ts`). Set here
959
+ // rather than in `astro.config.mjs` so every caller that resolves the build's
960
+ // configuration also publishes its language, and cleared when the user
961
+ // configured none so a value left by an earlier build in the same process
962
+ // cannot leak into this one. The spelling is `SITE_LANGUAGE_VARIABLE` in
963
+ // `src/lib/translations.ts`; `tests/config.test.ts` holds the two equal.
964
+ if (config.language === undefined)
965
+ delete process.env[SITE_LANGUAGE_VARIABLE];
966
+ else
967
+ process.env[SITE_LANGUAGE_VARIABLE] = config.language;
968
+ return config;
969
+ }
970
+ /**
971
+ * The environment variable `src/lib/translations.ts` reads the site language
972
+ * from. Spelled out rather than imported: importing `translations.ts` here would
973
+ * evaluate its `NAV_LANGUAGE` before this module has set the variable.
974
+ */
975
+ export const SITE_LANGUAGE_VARIABLE = 'PUBLISH_SITE_LANGUAGE';