@heroiclands/package-build 0.6.0 → 3.0.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 (127) hide show
  1. package/CHANGELOG-content-build.md +965 -0
  2. package/CHANGELOG.md +95 -0
  3. package/CONTENT.md +824 -0
  4. package/MIGRATING.md +127 -0
  5. package/README.md +29 -17
  6. package/bin/content-build.mjs +1082 -0
  7. package/bin/package-build.mjs +2 -2
  8. package/bin/report.mjs +2 -2
  9. package/config.mjs +21 -18
  10. package/content-config.mjs +1424 -0
  11. package/coverage.mjs +1 -1
  12. package/e2e.mjs +1 -1
  13. package/engine/abbreviations.mjs +0 -0
  14. package/engine/base-compiler.mjs +608 -0
  15. package/engine/code-fences.mjs +311 -0
  16. package/engine/compendiums.mjs +353 -0
  17. package/engine/content-address.mjs +141 -0
  18. package/engine/content-links.mjs +438 -0
  19. package/engine/content-lint.mjs +204 -0
  20. package/engine/content-package.mjs +67 -0
  21. package/engine/content-slug.mjs +169 -0
  22. package/engine/content-tables.mjs +1216 -0
  23. package/engine/content-tree.mjs +56 -0
  24. package/engine/diagnostics.mjs +270 -0
  25. package/engine/field-reference.mjs +242 -0
  26. package/engine/field-spec.mjs +218 -0
  27. package/engine/foreign-catalog.mjs +450 -0
  28. package/engine/foreign-manifests.mjs +129 -0
  29. package/engine/frontmatter-lint.mjs +343 -0
  30. package/engine/frontmatter.mjs +258 -0
  31. package/engine/generate.mjs +305 -0
  32. package/engine/helpers.mjs +809 -0
  33. package/engine/ids.mjs +192 -0
  34. package/engine/index.mjs +142 -0
  35. package/engine/item-docs.mjs +153 -0
  36. package/engine/item-registry.mjs +129 -0
  37. package/engine/journals.mjs +374 -0
  38. package/engine/kb-manifest.mjs +473 -0
  39. package/engine/macros.mjs +333 -0
  40. package/engine/manifest-emit.mjs +339 -0
  41. package/engine/map-notes.mjs +1302 -0
  42. package/engine/pack-config.mjs +479 -0
  43. package/engine/pack-router.mjs +254 -0
  44. package/engine/prose-config.mjs +238 -0
  45. package/engine/prose-lint.mjs +294 -0
  46. package/engine/region-events.mjs +79 -0
  47. package/engine/scene-levels.mjs +420 -0
  48. package/engine/scenes.mjs +528 -0
  49. package/engine/site-build.mjs +778 -0
  50. package/engine/site-index.mjs +332 -0
  51. package/engine/web-wikilinks.mjs +330 -0
  52. package/engine/wikilink-syntax.mjs +105 -0
  53. package/engine/wikilinks.mjs +645 -0
  54. package/index.mjs +42 -25
  55. package/lang.mjs +1 -1
  56. package/manifest.mjs +79 -5
  57. package/markdownlint-config.mjs +45 -0
  58. package/package.json +78 -34
  59. package/prettier-config.mjs +34 -0
  60. package/sohl/actors.mjs +581 -0
  61. package/sohl/affiliation-standings.mjs +44 -0
  62. package/sohl/being-info.mjs +173 -0
  63. package/sohl/default-item-art.mjs +82 -0
  64. package/sohl/index.mjs +55 -0
  65. package/sohl/item-builders.mjs +114 -0
  66. package/sohl/item-fields.mjs +867 -0
  67. package/sohl/items.mjs +197 -0
  68. package/sohl/kb-passes.mjs +207 -0
  69. package/sohl/note-schemas.mjs +322 -0
  70. package/templates.mjs +1 -1
  71. package/types/config.d.mts +1 -1
  72. package/types/content-config.d.mts +571 -0
  73. package/types/engine/abbreviations.d.mts +44 -0
  74. package/types/engine/base-compiler.d.mts +320 -0
  75. package/types/engine/code-fences.d.mts +102 -0
  76. package/types/engine/compendiums.d.mts +77 -0
  77. package/types/engine/content-address.d.mts +61 -0
  78. package/types/engine/content-links.d.mts +87 -0
  79. package/types/engine/content-lint.d.mts +47 -0
  80. package/types/engine/content-package.d.mts +36 -0
  81. package/types/engine/content-slug.d.mts +59 -0
  82. package/types/engine/content-tables.d.mts +131 -0
  83. package/types/engine/content-tree.d.mts +12 -0
  84. package/types/engine/diagnostics.d.mts +152 -0
  85. package/types/engine/field-reference.d.mts +20 -0
  86. package/types/engine/field-spec.d.mts +181 -0
  87. package/types/engine/foreign-catalog.d.mts +103 -0
  88. package/types/engine/foreign-manifests.d.mts +43 -0
  89. package/types/engine/frontmatter-lint.d.mts +74 -0
  90. package/types/engine/frontmatter.d.mts +99 -0
  91. package/types/engine/generate.d.mts +56 -0
  92. package/types/engine/helpers.d.mts +348 -0
  93. package/types/engine/ids.d.mts +126 -0
  94. package/types/engine/index.d.mts +33 -0
  95. package/types/engine/item-docs.d.mts +73 -0
  96. package/types/engine/item-registry.d.mts +57 -0
  97. package/types/engine/journals.d.mts +121 -0
  98. package/types/engine/kb-manifest.d.mts +214 -0
  99. package/types/engine/macros.d.mts +201 -0
  100. package/types/engine/manifest-emit.d.mts +137 -0
  101. package/types/engine/map-notes.d.mts +283 -0
  102. package/types/engine/pack-config.d.mts +77 -0
  103. package/types/engine/pack-router.d.mts +53 -0
  104. package/types/engine/prose-config.d.mts +121 -0
  105. package/types/engine/prose-lint.d.mts +59 -0
  106. package/types/engine/region-events.d.mts +43 -0
  107. package/types/engine/scene-levels.d.mts +36 -0
  108. package/types/engine/scenes.d.mts +48 -0
  109. package/types/engine/site-build.d.mts +214 -0
  110. package/types/engine/site-index.d.mts +141 -0
  111. package/types/engine/web-wikilinks.d.mts +67 -0
  112. package/types/engine/wikilink-syntax.d.mts +101 -0
  113. package/types/engine/wikilinks.d.mts +172 -0
  114. package/types/index.d.mts +3 -0
  115. package/types/manifest.d.mts +52 -4
  116. package/types/markdownlint-config.d.mts +8 -0
  117. package/types/prettier-config.d.mts +1 -0
  118. package/types/sohl/actors.d.mts +65 -0
  119. package/types/sohl/affiliation-standings.d.mts +22 -0
  120. package/types/sohl/being-info.d.mts +78 -0
  121. package/types/sohl/default-item-art.d.mts +28 -0
  122. package/types/sohl/index.d.mts +7 -0
  123. package/types/sohl/item-builders.d.mts +11 -0
  124. package/types/sohl/item-fields.d.mts +20 -0
  125. package/types/sohl/items.d.mts +19 -0
  126. package/types/sohl/kb-passes.d.mts +71 -0
  127. package/types/sohl/note-schemas.d.mts +7 -0
