@heroiclands/package-build 20.4.0 → 20.6.0

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 (78) hide show
  1. package/CHANGELOG.md +288 -0
  2. package/CONTENT.md +213 -20
  3. package/README.md +19 -1
  4. package/bin/content-build.mjs +135 -32
  5. package/bin/package-build.mjs +46 -13
  6. package/content-config.mjs +345 -101
  7. package/docs/api.md +1352 -0
  8. package/docs/commands.md +1609 -0
  9. package/docs/configuration.md +1432 -0
  10. package/docs/content-format.md +16 -6
  11. package/docs/diagnostics.md +356 -0
  12. package/docs/getting-started.md +813 -0
  13. package/docs/project-setup.md +469 -0
  14. package/engine/actor-compiler.mjs +30 -27
  15. package/engine/address-diff.mjs +45 -41
  16. package/engine/base-compiler.mjs +6 -0
  17. package/engine/bundles.mjs +9 -0
  18. package/engine/content-address.mjs +9 -9
  19. package/engine/content-index.mjs +44 -23
  20. package/engine/content-links.mjs +44 -11
  21. package/engine/content-lint.mjs +44 -10
  22. package/engine/content-tables.mjs +32 -27
  23. package/engine/folder-notes.mjs +4 -2
  24. package/engine/frontmatter-lint.mjs +35 -38
  25. package/engine/generate.mjs +5 -0
  26. package/engine/helpers.mjs +86 -32
  27. package/engine/index.mjs +12 -2
  28. package/engine/journals.mjs +9 -0
  29. package/engine/note-claims.mjs +18 -10
  30. package/engine/note-schemas.mjs +0 -5
  31. package/engine/note-vocabulary.mjs +32 -31
  32. package/engine/pack-config.mjs +26 -12
  33. package/engine/pack-router.mjs +0 -0
  34. package/engine/pdf-build.mjs +464 -0
  35. package/engine/pdf-fonts.mjs +420 -0
  36. package/engine/pdf-render.mjs +876 -0
  37. package/engine/pdf-toc.mjs +525 -0
  38. package/engine/scenes.mjs +14 -5
  39. package/engine/schema-check.mjs +1 -1
  40. package/engine/site-build.mjs +21 -3
  41. package/engine/web-wikilinks.mjs +6 -3
  42. package/engine/wikilinks.mjs +2 -4
  43. package/hm3/actors.mjs +8 -0
  44. package/hm3/items.mjs +8 -0
  45. package/package.json +1 -1
  46. package/release.mjs +63 -3
  47. package/sohl/actors.mjs +8 -0
  48. package/sohl/items.mjs +8 -0
  49. package/sohl/note-schemas.mjs +5 -5
  50. package/types/content-config.d.mts +66 -15
  51. package/types/engine/actor-compiler.d.mts +34 -30
  52. package/types/engine/address-diff.d.mts +57 -3
  53. package/types/engine/base-compiler.d.mts +10 -2
  54. package/types/engine/bundles.d.mts +9 -0
  55. package/types/engine/content-address.d.mts +9 -9
  56. package/types/engine/content-index.d.mts +57 -13
  57. package/types/engine/content-lint.d.mts +6 -4
  58. package/types/engine/content-tables.d.mts +49 -18
  59. package/types/engine/frontmatter-lint.d.mts +3 -2
  60. package/types/engine/helpers.d.mts +105 -31
  61. package/types/engine/index.d.mts +4 -0
  62. package/types/engine/journals.d.mts +9 -0
  63. package/types/engine/note-claims.d.mts +17 -10
  64. package/types/engine/note-vocabulary.d.mts +23 -196
  65. package/types/engine/pack-config.d.mts +4 -4
  66. package/types/engine/pdf-build.d.mts +42 -0
  67. package/types/engine/pdf-fonts.d.mts +30 -0
  68. package/types/engine/pdf-render.d.mts +156 -0
  69. package/types/engine/pdf-toc.d.mts +114 -0
  70. package/types/engine/scenes.d.mts +10 -1
  71. package/types/engine/schema-check.d.mts +2 -2
  72. package/types/engine/site-build.d.mts +34 -6
  73. package/types/engine/wikilinks.d.mts +2 -3
  74. package/types/hm3/actors.d.mts +8 -0
  75. package/types/hm3/items.d.mts +8 -0
  76. package/types/release.d.mts +15 -4
  77. package/types/sohl/actors.d.mts +10 -2
  78. package/types/sohl/items.d.mts +8 -0
