@heroiclands/package-build 17.1.0 → 18.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 (153) hide show
  1. package/CHANGELOG.md +1773 -0
  2. package/CONTENT.md +288 -72
  3. package/README.md +1 -1
  4. package/bin/content-build.mjs +218 -137
  5. package/bin/package-build.mjs +43 -0
  6. package/content-config.mjs +62 -67
  7. package/docs/content-format.md +768 -58
  8. package/engine/actor-compiler.mjs +586 -0
  9. package/engine/address-charset.mjs +11 -8
  10. package/engine/address-diff.mjs +266 -22
  11. package/engine/anchored-sections.mjs +83 -0
  12. package/engine/anchors.mjs +83 -0
  13. package/engine/base-compiler.mjs +148 -18
  14. package/engine/bundle-notes.mjs +276 -0
  15. package/engine/bundles.mjs +307 -0
  16. package/engine/code-fences.mjs +103 -0
  17. package/engine/compile-corpus.mjs +89 -0
  18. package/engine/content-address.mjs +267 -5
  19. package/engine/content-format-check.mjs +54 -11
  20. package/engine/content-format.mjs +37 -5
  21. package/engine/content-index.mjs +394 -111
  22. package/engine/content-links.mjs +185 -77
  23. package/engine/content-lint.mjs +232 -14
  24. package/engine/content-package.mjs +2 -1
  25. package/engine/content-tables.mjs +99 -3
  26. package/engine/document-subtypes.mjs +49 -6
  27. package/engine/field-reference.mjs +1 -1
  28. package/engine/field-spec.mjs +56 -6
  29. package/engine/folder-notes.mjs +470 -0
  30. package/engine/foreign-catalog.mjs +195 -4
  31. package/engine/{manifest-emit.mjs → foundry-entries.mjs} +78 -81
  32. package/engine/frontmatter-lint.mjs +245 -19
  33. package/engine/frontmatter.mjs +88 -8
  34. package/engine/generate.mjs +304 -13
  35. package/engine/helpers.mjs +293 -64
  36. package/engine/ids.mjs +137 -2
  37. package/engine/index-records.mjs +126 -0
  38. package/engine/index.mjs +23 -5
  39. package/engine/item-compiler.mjs +349 -0
  40. package/engine/item-docs.mjs +7 -1
  41. package/engine/item-registry.mjs +6 -0
  42. package/engine/journals.mjs +93 -27
  43. package/engine/macros.mjs +4 -2
  44. package/engine/metadata-index.mjs +495 -0
  45. package/engine/note-claims.mjs +146 -25
  46. package/engine/note-ids.mjs +112 -0
  47. package/engine/note-renames.mjs +134 -0
  48. package/engine/note-schemas.mjs +25 -0
  49. package/engine/note-vocabulary.mjs +110 -27
  50. package/engine/pack-config.mjs +4 -0
  51. package/engine/pack-router.mjs +23 -0
  52. package/engine/retired-fields.mjs +137 -3
  53. package/engine/scenes.mjs +66 -34
  54. package/engine/schema-check.mjs +58 -17
  55. package/engine/site-build.mjs +71 -12
  56. package/engine/site-index.mjs +20 -7
  57. package/engine/sql-tables.mjs +485 -0
  58. package/engine/subtype-registry.mjs +102 -0
  59. package/engine/system-block.mjs +96 -12
  60. package/engine/systems.mjs +201 -0
  61. package/engine/web-wikilinks.mjs +50 -20
  62. package/engine/wikilink-syntax.mjs +17 -8
  63. package/engine/wikilinks.mjs +124 -51
  64. package/engine/yaml-lint.mjs +282 -0
  65. package/hm3/actors.mjs +352 -0
  66. package/hm3/default-item-art.mjs +75 -0
  67. package/hm3/document-subtypes.mjs +134 -0
  68. package/hm3/index.mjs +56 -0
  69. package/hm3/item-builders.mjs +84 -0
  70. package/hm3/item-fields.mjs +180 -0
  71. package/hm3/items.mjs +96 -0
  72. package/hm3/template-priority.mjs +85 -0
  73. package/labels.mjs +191 -0
  74. package/manifest.mjs +43 -2
  75. package/package.json +19 -4
  76. package/release.mjs +62 -7
  77. package/sohl/actors.mjs +33 -487
  78. package/sohl/being-info.mjs +16 -7
  79. package/sohl/default-item-art.mjs +14 -3
  80. package/sohl/document-subtypes.mjs +16 -10
  81. package/sohl/item-builders.mjs +14 -5
  82. package/sohl/item-fields.mjs +68 -7
  83. package/sohl/items.mjs +44 -258
  84. package/sohl/note-schemas.mjs +2 -2
  85. package/types/content-config.d.mts +21 -42
  86. package/types/engine/actor-compiler.d.mts +204 -0
  87. package/types/engine/address-charset.d.mts +11 -8
  88. package/types/engine/address-diff.d.mts +53 -5
  89. package/types/engine/anchored-sections.d.mts +21 -0
  90. package/types/engine/anchors.d.mts +20 -0
  91. package/types/engine/base-compiler.d.mts +17 -17
  92. package/types/engine/bundle-notes.d.mts +173 -0
  93. package/types/engine/bundles.d.mts +60 -0
  94. package/types/engine/code-fences.d.mts +43 -0
  95. package/types/engine/compile-corpus.d.mts +32 -0
  96. package/types/engine/content-address.d.mts +205 -5
  97. package/types/engine/content-format-check.d.mts +6 -2
  98. package/types/engine/content-format.d.mts +57 -1
  99. package/types/engine/content-index.d.mts +64 -80
  100. package/types/engine/content-links.d.mts +52 -7
  101. package/types/engine/content-lint.d.mts +10 -1
  102. package/types/engine/content-package.d.mts +2 -1
  103. package/types/engine/content-tables.d.mts +21 -39
  104. package/types/engine/document-subtypes.d.mts +37 -3
  105. package/types/engine/field-spec.d.mts +76 -5
  106. package/types/engine/folder-notes.d.mts +159 -0
  107. package/types/engine/foreign-catalog.d.mts +53 -0
  108. package/types/engine/{manifest-emit.d.mts → foundry-entries.d.mts} +16 -38
  109. package/types/engine/frontmatter-lint.d.mts +10 -2
  110. package/types/engine/frontmatter.d.mts +64 -0
  111. package/types/engine/generate.d.mts +38 -0
  112. package/types/engine/helpers.d.mts +94 -30
  113. package/types/engine/ids.d.mts +96 -0
  114. package/types/engine/index-records.d.mts +68 -0
  115. package/types/engine/index.d.mts +9 -3
  116. package/types/engine/item-compiler.d.mts +131 -0
  117. package/types/engine/journals.d.mts +47 -9
  118. package/types/engine/metadata-index.d.mts +226 -0
  119. package/types/engine/note-claims.d.mts +57 -11
  120. package/types/engine/note-ids.d.mts +38 -0
  121. package/types/engine/note-renames.d.mts +102 -0
  122. package/types/engine/note-vocabulary.d.mts +44 -8
  123. package/types/engine/retired-fields.d.mts +75 -0
  124. package/types/engine/scenes.d.mts +3 -2
  125. package/types/engine/schema-check.d.mts +25 -4
  126. package/types/engine/site-build.d.mts +4 -4
  127. package/types/engine/site-index.d.mts +1 -1
  128. package/types/engine/sql-tables.d.mts +185 -0
  129. package/types/engine/subtype-registry.d.mts +49 -0
  130. package/types/engine/system-block.d.mts +40 -1
  131. package/types/engine/systems.d.mts +106 -0
  132. package/types/engine/web-wikilinks.d.mts +4 -2
  133. package/types/engine/wikilink-syntax.d.mts +10 -3
  134. package/types/engine/wikilinks.d.mts +41 -13
  135. package/types/engine/yaml-lint.d.mts +107 -0
  136. package/types/hm3/actors.d.mts +48 -0
  137. package/types/hm3/default-item-art.d.mts +42 -0
  138. package/types/hm3/document-subtypes.d.mts +24 -0
  139. package/types/hm3/index.d.mts +7 -0
  140. package/types/hm3/item-builders.d.mts +11 -0
  141. package/types/hm3/item-fields.d.mts +12 -0
  142. package/types/hm3/items.d.mts +23 -0
  143. package/types/hm3/template-priority.d.mts +21 -0
  144. package/types/manifest.d.mts +22 -1
  145. package/types/release.d.mts +9 -4
  146. package/types/sohl/actors.d.mts +7 -74
  147. package/types/sohl/being-info.d.mts +8 -27
  148. package/types/sohl/default-item-art.d.mts +5 -3
  149. package/types/sohl/items.d.mts +17 -32
  150. package/engine/foreign-manifests.mjs +0 -126
  151. package/engine/kb-manifest.mjs +0 -490
  152. package/types/engine/foreign-manifests.d.mts +0 -43
  153. package/types/engine/kb-manifest.d.mts +0 -241
