@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
@@ -22,7 +22,7 @@
22
22
  * disagree without anything detecting it, which the canonical-separator
23
23
  * handling already did once on each side.
24
24
  *
25
- * Three rules, all about a note's identity:
25
+ * Four rules, all about a note's identity:
26
26
  *
27
27
  * 1. **Shape** — a `shortcode` is strictly ASCII-alphanumeric. It is the
28
28
  * identity key referenced from saved world data, and it is half of the
@@ -34,6 +34,11 @@
34
34
  * reason the other two do: it is a statement about which note holds which
35
35
  * address, it needs no `site:` configuration to decide, and a package with
36
36
  * no front page is misconfigured whether or not anyone runs a site build.
37
+ * 4. **Vacated addresses** — a `renamedFrom:` entry names an address this note
38
+ * used to hold and nothing holds now (#278). It is the same statement as
39
+ * rule 2 read backwards, and it needs the same whole-tree view: an entry can
40
+ * only be checked against every *other* note's address, and two notes
41
+ * claiming one predecessor is the uniqueness rule applied to the past.
37
42
  *
38
43
  * **Nothing here writes.** A check reports and an author fixes.
39
44
  *
@@ -61,8 +66,11 @@ import path from "node:path";
61
66
 
62
67
  import { ADDRESS_SEGMENT_PATTERN } from "./address-charset.mjs";
63
68
  import { positionInFrontmatter } from "./diagnostics.mjs";
64
- import { walkMarkdownTree } from "./helpers.mjs";
69
+ import { assertStatedScope } from "./helpers.mjs";
70
+ // The corpus, read from the one pass that derives it (#243).
71
+ import { authoredFrontmatter, indexRecordsFor, isNoteRecord, noteFile } from "./content-index.mjs";
65
72
  import { checkHomepageCount, isHomepage } from "./homepage.mjs";
73
+ import { declaresRenamedFrom, renamedFrom, renamedFromEntries } from "./note-renames.mjs";
66
74
 
67
75
  /**
68
76
  * The shape every `shortcode` must match: ASCII letters and digits only.
@@ -98,33 +106,164 @@ export function isValidShortcode(value) {
98
106
  }
99
107
 
100
108
  /**
101
- * Collect the notes a lint pass reasons about.
109
+ * Collect the notes a lint pass reasons about, from the content index.
102
110
  *
103
111
  * Only notes carrying a `type` are content notes. Vault scaffolding —
104
112
  * `Templates/`, a `README`, a repository's own `CLAUDE.md` — has no type, is
105
113
  * neither addressed nor addressable, and would fail rules it can never satisfy.
106
114
  *
115
+ * **Read from the index, not from a walk of this pass's own** (#243). The
116
+ * `lint` command already derives the index — its link check and its `sql`
117
+ * tables are built from it — and then walked the tree a second time to get
118
+ * here, so one command held two answers to "which files are the corpus?" and
119
+ * compared findings drawn from both. It now holds one: the records are derived
120
+ * once by the command and handed to every pass, this one included.
121
+ *
122
+ * The frontmatter is the note's own, recovered with
123
+ * {@link module:engine/content-index.authoredFrontmatter} — a lint of what an
124
+ * author wrote must not be handed the keys the index derived, or it would
125
+ * report `address:` and `anchors:` as fields nobody may write.
126
+ *
107
127
  * @param {string} contentBase - Root of the content tree.
108
128
  * @param {object} [opts]
109
- * @param {readonly string[]} [opts.skipDirectories] - Passed to the walk.
129
+ * @param {readonly string[]} [opts.skipDirectories] - The corpus scope, stated
130
+ * by the caller.
131
+ * @param {object} [opts.config] - The resolved build configuration.
132
+ * @param {readonly object[]} [opts.records] - Records the caller derived.
133
+ * @param {object[]} [opts.problems] - Collects the notes the index cannot
134
+ * record, so one of them does not silence the lint.
110
135
  * @returns {Array<{fm: object, absPath: string, file: string}>} The notes, in
111
136
  * path order so findings read top to bottom.
112
137
  */
113
- function collectNotes(contentBase, { skipDirectories } = {}) {
138
+ function collectNotes(contentBase, { skipDirectories, config, records, problems } = {}) {
114
139
  const notes = [];
115
- const walkOpts = skipDirectories ? { skipDirectories } : undefined;
116
- for (const { frontmatter: fm, absPath } of walkMarkdownTree(contentBase, walkOpts)) {
117
- if (!fm || !fm.type) continue;
140
+ const corpus =
141
+ records ??
142
+ (assertStatedScope(skipDirectories, "collectNotes"),
143
+ fs.existsSync(contentBase) ?
144
+ indexRecordsFor({ contentBase, config, skipDirectories, problems })
145
+ : []);
146
+
147
+ for (const record of corpus) {
148
+ // A documentation journal is a document this tree emits, not a note in
149
+ // it: it has no authored frontmatter for a lint to reason about.
150
+ if (!isNoteRecord(record) || !record.type) continue;
151
+ const absPath = noteFile(contentBase, record);
118
152
  notes.push({
119
- fm,
153
+ fm: authoredFrontmatter(record),
120
154
  absPath,
121
155
  file: path.relative(process.cwd(), absPath),
122
156
  });
123
157
  }
158
+ // The records already come in content-path order, so this only re-states
159
+ // the guarantee findings depend on: they read top to bottom.
124
160
  notes.sort((a, b) => (a.absPath < b.absPath ? -1 : 1));
125
161
  return notes;
126
162
  }
127
163
 
164
+ /**
165
+ * What one note's `renamedFrom:` says, checked against itself (#278).
166
+ *
167
+ * The entries a note can be wrong about on its own: a value that is not a
168
+ * shortcode, one naming the address the note holds *now*, one written twice.
169
+ * Every one of them is silent without a check — a malformed entry is skipped by
170
+ * the diff, so the author who wrote it sees the rename they were trying to
171
+ * announce reported as a withdrawal anyway, with nothing saying why.
172
+ *
173
+ * The cross-note questions are not here, because one note cannot answer them:
174
+ * whether an entry names an address some *other* note still publishes, and
175
+ * whether two notes claim one predecessor, both need the whole tree and are
176
+ * asked in {@link lintContentTree} once it has one.
177
+ *
178
+ * @param {object} note - The note, as {@link collectNotes} yields it.
179
+ * @param {() => string} raw - Reads the file, deferred so a clean note costs
180
+ * nothing.
181
+ * @returns {Array<object>} The findings.
182
+ */
183
+ function checkRenamedFrom({ fm, file }, raw) {
184
+ if (!declaresRenamedFrom(fm)) return [];
185
+ const findings = [];
186
+ const at = (value) => positionInFrontmatter(raw(), "renamedFrom", value);
187
+ const shortcode = typeof fm.shortcode === "string" ? fm.shortcode.trim() : "";
188
+
189
+ // A rename is a statement about where this note's address moved *to*, so a
190
+ // note with no address of its own has made no such statement. Reported
191
+ // before the entries: telling the author their entries are fine would be
192
+ // the less useful half of the answer.
193
+ if (!shortcode) {
194
+ return [
195
+ {
196
+ file,
197
+ ...at(undefined),
198
+ severity: "error",
199
+ message:
200
+ "`renamedFrom` names the address this note used to hold, " +
201
+ "but the note declares no `shortcode`, so it holds none now " +
202
+ "and nothing was renamed",
203
+ },
204
+ ];
205
+ }
206
+
207
+ const seen = new Set();
208
+ for (const entry of renamedFromEntries(fm)) {
209
+ // Not `String(entry)`: the point is that the author wrote something
210
+ // that is not a shortcode, and rendering a list as `a,b` would show
211
+ // them a string they never typed.
212
+ if (typeof entry !== "string" || !entry.trim()) {
213
+ findings.push({
214
+ file,
215
+ ...at(undefined),
216
+ severity: "error",
217
+ message:
218
+ `\`renamedFrom\` takes shortcodes, and one entry is ` +
219
+ `${entry === "" || (typeof entry === "string" && !entry.trim()) ? "blank" : `a ${typeof entry}`}; ` +
220
+ `it is skipped, so the rename it was meant to announce is ` +
221
+ `still reported as a withdrawal`,
222
+ });
223
+ continue;
224
+ }
225
+ const value = entry.trim();
226
+ if (!isValidShortcode(value)) {
227
+ findings.push({
228
+ file,
229
+ ...at(value),
230
+ severity: "error",
231
+ message:
232
+ `\`renamedFrom: ${value}\` is not strictly alphanumeric, so ` +
233
+ `it is not an address this package ever published — a ` +
234
+ `shortcode is held to one charset whether it is current or past`,
235
+ });
236
+ continue;
237
+ }
238
+ if (value === shortcode) {
239
+ findings.push({
240
+ file,
241
+ ...at(value),
242
+ severity: "error",
243
+ message:
244
+ `\`renamedFrom: ${value}\` is this note's own shortcode, so ` +
245
+ `it declares a rename from itself; name the shortcode it ` +
246
+ `was published under before, or drop the key`,
247
+ });
248
+ continue;
249
+ }
250
+ if (seen.has(value)) {
251
+ findings.push({
252
+ file,
253
+ ...at(value),
254
+ // The declaration still works — the reader de-duplicates — so
255
+ // this is tidiness, and failing a build over it would red a
256
+ // tree whose renames are all correctly announced.
257
+ severity: "warning",
258
+ message: `\`renamedFrom: ${value}\` is listed twice; the repeat says nothing new`,
259
+ });
260
+ continue;
261
+ }
262
+ seen.add(value);
263
+ }
264
+ return findings;
265
+ }
266
+
128
267
  /**
129
268
  * Lint every address in a content tree.
130
269
  *
@@ -134,26 +273,58 @@ function collectNotes(contentBase, { skipDirectories } = {}) {
134
273
  * ignores. Defaults to the configured list.
135
274
  * @param {string} [opts.contentPackage] - The package this tree builds, for the
136
275
  * homepage rule. Dropped from that finding when unknown rather than guessed.
276
+ * @param {object} [opts.config] - The resolved build configuration, which the
277
+ * corpus is derived against.
278
+ * @param {readonly object[]} [opts.records] - Index records the caller already
279
+ * derived, so a command reads one corpus (#243).
280
+ * @param {object[]} [opts.problems] - Collects the notes the index cannot
281
+ * record, instead of letting one of them silence the lint.
137
282
  * @returns {{findings: Array<{file: string, line?: number, column?: number,
138
283
  * severity: "error"|"warning", message: string}>, notes: number,
139
284
  * keys: number}} The findings, and what was inspected to produce them.
140
285
  */
141
- export function lintContentTree(contentBase, { skipDirectories, contentPackage } = {}) {
286
+ export function lintContentTree(
287
+ contentBase,
288
+ { skipDirectories, contentPackage, config, records, problems } = {},
289
+ ) {
142
290
  const findings = [];
143
- const notes = collectNotes(contentBase, { skipDirectories });
291
+ const notes = collectNotes(contentBase, { skipDirectories, config, records, problems });
144
292
 
145
293
  /** @type {Map<string, Array<{file: string, absPath: string}>>} */
146
294
  const byKey = new Map();
295
+ /** @type {Map<string, Array<{file: string, absPath: string, shortcode: string}>>} */
296
+ const claimedPredecessors = new Map();
147
297
 
148
- for (const { fm, absPath, file } of notes) {
298
+ for (const note of notes) {
299
+ const { fm, absPath, file } = note;
149
300
  const shortcode = fm.shortcode;
150
- // Folder documents and keyless entries carry no address at all.
151
- if (!shortcode) continue;
152
301
 
153
302
  // Read only when there is something to say about the note, so a clean
154
303
  // tree costs one pass rather than two.
155
304
  const raw = () => fs.readFileSync(absPath, "utf8");
156
305
 
306
+ // Before the keyless `continue` below, because a note declaring a
307
+ // rename while carrying no address of its own is exactly one of the
308
+ // things this reports — and reaching it after the skip would mean it
309
+ // never ran on the case that needs it most.
310
+ findings.push(...checkRenamedFrom(note, raw));
311
+ if (shortcode) {
312
+ // The de-duplicated reader, not the raw entries: a note that listed
313
+ // one predecessor twice has made one claim, and indexing it twice
314
+ // would make the note collide with itself and be reported as two
315
+ // notes claiming one address.
316
+ for (const value of renamedFrom(fm)) {
317
+ if (value === shortcode) continue;
318
+ const claim = `${fm.type}:${value}`;
319
+ const seen = claimedPredecessors.get(claim);
320
+ if (seen) seen.push({ file, absPath, shortcode });
321
+ else claimedPredecessors.set(claim, [{ file, absPath, shortcode }]);
322
+ }
323
+ }
324
+
325
+ // Folder documents and keyless entries carry no address at all.
326
+ if (!shortcode) continue;
327
+
157
328
  const key = `${fm.type}:${shortcode}`;
158
329
  const seen = byKey.get(key);
159
330
  if (seen) seen.push({ file, absPath });
@@ -230,5 +401,52 @@ export function lintContentTree(contentBase, { skipDirectories, contentPackage }
230
401
  }
231
402
  }
232
403
 
404
+ // The two questions about a declared rename that need the whole tree
405
+ // (#278). Both are the uniqueness rule above, applied to the past: an
406
+ // address has one holder, so it has one successor and it cannot be both
407
+ // vacated and occupied.
408
+ for (const [claim, claimants] of claimedPredecessors) {
409
+ const live = byKey.get(claim);
410
+ if (live) {
411
+ for (const { file, absPath } of claimants) {
412
+ findings.push({
413
+ file,
414
+ ...positionInFrontmatter(
415
+ fs.readFileSync(absPath, "utf8"),
416
+ "renamedFrom",
417
+ claim.slice(claim.indexOf(":") + 1),
418
+ ),
419
+ severity: "error",
420
+ message:
421
+ `\`renamedFrom\` claims "${claim}", which ` +
422
+ `${live.map((f) => f.file).join(", ")} still publishes; ` +
423
+ `that address was never vacated, so nothing was renamed ` +
424
+ `away from it`,
425
+ });
426
+ }
427
+ }
428
+ if (claimants.length < 2) continue;
429
+ // Named on every claimant rather than once on the address, for the same
430
+ // reason a duplicate address is: each is a file an author has to open,
431
+ // and a finding naming only the address sends them hunting for the rest.
432
+ for (const { file, absPath, shortcode } of claimants) {
433
+ const others = claimants.filter((c) => c.file !== file);
434
+ findings.push({
435
+ file,
436
+ ...positionInFrontmatter(
437
+ fs.readFileSync(absPath, "utf8"),
438
+ "renamedFrom",
439
+ claim.slice(claim.indexOf(":") + 1),
440
+ ),
441
+ severity: "error",
442
+ message:
443
+ `"${claim}" is claimed as a predecessor by more than one ` +
444
+ `note — this one (now "${shortcode}") and ` +
445
+ `${others.map((c) => `${c.file} (now "${c.shortcode}")`).join(", ")}; ` +
446
+ `an address had one holder, so it has one successor`,
447
+ });
448
+ }
449
+ }
450
+
233
451
  return { findings, notes: notes.length, keys: byKey.size };
234
452
  }
@@ -28,7 +28,8 @@ import { loadPackConfig } from "./pack-config.mjs";
28
28
  * The **content** package: the distribution unit this repository's notes belong
29
29
  * to, and the **address namespace** every one of them is published under.
30
30
  *
31
- * It is the first segment of every canonical key (`sohl-skill-clmb`), the name
31
+ * It is the first segment of every canonical key
32
+ * (`package-system-type-shortcode`, so `sohl-none-doc-gear`), the name
32
33
  * of the link manifest this build emits (`sohl.json`), and the package a
33
34
  * cross-package wikilink writes to reach one of these notes. So it is the
34
35
  * repository's identity in the address space, not a switch — and never dead
@@ -23,7 +23,7 @@
23
23
  * TABLE WITHOUT ID
24
24
  * link(file.path, name.full) AS "Name",
25
25
  * sohl.weight AS "Weight"
26
- * WHERE type = "armorgear" and sohl.material = "Cloth"
26
+ * WHERE type = "armor" and sohl.material = "Cloth"
27
27
  * SORT name.full ASC
28
28
  * ```
29
29
  *
@@ -1087,11 +1087,42 @@ export function renderContentTable(spec, rows, linkable, self) {
1087
1087
  * body can name an authored position (#17). An `errors` entry carries the
1088
1088
  * 0-based line of the directive that failed, for the same reason.
1089
1089
  */
1090
+ /**
1091
+ * The `WHERE` clause of a query, as authored, for a message that has to name
1092
+ * what matched nothing.
1093
+ *
1094
+ * The parsed spec holds an expression tree, and rendering that back to text
1095
+ * would be a second dialect of the query language to keep in step. The authored
1096
+ * line is what the author wrote and what they will edit, so it is what the
1097
+ * finding quotes.
1098
+ *
1099
+ * @param {string} query - The fence's contents.
1100
+ * @returns {string} The clause, or "" when the query has none.
1101
+ */
1102
+ function whereText(query) {
1103
+ const match = /^\s*WHERE\s+(.+?)\s*$/im.exec(String(query ?? ""));
1104
+ return match ? `\`${match[1]}\`` : "";
1105
+ }
1106
+
1090
1107
  export function expandContentTables(
1091
1108
  markdown,
1092
- { docs = [], linkable = () => false, source = "", self = undefined } = {},
1109
+ {
1110
+ docs = [],
1111
+ linkable = () => false,
1112
+ source = "",
1113
+ self = undefined,
1114
+ sqlTables = undefined,
1115
+ } = {},
1093
1116
  ) {
1094
1117
  const errors = [];
1118
+ // How many `sql` directives have been seen, which is how a prepared
1119
+ // result is found: the passes disagree about a body's leading whitespace,
1120
+ // so a line number would not survive the trip, and an ordinal does.
1121
+ let sqlOrdinal = 0;
1122
+ // One entry per `dataview` directive still authored, so a caller can say how
1123
+ // much of the corpus is still on the retiring language without this pass
1124
+ // failing a build over it (#246).
1125
+ const warnings = [];
1095
1126
  const lines = String(markdown ?? "").split("\n");
1096
1127
  const out = [];
1097
1128
  // Which authored line each emitted line came from, so a later pass can
@@ -1125,17 +1156,82 @@ export function expandContentTables(
1125
1156
  const closer = new RegExp(`^[ \\t]*${marker[0]}{${marker.length},}[ \\t]*$`);
1126
1157
  while (close < lines.length && !closer.test(lines[close])) close++;
1127
1158
  const isQuery = /^dataview\b/i.test(info.trim());
1159
+ const isSql = /^sql\b/i.test(info.trim());
1128
1160
  const block = lines.slice(i, Math.min(close + 1, lines.length));
1161
+
1162
+ // A `sql` directive was run before this pass began — DuckDB is async and
1163
+ // this is not (see `prepareSqlTables`). What is left here is splicing
1164
+ // the rendered table in at the directive's own position.
1165
+ if (isSql && close < lines.length) {
1166
+ const prepared = sqlTables?.[sqlOrdinal];
1167
+ sqlOrdinal += 1;
1168
+ const failure =
1169
+ !prepared ?
1170
+ "sql content table was not prepared — this pass was given no " +
1171
+ "`sqlTables`, so the query could not be run"
1172
+ : prepared.reason ? prepared.reason
1173
+ : prepared.rows === 0 && !prepared.allowEmpty ?
1174
+ "sql query selects no notes — write ```sql allow-empty if " + "that is intended"
1175
+ : undefined;
1176
+ if (failure) {
1177
+ errors.push({
1178
+ source,
1179
+ directive: block.join("\n"),
1180
+ reason: failure,
1181
+ line: i,
1182
+ column: indent.length + 1,
1183
+ });
1184
+ block.forEach((text, k) => emit(text, i + k));
1185
+ i = close;
1186
+ continue;
1187
+ }
1188
+ if (out.length > 0 && out[out.length - 1].trim() !== "") emit("", i, true);
1189
+ for (const row of prepared.markdown.split("\n")) emit(`${indent}${row}`, i, true);
1190
+ if (close + 1 < lines.length && lines[close + 1].trim() !== "") emit("", i, true);
1191
+ i = close;
1192
+ continue;
1193
+ }
1194
+
1129
1195
  if (!isQuery || close >= lines.length) {
1130
1196
  block.forEach((text, k) => emit(text, i + k));
1131
1197
  i = close;
1132
1198
  continue;
1133
1199
  }
1134
1200
  const query = lines.slice(i + 1, close).join("\n");
1201
+ warnings.push({
1202
+ source,
1203
+ line: i,
1204
+ column: indent.length + 1,
1205
+ reason:
1206
+ "`dataview` content tables are being replaced by `sql` over the " +
1207
+ "content index (#246)",
1208
+ });
1209
+ // `dataview allow-empty` says a table selecting nothing is the intended
1210
+ // state. Spelled on the fence rather than in the query, because it is a
1211
+ // statement about this *directive* and not part of the query language.
1212
+ const allowEmpty = /\ballow-empty\b/i.test(info);
1135
1213
  let table;
1136
1214
  try {
1137
1215
  const spec = parseDataviewQuery(query);
1138
1216
  const rows = selectRows(spec, docs, self);
1217
+ // A table that selects nothing is almost always a stale query — a
1218
+ // renamed type, a retired category, a typo'd path — and publishing
1219
+ // a header with nothing under it makes that indistinguishable from
1220
+ // a category that is legitimately empty. Eight tables in `sohl`'s
1221
+ // `Rules/Bestiary.md` published that way for months after the
1222
+ // `creature` → `being` rename, and no build said a word (#223).
1223
+ if (rows.length === 0 && !allowEmpty) {
1224
+ errors.push({
1225
+ source,
1226
+ directive: block.join("\n"),
1227
+ reason:
1228
+ `dataview query selects no notes` +
1229
+ `${whereText(query) ? `: nothing matches ${whereText(query)}` : ""}` +
1230
+ ` — write \`\`\`dataview allow-empty if that is intended`,
1231
+ line: i,
1232
+ column: indent.length + 1,
1233
+ });
1234
+ }
1139
1235
  table = renderContentTable(spec, rows, linkable, self);
1140
1236
  } catch (err) {
1141
1237
  errors.push({
@@ -1159,5 +1255,5 @@ export function expandContentTables(
1159
1255
  }
1160
1256
  i = close;
1161
1257
  }
1162
- return { markdown: out.join("\n"), errors, lineMap };
1258
+ return { markdown: out.join("\n"), errors, warnings, lineMap };
1163
1259
  }
@@ -64,7 +64,10 @@
64
64
  * @module
65
65
  */
66
66
 
67
- import { assertTypeNotRetired } from "./ids.mjs";
67
+ import { assertTypeNotRetired, currentType } from "./ids.mjs";
68
+ import { NO_SYSTEM } from "./systems.mjs";
69
+
70
+ export { NO_SYSTEM };
68
71
  import { locateFrontmatterKey } from "./retired-fields.mjs";
69
72
 
70
73
  /**
@@ -199,6 +202,13 @@ function frozenRow(system, noteType, row) {
199
202
  /**
200
203
  * The row a system declares for a note type, or nothing.
201
204
  *
205
+ * The **one** place a map is indexed, so the retirement window for a renamed
206
+ * type is honoured everywhere a row is asked for — {@link documentSubtype},
207
+ * {@link mapsNoteType}, {@link noteTypesFor}'s consumers and
208
+ * {@link systemOf} all arrive here. A note still spelling `armorgear` finds the
209
+ * `armor` row, compiles into the `armorgear` document it always did, and keeps
210
+ * the address it publishes at; only the *report* tells it to move (#78).
211
+ *
202
212
  * @param {DocumentSubtypeMap} map - The system's map.
203
213
  * @param {string|undefined} noteType - The note's declared `type`.
204
214
  * @returns {Readonly<DocumentSubtypeRow>|undefined} The row, or `undefined`
@@ -206,7 +216,7 @@ function frozenRow(system, noteType, row) {
206
216
  */
207
217
  export function subtypeRow(map, noteType) {
208
218
  if (!noteType || typeof noteType !== "string") return undefined;
209
- return map?.types?.[noteType];
219
+ return map?.types?.[currentType(noteType)];
210
220
  }
211
221
 
212
222
  /**
@@ -339,9 +349,17 @@ export function documentSubtype(map, noteType, fm, { file, absPath } = {}) {
339
349
  * whole point of the issue.
340
350
  *
341
351
  * A **retired** spelling is refused by name before any of that. Without it a
342
- * reference left behind by a rename would take the unmapped fallback and
343
- * address a document of the old name — resolving silently, which is precisely
344
- * what a retirement exists to stop (#78).
352
+ * reference left behind by a merge would take the unmapped fallback and address
353
+ * a document of the old name — resolving silently, which is precisely what a
354
+ * retirement exists to stop.
355
+ *
356
+ * A **renamed** spelling is the opposite case and resolves normally, through
357
+ * the same {@link currentType} every other lookup goes through (#78). It has to
358
+ * be this side as much as the note's own `type:`: the overwhelming majority of
359
+ * the 31,000 occurrences of the old names are `(type, shortcode)` references
360
+ * inside a being's `items:` list, so a window that resolved notes but not
361
+ * references would silently drop 30,000 embedded items rather than compile them
362
+ * unchanged.
345
363
  *
346
364
  * @param {DocumentSubtypeMap} map - The system's map.
347
365
  * @param {string|undefined} noteType - The type the reference names.
@@ -361,7 +379,9 @@ export function referencedSubtype(map, noteType, document) {
361
379
 
362
380
  const row = subtypeRow(map, noteType);
363
381
  // No row: the type is the consumer's own, and its document is stamped with
364
- // the note type. See the note on the unmapped fallback above.
382
+ // the note type. See the note on the unmapped fallback above. Returned as
383
+ // *authored*, not normalised — the Item pass stamps an unmapped document
384
+ // with `fm.type` verbatim, and the two answers have to agree.
365
385
  if (!row) return { subType: noteType };
366
386
 
367
387
  if (row.document !== document) {
@@ -438,3 +458,26 @@ function list(values) {
438
458
  if (quoted.length <= 1) return quoted.join("");
439
459
  return `${quoted.slice(0, -1).join(", ")} or ${quoted[quoted.length - 1]}`;
440
460
  }
461
+
462
+ /**
463
+ * Which system defines the document a note of this type compiles into.
464
+ *
465
+ * A journal, a macro, a scene and an item's documentation journal belong to no
466
+ * system and answer {@link NO_SYSTEM}; an item or an actor belongs to whichever
467
+ * system's map claims its note type.
468
+ *
469
+ * `maps` is required rather than defaulted, so this stays reachable from any
470
+ * pass: the registry of maps lives in `note-claims.mjs`, which imports half the
471
+ * engine, and defaulting to it here would drag that in wherever an address is
472
+ * built.
473
+ *
474
+ * @param {string} type - The note's `type`.
475
+ * @param {readonly object[]} maps - The document-subtype maps this build ships.
476
+ * @returns {string} The system id, or {@link NO_SYSTEM}.
477
+ */
478
+ export function systemOf(type, maps) {
479
+ for (const map of maps ?? []) {
480
+ if (subtypeRow(map, type)) return map.system;
481
+ }
482
+ return NO_SYSTEM;
483
+ }
@@ -170,7 +170,7 @@ function workedExample(type, fields) {
170
170
  // this example is the smallest note that compiles.
171
171
  "id: <16-character id>",
172
172
  "sohl:",
173
- " archetype: null",
173
+ " templatePriority: null",
174
174
  ];
175
175
  for (const field of required) {
176
176
  lines.push(` ${field.name}: <${field.shape ?? "value"}>`);