@@ -346,7 +346,10 @@ there are exactly three answers:
346
346
 
347
347
  `<assetRoot>` is `<packageKind>/<foundryPackage>/assets`, derived from the
348
348
  configuration — `systems/sohl/assets` for the system,
349
- `modules/sohl-thalorna/assets` for that module. So one authored
349
+ `modules/sohl-thalorna/assets` for that module. A `documentation` package has
350
+ no asset root at all, because Foundry installs no such package and serves no
351
+ files for it: there, the third row is refused, and a note names the owning
352
+ package (`systems/…`, `modules/…`) or a URL. So one authored
350
353
  `icons/relic.svg` means "my own `assets/icons/relic.svg`" in whichever package
351
354
  writes it, while an authored `systems/sohl/assets/icons/noun/shield.svg` names
352
355
  the system's file and is left exactly as written wherever it appears. That
@@ -823,9 +826,9 @@ only in case are two names nobody can tell apart, and they collapsed onto one
823
826
  address, one `_id` and one URL with nothing to report it.
824
827
 
825
828
  **Parsing is positional counting from the right, and nothing else.** Every
826
- segment is alphanumeric — shortcodes, **types** and **subTypes** are all
827
- `^[a-z0-9]+$`, systems come from a closed registry, and `contentPackage` is
828
- alphanumeric — so the hyphen is purely a separator. There is no longest-match
829
+ segment is lowercase alphanumeric — shortcodes, **types** and **subTypes** are
830
+ all `^[a-z0-9]+$`, systems come from a closed registry, and `contentPackage` is
831
+ `^[a-z0-9]+$` too — so the hyphen is purely a separator. There is no longest-match
829
832
  against a roster and no vocabulary check before splitting.
830
833
 
831
834
  **`type` and `subType` are held to that charset, not merely expected to meet
@@ -2266,9 +2269,15 @@ Content prepared to be played — a situation with its cast, places, and possibl
2266
2269
  - rules: The rules of the game, independent of medium — valid at a table with paper and dice.
2267
2270
  - userguide: How to operate the Foundry implementation to play by the rules.
2268
2271
  - reference: Out-of-world lookup material about the setting or system — correspondences, conversions, glossaries.
2272
+ - howto: A task with an outcome, written as the steps that reach it.
2273
+ - concept: An explanation of how something works and why it is shaped that way, read to understand rather than to follow.
2269
2274
 
2270
2275
  A `doc` declares no properties of its own.
2271
2276
 
2277
+ The field is a genre and only a genre: it says what kind of page this is, never
2278
+ who reads it. A page written for a developer is a `howto` or a `concept` like
2279
+ any other, and the audience is the section it sits in.
2280
+
2272
2281
  **A page that introduces a type is an ordinary note, named by convention.**
2273
2282
  Write `type: doc`, `subType: reference`, `shortcode: <type>` — so the
2274
2283
  affiliations introduction is `doc-affiliation`, addressed and linked like
@@ -2452,8 +2461,9 @@ data:
2452
2461
  A folder is addressed `<package>-none-folder-<shortcode>` — **`none`**, because a
2453
2462
  `Folder` is a core Foundry document like a `JournalEntry` or a `Scene`, not a
2454
2463
  system's. Its shortcode is [an address segment](#the-canonical-address) like
2455
- every other, so it is strictly alphanumeric: `possessionscooking`, never
2456
- `possessions-cooking`, which would read as two segments and resolve to nothing.
2464
+ every other, so it is strictly lowercase alphanumeric: `possessionscooking`,
2465
+ never `possessions-cooking`, which would read as two segments and resolve to
2466
+ nothing.
2457
2467
 
2458
2468
  **`color` must be quoted**, and YAML gives no third option: `color: #7a4b2a`