@@ -0,0 +1,56 @@
1
+ /*
2
+ * This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
3
+ * Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
4
+ *
5
+ * This work is licensed under the GNU General Public License v3.0 (GPLv3).
6
+ * You may copy, modify, and distribute it under the terms of that license.
7
+ *
8
+ * For full terms, see the LICENSE.md file in the project root or visit:
9
+ * https://www.gnu.org/licenses/gpl-3.0.html
10
+ *
11
+ * SPDX-License-Identifier: GPL-3.0-or-later
12
+ */
13
+
14
+ /**
15
+ * Is there any content to compile?
16
+ *
17
+ * The pack build's worst failure is not an error but a **success on nothing**: a
18
+ * content tree that is absent or empty compiles zero documents, reports no
19
+ * error, and ships compendiums that are simply empty. Nothing downstream
20
+ * notices — the LevelDB packs are written, the deploy mirrors them, and the
21
+ * defect surfaces as "my compendiums are blank" long after the build went green.
22
+ *
23
+ * `assets/content/` is this repository's own source (#1445), but an empty or
24
+ * partial tree is still possible — a bad checkout, or a sparse clone. This
25
+ * counts what is actually there so the build can refuse.
26
+ */
27
+
28
+ import fs from "node:fs";
29
+ import path from "node:path";
30
+
31
+ /**
32
+ * Count the Markdown notes below a content tree.
33
+ *
34
+ * Only notes count. The per-pack folder manifests are not content, so a tree
35
+ * holding nothing but manifests counts zero — which is correct, because it
36
+ * compiles zero documents. Dot directories are skipped so stale editor caches
37
+ * cannot make an empty tree look populated.
38
+ *
39
+ * @param {string} root - Absolute path to the content tree.
40
+ * @returns {number} The number of `.md` notes, or 0 when the tree is absent.
41
+ */
42
+ export function countContentNotes(root) {
43
+ if (!fs.existsSync(root)) return 0;
44
+
45
+ let count = 0;
46
+ /** @param {string} dir */
47
+ const walk = (dir) => {
48
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
49
+ if (entry.name.startsWith(".")) continue;
50
+ if (entry.isDirectory()) walk(path.join(dir, entry.name));
51
+ else if (entry.isFile() && entry.name.endsWith(".md")) count += 1;
52
+ }
53
+ };
54
+ walk(root);
55
+ return count;
56
+ }
@@ -0,0 +1,270 @@
1
+ /*
2
+ * This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
3
+ * Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
4
+ *
5
+ * This work is licensed under the GNU General Public License v3.0 (GPLv3).
6
+ * You may copy, modify, and distribute it under the terms of that license.
7
+ *
8
+ * For full terms, see the LICENSE.md file in the project root or visit:
9
+ * https://www.gnu.org/licenses/gpl-3.0.html
10
+ *
11
+ * SPDX-License-Identifier: GPL-3.0-or-later
12
+ */
13
+
14
+ /**
15
+ * Build diagnostics that name the file, line and column they are about.
16
+ *
17
+ * A build that reports a problem by the **note's name** — `Unresolved wikilink
18
+ * in "The Capital Nome"` — has told the author what is wrong and not where it
19
+ * is. A name is not an address: finding it means searching the tree for a note
20
+ * whose `name.full` matches and then searching that file for the link, and
21
+ * four identical warnings on one note are indistinguishable from one another
22
+ * (#17).
23
+ *
24
+ * So every diagnostic about a note is emitted in the form every C-family
25
+ * compiler, `tsc` and ESLint already use:
26
+ *
27
+ * ```text
28
+ * assets/content/Nomes/Capital.md:42:17: warning: unresolved wikilink [[Kenbet_Pat]] (unknown)
29
+ * ```
30
+ *
31
+ * `file:line:column: severity: message`. Nothing here is invented, which is
32
+ * the point: an editor, a CI annotator or a `grep` already parses it, with no
33
+ * knowledge of this build and nothing extra for it to emit.
34
+ *
35
+ * **Two rules keep the form parseable.**
36
+ *
37
+ * - _The locator starts the line._ Diagnostics deliberately bypass `loglevel`,
38
+ * whose `[timestamp] [WARN]:` prefix occupies exactly the position a parser
39
+ * reads the path from — a greedy path pattern swallows the prefix and yields
40
+ * a filename no editor can open.
41
+ * - _A field is dropped, never guessed._ A position that cannot be established
42
+ * honestly is omitted (`file: warning: …`) rather than defaulted to `1:1`,
43
+ * which would send a reader to the frontmatter every time.
44
+ */
45
+
46
+ import path from "node:path";
47
+
48
+ /**
49
+ * The `file:line:column` locator, with whatever is known.
50
+ *
51
+ * The path is relativized against the working directory — during a build that
52
+ * is the consuming repository's root, so the result is both shorter to read
53
+ * and what an editor resolves a relative diagnostic against. A path outside
54
+ * the tree stays absolute, since a `../../..` locator helps nobody.
55
+ *
56
+ * @param {object} at
57
+ * @param {string} [at.file] - Absolute or relative path to the source file.
58
+ * @param {number} [at.line] - 1-based line.
59
+ * @param {number} [at.column] - 1-based column. Ignored without a line.
60
+ * @returns {string} The locator, or `""` when not even a file is known.
61
+ */
62
+ export function formatLocator({ file, line, column } = {}) {
63
+ if (!file) return "";
64
+ let shown = file;
65
+ if (path.isAbsolute(file)) {
66
+ const rel = path.relative(process.cwd(), file);
67
+ // `..` means the file sits outside the working directory; an absolute
68
+ // path is the more useful of the two there.
69
+ if (rel && !rel.startsWith("..")) shown = rel;
70
+ }
71
+ if (!Number.isFinite(line)) return shown;
72
+ if (!Number.isFinite(column)) return `${shown}:${line}`;
73
+ return `${shown}:${line}:${column}`;
74
+ }
75
+
76
+ /**
77
+ * One diagnostic, as a parseable line.
78
+ *
79
+ * @param {object} d
80
+ * @param {string} [d.file] - Source file the diagnostic is about.
81
+ * @param {number} [d.line] - 1-based line.
82
+ * @param {number} [d.column] - 1-based column.
83
+ * @param {"warning"|"error"} d.severity - Which of the two levels this is.
84
+ * @param {string} d.message - What is wrong, in one sentence.
85
+ * @returns {string} `file:line:column: severity: message`, with any unknown
86
+ * leading field omitted.
87
+ */
88
+ export function formatDiagnostic({ file, line, column, severity, message }) {
89
+ const locator = formatLocator({ file, line, column });
90
+ return `${locator ? `${locator}: ` : ""}${severity}: ${message}`;
91
+ }
92
+
93
+ /**
94
+ * Prints one diagnostic on the console, unprefixed.
95
+ *
96
+ * **Both severities go to stderr**, which is what keeps findings clear of the
97
+ * progress and summary prose a build writes to stdout. That is Node's doing,
98
+ * not a choice made here: `console.warn` is an alias for `console.error` and
99
+ * writes to `process.stderr`, so the two branches below differ only in which
100
+ * severity word the line carries, never in the stream.
101
+ *
102
+ * Saying otherwise has already cost something — an earlier version of this
103
+ * comment claimed warnings went to stdout, and a consumer wrote a whole
104
+ * local wrapper to obtain the stderr routing it already had. Anything relying
105
+ * on the separation should split on the `severity` field, not on the stream.
106
+ *
107
+ * This deliberately sidesteps `loglevel`, for the reason given in the module
108
+ * docs.
109
+ *
110
+ * @param {object} d - As {@link formatDiagnostic}.
111
+ * @returns {void}
112
+ */
113
+ export function emitDiagnostic(d) {
114
+ const line = formatDiagnostic(d);
115
+ if (d.severity === "error") console.error(line);
116
+ else console.warn(line);
117
+ }
118
+
119
+ /**
120
+ * Where a character offset within a note's **body** falls in its **file**.
121
+ *
122
+ * Three corrections separate the two, and each is applied only where it is
123
+ * true:
124
+ *
125
+ * 1. _The frontmatter._ A body offset is not a file line until the lines
126
+ * before the body are added — `bodyLine`.
127
+ * 2. _The trimmed first line._ `parseMarkdownFile` trims the body, so its
128
+ * first line may have lost indentation the file still has. `bodyColumn`
129
+ * restores it, and only on that line.
130
+ * 3. _Generated text._ A body is scanned **after** its content tables expand,
131
+ * so an offset may fall in text no one authored. `lineMap` maps each
132
+ * scanned line back to the line it came from; a generated line reports the
133
+ * directive that produced it and **no column**, since there is no authored
134
+ * character to point at.
135
+ *
136
+ * @param {string} body - The text the offset indexes into.
137
+ * @param {number} offset - 0-based character offset within `body`.
138
+ * @param {object} [opts]
139
+ * @param {number} [opts.bodyLine=1] - 1-based file line of the body's line 0.
140
+ * @param {number} [opts.bodyColumn=1] - 1-based file column of the body's
141
+ * first character.
142
+ * @param {Array<{line: number, generated: boolean}>} [opts.lineMap] - Per
143
+ * scanned line, the 0-based body line it came from. From
144
+ * {@link expandContentTables}.
145
+ * @returns {{line: number, column: number|undefined, generated: boolean}}
146
+ */
147
+ export function positionInBody(
148
+ body,
149
+ offset,
150
+ { bodyLine = 1, bodyColumn = 1, lineMap } = {},
151
+ ) {
152
+ const upTo = String(body ?? "").slice(0, Math.max(0, offset));
153
+ const nl = upTo.lastIndexOf("\n");
154
+ const scannedLine = upTo.length === 0 ? 0 : upTo.split("\n").length - 1;
155
+ const column = upTo.length - nl; // 1-based: offset - (nl + 1) + 1
156
+
157
+ const mapped = lineMap?.[scannedLine];
158
+ const sourceLine = mapped ? mapped.line : scannedLine;
159
+ const generated = mapped ? mapped.generated : false;
160
+
161
+ return {
162
+ line: bodyLine + sourceLine,
163
+ // A generated line has no authored column, and the first line's
164
+ // column is the only one the trim can have moved.
165
+ column:
166
+ generated ? undefined
167
+ : sourceLine === 0 ? bodyColumn + column - 1
168
+ : column,
169
+ generated,
170
+ };
171
+ }
172
+
173
+ /**
174
+ * Where a **frontmatter key** is declared in a note's file.
175
+ *
176
+ * {@link positionInBody} answers the same question for the body, and the two
177
+ * are separate because the body is what the compilers scan while frontmatter is
178
+ * what the linters read — a key sits *before* the body, so a body offset can
179
+ * never reach it.
180
+ *
181
+ * The search is deliberately scoped to the frontmatter block rather than run
182
+ * over the whole file. A bare search for the key would match the first place
183
+ * the word appears anywhere, which for a key like `name` or `type` is routinely
184
+ * a line of prose — sending the reader to a position that is not the problem,
185
+ * which is the one thing the located form exists to prevent.
186
+ *
187
+ * @param {string} raw - The file's full contents, frontmatter included.
188
+ * @param {string} key - The top-level frontmatter key.
189
+ * @param {string} [value] - When given, prefer the occurrence whose line also
190
+ * carries this text. A list-valued key (`aliases`) is reported at the entry
191
+ * that is wrong, not at the key that introduces it.
192
+ * @returns {{line?: number, column?: number}} Spreadable position fields, empty
193
+ * when the key cannot be located — dropped rather than guessed, as
194
+ * {@link formatDiagnostic} requires.
195
+ */
196
+ export function positionInFrontmatter(raw, key, value = undefined) {
197
+ if (typeof raw !== "string" || !key) return {};
198
+ const fence = raw.match(/^---\n([\s\S]*?)\n---/);
199
+ if (!fence) return {};
200
+ const block = fence[1];
201
+ const lines = block.split("\n");
202
+
203
+ // A value locates the exact entry; the key alone locates its declaration.
204
+ const wanted = value == null ? undefined : String(value);
205
+ let keyLine = -1;
206
+ for (let i = 0; i < lines.length; i++) {
207
+ if (wanted != null && lines[i].includes(wanted)) {
208
+ keyLine = i;
209
+ break;
210
+ }
211
+ if (
212
+ keyLine === -1 &&
213
+ new RegExp(`^\\s*${escape(key)}\\s*:`).test(lines[i])
214
+ ) {
215
+ keyLine = i;
216
+ if (wanted == null) break;
217
+ }
218
+ }
219
+ if (keyLine === -1) return {};
220
+
221
+ const needle = wanted != null ? wanted : key;
222
+ const column = lines[keyLine].indexOf(needle);
223
+ return {
224
+ // +2: the file's line 1 is the opening `---`, so the block's line 0 is
225
+ // the file's line 2.
226
+ line: keyLine + 2,
227
+ ...(column === -1 ? {} : { column: column + 1 }),
228
+ };
229
+ }
230
+
231
+ /** Escape a literal for use inside a `RegExp`. */
232
+ function escape(literal) {
233
+ return String(literal).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
234
+ }
235
+
236
+ /**
237
+ * Where a literal sits in a text, so a finding about it can be opened.
238
+ *
239
+ * {@link positionInBody} maps an offset within a parsed note body, and
240
+ * {@link positionInFrontmatter} finds a key in the fence. This is the plainer
241
+ * case: a finding about a string the reader can see in a file that is neither —
242
+ * a manifest, a lockfile, a config.
243
+ *
244
+ * `@heroiclands/package-build` carries the same arithmetic for the files *it*
245
+ * reads. That is a duplicate worth naming: unlike the diagnostic *format* or a
246
+ * validation *rule*, "which line and column is this substring on" has exactly
247
+ * one correct answer and cannot drift into disagreement. The tidier arrangement
248
+ * is for that package to re-export this one — the dependency runs that way — and
249
+ * it should, next time either is touched.
250
+ *
251
+ * @param {string} text - The file's contents.
252
+ * @param {string} needle - The literal to locate.
253
+ * @param {number} [occurrence] - Which occurrence, 1-based. Repeats of one
254
+ * literal are otherwise indistinguishable.
255
+ * @returns {{line?: number, column?: number}} Spreadable position fields, empty
256
+ * when the literal is not there — dropped rather than guessed.
257
+ */
258
+ export function positionOfLiteral(text, needle, occurrence = 1) {
259
+ if (typeof text !== "string" || !needle) return {};
260
+ let at = -1;
261
+ for (let n = 0; n < occurrence; n++) {
262
+ at = text.indexOf(needle, at + 1);
263
+ if (at === -1) return {};
264
+ }
265
+ const before = text.slice(0, at);
266
+ return {
267
+ line: before.split("\n").length,
268
+ column: at - before.lastIndexOf("\n"),
269
+ };
270
+ }
@@ -0,0 +1,242 @@
1
+ /*
2
+ * This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
3
+ * Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
4
+ *
5
+ * This work is licensed under the GNU General Public License v3.0 (GPLv3).
6
+ * You may copy, modify, and distribute it under the terms of that license.
7
+ *
8
+ * For full terms, see the LICENSE.md file in the project root or visit:
9
+ * https://www.gnu.org/licenses/gpl-3.0.html
10
+ *
11
+ * SPDX-License-Identifier: GPL-3.0-or-later
12
+ */
13
+
14
+ /**
15
+ * The authoring reference, generated from the declared item fields.
16
+ *
17
+ * **Generated because a hand-written one would be wrong within a release.** The
18
+ * overwhelming majority of a content repository's compiled documents are items
19
+ * — 1,230 of SoHL's 1,362 when this was written — and a table spanning a dozen
20
+ * types, each with a shape, a default and a requiredness, is exactly the kind of
21
+ * prose that drifts silently from the code it describes. The declarations
22
+ * (`fields` on each `itemBuilders` entry) already *are* that table, so the page
23
+ * is rendered from them rather than transcribed (#22).
24
+ *
25
+ * **It ships here, not in the consumer.** Any repository can define an item
26
+ * type of its own, so "what frontmatter does this type accept?" is a question
27
+ * every consuming repository has. Rendering from the resolved configuration
28
+ * means each one documents *its own* registry with the same command, rather
29
+ * than SoHL owning a generator the others cannot run.
30
+ *
31
+ * Package-agnostic: it knows the declaration vocabulary, and nothing about any
32
+ * particular type. The types themselves come from configuration.
33
+ *
34
+ * @module
35
+ */
36
+
37
+ import { authoredFields } from "./field-spec.mjs";
38
+ import { loadPackConfig } from "./pack-config.mjs";
39
+
40
+ /**
41
+ * Render a value the way an author would write it in YAML frontmatter.
42
+ *
43
+ * @param {any} value - The default a field declares.
44
+ * @returns {string} An inline-code cell, or an em dash when there is no
45
+ * default to show.
46
+ */
47
+ function renderDefault(value) {
48
+ if (value === undefined) return "—";
49
+ if (value === null) return "`null`";
50
+ if (typeof value === "string")
51
+ return value === "" ? '`""`' : `\`${value}\``;
52
+ if (Array.isArray(value))
53
+ return value.length === 0 ? "`[]`" : `\`${JSON.stringify(value)}\``;
54
+ if (typeof value === "object") {
55
+ const json = JSON.stringify(value);
56
+ return json === "{}" ? "`{}`" : `\`${json}\``;
57
+ }
58
+ return `\`${String(value)}\``;
59
+ }
60
+
61
+ /** Escape the pipe that would otherwise end a markdown table cell. */
62
+ function cell(text) {
63
+ return String(text).replace(/\|/g, "\\|");
64
+ }
65
+
66
+ /**
67
+ * A markdown table with every column padded to its widest cell.
68
+ *
69
+ * **Padded so the generated page is stable under Prettier.** A consumer commits
70
+ * this page and formats its repository; Prettier aligns markdown table columns,
71
+ * so an unpadded table is rewritten the moment the formatter runs — and the
72
+ * `--check` guard then reports the page stale on every clean checkout, with the
73
+ * formatter and the generator each undoing the other.
74
+ *
75
+ * Plain `.length` rather than a display-width measure, because that is what
76
+ * Prettier's alignment comes to for this content: every cell is Latin text,
77
+ * backticks and the odd em dash, each of which counts one. A cell holding a
78
+ * wide character would need the measure Prettier uses; there are none, and
79
+ * `tests/field-reference.test.ts` fails if the rendered page ever stops
80
+ * agreeing with Prettier.
81
+ *
82
+ * @param {string[][]} rows - The header row, then the body.
83
+ * @returns {string[]} Markdown lines.
84
+ */
85
+ function padTable(rows) {
86
+ const widths = rows[0].map((_, column) =>
87
+ Math.max(...rows.map((row) => row[column].length)),
88
+ );
89
+ const line = (cells) =>
90
+ `| ${cells.map((c, i) => c.padEnd(widths[i])).join(" | ")} |`;
91
+ const [header, ...body] = rows;
92
+ return [
93
+ line(header),
94
+ `| ${widths.map((w) => "-".repeat(w)).join(" | ")} |`,
95
+ ...body.map(line),
96
+ ];
97
+ }
98
+
99
+ /**
100
+ * The field table for one type.
101
+ *
102
+ * @param {readonly object[]} fields - The type's declaration.
103
+ * @returns {string[]} Markdown lines.
104
+ */
105
+ function fieldTable(fields) {
106
+ const authored = authoredFields(fields);
107
+ if (!authored.length) {
108
+ return ["This type reads no `sohl:` fields of its own."];
109
+ }
110
+ const rows = [
111
+ ["Field", "Shape", "Required", "Default", "Description"],
112
+ ...authored.map((field) => [
113
+ `\`${field.name}\``,
114
+ cell(field.shape ?? "as authored"),
115
+ field.required ? "**yes**" : "no",
116
+ field.required ? "—" : renderDefault(field.default),
117
+ cell(field.describe ?? ""),
118
+ ]),
119
+ ];
120
+ return padTable(rows);
121
+ }
122
+
123
+ /**
124
+ * A minimal note for one type: the frontmatter envelope every note carries,
125
+ * plus exactly the `sohl:` fields the type requires.
126
+ *
127
+ * Minimal is the point — an example carrying every optional field would teach
128
+ * that they are expected, and would itself need maintaining. What is shown is
129
+ * the smallest note that compiles.
130
+ *
131
+ * @param {string} type - The item type.
132
+ * @param {readonly object[]} fields - Its declaration.
133
+ * @returns {string[]} Markdown lines, a fenced YAML block.
134
+ */
135
+ function workedExample(type, fields) {
136
+ const required = authoredFields(fields).filter((field) => field.required);
137
+ const lines = [
138
+ // `markdown`, not `yaml`: the block is a whole note — frontmatter *and*
139
+ // the prose beneath it — so labelling it YAML was wrong about the
140
+ // content. It also made the page unstable, because Prettier formats a
141
+ // fenced block in the language it declares, and reformatting this one as
142
+ // YAML dropped the blank line after the frontmatter.
143
+ "```markdown",
144
+ "---",
145
+ "name:",
146
+ ` full: An Example ${type}`,
147
+ `type: ${type}`,
148
+ "shortcode: xmpl",
149
+ "package: <your content package>",
150
+ "id: <16-character id>",
151
+ "sohl:",
152
+ " archetype: null",
153
+ ];
154
+ for (const field of required) {
155
+ lines.push(` ${field.name}: <${field.shape ?? "value"}>`);
156
+ }
157
+ lines.push(
158
+ "---",
159
+ "",
160
+ "The prose here compiles into the item's documentation.",
161
+ "```",
162
+ );
163
+ return lines;
164
+ }
165
+
166
+ /**
167
+ * Render the per-type item frontmatter reference.
168
+ *
169
+ * @param {object} [options] - Rendering options.
170
+ * @param {string} [options.title] - The page's H1.
171
+ * @param {string[]} [options.preamble] - Lines placed after the H1, before the
172
+ * first type. Written by the consumer, since only it knows what its page
173
+ * should link to.
174
+ * @param {string} [options.generatedBy] - What a reader should re-run to
175
+ * regenerate the page, named in the do-not-edit banner.
176
+ * @param {object} [options.config] - Resolved configuration. Defaults to the
177
+ * consumer's own.
178
+ * @returns {string} The complete markdown page.
179
+ */
180
+ export function renderItemFieldReference({
181
+ title = "Item Note Frontmatter",
182
+ preamble = [],
183
+ generatedBy = "the content-build field reference generator",
184
+ config = loadPackConfig(),
185
+ } = {}) {
186
+ const declared = config.itemFields ?? {};
187
+ const types = [...config.itemTypes].sort();
188
+ const documented = types.filter((type) => declared[type]?.length);
189
+ const undocumented = types.filter((type) => !declared[type]?.length);
190
+
191
+ const lines = [
192
+ `# ${title}`,
193
+ "",
194
+ `<!-- AUTO-GENERATED FILE — do not edit by hand.`,
195
+ ` Generated by ${generatedBy}.`,
196
+ ` Every field comes from the \`fields\` declaration on that type's`,
197
+ ` \`itemBuilders\` entry, which is also what builds the document. -->`,
198
+ "",
199
+ ...preamble,
200
+ ];
201
+ if (preamble.length) lines.push("");
202
+
203
+ lines.push(
204
+ `${documented.length} of the ${types.length} item types this ` +
205
+ `repository compiles declare their frontmatter below. Every field ` +
206
+ `is written under a note's \`sohl:\` block; a dotted name such as ` +
207
+ `\`impact.die\` is a nested key. A field a note does not carry ` +
208
+ `takes the default shown, and a **required** field has none — ` +
209
+ `omitting it fails the build rather than guessing.`,
210
+ "",
211
+ );
212
+
213
+ for (const type of documented) {
214
+ lines.push(
215
+ `## ${type}`,
216
+ "",
217
+ ...fieldTable(declared[type]),
218
+ "",
219
+ ...workedExample(type, declared[type]),
220
+ "",
221
+ );
222
+ }
223
+
224
+ if (undocumented.length) {
225
+ lines.push(
226
+ "## Types that declare no fields",
227
+ "",
228
+ "These compile, but their `itemBuilders` entry declares no " +
229
+ "`fields`, so nothing here can say what they accept:",
230
+ "",
231
+ ...undocumented.map((type) => `- \`${type}\``),
232
+ "",
233
+ );
234
+ }
235
+
236
+ // Trailing blank lines trimmed, so a caller adding its own newline gets
237
+ // exactly one. Sections push a separating "" after themselves, which leaves
238
+ // one at the end; Prettier strips it, and a page that disagrees with the
239
+ // formatter by one character is rewritten on the consumer's next format run
240
+ // and then reported stale by `--check` forever after.
241
+ return lines.join("\n").replace(/\n+$/, "");
242
+ }