@heroiclands/package-build 20.3.1 → 20.5.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 (195) hide show
  1. package/CHANGELOG.md +351 -0
  2. package/CONTENT.md +322 -110
  3. package/README.md +21 -10
  4. package/bin/content-build.mjs +204 -92
  5. package/bin/package-build.mjs +48 -15
  6. package/config.mjs +6 -8
  7. package/content-config.mjs +518 -151
  8. package/docs/api.md +1352 -0
  9. package/docs/commands.md +1609 -0
  10. package/docs/configuration.md +1432 -0
  11. package/docs/content-format.md +73 -74
  12. package/docs/diagnostics.md +356 -0
  13. package/docs/getting-started.md +813 -0
  14. package/docs/project-setup.md +469 -0
  15. package/e2e.mjs +4 -4
  16. package/engine/actor-compiler.mjs +64 -61
  17. package/engine/address-charset.mjs +6 -6
  18. package/engine/address-diff.mjs +57 -52
  19. package/engine/anchored-sections.mjs +1 -1
  20. package/engine/anchors.mjs +1 -1
  21. package/engine/base-compiler.mjs +42 -38
  22. package/engine/bundle-notes.mjs +4 -4
  23. package/engine/bundles.mjs +17 -8
  24. package/engine/code-fences.mjs +3 -3
  25. package/engine/compendiums.mjs +8 -8
  26. package/engine/compile-corpus.mjs +4 -5
  27. package/engine/content-address.mjs +24 -24
  28. package/engine/content-charset.mjs +1 -1
  29. package/engine/content-format-check.mjs +24 -25
  30. package/engine/content-format.mjs +8 -8
  31. package/engine/content-html.mjs +202 -0
  32. package/engine/content-icons.mjs +196 -165
  33. package/engine/content-index.mjs +61 -40
  34. package/engine/content-links.mjs +74 -41
  35. package/engine/content-lint.mjs +64 -34
  36. package/engine/content-package.mjs +7 -9
  37. package/engine/content-slug.mjs +2 -2
  38. package/engine/content-tables.mjs +38 -34
  39. package/engine/content-tree.mjs +1 -1
  40. package/engine/derived-fields.mjs +174 -0
  41. package/engine/diagnostics.mjs +3 -4
  42. package/engine/document-subtypes.mjs +6 -7
  43. package/engine/field-reference.mjs +8 -8
  44. package/engine/field-spec.mjs +29 -30
  45. package/engine/folder-notes.mjs +18 -16
  46. package/engine/foreign-catalog.mjs +8 -9
  47. package/engine/foundry-entries.mjs +19 -20
  48. package/engine/frontmatter-lint.mjs +95 -103
  49. package/engine/frontmatter.mjs +11 -11
  50. package/engine/generate.mjs +44 -39
  51. package/engine/helpers.mjs +147 -84
  52. package/engine/homepage.mjs +20 -22
  53. package/engine/ids.mjs +15 -15
  54. package/engine/index-records.mjs +4 -4
  55. package/engine/index.mjs +26 -10
  56. package/engine/item-compiler.mjs +54 -29
  57. package/engine/item-docs.mjs +11 -13
  58. package/engine/item-registry.mjs +13 -13
  59. package/engine/journals.mjs +24 -15
  60. package/engine/macros.mjs +3 -3
  61. package/engine/map-notes.mjs +6 -6
  62. package/engine/metadata-index.mjs +5 -5
  63. package/engine/note-claims.mjs +51 -41
  64. package/engine/note-ids.mjs +5 -5
  65. package/engine/note-package.mjs +4 -4
  66. package/engine/note-renames.mjs +3 -3
  67. package/engine/note-schemas.mjs +3 -8
  68. package/engine/note-vocabulary.mjs +56 -54
  69. package/engine/pack-config.mjs +39 -25
  70. package/engine/pack-router.mjs +0 -0
  71. package/engine/pdf-build.mjs +464 -0
  72. package/engine/pdf-fonts.mjs +420 -0
  73. package/engine/pdf-render.mjs +798 -0
  74. package/engine/pdf-toc.mjs +525 -0
  75. package/engine/prose-config.mjs +9 -9
  76. package/engine/prose-lint.mjs +12 -13
  77. package/engine/region-events.mjs +2 -2
  78. package/engine/retired-fields.mjs +24 -22
  79. package/engine/runtime-only-fields.mjs +1 -1
  80. package/engine/scene-levels.mjs +1 -1
  81. package/engine/scenes.mjs +33 -25
  82. package/engine/schema-check.mjs +21 -21
  83. package/engine/schema-extract.mjs +1 -1
  84. package/engine/site-build.mjs +66 -50
  85. package/engine/site-index.mjs +17 -18
  86. package/engine/sql-tables.mjs +21 -13
  87. package/engine/subtype-registry.mjs +5 -4
  88. package/engine/system-block.mjs +18 -19
  89. package/engine/systems.mjs +2 -2
  90. package/engine/web-wikilinks.mjs +41 -38
  91. package/engine/wikilink-syntax.mjs +16 -16
  92. package/engine/wikilinks.mjs +40 -42
  93. package/hm3/actors.mjs +30 -22
  94. package/hm3/document-subtypes.mjs +5 -5
  95. package/hm3/index.mjs +2 -2
  96. package/hm3/item-builders.mjs +1 -1
  97. package/hm3/item-fields.mjs +3 -3
  98. package/hm3/items.mjs +28 -8
  99. package/hm3/template-priority.mjs +2 -2
  100. package/lang.mjs +3 -3
  101. package/manifest.mjs +17 -20
  102. package/package.json +1 -2
  103. package/release.mjs +66 -6
  104. package/sohl/actors.mjs +36 -28
  105. package/sohl/affiliation-standings.mjs +2 -2
  106. package/sohl/being-info.mjs +5 -5
  107. package/sohl/default-item-art.mjs +5 -5
  108. package/sohl/document-subtypes.mjs +5 -5
  109. package/sohl/index.mjs +3 -3
  110. package/sohl/item-builders.mjs +7 -7
  111. package/sohl/item-fields.mjs +8 -9
  112. package/sohl/items.mjs +28 -6
  113. package/sohl/kb-passes.mjs +5 -5
  114. package/sohl/note-schemas.mjs +12 -12
  115. package/sohl/skill-base.mjs +1 -1
  116. package/types/content-config.d.mts +91 -39
  117. package/types/e2e.d.mts +3 -3
  118. package/types/engine/actor-compiler.d.mts +45 -40
  119. package/types/engine/address-charset.d.mts +6 -6
  120. package/types/engine/address-diff.d.mts +60 -6
  121. package/types/engine/base-compiler.d.mts +27 -21
  122. package/types/engine/bundle-notes.d.mts +3 -3
  123. package/types/engine/bundles.d.mts +10 -1
  124. package/types/engine/code-fences.d.mts +3 -3
  125. package/types/engine/compendiums.d.mts +3 -3
  126. package/types/engine/compile-corpus.d.mts +1 -1
  127. package/types/engine/content-address.d.mts +20 -20
  128. package/types/engine/content-format-check.d.mts +6 -6
  129. package/types/engine/content-format.d.mts +2 -2
  130. package/types/engine/content-html.d.mts +78 -0
  131. package/types/engine/content-icons.d.mts +139 -92
  132. package/types/engine/content-index.d.mts +59 -15
  133. package/types/engine/content-links.d.mts +8 -8
  134. package/types/engine/content-lint.d.mts +8 -6
  135. package/types/engine/content-package.d.mts +6 -8
  136. package/types/engine/content-tables.d.mts +49 -18
  137. package/types/engine/derived-fields.d.mts +101 -0
  138. package/types/engine/diagnostics.d.mts +2 -2
  139. package/types/engine/document-subtypes.d.mts +3 -3
  140. package/types/engine/field-spec.d.mts +37 -39
  141. package/types/engine/folder-notes.d.mts +4 -5
  142. package/types/engine/foreign-catalog.d.mts +3 -3
  143. package/types/engine/foundry-entries.d.mts +6 -7
  144. package/types/engine/frontmatter-lint.d.mts +10 -9
  145. package/types/engine/frontmatter.d.mts +7 -7
  146. package/types/engine/generate.d.mts +7 -7
  147. package/types/engine/helpers.d.mts +128 -56
  148. package/types/engine/homepage.d.mts +16 -18
  149. package/types/engine/ids.d.mts +13 -13
  150. package/types/engine/index-records.d.mts +3 -3
  151. package/types/engine/index.d.mts +6 -0
  152. package/types/engine/item-compiler.d.mts +21 -5
  153. package/types/engine/item-docs.d.mts +2 -2
  154. package/types/engine/item-registry.d.mts +6 -6
  155. package/types/engine/journals.d.mts +12 -3
  156. package/types/engine/map-notes.d.mts +2 -2
  157. package/types/engine/metadata-index.d.mts +4 -4
  158. package/types/engine/note-claims.d.mts +30 -22
  159. package/types/engine/note-ids.d.mts +4 -4
  160. package/types/engine/note-package.d.mts +1 -1
  161. package/types/engine/note-renames.d.mts +3 -3
  162. package/types/engine/note-vocabulary.d.mts +31 -203
  163. package/types/engine/pack-config.d.mts +7 -7
  164. package/types/engine/pack-router.d.mts +1 -1
  165. package/types/engine/pdf-build.d.mts +42 -0
  166. package/types/engine/pdf-fonts.d.mts +30 -0
  167. package/types/engine/pdf-render.d.mts +144 -0
  168. package/types/engine/pdf-toc.d.mts +114 -0
  169. package/types/engine/prose-config.d.mts +9 -9
  170. package/types/engine/prose-lint.d.mts +3 -4
  171. package/types/engine/region-events.d.mts +2 -2
  172. package/types/engine/retired-fields.d.mts +10 -9
  173. package/types/engine/scenes.d.mts +10 -1
  174. package/types/engine/schema-check.d.mts +13 -13
  175. package/types/engine/site-build.d.mts +52 -25
  176. package/types/engine/site-index.d.mts +3 -4
  177. package/types/engine/sql-tables.d.mts +11 -5
  178. package/types/engine/subtype-registry.d.mts +3 -3
  179. package/types/engine/system-block.d.mts +3 -3
  180. package/types/engine/web-wikilinks.d.mts +7 -7
  181. package/types/engine/wikilink-syntax.d.mts +17 -17
  182. package/types/engine/wikilinks.d.mts +14 -14
  183. package/types/hm3/actors.d.mts +9 -1
  184. package/types/hm3/document-subtypes.d.mts +1 -1
  185. package/types/hm3/items.d.mts +9 -2
  186. package/types/hm3/template-priority.d.mts +1 -1
  187. package/types/manifest.d.mts +8 -8
  188. package/types/release.d.mts +15 -4
  189. package/types/sohl/actors.d.mts +12 -4
  190. package/types/sohl/affiliation-standings.d.mts +2 -2
  191. package/types/sohl/being-info.d.mts +2 -2
  192. package/types/sohl/document-subtypes.d.mts +1 -1
  193. package/types/sohl/items.d.mts +9 -2
  194. package/types/sohl/note-schemas.d.mts +1 -1
  195. package/MIGRATING.md +0 -608