2459
2469
  parses as `null` (a `#` after a space opens a comment) and `color: 000000` parses
@@ -0,0 +1,356 @@
1
+ # Diagnostics
2
+
3
+ Every warning or error this toolchain emits is machine-parseable and says
4
+ where it is. This document is for the person reading a wall of that output:
5
+ what the fields mean, why a field is sometimes missing, why a run's exit code
6
+ does not always track its warning count, and how to parse the output
7
+ yourself. `engine/diagnostics.mjs` is the module that owns the contract;
8
+ everything here is read from it.
9
+
10
+ ## The located form
11
+
12
+ A finding about a file is printed as:
13
+
14
+ ```text
15
+ file:line:column: severity: message
16
+ ```
17
+
18
+ For example:
19
+
20
+ ```text
21
+ assets/content/Regions/Capital_Nome.md:43:635: error: address [[place-kenbetpat]] resolves to no note — no package publishes it. Fix the shortcode, or declare the package that does as a dependency and run `content-build deps fetch` — in "The Capital Nome".
22
+ ```
23
+
24
+ That is the same shape every C-family compiler, `tsc` and ESLint already use,
25
+ which is deliberate: an editor, a CI annotator or a `grep` parses it with no
26
+ knowledge of this build. `formatDiagnostic` builds the line and
27
+ `formatLocator` builds the `file:line:column` part of it; `severity` is
28
+ either `warning` or `error`, and `message` is one sentence.
29
+
30
+ The path is relative to the working directory — during a build, the
31
+ consuming repository's root — because that is both shorter to read and what
32
+ an editor resolves a relative diagnostic against. A file outside the working
33
+ directory keeps its absolute path; a `../../..` locator would help nobody.
34
+
35
+ Three rules hold the form together.
36
+
37
+ ### The path starts the line
38
+
39
+ A located diagnostic is never indented and never carries a prefix — not a
40
+ timestamp, not a severity tag, nothing before the file name. `emitDiagnostic`
41
+ prints every diagnostic with a bare `console.error` or `console.warn`,
42
+ deliberately bypassing this toolchain's own `loglevel` logger, whose
43
+ configured `[timestamp] [WARN]:` prefix occupies exactly the position a
44
+ parser reads the path from. A greedy path pattern would swallow that prefix
45
+ and hand a reader a filename nothing can open. Progress and summary lines are
46
+ not diagnostics and go through `loglevel` as ordinary prose — see
47
+ [Summary counts and prose are not findings](#summary-counts-and-prose-are-not-findings)
48
+ below.
49
+
50
+ ### A field is dropped, never guessed
51
+
52
+ `formatLocator` reports the most precise position it can honestly establish
53
+ and nothing more:
54
+
55
+ - `file:line:column: …` when a line and a meaningful column are both known.
56
+ - `file:line: …` when only the line is — a column is ignored entirely once
57
+ there is no line to pair it with, since a column alone locates nothing.
58
+ - `file: …` when nothing beyond the file itself could be established.
59
+
60
+ Nothing ever defaults to `1:1`. A missing position that silently became `1:1`
61
+ would send a reader to the top of the file — usually the frontmatter — for
62
+ every diagnostic that could not otherwise be placed, and it would look
63
+ exactly like a real position instead of an absent one.
64
+
65
+ ### An implicit position is recovered by search
66
+
67
+ Some diagnostics know their position by construction — a compile pass
68
+ tracking a scanner's offset as it reads a note's body. Others know only that
69
+ something is wrong with a literal a file contains — a wikilink, a caption, a
70
+ localization key — and never carried a byte offset in the first place.
71
+ `engine/diagnostics.mjs` supplies one function for the first case and three
72
+ for the second, and all of them fall back to `{}` — an empty spreadable
73
+ object — when a position cannot be established, so a caller writes
74
+ `{ ...position }` and gets the drop-not-guess rule for free.
75
+
76
+ **`positionInBody(body, offset, opts)`** answers the constructed case: where
77
+ a character offset into a note's parsed **body** falls in the note's **file**.
78
+ Three corrections apply, each only where it is true:
79
+
80
+ 1. The frontmatter's own lines are not part of the body, so an offset is not
81
+ a file line until they are added back (`opts.bodyLine`).
82
+ 2. The body is trimmed before it is scanned, which can strip leading
83
+ whitespace from only its first line; `opts.bodyColumn` restores it, and
84
+ only on that line.
85
+ 3. A body is scanned **after** its content tables expand, so an offset can
86
+ land in text nobody authored. `opts.lineMap` — from
87
+ `expandContentTables` — maps each scanned line back to the authored line
88
+ it came from. A line with no authored source reports the directive that
89
+ generated it and **no column**, because there is no authored character to
90
+ point a column at; `positionInBody` marks this `generated: true`.
91
+
92
+ **`positionOfLiteral(text, needle, occurrence)`** answers the plain case: a
93
+ string the reader can see in a file that is not a note body — a manifest, a
94
+ lockfile, a config file, a file being checked as raw text rather than parsed
95
+ frontmatter. Because one literal can appear more than once, `occurrence`
96
+ (1-based, default `1`) selects which match the position is for — the caller
97
+ counts how many times it has already seen the literal in this file and passes
98
+ the next number, so four identical findings on one note land on four
99
+ different lines or columns instead of collapsing onto the first.
100
+
101
+ **`positionInFrontmatter(raw, key, value, opts)`** locates a **top-level
102
+ frontmatter key** by searching the fence rather than the whole file — a bare
103
+ search for a key like `name` or `type` would routinely match a line of prose
104
+ first. Passing `value` narrows the match to the entry that carries it, which
105
+ is what a list-valued key (`aliases`) needs: the finding belongs on the
106
+ wrong entry, not on the key that introduces the list. `opts.topLevel`
107
+ requires the match at column 1, for a key name that is legal both at the top
108
+ level and nested (`aliases` is both a retired top-level field and a
109
+ permitted `name.aliases`) — without it, a finding about the retired field
110
+ could resolve to the nested one and tell an author to delete a field they are
111
+ allowed to write.
112
+
113
+ **`positionOfYamlPath(text, keyPath, opts)`** and
114
+ **`positionOfFrontmatterPath(raw, keyPath, opts)`** locate a node by
115
+ **path** rather than by searching for a name, which is the right tool once a
116
+ name can legally appear in more than one place — `data.weight` and a
117
+ top-level `weight` are different keys, and a name search finds whichever
118
+ comes first. `keyPath` is an array of map keys (strings) and sequence indices
119
+ (numbers); `yamlKeyPath(field)` turns a dotted path as a message would write
120
+ it — `packs[1].name`, `site.sections.affliction.title` — into that array,
121
+ refusing the whole path rather than resolving part of it if any segment does
122
+ not parse. `positionOfYamlPath` re-parses the document to get a node with a
123
+ range attached — the loader that originally read the file discards ranges
124
+ once the data is materialized, so carrying a parallel position tree through
125
+ configuration resolution would be a second representation of the same file to
126
+ keep in sync. `positionOfFrontmatterPath` is the same lookup scoped to a
127
+ note's frontmatter fence rather than a whole document.
128
+
129
+ By default the position is the node's **value** — a finding that a value is
130
+ wrong belongs on the value. Passing `opts.key: true` reports where the last
131
+ segment is **declared** instead, for a finding that names the field itself
132
+ (`site.sections.x is not a recognized option`) — in a flow mapping like
133
+ `{ title: X, banner: Y }` the key and the value are different columns on the
134
+ same line, and a field-naming finding should send the reader to the field,
135
+ not to whatever happens to be written there.
136
+
137
+ ## A configuration error is located the same way
138
+
139
+ `content-config.mjs` and `config.mjs` report every configuration check
140
+ through one `fail()`, which names the offending key's dotted path and knows
141
+ nothing about where in the file it was written.
142
+ `locateConfigError`, in `engine/pack-config.mjs`, is where the path becomes a
143
+ position: it turns the dotted field into a `keyPath` with `yamlKeyPath`, asks
144
+ `positionOfYamlPath` for that key's own declaration (`{ key: true }`), and
145
+ falls back one level to the **enclosing mapping** when the exact key has no
146
+ node of its own — a required key that is simply absent has nothing to point
147
+ at but the block it belongs in. A field one level deep with nothing above it
148
+ but the whole document gets no position at all, which is the same
149
+ drop-rather-than-guess rule applied one level higher.
150
+
151
+ This only runs against a YAML configuration. An `.mjs` configuration is
152
+ JavaScript, not data a YAML parser can be asked for a range in — feeding it
153
+ to one would not fail, since arbitrary JavaScript source routinely also
154
+ parses as _some_ YAML document, and the resulting position would point at a
155
+ line that has nothing to do with the key. A wrong position is worse than
156
+ none, so `positionInConfig` (the function behind `locateConfigError`) checks
157
+ the file extension first and returns `{}` for anything that is not
158
+ `.yaml`/`.yml`.
159
+
160
+ A configuration finding reads the same as any other:
161
+
162
+ ```text
163
+ package-build.config.yaml:382:64: error: package-build config: `site.sections.being.descrption` is not a recognized option (expected one of: title, banner, description, listType, listSubType).
164
+ ```
165
+
166
+ ## Summary counts and prose are not findings
167
+
168
+ A run's progress and summary lines — `42 address(es) across 10 note(s).`,
169
+ `Formatting is clean (120 file(s)).`, `3 of 22 compared field pair(s)
170
+ disagree between the specification and the declaration that compiles them.`
171
+ — are not diagnostics and are never emitted through `emitDiagnostic`. They
172
+ carry no file, no position and nothing for a parser to act on; printing them
173
+ in the located form would be a lie about what is known; a machine reading the
174
+ findings has to skip them regardless, since they have no `file:line:column:`
175
+ prefix to key on. They go through this toolchain's ordinary `loglevel`
176
+ logger and keep whatever prefix that logger is configured with.
177
+
178
+ ## Both severities go to stderr
179
+
180
+ `emitDiagnostic` writes both a `warning` and an `error` to `console.error`.
181
+ `console.warn` writes to `process.stderr` too — in Node it is simply another
182
+ name for `console.error` — so there is no separate warning stream to redirect
183
+ a build's findings away from its errors. Splitting warnings from errors, or
184
+ suppressing one severity, means reading the emitted `severity` field, not
185
+ picking which stream to listen on. A shell filter that assumes warnings land
186
+ on stdout and errors on stderr will see nothing on stdout and everything on
187
+ stderr, including every warning.
188
+
189
+ ## Which commands exit non-zero
190
+
191
+ The rule that recurs everywhere a command reports diagnostics: **a run fails
192
+ when it reports at least one `error`-severity diagnostic. A `warning` alone
193
+ never fails a run.** `bin/report.mjs`'s `reportFindings` draws this line once
194
+ for the commands built from the package's pure rule functions (`content-build
195
+ lint`, `package-build lang check`, `package-build lang coverage`,
196
+ `package-build yaml`, `package-build labels check`): it emits every finding
197
+ and returns only the count whose `severity` is `error`, and the command sets
198
+ `process.exitCode = 1` exactly when that count is nonzero. Commands whose
199
+ findings are hand-assembled — `content-build links`, `content-build
200
+ reachability`, `content-build site`, `content-build package compile` — apply
201
+ the same rule by construction: everything they can report is a broken
202
+ address, a dead link or a document Foundry would silently drop a field from,
203
+ so every finding they emit is already `severity: "error"` and any finding at
204
+ all fails the run.
205
+
206
+ **A corpus problem always fails, regardless of severity or a `--strict`
207
+ flag.** When the content tree itself cannot be indexed — a note the walk
208
+ cannot record, most often a retired `package:` key — the note is absent from
209
+ every other answer a command gives. Reporting it and still exiting `0` would
210
+ call the tree clean while silently omitting a note, so every command that
211
+ indexes a corpus (`lint`, `links`, `reachability`, `content-format notes`,
212
+ `addresses diff`) fails on a corpus problem unconditionally, before it
213
+ evaluates anything else.
214
+
215
+ **A few commands report advisory findings that never fail the run on their
216
+ own**, and say so where they emit them: `content-format notes` measures a
217
+ content tree against the format specification as a progress report during a
218
+ migration, printing every finding as a `warning` and passing `--strict` to
219
+ promote a class to `error` only once that class has reached zero; `format`
220
+ reports where this repository's Prettier configuration diverges from the
221
+ shared convention as a `warning`, because a repository's own configuration
222
+ is allowed to win on purpose; `addresses diff` reports a withdrawn or renamed
223
+ published address as a `warning` by default and only as an `error` under
224
+ `--strict`, since retiring or renaming content is legitimate and the check
225
+ exists to make sure it was noticed, not to forbid it; `pdf` reports a
226
+ book-build finding but fails only when it could not build the book for a
227
+ reason that is not a deliberate no-op (an empty filter, an absent block); and
228
+ `lang coverage`'s unreferenced-key half is advisory without a way to promote
229
+ it — nothing can see every way a key might be reached, so failing a build
230
+ over one would teach people to stop reading the report.
231
+
232
+ | command | fails when |
233
+ | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
234
+ | `content-build lint [root]` | an address, frontmatter, schema, charset, icon or HTML finding is `error`-severity, or the corpus cannot be indexed |
235
+ | `content-build content-format notes [root]` | `--strict` was passed and it reported any finding (all findings are `error`-severity only under `--strict`), or the corpus cannot be indexed |
236
+ | `content-build content-format schema` | it reports any finding at all |
237
+ | `content-build content-format fields` | it reports any finding at all |
238
+ | `content-build links [root]` | it reports any finding at all, an index is unusable (a stale or unaddressable foreign manifest), or the corpus cannot be indexed |
239
+ | `content-build reachability <dir> [file]` | it reports any orphaned document, or the corpus cannot be indexed |
240
+ | `content-build addresses diff --from <artifact>` | the corpus cannot be indexed (always); otherwise only with `--strict`, and only then are the address findings `error`-severity |
241
+ | `content-build format [paths..]` | a file it checked is not formatted (or, under `--write`, could not be brought to a fixpoint) — never on a shared-convention divergence, which is always advisory |
242
+ | `content-build markdown [paths..]` | it reports any markdownlint finding |
243
+ | `content-build site` | any gate finding (no homepage, a wikilink in frontmatter, an address that cannot be derived, an unusable or unaddressable foreign manifest, a conflicting address) or any table or wikilink error while writing pages — the run stops at the first gate that fires |
244
+ | `content-build pdf` | it could not build the book for a reason other than a deliberate no-op — never on a reported finding by itself |
245
+ | `content-build docs item-fields --check` | the generated page differs from what is committed |
246
+ | `content-build package compile` | pack JSON generation reported any `error`-severity finding (checked, and the compile refused, before any pack is written), or a compiled pack fails its Scene/Level integrity check |
247
+ | `content-build deps fetch` | the fetch itself throws — it emits no diagnostics |
248
+ | `package-build lang check` | any localization finding is `error`-severity |
249
+ | `package-build lang coverage` | a referenced key is missing (`error`-severity); an unreferenced key is always advisory and never fails the run |
250
+ | `package-build yaml [paths..]` | any YAML finding is `error`-severity |
251
+ | `package-build labels check` | the label registry and the documented table disagree on any label |
252
+
253
+ Every command in both binaries also fails on a thrown error unrelated to a
254
+ diagnostic — a missing configuration, an unreadable file, a network failure —
255
+ through the same `process.exitCode = 1` (or `process.exit(1)`), reported as
256
+ one line with no stack.
257
+
258
+ ## A worked example
259
+
260
+ This parses a run's diagnostics the way a script or a CI step would: run the
261
+ command, capture stderr, turn each line into a record, and decide what to do
262
+ based on the `severity` field.
263
+
264
+ A command that emits a few diagnostics, using the same `emitDiagnostic` every
265
+ pass in this package calls:
266
+
267
+ ```js
268
+ // emit.mjs
269
+ import { emitDiagnostic } from "@heroiclands/package-build/engine/diagnostics";
270
+
271
+ emitDiagnostic({
272
+ file: "assets/content/Regions/Capital_Nome.md",
273
+ line: 43,
274
+ column: 635,
275
+ severity: "error",
276
+ message:
277
+ "address [[place-kenbetpat]] resolves to no note — no package publishes it. " +
278
+ "Fix the shortcode, or declare the package that does as a dependency and " +
279
+ 'run `content-build deps fetch` — in "The Capital Nome".',
280
+ });
281
+ emitDiagnostic({
282
+ file: "assets/content/Regions/Capital_Nome.md",
283
+ line: 12,
284
+ severity: "warning",
285
+ message: "`system.sohl.unemitted` is declared but no builder ever sets it",
286
+ });
287
+ emitDiagnostic({
288
+ file: "assets/content/Regions/Capital_Nome.md",
289
+ severity: "error",
290
+ message: "duplicate frontmatter key `name` — the second declaration wins and the first is dead",
291
+ });
292
+
293
+ // A build's own summary line: prose, not a finding.
294
+ console.log("3 finding(s) across 1 note(s).");
295
+ ```
296
+
297
+ A parser that runs it, reads its diagnostics off stderr, and reports the
298
+ split:
299
+
300
+ ```js
301
+ // parse.mjs
302
+ import { spawn } from "node:child_process";
303
+ import { fileURLToPath } from "node:url";
304
+
305
+ // `formatDiagnostic`'s own grammar, read backwards: the path is whatever
306
+ // comes before an optional `:line` and `:line:column`, then
307
+ // `severity: message`. A field the diagnostic dropped is simply absent from
308
+ // its capture group — never a guessed `1:1`.
309
+ const LOCATED =
310
+ /^(?<file>[^:]+?)(?::(?<line>\d+))?(?::(?<column>\d+))?: (?<severity>warning|error): (?<message>.+)$/;
311
+
312
+ function parseDiagnostic(line) {
313
+ const m = LOCATED.exec(line);
314
+ if (!m) return null;
315
+ const { file, line: ln, column, severity, message } = m.groups;
316
+ return {
317
+ file,
318
+ ...(ln ? { line: Number(ln) } : {}),
319
+ ...(column ? { column: Number(column) } : {}),
320
+ severity,
321
+ message,
322
+ };
323
+ }
324
+
325
+ const child = spawn(process.execPath, [fileURLToPath(new URL("./emit.mjs", import.meta.url))]);
326
+ let stderr = "";
327
+ child.stderr.on("data", (chunk) => (stderr += chunk));
328
+ await new Promise((resolve) => child.on("close", resolve));
329
+
330
+ const findings = stderr.split("\n").filter(Boolean).map(parseDiagnostic).filter(Boolean);
331
+ const errors = findings.filter((f) => f.severity === "error");
332
+ const warnings = findings.filter((f) => f.severity === "warning");
333
+
334
+ console.log(`${errors.length} error(s), ${warnings.length} warning(s):\n`);
335
+ for (const f of findings) {
336
+ const where = f.file + (f.line ? `:${f.line}` + (f.column ? `:${f.column}` : "") : "");
337
+ console.log(`[${f.severity}] ${where} — ${f.message}`);
338
+ }
339
+
340
+ process.exitCode = errors.length ? 1 : 0;
341
+ ```
342
+
343
+ Running `node parse.mjs` prints:
344
+
345
+ ```text
346
+ 2 error(s), 1 warning(s):
347
+
348
+ [error] assets/content/Regions/Capital_Nome.md:43:635 — address [[place-kenbetpat]] resolves to no note — no package publishes it. Fix the shortcode, or declare the package that does as a dependency and run `content-build deps fetch` — in "The Capital Nome".
349
+ [warning] assets/content/Regions/Capital_Nome.md:12 — `system.sohl.unemitted` is declared but no builder ever sets it
350
+ [error] assets/content/Regions/Capital_Nome.md — duplicate frontmatter key `name` — the second declaration wins and the first is dead
351
+ ```
352
+
353
+ and exits `1`, because it saw two `error`-severity diagnostics. The summary
354
+ line the emitting process printed to stdout, `3 finding(s) across 1
355
+ note(s).`, never reaches the parser at all — it was never on stderr, and the
356
+ regex would not have matched it if it had been.