@@ -0,0 +1,485 @@
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
+ * Content tables written in SQL, queried over the content index.
16
+ *
17
+ * The tables were written in Dataview's query language, chosen when the corpus
18
+ * lived in an Obsidian vault so a table rendered live while authoring. The vault
19
+ * is gone, and what remained was a hand-written parser and evaluator for someone
20
+ * else's language, kept faithful to semantics nothing checked it against (#246).
21
+ *
22
+ * **The query is real SQL, run by DuckDB** — not a dialect maintained here. That
23
+ * is the whole point: a partial reimplementation would accept some valid SQL and
24
+ * silently misread the rest, which is worse than an unfamiliar language, because
25
+ * the boundary is invisible.
26
+ *
27
+ * **What SQL cannot say, the projection says.** Rendering a table is not a
28
+ * relational operation: which column links, and where a section breaks, are
29
+ * decisions about output. They are carried as **underscore-prefixed aliases** —
30
+ * `_ref` and `_section` — which are ordinary SQL, need no fence options, and are
31
+ * visible in the query where an author is already looking.
32
+ *
33
+ * @module
34
+ */
35
+
36
+ import fs from "node:fs";
37
+ import os from "node:os";
38
+ import path from "node:path";
39
+
40
+ import { FENCE_LINE, parseHeaderArgs } from "./code-fences.mjs";
41
+ import { parseMarkdownFile } from "./helpers.mjs";
42
+ // The record accessors only — see `engine/index-records.mjs` (#243).
43
+ import { isNoteRecord, noteFile } from "./index-records.mjs";
44
+
45
+ /** Rendered in a cell whose value is absent. */
46
+ const EMPTY_CELL = "—";
47
+
48
+ /**
49
+ * Aliases the renderer reads rather than printing.
50
+ *
51
+ * `_ref` is the note a row points at, as the `type-shortcode` address a wikilink
52
+ * resolves — `address.slug` in the index. `_section` partitions the result into
53
+ * headed tables.
54
+ */
55
+ export const RENDER_ALIASES = Object.freeze({ ref: "_ref", section: "_section" });
56
+
57
+ /** A table cell may not carry a raw `|` or a line break. */
58
+ const escapeCell = (text) =>
59
+ String(text)
60
+ .replace(/\|/g, "\\|")
61
+ .replace(/[\r\n]+/g, " ");
62
+
63
+ /**
64
+ * Every `sql` fence in a markdown body, with the position each occupies.
65
+ *
66
+ * Positions are 0-based lines into the body as given, which is what a diagnostic
67
+ * about a directive needs (#17) and what the expander uses to splice results
68
+ * back in.
69
+ *
70
+ * @param {string} markdown - The note body, frontmatter already stripped.
71
+ * @returns {Array<{line: number, close: number, indent: string, query: string,
72
+ * allowEmpty: boolean, sectionLevel: number, block: string}>} One entry per
73
+ * fence, in document order.
74
+ */
75
+ export function findSqlBlocks(markdown) {
76
+ const lines = String(markdown ?? "").split("\n");
77
+ const blocks = [];
78
+ for (let i = 0; i < lines.length; i += 1) {
79
+ const opening = FENCE_LINE.exec(lines[i]);
80
+ if (!opening) continue;
81
+ const [, indent, marker, info] = opening;
82
+ const closer = new RegExp(`^[ \\t]*${marker[0]}{${marker.length},}[ \\t]*$`);
83
+ let close = i + 1;
84
+ while (close < lines.length && !closer.test(lines[close])) close += 1;
85
+ const { language, args } = parseHeaderArgs(info);
86
+ if (language !== "sql") {
87
+ // Not ours, but still a fence: skip its body so a `sql` line inside
88
+ // some other block is never read as a directive.
89
+ i = close;
90
+ continue;
91
+ }
92
+ if (close >= lines.length) continue;
93
+ const level = Number(args["section-level"]);
94
+ blocks.push({
95
+ line: i,
96
+ close,
97
+ indent,
98
+ query: lines.slice(i + 1, close).join("\n"),
99
+ // `:allow-empty` says a table selecting nothing is intended.
100
+ // Spelled on the fence rather than in the query because it is a
101
+ // statement about this directive and not part of SQL (#223).
102
+ allowEmpty: args["allow-empty"] === true,
103
+ sectionLevel: Number.isInteger(level) && level >= 1 && level <= 6 ? level : 2,
104
+ // Every header argument, so a caller can read one this module makes
105
+ // no use of — the point of taking a real grammar rather than a
106
+ // regex per property (#246).
107
+ args,
108
+ block: lines.slice(i, close + 1).join("\n"),
109
+ });
110
+ i = close;
111
+ }
112
+ return blocks;
113
+ }
114
+
115
+ /**
116
+ * Open a DuckDB view over the content index.
117
+ *
118
+ * The records are written as JSON Lines to a temporary file and read with
119
+ * `read_json_auto`, rather than inserted row by row, because that is what makes
120
+ * the queries readable: DuckDB infers a `STRUCT` for every nested object, so a
121
+ * note's `sohl.weight` and `name.full` are addressed in a query exactly as they
122
+ * are authored in the note. A column-per-path table would force
123
+ * `"sohl.weight"` in quotes, and a JSON column would force `sohl->>'weight'`.
124
+ *
125
+ * `union_by_name` is what makes that work across a heterogeneous corpus: a
126
+ * `sohl:` block differs by note type, and the inferred struct is the union of
127
+ * every type's fields, with `NULL` where a record does not have one.
128
+ *
129
+ * `threads=1` so a result is byte-identical between runs. Rows tied under the
130
+ * authored `ORDER BY` then fall back to the index's own order, which is itself
131
+ * deterministic — the index is emitted sorted and byte-stable.
132
+ *
133
+ * ## A dependency is a schema
134
+ *
135
+ * A package that depends on another can tabulate what it depends on —
136
+ * `FROM sohl.notes` — because each declared dependency's published index is
137
+ * attached as a **schema** named after the package, with this package's own
138
+ * notes staying at the unqualified `notes`.
139
+ *
140
+ * It is `FROM` rather than a fence property naming a file, for two reasons. A
141
+ * path in authored content is a build artifact's name written into the corpus,
142
+ * so renaming the artifact means sweeping every note that cites it — the
143
+ * coupling #126 exists to undo. And *which dataset a query reads* is what
144
+ * `FROM` is for: the same rule that keeps `_ref` and `_section` ordinary SQL,
145
+ * visible where an author is already looking, rather than fence options.
146
+ *
147
+ * It costs no fetch. Every dependency's JSONL is already in the metadata cache
148
+ * when a compile starts, because resolving addresses across packages needs it.
149
+ *
150
+ * @param {object[]} records - Content-index records, as
151
+ * {@link module:engine/content-index.collectContentIndex} returns them.
152
+ * @param {object} [opts]
153
+ * @param {string} [opts.dir] - Directory for the temporary file.
154
+ * @param {Array<{id: string, file: string}>} [opts.dependencies] - Each
155
+ * declared dependency's cached index, attached as a schema named `id`.
156
+ * @returns {Promise<{query: (sql: string) => Promise<object[]>,
157
+ * close: () => Promise<void>}>} The open database.
158
+ */
159
+ export async function openNotesDatabase(records, { dir, dependencies = [] } = {}) {
160
+ const { DuckDBInstance } = await import("@duckdb/node-api");
161
+ const base = dir ?? fs.mkdtempSync(path.join(os.tmpdir(), "content-sql-"));
162
+ fs.mkdirSync(base, { recursive: true });
163
+ const jsonl = path.join(base, "notes.jsonl");
164
+ fs.writeFileSync(jsonl, records.map((record) => JSON.stringify(record)).join("\n"));
165
+
166
+ const instance = await DuckDBInstance.create(":memory:");
167
+ const connection = await instance.connect();
168
+ await connection.run("SET threads=1");
169
+ await connection.run(`CREATE VIEW notes AS ${readJsonAuto(jsonl)}`);
170
+
171
+ // One schema per declared dependency, so `FROM sohl.notes` reads the notes
172
+ // that package published. Quoted, because a package id may carry a hyphen
173
+ // (`sohl-thalorna`) and an unquoted identifier may not.
174
+ for (const dep of dependencies) {
175
+ if (!dep?.id || !dep?.file || !fs.existsSync(dep.file)) continue;
176
+ const schema = `"${String(dep.id).replace(/"/g, '""')}"`;
177
+ await connection.run(`CREATE SCHEMA IF NOT EXISTS ${schema}`);
178
+ await connection.run(`CREATE VIEW ${schema}.notes AS ${readJsonAuto(dep.file)}`);
179
+ }
180
+
181
+ return {
182
+ async query(sql) {
183
+ const reader = await connection.runAndReadAll(sql);
184
+ return {
185
+ // From the result's schema, not from the rows: a query that
186
+ // selects nothing still has columns, and reporting *that* is
187
+ // what tells a stale query from an empty category (#223).
188
+ columnNames: reader.columnNames(),
189
+ rows: reader
190
+ .getRowObjects()
191
+ .map((row) =>
192
+ Object.fromEntries(
193
+ Object.entries(row).map(([key, value]) => [key, toPlain(value)]),
194
+ ),
195
+ ),
196
+ };
197
+ },
198
+ async close() {
199
+ if (!dir) fs.rmSync(base, { recursive: true, force: true });
200
+ },
201
+ };
202
+ }
203
+
204
+ /**
205
+ * The `read_json_auto` clause both the own-notes view and a dependency's use.
206
+ *
207
+ * Written once because the options are the load-bearing part, not the file:
208
+ * `union_by_name` is what makes a heterogeneous corpus one relation — a `sohl:`
209
+ * block differs by note type, and the inferred struct is the union of every
210
+ * type's fields with `NULL` where a record lacks one. A dependency's index has
211
+ * exactly the same shape and needs exactly the same reading.
212
+ *
213
+ * @param {string} file - The JSONL to read.
214
+ * @returns {string} The `SELECT … FROM read_json_auto(…)` clause.
215
+ */
216
+ function readJsonAuto(file) {
217
+ return (
218
+ `SELECT * FROM read_json_auto('${file.replace(/'/g, "''")}', ` +
219
+ `format='newline_delimited', union_by_name=true, maximum_object_size=20000000)`
220
+ );
221
+ }
222
+
223
+ /**
224
+ * Run one authored query and shape its result for the renderer.
225
+ *
226
+ * @param {object} db - From {@link openNotesDatabase}.
227
+ * @param {string} sql - The query, as authored.
228
+ * @returns {Promise<{columns: string[], rows: object[]}>} The rendered columns
229
+ * — every selected alias except the underscore-prefixed ones — and the rows.
230
+ */
231
+ export async function runSqlQuery(db, sql) {
232
+ const { rows, columnNames } = await db.query(sql);
233
+ return { columns: columnNames.filter((key) => !key.startsWith("_")), rows };
234
+ }
235
+
236
+ /**
237
+ * One DuckDB value as the plain JavaScript the renderer works with.
238
+ *
239
+ * DuckDB returns its own wrapper for every non-primitive: a list is a
240
+ * `DuckDBListValue` holding `items`, a struct a `DuckDBStructValue` holding
241
+ * `entries`, and — the one that would otherwise reach a cell as `[object
242
+ * Object]` — a decimal is `{width, scale, value}`, where `1.5` arrives as
243
+ * `value: 15n, scale: 1`. Everything else DuckDB wraps (dates, timestamps,
244
+ * intervals, blobs) renders through its own `toString`, which is the value a
245
+ * reader expects to see.
246
+ *
247
+ * A struct is deliberately left an object: a column that resolves to one is a
248
+ * mistake the renderer reports rather than prints.
249
+ *
250
+ * @param {unknown} value - As DuckDB returned it.
251
+ * @returns {unknown} The plain value.
252
+ */
253
+ function toPlain(value) {
254
+ if (value == null || typeof value !== "object") return value;
255
+ if (Array.isArray(value.items)) return value.items.map(toPlain);
256
+ if (value.entries && typeof value.entries === "object") {
257
+ return Object.fromEntries(
258
+ Object.entries(value.entries).map(([key, entry]) => [key, toPlain(entry)]),
259
+ );
260
+ }
261
+ if (typeof value.scale === "number" && value.value != null) {
262
+ return Number(value.value) / 10 ** value.scale;
263
+ }
264
+ if (value.constructor?.name?.startsWith("DuckDB")) return String(value);
265
+ return value;
266
+ }
267
+
268
+ /**
269
+ * One value as the text a cell shows.
270
+ *
271
+ * DuckDB returns an integer as a `BigInt`, which `String()` renders without its
272
+ * `n` — but a struct or a list would stringify as `[object Object]`, so those
273
+ * are refused rather than printed. A list of scalars is joined, because that is
274
+ * what a `tags` or `aliases` column means.
275
+ *
276
+ * @param {unknown} value - The cell value.
277
+ * @param {string} column - Its column, named in the message.
278
+ * @returns {string} The cell.
279
+ */
280
+ function cellText(value, column) {
281
+ if (value == null || value === "") return EMPTY_CELL;
282
+ if (Array.isArray(value)) {
283
+ if (value.some((entry) => entry != null && typeof entry === "object")) {
284
+ throw new Error(`column "${column}" resolves to a list of objects`);
285
+ }
286
+ return value.length ? escapeCell(value.join(", ")) : EMPTY_CELL;
287
+ }
288
+ if (typeof value === "object" && !(value instanceof Date)) {
289
+ throw new Error(`column "${column}" resolves to an object`);
290
+ }
291
+ if (typeof value === "boolean") return value ? "yes" : "no";
292
+ if (typeof value === "bigint") return escapeCell(value.toString());
293
+ return escapeCell(value);
294
+ }
295
+
296
+ /**
297
+ * Render one query's result as markdown.
298
+ *
299
+ * A `_ref` alias makes the row's **first rendered column** a wikilink to that
300
+ * address; the reference is dropped when nothing can be linked, so a table never
301
+ * ships a link that does not resolve. A `_section` alias splits the result into
302
+ * a headed table per distinct value, in the order the rows arrive — so the
303
+ * authored `ORDER BY` decides the section order too, and one query replaces the
304
+ * forty near-identical blocks the language used to require.
305
+ *
306
+ * @param {{columns: string[], rows: object[]}} result - From
307
+ * {@link runSqlQuery}.
308
+ * @param {object} [opts]
309
+ * @param {(ref: string) => boolean} [opts.linkable] - Whether an address can be
310
+ * linked to; defaults to linking any non-empty `_ref`.
311
+ * @param {number} [opts.sectionLevel=2] - Heading level for `_section`.
312
+ * @returns {string} The markdown.
313
+ */
314
+ export function renderSqlTable(result, { linkable = () => true, sectionLevel = 2 } = {}) {
315
+ const { columns, rows } = result;
316
+ if (!columns.length) throw new Error("query selects no rendered column");
317
+
318
+ const groups = [];
319
+ for (const row of rows) {
320
+ const section =
321
+ Object.hasOwn(row, RENDER_ALIASES.section) ?
322
+ String(row[RENDER_ALIASES.section] ?? "")
323
+ : null;
324
+ const last = groups[groups.length - 1];
325
+ if (last && last.section === section) last.rows.push(row);
326
+ else groups.push({ section, rows: [row] });
327
+ }
328
+
329
+ const out = [];
330
+ for (const group of groups) {
331
+ if (group.section !== null) {
332
+ out.push(`${"#".repeat(sectionLevel)} ${group.section}`, "");
333
+ }
334
+ const cells = group.rows.map((row) =>
335
+ columns.map((column, index) => {
336
+ const text = cellText(row[column], column);
337
+ const ref = row[RENDER_ALIASES.ref];
338
+ if (index !== 0 || !ref || !linkable(String(ref))) return text;
339
+ // A wikilink's own separator is a literal `|`, written `\|`
340
+ // inside a table cell, so the label must not carry one.
341
+ return `[[${ref}\\|${text.replace(/\\?\|/g, "/")}]]`;
342
+ }),
343
+ );
344
+ const align = columns.map((_column, index) => {
345
+ const shown = cells.map((row) => row[index]).filter((cell) => cell !== EMPTY_CELL);
346
+ const numeric =
347
+ shown.length > 0 &&
348
+ shown.every((cell) => cell.trim() !== "" && Number.isFinite(Number(cell)));
349
+ return numeric ? "---:" : "---";
350
+ });
351
+ const line = (values) => `| ${values.join(" | ")} |`;
352
+ out.push(line(columns.map(escapeCell)), line(align), ...cells.map(line));
353
+ if (group !== groups[groups.length - 1]) out.push("");
354
+ }
355
+ return out.join("\n");
356
+ }
357
+
358
+ /**
359
+ * Run every `sql` directive in a set of note bodies, ahead of expansion.
360
+ *
361
+ * **Why a separate pass.** DuckDB's API is asynchronous and
362
+ * {@link module:engine/content-tables.expandContentTables} is not — nor should
363
+ * it become so: two of its three callers are synchronous, and one of those,
364
+ * `renderPages`, is exported. Preparing the results first keeps every one of
365
+ * those signatures intact, and it is the shape #243 is heading for anyway —
366
+ * the corpus enumerated once, each pass reading the answer rather than
367
+ * deriving it again.
368
+ *
369
+ * A query that fails is recorded rather than thrown, so one bad directive costs
370
+ * its own table and not the whole build's report.
371
+ *
372
+ * @param {object} db - From {@link openNotesDatabase}.
373
+ * @param {Array<{source: string, markdown: string}>} sources - The bodies to
374
+ * scan.
375
+ * @param {object} [opts]
376
+ * @param {(ref: string) => boolean} [opts.linkable] - Passed to
377
+ * {@link renderSqlTable}.
378
+ * **Keyed by note, then by the directive's ordinal within it** — not by its
379
+ * line. The passes do not agree on what a body is: `walkMarkdownTree` trims it,
380
+ * while the link checker strips the frontmatter fence and leaves the newlines
381
+ * that followed, so the same directive sits on different lines in each. Its
382
+ * position in the sequence of fences is the same in both.
383
+ *
384
+ * @returns {Promise<Map<string, object[]>>} Note to results, in document order,
385
+ * each carrying either a rendered `markdown` and its `rows`, or a `reason`.
386
+ */
387
+ export async function prepareSqlTables(db, sources, { linkable } = {}) {
388
+ const prepared = new Map();
389
+ for (const { source, markdown } of sources) {
390
+ const blocks = findSqlBlocks(markdown);
391
+ if (!blocks.length) continue;
392
+ const forNote = [];
393
+ prepared.set(source, forNote);
394
+ for (const block of blocks) {
395
+ try {
396
+ const result = await runSqlQuery(db, block.query);
397
+ forNote.push({
398
+ markdown: renderSqlTable(result, {
399
+ linkable,
400
+ sectionLevel: block.sectionLevel,
401
+ }),
402
+ rows: result.rows.length,
403
+ allowEmpty: block.allowEmpty,
404
+ });
405
+ } catch (err) {
406
+ forNote.push({
407
+ reason: String(err?.message ?? err).split("\n")[0],
408
+ allowEmpty: block.allowEmpty,
409
+ });
410
+ }
411
+ }
412
+ }
413
+ return prepared;
414
+ }
415
+
416
+ /**
417
+ * Answer every `sql` directive in a content tree.
418
+ *
419
+ * The one entry point each pass uses, so the compiler, the link checker and the
420
+ * site build cannot disagree about what a table selects — the failure mode #243
421
+ * describes, where N passes each derive the corpus their own way.
422
+ *
423
+ * **Nothing is opened for a tree with no `sql` directive.** The corpus is still
424
+ * written entirely in the retiring language, so until a table is converted this
425
+ * costs one walk and no database at all — which is what lets every pass call it
426
+ * unconditionally.
427
+ *
428
+ * @param {string} contentBase - Root of the content tree.
429
+ * @param {object} [opts]
430
+ * @param {object} [opts.config] - Resolved configuration, defaulting to ambient.
431
+ * @param {readonly string[]} [opts.skipDirectories] - The walk's scope.
432
+ * @param {object[]} [opts.records] - Index records the caller already derived.
433
+ * A command that also builds a link index holds them already, and deriving
434
+ * them twice is the duplicated-corpus failure #243 is closing.
435
+ * @returns {Promise<Map<string, object[]>|undefined>} Results by note path, or
436
+ * nothing when the tree has no such directive.
437
+ */
438
+ export async function prepareTreeSqlTables(contentBase, { config, skipDirectories, records } = {}) {
439
+ // Imported here rather than at module scope: the index reaches the pack
440
+ // compilers through `manifest-emit` → `journals`, so a static import from a
441
+ // module they load would close a cycle and leave `BasePackCompiler`
442
+ // uninitialised for whichever module the runtime happened to load first.
443
+ const { indexRecordsFor } = await import("./content-index.mjs");
444
+ const indexRecords = records ?? indexRecordsFor({ contentBase, config, skipDirectories });
445
+
446
+ // Which notes carry a directive, discovered over the same corpus every
447
+ // other pass reads rather than over a walk of this one's own (#243). The
448
+ // body has to be read to find a fence — the index carries no note text —
449
+ // but *which files* to read is no longer a second answer.
450
+ //
451
+ // `parseMarkdownFile` yields the body `walkMarkdownTree` yielded, trimmed
452
+ // the same way, which matters: results are keyed by note and looked up by
453
+ // the ordinal of the directive within it, so the two readings have to agree
454
+ // about what a body is.
455
+ const sources = [];
456
+ for (const record of indexRecords) {
457
+ if (!isNoteRecord(record)) continue;
458
+ const absPath = noteFile(contentBase, record);
459
+ const { body } = parseMarkdownFile(absPath);
460
+ if (body && findSqlBlocks(body).length) sources.push({ source: absPath, markdown: body });
461
+ }
462
+ if (!sources.length) return undefined;
463
+ // A cell links only where the address it would emit resolves, so a table
464
+ // never ships a link the wikilink pass will then report dead.
465
+ const addresses = new Set(indexRecords.map((record) => record.address?.slug).filter(Boolean));
466
+ // Each declared dependency's published index, attached as its own schema so
467
+ // a table can read `FROM <package>.notes` (#246). Imported here for the
468
+ // same cycle reason the index is, and tolerated when absent: a tree with no
469
+ // `sql` directive never reaches this line, and one whose dependency has not
470
+ // been fetched already fails earlier with a message naming the fetch.
471
+ let dependencies = [];
472
+ try {
473
+ const { cachedMetadataIndexes } = await import("./metadata-index.mjs");
474
+ const { loadPackConfig } = await import("./pack-config.mjs");
475
+ dependencies = cachedMetadataIndexes(config ?? loadPackConfig());
476
+ } catch {
477
+ dependencies = [];
478
+ }
479
+ const db = await openNotesDatabase(indexRecords, { dependencies });
480
+ try {
481
+ return await prepareSqlTables(db, sources, { linkable: (ref) => addresses.has(ref) });
482
+ } finally {
483
+ await db.close();
484
+ }
485
+ }
@@ -0,0 +1,102 @@
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
+ * Which note-type → document-subtype maps this toolchain ships, and the two
16
+ * questions asked of the *list* rather than of any one map.
17
+ *
18
+ * One frozen list, and the two lookups that need to choose among its members
19
+ * before a map can be consulted at all. It lived in `note-claims.mjs` until #270,
20
+ * which is where the *questions* asked of it live — but that module imports
21
+ * half the engine, so anything needing the bare list had to take all of it, and
22
+ * `helpers.mjs` could not take it at all: `note-claims.mjs` imports
23
+ * `walkMarkdownTree` from there, so the dependency would have closed a cycle.
24
+ *
25
+ * That is the same reason `engine/ids.mjs` is its own module, and the same
26
+ * reason {@link module:engine/document-subtypes.systemOf} requires its maps as
27
+ * an argument rather than defaulting to them: a leaf with no local imports can
28
+ * be depended on from anywhere. `note-claims.mjs` re-exports this so no
29
+ * consumer had to move.
30
+ *
31
+ * The one local import is `document-subtypes.mjs`, which is the mechanism these
32
+ * maps are instances of — every module that reaches this one already has it,
33
+ * so nothing new is dragged along and no cycle is opened.
34
+ *
35
+ * @module
36
+ */
37
+
38
+ import { referencedSubtype } from "./document-subtypes.mjs";
39
+ import { SOHL_DOCUMENT_SUBTYPES } from "../sohl/document-subtypes.mjs";
40
+ import { HM3_DOCUMENT_SUBTYPES } from "../hm3/document-subtypes.mjs";
41
+
42
+ /**
43
+ * The note-type → document-subtype maps this toolchain ships.
44
+ *
45
+ * Two, since `hm3/` landed (#139) — and it joined this list rather than the
46
+ * claim table growing a second copy of the same fact, which is what the list
47
+ * was for.
48
+ *
49
+ * The union is what makes the vocabulary wider than any one repository's
50
+ * configuration: `armorlocation` is a real content type because HM3 maps it,
51
+ * however a given repository is configured, so a tree full of them is a
52
+ * repository that has not finished configuring itself rather than an author who
53
+ * invented a word.
54
+ *
55
+ * `engine/` importing from `sohl/` is the arrangement `generate.mjs` already
56
+ * has — its `COMPILERS` table names the SoHL compilers by class — and for the
57
+ * same reason: the engine owns the *mechanism* that asks each system what it
58
+ * compiles, and the systems own the answers.
59
+ *
60
+ * @type {readonly import("./document-subtypes.mjs").DocumentSubtypeMap[]}
61
+ */
62
+ export const KNOWN_DOCUMENT_SUBTYPE_MAPS = Object.freeze([
63
+ SOHL_DOCUMENT_SUBTYPES,
64
+ HM3_DOCUMENT_SUBTYPES,
65
+ ]);
66
+
67
+ /**
68
+ * The map one system ships, by its id.
69
+ *
70
+ * @param {string|undefined} system - The system id (`"sohl"`, `"hm3"`).
71
+ * @returns {import("./document-subtypes.mjs").DocumentSubtypeMap|undefined} Its
72
+ * map, or `undefined` where this toolchain ships none for it.
73
+ */
74
+ export function subtypeMapFor(system) {
75
+ if (!system) return undefined;
76
+ return KNOWN_DOCUMENT_SUBTYPE_MAPS.find((map) => map.system === system);
77
+ }
78
+
79
+ /**
80
+ * The document subtype a note type compiles into for one system — the
81
+ * translation the *schema* check needs, and the reason it needs one.
82
+ *
83
+ * A schema artifact is keyed by document subtype; a field declaration is keyed
84
+ * by note type. Those were the same string until #78 renamed three of them, and
85
+ * a check that went on joining them by name simply stopped reporting on
86
+ * `armorgear` — a warning that vanishes is worse than one that fires, because
87
+ * nothing says it went.
88
+ *
89
+ * Falls back to the note type on a one-to-many row, where nothing but a note
90
+ * can say which subtype is meant and this has none: that is the answer the
91
+ * identity default already gave, and HM3's four such rows all name the note
92
+ * type among their subtypes.
93
+ *
94
+ * @param {string|undefined} system - The system whose schema is being checked.
95
+ * @param {string} type - The note type a field declaration is keyed by.
96
+ * @returns {string} The document subtype to look up.
97
+ */
98
+ export function schemaSubtypeOf(system, type) {
99
+ const map = subtypeMapFor(system);
100
+ if (!map) return type;
101
+ return referencedSubtype(map, type, "Item").subType ?? type;
102
+ }