@@ -0,0 +1,525 @@
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 document tree a PDF is built from, and the plan it resolves to (#316).
16
+ *
17
+ * The packs and the website both render the *whole* content tree: every note
18
+ * becomes a document and a page, and the three surfaces agreeing about what the
19
+ * content is, is the point. **A book is not that.** It is a selection — a
20
+ * declared structure whose leaves pick notes out of the corpus by a `WHERE`
21
+ * clause, interleaved with prose that may not live in the content tree at all.
22
+ *
23
+ * Three consequences follow, and they are behaviour rather than oversights:
24
+ *
25
+ * - **A note no clause selects is not in the book.** A project decides what its
26
+ * own volume carries, so an omission is an editorial act and this pass has no
27
+ * standing to call it a defect.
28
+ * - **A note several clauses select appears several times.** A reference work
29
+ * may legitimately carry an entry under more than one heading, so each
30
+ * occurrence is its own page, its own outline node and its own anchor — and
31
+ * inbound links are pointed at the first, so a wikilink resolves to one place
32
+ * however often the book prints it.
33
+ * - **Prose from `file:` is in the book but not of the tree.** It carries no
34
+ * address, so nothing can link *to* it, but it is anchored for the table of
35
+ * contents and its own links still resolve.
36
+ *
37
+ * **What is here is pure.** The tree is validated, flattened and planned without
38
+ * reading a file or opening a database: the filters are run by the caller and
39
+ * handed back as `selections`, the way `expandContentTables` is handed its
40
+ * `sqlTables` because DuckDB is async and the pass is not. That is what lets the
41
+ * whole structure of a 2,500-entry book be asserted in a unit test with no
42
+ * renderer present, which is most of what #316 asks for.
43
+ *
44
+ * **Every gate reports; none exits.** A filter that will not parse, a `file:`
45
+ * that resolves nowhere and a filter reaching another package's schema are
46
+ * findings the command decides about, as everywhere else in this engine.
47
+ *
48
+ * @module
49
+ */
50
+
51
+ import { slugify } from "./content-slug.mjs";
52
+ import { positionOfYamlPath } from "./diagnostics.mjs";
53
+
54
+ /**
55
+ * Presentation a node may declare, and that its descendants inherit.
56
+ *
57
+ * Reserved now although the first release renders none of them, because the
58
+ * shape of the file is the thing consumers commit to: a book that has to be
59
+ * restructured to gain a running head has the wrong format, not the wrong
60
+ * renderer. Inheritance is what makes them worth declaring at all — `Gear` says
61
+ * once which infobox its entries use, and nine sections beneath it agree.
62
+ *
63
+ * @type {readonly string[]}
64
+ */
65
+ export const PRESENTATION_KEYS = Object.freeze(["header", "footer", "infobox", "page"]);
66
+
67
+ /** Keys a section node may carry. @type {readonly string[]} */
68
+ const SECTION_KEYS = Object.freeze(["sectionName", "contents", ...PRESENTATION_KEYS]);
69
+
70
+ /** Keys a content entry may carry instead of being a section. */
71
+ const LEAF_KEYS = Object.freeze(["file", "filter"]);
72
+
73
+ /**
74
+ * A qualified table name — `sohl.notes` rather than `notes`.
75
+ *
76
+ * The build owns the `SELECT … FROM notes`, so a filter cannot name a table at
77
+ * all; the one way back out of that is a subquery in the `WHERE` clause, which
78
+ * would reach a dependency's schema and put another package's notes in this
79
+ * package's book. Matched loosely on purpose: this refuses a shape rather than
80
+ * parsing SQL, and a false positive is a filter that has to be rewritten while a
81
+ * false negative is a book quietly containing someone else's content.
82
+ */
83
+ const QUALIFIED_TABLE = /\bfrom\s+["'`]?[A-Za-z_][\w-]*["'`]?\s*\./i;
84
+
85
+ /**
86
+ * Validate the raw tree and flatten it into nodes.
87
+ *
88
+ * The format has no chapter/section distinction: every node is a section, and
89
+ * the leaves of the *document* are the notes and prose its contents resolve to.
90
+ * Depth is whatever the tree says, so nothing here caps or normalises it.
91
+ *
92
+ * `contents` is an **ordered, heterogeneous** list — prose, filters and child
93
+ * sections interleave in the sequence written, and that sequence is preserved
94
+ * exactly. A section that opens with a `file:` and then lists its entries is
95
+ * saying something different from one that does the reverse.
96
+ *
97
+ * @param {unknown} raw - The parsed document, as YAML returns it.
98
+ * @param {object} [opts] - Options.
99
+ * @param {string} [opts.text] - The file's source, for finding positions. Every
100
+ * finding without one is still reported, just without a locator.
101
+ * @returns {{nodes: object[], findings: object[]}} The flattened tree and what
102
+ * was wrong with it.
103
+ */
104
+ export function parseDocumentTree(raw, { text } = {}) {
105
+ const findings = [];
106
+ const nodes = [];
107
+ if (!raw || typeof raw !== "object" || !Array.isArray(raw.contents)) {
108
+ findings.push({
109
+ severity: "error",
110
+ message: "the document tree must be a mapping with a `contents:` list",
111
+ });
112
+ return { nodes, findings };
113
+ }
114
+ walkSections(raw.contents, ["contents"], [], {}, { nodes, findings, text });
115
+ return { nodes, findings };
116
+ }
117
+
118
+ /**
119
+ * One level of `contents`, recursing into the sections it holds.
120
+ *
121
+ * Presentation is resolved on the way down rather than looked up on the way
122
+ * back: a node's own keys override what it inherits, and its children see the
123
+ * merged result, so nothing downstream has to walk back up an ancestry chain to
124
+ * learn which infobox an entry uses.
125
+ *
126
+ * @param {unknown[]} contents - The list to read.
127
+ * @param {Array<string|number>} keyPath - Where it sits in the document.
128
+ * @param {string[]} trail - Section titles above this level.
129
+ * @param {object} inherited - Presentation from ancestors.
130
+ * @param {object} ctx - `{ nodes, findings, text }`, accumulated.
131
+ * @returns {void}
132
+ */
133
+ function walkSections(contents, keyPath, trail, inherited, ctx) {
134
+ contents.forEach((entry, i) => {
135
+ const at = [...keyPath, i];
136
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
137
+ ctx.findings.push(finding(ctx, at, "each entry in `contents:` must be a mapping"));
138
+ return;
139
+ }
140
+ if (!("sectionName" in entry)) {
141
+ // A bare `file:` or `filter:` at this level belongs to the enclosing
142
+ // section and is collected there, not here.
143
+ if (!LEAF_KEYS.some((k) => k in entry)) {
144
+ ctx.findings.push(
145
+ finding(ctx, at, "expected `sectionName:`, `file:` or `filter:`"),
146
+ );
147
+ }
148
+ return;
149
+ }
150
+ const title = entry.sectionName;
151
+ if (typeof title !== "string" || !title.trim()) {
152
+ ctx.findings.push(
153
+ finding(ctx, [...at, "sectionName"], "`sectionName:` must be a name"),
154
+ );
155
+ return;
156
+ }
157
+ for (const key of Object.keys(entry)) {
158
+ if (!SECTION_KEYS.includes(key)) {
159
+ ctx.findings.push(
160
+ finding(ctx, [...at, key], `unknown key \`${key}:\` on a section`, {
161
+ key: true,
162
+ }),
163
+ );
164
+ }
165
+ }
166
+
167
+ const presentation = { ...inherited };
168
+ for (const key of PRESENTATION_KEYS) {
169
+ if (entry[key] !== undefined) presentation[key] = entry[key];
170
+ }
171
+
172
+ const here = [...trail, title];
173
+ const list = Array.isArray(entry.contents) ? entry.contents : [];
174
+ if (entry.contents !== undefined && !Array.isArray(entry.contents)) {
175
+ ctx.findings.push(
176
+ finding(ctx, [...at, "contents"], "`contents:` must be a list", { key: true }),
177
+ );
178
+ }
179
+
180
+ ctx.nodes.push({
181
+ title,
182
+ trail: here,
183
+ depth: here.length,
184
+ keyPath: at,
185
+ presentation,
186
+ items: collectItems(list, [...at, "contents"], here, ctx),
187
+ });
188
+
189
+ walkSections(list, [...at, "contents"], here, presentation, ctx);
190
+ });
191
+ }
192
+
193
+ /**
194
+ * A section's own leaves, in the order written.
195
+ *
196
+ * Child sections are *not* included: they are walked separately so the flattened
197
+ * node list stays in document order, and a section's items are only the prose
198
+ * and the filters that belong to it directly.
199
+ *
200
+ * @param {unknown[]} contents - The section's `contents`.
201
+ * @param {Array<string|number>} keyPath - Where that list sits.
202
+ * @param {string[]} trail - The section's titles, for a stable item id.
203
+ * @param {object} ctx - `{ findings, text }`.
204
+ * @returns {object[]} Items, tagged by kind.
205
+ */
206
+ function collectItems(contents, keyPath, trail, ctx) {
207
+ const items = [];
208
+ contents.forEach((entry, i) => {
209
+ if (!entry || typeof entry !== "object" || "sectionName" in entry) return;
210
+ const at = [...keyPath, i];
211
+ if (typeof entry.file === "string" && entry.file.trim()) {
212
+ items.push({ kind: "prose", file: entry.file.trim(), keyPath: at, id: id(trail, i) });
213
+ return;
214
+ }
215
+ if (typeof entry.filter === "string" && entry.filter.trim()) {
216
+ const where = entry.filter.trim();
217
+ if (QUALIFIED_TABLE.test(where)) {
218
+ ctx.findings.push(
219
+ finding(
220
+ ctx,
221
+ [...at, "filter"],
222
+ "a filter may not name another package's notes — a book " +
223
+ "selects from its own project only",
224
+ ),
225
+ );
226
+ return;
227
+ }
228
+ items.push({ kind: "filter", where, keyPath: at, id: id(trail, i) });
229
+ return;
230
+ }
231
+ if ("file" in entry || "filter" in entry) {
232
+ ctx.findings.push(finding(ctx, at, "`file:` and `filter:` must be non-empty strings"));
233
+ }
234
+ });
235
+ return items;
236
+ }
237
+
238
+ /**
239
+ * A stable identity for one item, so selections can be keyed without the tree.
240
+ *
241
+ * The key is opaque — it is a `Map` key and nothing ever takes it apart again —
242
+ * so the only requirement is that two different items cannot produce the same
243
+ * string. `JSON.stringify` of the trail and the index gives that for free: it
244
+ * quotes and escapes each title, so a title containing the separator cannot
245
+ * forge a neighbour's key.
246
+ *
247
+ * Joining on a delimiter would work too, but every delimiter safe enough to be
248
+ * collision-proof is a control character, and a control character written into
249
+ * source is a character nobody can see in a diff, a test failure or a review.
250
+ * A JSON array is printable, greppable, and reads as itself when a plan is
251
+ * dumped.
252
+ *
253
+ * @param {string[]} trail - The owning section's titles.
254
+ * @param {number} index - The item's place in `contents`.
255
+ * @returns {string} The key.
256
+ */
257
+ function id(trail, index) {
258
+ return JSON.stringify([...trail, index]);
259
+ }
260
+
261
+ /**
262
+ * A finding, positioned in the source when the source was supplied.
263
+ *
264
+ * Carries no `file`: only the caller knows which document this was, which is the
265
+ * rule every rule in this engine follows.
266
+ *
267
+ * @param {object} ctx - `{ text }`.
268
+ * @param {Array<string|number>} keyPath - Where the problem is.
269
+ * @param {string} message - What is wrong.
270
+ * @param {object} [opts] - Passed to {@link positionOfYamlPath}.
271
+ * @returns {object} The finding.
272
+ */
273
+ function finding(ctx, keyPath, message, opts) {
274
+ return { severity: "error", message, ...position(ctx.text, keyPath, opts) };
275
+ }
276
+
277
+ /**
278
+ * Run every filter, and report the ones that would not run.
279
+ *
280
+ * The I/O half, kept apart from the planner for the reason the whole engine
281
+ * keeps them apart: the plan is then assertable without a database. The build
282
+ * owns the statement — `SELECT * FROM notes WHERE <clause>` — which is what
283
+ * makes a filter unable to reach another package's schema, unable to project
284
+ * something that is not a note, and unable to pick up the `doc<type>`
285
+ * documentation rows that ride the same index as the notes they document.
286
+ *
287
+ * **A filter that selects nothing is an error**, and the distinction that makes
288
+ * that consistent is worth stating. A *note* no clause selects is expected: the
289
+ * book is a selection and a project decides what its own volume carries. A
290
+ * *clause* that selects no note is not the same thing — a filter is a deliberate
291
+ * act, so one matching nothing is either wrong or left over from a structure
292
+ * that has moved on, and in both cases the tree should not carry it. Reported
293
+ * with the section's name and the filter's position, so the choice between
294
+ * fixing it and deleting it is the author's.
295
+ *
296
+ * @param {object[]} nodes - From {@link parseDocumentTree}.
297
+ * @param {{query: (sql: string) => Promise<{rows: object[]}>}} db - An open
298
+ * database, from {@link module:engine/sql-tables.openNotesDatabase}.
299
+ * @param {object} [opts] - Options.
300
+ * @param {(record: object) => boolean} [opts.keep] - Which rows are notes.
301
+ * @param {string} [opts.text] - The document's source, for finding positions.
302
+ * @returns {Promise<{selections: Map<string, object[]>, findings: object[]}>}
303
+ */
304
+ export async function runTreeFilters(nodes, db, { keep = () => true, text } = {}) {
305
+ const selections = new Map();
306
+ const findings = [];
307
+ for (const node of nodes) {
308
+ for (const item of node.items) {
309
+ if (item.kind !== "filter") continue;
310
+ const where = `\`${node.trail.join(" › ")}\``;
311
+ try {
312
+ const { rows } = await db.query(`SELECT * FROM notes WHERE ${item.where}`);
313
+ const kept = rows.filter(keep);
314
+ selections.set(item.id, kept);
315
+ if (!kept.length) {
316
+ findings.push({
317
+ severity: "error",
318
+ message:
319
+ `${where}: the filter selected no notes — ` +
320
+ `\`${item.where}\` matches nothing in this project, so ` +
321
+ `either it is wrong or the section should not be here`,
322
+ ...position(text, [...item.keyPath, "filter"]),
323
+ });
324
+ }
325
+ } catch (err) {
326
+ findings.push({
327
+ severity: "error",
328
+ message:
329
+ `${where}: the filter did not run — ` + String(err.message).split("\n")[0],
330
+ ...position(text, [...item.keyPath, "filter"]),
331
+ });
332
+ selections.set(item.id, []);
333
+ }
334
+ }
335
+ }
336
+ return { selections, findings };
337
+ }
338
+
339
+ /**
340
+ * Resolve the flattened tree into the document plan.
341
+ *
342
+ * The plan is an ordered list of entries — `section`, `prose`, `note` — and it
343
+ * is the artifact worth having. Order, depth, the outline, the table of
344
+ * contents, anchor uniqueness and every link destination are all readable from
345
+ * it, so nearly the whole of what #316 asks for can be asserted here, on data,
346
+ * without a renderer or a PDF. Only how the result *looks* needs eyes.
347
+ *
348
+ * **Notes are sorted, prose is not.** A section's entries come out in
349
+ * `nameAscii` order — the ASCII fold `buildIndexRecord` already derives, so a
350
+ * circumflex sorts with its letter instead of after `Z` as a raw codepoint
351
+ * comparison would put it. Prose keeps the position it was written in, because
352
+ * its place in the sequence is the author's statement.
353
+ *
354
+ * **A section with nothing in it does not print**, and emptiness is judged after
355
+ * its descendants are: a section holding only sections that all resolved to
356
+ * nothing is itself empty. In a correct tree this never fires — a filter that
357
+ * selects nothing is reported by {@link runTreeFilters} as the error it is — so
358
+ * this is the graceful half of that failure rather than a feature: a build whose
359
+ * filters are broken still produces a readable document to look at while they
360
+ * are fixed. A section holding only prose is not empty; it is a preface.
361
+ *
362
+ * @param {object[]} nodes - From {@link parseDocumentTree}.
363
+ * @param {object} [opts] - Options.
364
+ * @param {Map<string, object[]>} [opts.selections] - Records each filter
365
+ * selected, keyed by item id. Run by the caller: DuckDB is async and this is
366
+ * not.
367
+ * @returns {{entries: object[], links: Map<string, string>, stats: object}}
368
+ * The plan, the address→anchor map inbound wikilinks resolve through, and
369
+ * what the selection came to.
370
+ */
371
+ export function planDocument(nodes, { selections = new Map() } = {}) {
372
+ const entries = [];
373
+ const links = new Map();
374
+ const seen = new Map();
375
+ let selected = 0;
376
+
377
+ // Depth-first in document order. `nodes` is already in that order, so a
378
+ // section's children are every later node whose trail begins with its own.
379
+ const roots = nodes.filter((n) => n.depth === 1);
380
+ for (const root of roots) emitSection(root, nodes, { entries, links, seen, selections });
381
+
382
+ for (const node of nodes) {
383
+ for (const item of node.items) {
384
+ if (item.kind === "filter") selected += (selections.get(item.id) ?? []).length;
385
+ }
386
+ }
387
+
388
+ return {
389
+ entries,
390
+ links,
391
+ stats: {
392
+ sections: entries.filter((e) => e.kind === "section").length,
393
+ prose: entries.filter((e) => e.kind === "prose").length,
394
+ notes: entries.filter((e) => e.kind === "note").length,
395
+ distinct: links.size,
396
+ repeated: selected - links.size,
397
+ skipped: nodes.length - entries.filter((e) => e.kind === "section").length,
398
+ },
399
+ };
400
+ }
401
+
402
+ /**
403
+ * Emit one section and everything beneath it, or nothing if it is empty.
404
+ *
405
+ * Builds the section's own entries into a scratch list first, so the decision to
406
+ * drop it can be made after its children have answered — which is the only way
407
+ * a section of empty sections is itself recognised as empty.
408
+ *
409
+ * @param {object} node - The section.
410
+ * @param {object[]} nodes - Every node, in document order.
411
+ * @param {object} ctx - `{ entries, links, seen, selections }`.
412
+ * @returns {number} How many entries it contributed.
413
+ */
414
+ function emitSection(node, nodes, ctx) {
415
+ const start = ctx.entries.length;
416
+ ctx.entries.push({
417
+ kind: "section",
418
+ title: node.title,
419
+ trail: node.trail,
420
+ depth: node.depth,
421
+ presentation: node.presentation,
422
+ anchor: anchorFor(slugify(node.trail.join(" ")) || "section", ctx.seen),
423
+ });
424
+
425
+ const children = nodes.filter(
426
+ (n) =>
427
+ n.depth === node.depth + 1 &&
428
+ n.trail.length === node.trail.length + 1 &&
429
+ node.trail.every((t, i) => n.trail[i] === t),
430
+ );
431
+ let contributed = 0;
432
+
433
+ for (const item of node.items) {
434
+ if (item.kind === "prose") {
435
+ ctx.entries.push({
436
+ kind: "prose",
437
+ file: item.file,
438
+ trail: node.trail,
439
+ depth: node.depth,
440
+ presentation: node.presentation,
441
+ anchor: anchorFor(slugify(item.file.replace(/\.md$/i, "")) || "prose", ctx.seen),
442
+ });
443
+ contributed++;
444
+ continue;
445
+ }
446
+ for (const record of sortRecords(ctx.selections.get(item.id) ?? [])) {
447
+ const slug = record?.address?.slug ?? record?.shortcode ?? String(ctx.entries.length);
448
+ const anchor = anchorFor(slug, ctx.seen);
449
+ // First occurrence wins the address: however many times the book
450
+ // prints an entry, `[[weapongear-dagger]]` reaches one page.
451
+ if (!ctx.links.has(slug)) ctx.links.set(slug, anchor);
452
+ ctx.entries.push({
453
+ kind: "note",
454
+ record,
455
+ trail: node.trail,
456
+ depth: node.depth,
457
+ presentation: node.presentation,
458
+ anchor,
459
+ });
460
+ contributed++;
461
+ }
462
+ }
463
+
464
+ for (const child of children) contributed += emitSection(child, nodes, ctx);
465
+
466
+ if (!contributed) {
467
+ ctx.entries.length = start;
468
+ return 0;
469
+ }
470
+ return contributed + 1;
471
+ }
472
+
473
+ /**
474
+ * A section's notes, in the order the book prints them.
475
+ *
476
+ * Ordering is deliberately not in the file format: a filter says *which* notes,
477
+ * never in what sequence. The tiebreak on address keeps the result stable when
478
+ * two entries share a name, which a corpus of 2,500 does eventually.
479
+ *
480
+ * @param {object[]} records - Selected records.
481
+ * @returns {object[]} A sorted copy.
482
+ */
483
+ function sortRecords(records) {
484
+ return [...records].sort((a, b) => {
485
+ const an = a?.nameAscii ?? a?.name?.full ?? "";
486
+ const bn = b?.nameAscii ?? b?.name?.full ?? "";
487
+ if (an !== bn) return an < bn ? -1 : 1;
488
+ const as = a?.address?.slug ?? "";
489
+ const bs = b?.address?.slug ?? "";
490
+ return (
491
+ as < bs ? -1
492
+ : as > bs ? 1
493
+ : 0
494
+ );
495
+ });
496
+ }
497
+
498
+ /**
499
+ * A unique anchor, suffixed when the base is already taken.
500
+ *
501
+ * A note selected by two sections is two pages, and two pages cannot share a
502
+ * destination: the outline, the table of contents and any link would all reach
503
+ * whichever the renderer happened to emit last.
504
+ *
505
+ * @param {string} base - The preferred anchor.
506
+ * @param {Map<string, number>} seen - How many times each base has been used.
507
+ * @returns {string} The anchor.
508
+ */
509
+ function anchorFor(base, seen) {
510
+ const n = (seen.get(base) ?? 0) + 1;
511
+ seen.set(base, n);
512
+ return n === 1 ? base : `${base}-${n}`;
513
+ }
514
+
515
+ /**
516
+ * Where a node sits in the source, when the source was supplied.
517
+ *
518
+ * @param {string|undefined} text - The document source.
519
+ * @param {Array<string|number>} keyPath - Path to the node.
520
+ * @param {object} [opts] - Options.
521
+ * @returns {object} Spreadable position fields.
522
+ */
523
+ function position(text, keyPath, opts) {
524
+ return text ? positionOfYamlPath(text, keyPath, opts) : {};
525
+ }
@@ -14,14 +14,14 @@
14
14
  /**
15
15
  * The prose conventions every content repository writes to — one Prettier
16
16
  * configuration and one markdownlint rule set, declared here so a note
17
- * formatted in one repository is formatted the same way in the next (#69).
17
+ * formatted in one repository is formatted the same way in the next.
18
18
  *
19
- * These used to exist in exactly one consumer. The SoHL repository carried
19
+ * These belong in no single consumer. The SoHL repository carried
20
20
  * both; `sohl-thalorna` had Prettier but never ran it from `lint`; and
21
21
  * `sohl-kethira-basic` had neither, so the package least likely to have been
22
22
  * proofread was checked for addresses and nothing else. A rule set copied into
23
23
  * three repositories is one rule with three implementations, which is the drift
24
- * #20 exists to remove — so it is declared once, here, and every consumer
24
+ * this exists to remove — so it is declared once, here, and every consumer
25
25
  * invokes it.
26
26
  *
27
27
  * **Neither of these is an override.** A consumer that declares its own
@@ -89,7 +89,7 @@ export const PRETTIER_BASE = Object.freeze({
89
89
  * **Declared apart from the `overrides` block, not derived from it.** Prettier
90
90
  * applies `overrides` only while resolving a config *file*; options handed to
91
91
  * it directly keep the global values, so a consumer with no config of its own
92
- * silently got markdown at 4 (#76). The runner needs the adjustment as data it
92
+ * silently got markdown at 4. The runner needs the adjustment as data it
93
93
  * can apply itself, and {@link PRETTIER_CONFIG} composes the same values into
94
94
  * the shape a config file wants — one source, two presentations.
95
95
  *
@@ -128,7 +128,7 @@ export const PRETTIER_CONFIG = Object.freeze({
128
128
  *
129
129
  * @param {string} file - Path of the file about to be formatted.
130
130
  * @returns {object} Options to hand Prettier directly. Never carries
131
- * `overrides`: passing that inline is what silently did nothing (#76).
131
+ * `overrides`: passing that inline is what silently did nothing.
132
132
  */
133
133
  export function sharedPrettierOptionsFor(file) {
134
134
  return /\.md$/i.test(file) ? { ...PRETTIER_BASE, ...PRETTIER_MARKDOWN } : { ...PRETTIER_BASE };
@@ -140,11 +140,11 @@ export function sharedPrettierOptionsFor(file) {
140
140
  * The runner resolves each file's options as *either* the consumer's own config
141
141
  * or {@link sharedPrettierOptionsFor}, never a merge. That is what bare Prettier
142
142
  * does and it is the contract — but it means the conventions this package exists
143
- * to publish hold by convention alone, and they lapse in two opposite directions
144
- * (#133). A consumer that declares any config of its own gets whatever that
143
+ * to publish hold by convention alone, and they lapse in two opposite directions.
144
+ * A consumer that declares any config of its own gets whatever that
145
145
  * config says: spread {@link PRETTIER_BASE} without the markdown override and
146
- * every note reindents at 4, the reindentation the override was added to prevent
147
- * (#76); write a partial `.prettierrc` such as `{"tabWidth": 2}` and
146
+ * every note reindents at 4, the reindentation the override was added to prevent;
147
+ * write a partial `.prettierrc` such as `{"tabWidth": 2}` and
148
148
  * `printWidth`, `trailingComma`, `experimentalTernaries` and the rest fall back
149
149
  * to Prettier's own defaults. A consumer that declares *nothing* formats one way
150
150
  * under this command and another under a bare `npx prettier`.
@@ -13,13 +13,13 @@
13
13
 
14
14
  /**
15
15
  * Running the shared prose conventions over a repository — Prettier for
16
- * formatting, markdownlint for the structure Prettier is indifferent to (#69).
16
+ * formatting, markdownlint for the structure Prettier is indifferent to.
17
17
  *
18
18
  * Both are thin: the rules live in `./prose-config.mjs` and the tools are
19
19
  * Prettier and markdownlint themselves. What this module adds is the two things
20
20
  * a consumer would otherwise have to get right on its own — supplying the
21
21
  * shared configuration *as a default a local one overrides*, and reporting
22
- * findings in the one parseable form every check in this package emits (#17).
22
+ * findings in the one parseable form every check in this package emits.
23
23
  *
24
24
  * **Neither tool's own file discovery is reimplemented.** Prettier decides what
25
25
  * it formats and what an ignore file excludes, through `getFileInfo`;
@@ -68,7 +68,7 @@ const IGNORE_FILES = Object.freeze([".gitignore", ".prettierignore"]);
68
68
  * `format` is *assumed* idempotent and is not guaranteed to be: a single pass
69
69
  * can leave text the next pass would still change, and a `--write` run that
70
70
  * takes one pass then reports success has called such a file formatted while
71
- * `prettier --check` still rejects it (#125). Formatting to a fixpoint removes
71
+ * `prettier --check` still rejects it. Formatting to a fixpoint removes
72
72
  * the assumption — the file lands on the value repeated formatting converges
73
73
  * to, whatever it took to get there.
74
74
  *
@@ -86,7 +86,7 @@ const MAX_FORMAT_PASSES = 3;
86
86
  * configured to do means asking about a file. These are the two answers that
87
87
  * differ: markdown carries the shared `tabWidth` override and everything else
88
88
  * does not, so a single probe would check half the conventions and miss the one
89
- * most worth checking (#133).
89
+ * most worth checking.
90
90
  *
91
91
  * Ordinary names at the repository root, and neither has to exist —
92
92
  * `resolveConfig` reads the path to walk up from it and to match `overrides`
@@ -147,8 +147,7 @@ function walkFiles(root) {
147
147
  * @param {boolean} [opts.write=false] - Rewrite unformatted files in place
148
148
  * rather than reporting them. Each file is formatted to a fixpoint (up to
149
149
  * {@link MAX_FORMAT_PASSES} passes), so a written tree is one a second run
150
- * leaves alone; a file that will not converge is reported and left unchanged
151
- * (#125).
150
+ * leaves alone; a file that will not converge is reported and left unchanged.
152
151
  * @param {object} [opts.prettier] - The Prettier module, for tests.
153
152
  * @returns {Promise<{findings: Array<{file: string, severity: string,
154
153
  * message: string}>, checked: number, written: string[]}>} The findings, how
@@ -185,7 +184,7 @@ export async function checkFormatting(root, opts = {}) {
185
184
  });
186
185
  // `resolveConfig` has already applied any `overrides` the consumer's
187
186
  // own config declares. The shared fallback has to apply its own, since
188
- // Prettier ignores an `overrides` block passed inline (#76).
187
+ // Prettier ignores an `overrides` block passed inline.
189
188
  const options = {
190
189
  ...(local ?? sharedPrettierOptionsFor(file)),
191
190
  filepath: file,
@@ -199,7 +198,7 @@ export async function checkFormatting(root, opts = {}) {
199
198
  try {
200
199
  if (write) {
201
200
  // Format to a fixpoint rather than once, so what lands on disk
202
- // is what a second run would have produced (#125).
201
+ // is what a second run would have produced.
203
202
  let formatted = source;
204
203
  let converged = false;
205
204
  for (let pass = 0; pass < MAX_FORMAT_PASSES; pass += 1) {
@@ -221,7 +220,7 @@ export async function checkFormatting(root, opts = {}) {
221
220
  file,
222
221
  severity: "error",
223
222
  // No line or column: the verdict is about the whole
224
- // file, and #17's rule is to drop a field rather than
223
+ // file, and the rule is to drop a field rather than
225
224
  // invent one.
226
225
  message:
227
226
  `did not converge after ${MAX_FORMAT_PASSES} formatting passes; ` +
@@ -242,7 +241,7 @@ export async function checkFormatting(root, opts = {}) {
242
241
  file,
243
242
  severity: "error",
244
243
  // No line or column: Prettier's answer is about the whole
245
- // file, and #17's rule is to drop a field rather than
244
+ // file, and the rule is to drop a field rather than
246
245
  // invent one.
247
246
  message: "is not formatted; run `content-build format --write` to fix it",
248
247
  });
@@ -272,7 +271,7 @@ export async function checkFormatting(root, opts = {}) {
272
271
  *
273
272
  * The two cases read differently on purpose. A key set to something else is a
274
273
  * choice someone made and can defend; a key that is simply absent is the
275
- * silent half of #133 the consumer did not choose Prettier's default, it
274
+ * silent half — a consumer does not choose Prettier's default, it
276
275
  * arrived because declaring one option discards every option not restated.
277
276
  *
278
277
  * @param {{key: string, shared: unknown, local: unknown}} divergence - From
@@ -291,7 +290,7 @@ function divergenceMessage({ key, shared, local }, scope = "") {
291
290
 
292
291
  /**
293
292
  * Report where a repository's own Prettier configuration parts from the shared
294
- * one — or that it has none at all (#133).
293
+ * one — or that it has none at all.
295
294
  *
296
295
  * **Warnings, every one of them.** A consumer's config wins by design and this
297
296
  * does not change that; it only refuses to let the divergence be silent, which
@@ -312,7 +311,7 @@ function divergenceMessage({ key, shared, local }, scope = "") {
312
311
  * @returns {Promise<{findings: Array<{file?: string, severity: string,
313
312
  * message: string}>, configFile: string|null}>} The findings and the config
314
313
  * file they are about, which is `null` when the repository declares none. A
315
- * finding about a missing file carries no `file`: #17's rule is to drop a
314
+ * finding about a missing file carries no `file`: the rule is to drop a
316
315
  * field rather than invent one.
317
316
  */
318
317
  export async function checkPrettierConventions(root, opts = {}) {