@heroiclands/package-build 21.0.0 → 21.2.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 (112) hide show
  1. package/CHANGELOG.md +228 -0
  2. package/CONTENT.md +109 -7
  3. package/assets/fonts/libertinus/LibertinusMono-Regular.otf +0 -0
  4. package/assets/fonts/libertinus/LibertinusSans-Bold.otf +0 -0
  5. package/assets/fonts/libertinus/LibertinusSans-Italic.otf +0 -0
  6. package/assets/fonts/libertinus/LibertinusSans-Regular.otf +0 -0
  7. package/assets/fonts/libertinus/OFL.txt +94 -0
  8. package/assets/fonts/libertinus/provenance.yaml +11 -0
  9. package/assets/images/banners/afflictionbnr.webp +0 -0
  10. package/assets/images/banners/armorclothingbnr.webp +0 -0
  11. package/assets/images/banners/attributebnr.webp +0 -0
  12. package/assets/images/banners/containerbnr.webp +0 -0
  13. package/assets/images/banners/devdocsbnr.webp +0 -0
  14. package/assets/images/banners/miscgearbnr.webp +0 -0
  15. package/assets/images/banners/mysticalabilitybnr.webp +0 -0
  16. package/assets/images/banners/projectilebnr.webp +0 -0
  17. package/assets/images/banners/rulesbnr.webp +0 -0
  18. package/assets/images/banners/skillbnr.webp +0 -0
  19. package/assets/images/banners/traumabnr.webp +0 -0
  20. package/assets/images/banners/userguidebnr.webp +0 -0
  21. package/assets/images/banners/weapongearbnr.webp +0 -0
  22. package/assets/images/provenance.yaml +4 -0
  23. package/bin/content-build.mjs +19 -5
  24. package/ci/emit-asset-index.mjs +32 -0
  25. package/content-config.mjs +30 -1
  26. package/docs/api.md +100 -26
  27. package/docs/configuration.md +16 -7
  28. package/docs/content-format.md +512 -109
  29. package/engine/actor-compiler.mjs +58 -9
  30. package/engine/art-fields.mjs +292 -0
  31. package/engine/asset-index.mjs +397 -0
  32. package/engine/asset-types.mjs +192 -0
  33. package/engine/base-compiler.mjs +69 -1
  34. package/engine/bundles.mjs +5 -5
  35. package/engine/compile-corpus.mjs +4 -0
  36. package/engine/content-address.mjs +14 -2
  37. package/engine/content-charset.mjs +5 -1
  38. package/engine/content-embeds.mjs +314 -0
  39. package/engine/content-html.mjs +5 -1
  40. package/engine/content-icons.mjs +5 -1
  41. package/engine/content-images.mjs +26 -5
  42. package/engine/content-index.mjs +68 -33
  43. package/engine/content-links.mjs +164 -24
  44. package/engine/frontmatter-lint.mjs +36 -23
  45. package/engine/helpers.mjs +83 -31
  46. package/engine/index-records.mjs +63 -4
  47. package/engine/index.mjs +15 -0
  48. package/engine/infobox-registry.mjs +50 -3
  49. package/engine/infobox-render.mjs +4 -3
  50. package/engine/infobox.mjs +12 -8
  51. package/engine/item-compiler.mjs +10 -10
  52. package/engine/journals.mjs +3 -0
  53. package/engine/macros.mjs +16 -13
  54. package/engine/map-notes.mjs +29 -22
  55. package/engine/metadata-index.mjs +44 -36
  56. package/engine/note-vocabulary.mjs +71 -29
  57. package/engine/pack-router.mjs +14 -0
  58. package/engine/packagebuild-index.mjs +124 -0
  59. package/engine/packages.mjs +83 -0
  60. package/engine/pathnames.mjs +74 -0
  61. package/engine/pdf-build.mjs +133 -10
  62. package/engine/pdf-render.mjs +41 -41
  63. package/engine/scenes.mjs +12 -9
  64. package/engine/site-build.mjs +43 -16
  65. package/engine/site-index.mjs +10 -1
  66. package/engine/subtype-registry.mjs +21 -0
  67. package/engine/web-wikilinks.mjs +46 -12
  68. package/engine/wikilink-syntax.mjs +20 -2
  69. package/engine/wikilinks.mjs +15 -2
  70. package/hm3/actors.mjs +11 -38
  71. package/hm3/infobox.mjs +1 -1
  72. package/package.json +5 -2
  73. package/packagebuild-metadata.jsonl +13 -0
  74. package/sohl/actors.mjs +12 -86
  75. package/sohl/infobox.mjs +1 -1
  76. package/sohl/note-schemas.mjs +4 -16
  77. package/types/content-config.d.mts +8 -0
  78. package/types/engine/actor-compiler.d.mts +29 -0
  79. package/types/engine/art-fields.d.mts +192 -0
  80. package/types/engine/asset-index.d.mts +92 -0
  81. package/types/engine/asset-types.d.mts +110 -0
  82. package/types/engine/base-compiler.d.mts +42 -1
  83. package/types/engine/compile-corpus.d.mts +4 -1
  84. package/types/engine/content-charset.d.mts +4 -0
  85. package/types/engine/content-embeds.d.mts +218 -0
  86. package/types/engine/content-html.d.mts +4 -0
  87. package/types/engine/content-icons.d.mts +4 -0
  88. package/types/engine/content-images.d.mts +15 -2
  89. package/types/engine/content-index.d.mts +27 -15
  90. package/types/engine/frontmatter-lint.d.mts +11 -14
  91. package/types/engine/helpers.d.mts +7 -2
  92. package/types/engine/index-records.d.mts +44 -3
  93. package/types/engine/index.d.mts +5 -0
  94. package/types/engine/infobox-registry.d.mts +36 -1
  95. package/types/engine/infobox.d.mts +6 -4
  96. package/types/engine/macros.d.mts +9 -5
  97. package/types/engine/map-notes.d.mts +10 -6
  98. package/types/engine/metadata-index.d.mts +2 -17
  99. package/types/engine/note-vocabulary.d.mts +30 -0
  100. package/types/engine/packagebuild-index.d.mts +56 -0
  101. package/types/engine/packages.d.mts +62 -0
  102. package/types/engine/pathnames.d.mts +41 -81
  103. package/types/engine/pdf-build.d.mts +58 -1
  104. package/types/engine/pdf-render.d.mts +3 -3
  105. package/types/engine/scenes.d.mts +2 -1
  106. package/types/engine/site-build.d.mts +5 -1
  107. package/types/engine/site-index.d.mts +4 -1
  108. package/types/engine/subtype-registry.d.mts +20 -0
  109. package/types/engine/web-wikilinks.d.mts +14 -4
  110. package/types/engine/wikilink-syntax.d.mts +13 -1
  111. package/types/engine/wikilinks.d.mts +7 -1
  112. package/types/sohl/actors.d.mts +0 -1
@@ -92,6 +92,7 @@ import path from "node:path";
92
92
  import unidecode from "unidecode";
93
93
 
94
94
  import { metadataFileName } from "./metadata-index.mjs";
95
+ import { collectAssetRecords } from "./asset-index.mjs";
95
96
  import { addressSlug, canonicalKey } from "./content-address.mjs";
96
97
  // One reader for a note's anchors, shared with the link checker and with the
97
98
  // builds that emit a link. Re-exported because this is where callers
@@ -122,9 +123,25 @@ import { loadPackConfig } from "./pack-config.mjs";
122
123
  // The record accessors, which live apart so that a module the compilers load
123
124
  // can read a record without importing this one and closing a cycle.
124
125
  // Re-exported because this is where callers have always addressed them.
125
- import { authoredFrontmatter, DERIVED_KEYS, isNoteRecord, noteFile } from "./index-records.mjs";
126
+ import {
127
+ authoredFrontmatter,
128
+ DERIVED_KEYS,
129
+ isAssetRecord,
130
+ isNoteRecord,
131
+ noteFile,
132
+ recordPath,
133
+ sortKeysDeep,
134
+ } from "./index-records.mjs";
126
135
 
127
- export { authoredFrontmatter, DERIVED_KEYS, isNoteRecord, noteFile };
136
+ export {
137
+ authoredFrontmatter,
138
+ DERIVED_KEYS,
139
+ isAssetRecord,
140
+ isNoteRecord,
141
+ noteFile,
142
+ recordPath,
143
+ sortKeysDeep,
144
+ };
128
145
 
129
146
  /**
130
147
  * The address a wikilink writes to reach a note, or `null` when it has none.
@@ -176,27 +193,6 @@ export function noteAddress(frontmatter, contentPackage) {
176
193
  };
177
194
  }
178
195
 
179
- /**
180
- * Recursively sort an object's keys, so serialization is order-independent.
181
- *
182
- * Arrays keep their order — it is authored — but every object inside one is
183
- * sorted too. Anything that is not a plain object is returned as it is.
184
- *
185
- * @param {unknown} value - The value to normalize.
186
- * @returns {unknown} The value with every plain object's keys in sorted order.
187
- */
188
- export function sortKeysDeep(value) {
189
- if (Array.isArray(value)) return value.map(sortKeysDeep);
190
- if (value === null || typeof value !== "object") return value;
191
- // A Date or any other exotic object would lose itself in a rebuild from
192
- // entries, and YAML frontmatter can produce one.
193
- if (Object.getPrototypeOf(value) !== Object.prototype) return value;
194
- /** @type {Record<string, unknown>} */
195
- const out = {};
196
- for (const key of Object.keys(value).sort()) out[key] = sortKeysDeep(value[key]);
197
- return out;
198
- }
199
-
200
196
  /**
201
197
  * A note's display name reduced to printable 7-bit ASCII.
202
198
  *
@@ -537,12 +533,22 @@ function buildDocRecord({ frontmatter, address, entry, file, contentPackage, anc
537
533
  * An item note yields two records — the item, and the documentation journal
538
534
  * that is a document in its own right.
539
535
  *
536
+ * **The asset roots are walked in the same pass.** A package's addressable files
537
+ * sit beside `content/` rather than inside it, and they publish into the same
538
+ * index under the same address grammar — so there is no second walk, no second
539
+ * artifact, and no notion of an "art module" anywhere in the toolchain. A
540
+ * package whose tree holds only assets is one by consequence.
541
+ *
540
542
  * @param {string} contentBase - The content tree to walk.
541
543
  * @param {object} options - Options.
542
544
  * @param {string} options.contentPackage - The package the tree compiles as.
543
545
  * @param {readonly string[]} options.skipDirectories - The walk's scope, stated
544
546
  * by the caller. An absent one is the caller's omission, and
545
547
  * {@link module:engine/helpers.walkMarkdownTree} throws on it.
548
+ * @param {string} [options.assetsBase] - The package's asset directory, holding
549
+ * the three asset roots. Omitted, no asset is indexed — which is what a caller
550
+ * walking a bare content fixture wants, and what an asset-free package gets
551
+ * anyway.
546
552
  * @param {object} [options.manifest] - The package manifest, which the Foundry
547
553
  * entries are derived against.
548
554
  * @param {object[]} [options.problems] - Supplied by a **reader**: a note that
@@ -554,7 +560,7 @@ function buildDocRecord({ frontmatter, address, entry, file, contentPackage, anc
554
560
  */
555
561
  export function collectContentIndex(
556
562
  contentBase,
557
- { contentPackage, skipDirectories, manifest, problems },
563
+ { contentPackage, skipDirectories, assetsBase, manifest, problems },
558
564
  ) {
559
565
  const records = [];
560
566
  // Passed through rather than defaulted away: an absent scope is the
@@ -621,16 +627,31 @@ export function collectContentIndex(
621
627
  }
622
628
  }
623
629
 
624
- // Content path, then the canonical address, then the note id. The walk
630
+ if (assetsBase) {
631
+ // Sorted at every depth like a note's record, and for the same reason:
632
+ // the declaration order of the `asset` fields is a fact about the
633
+ // emitter, not about the content, and the artifact is meant to be
634
+ // byte-identical across two runs over an unchanged tree.
635
+ for (const record of collectAssetRecords(assetsBase, { contentPackage, problems })) {
636
+ records.push(/** @type {Record<string, any>} */ (sortKeysDeep(record)));
637
+ }
638
+ }
639
+
640
+ // Source path, then the canonical address, then the note id. The walk
625
641
  // yields in directory-read order, which is not a fact about the content,
626
642
  // and a rebuild that reordered lines would make every regeneration look
627
643
  // like a change. The address comes before the id because an item note's two
628
644
  // records share a file and carry two different ids — ordering on the id
629
645
  // first would sort the documentation against the item it documents by a
630
646
  // pair of hashes, which is no order at all.
647
+ //
648
+ // The path is read through {@link recordPath} because the two record shapes
649
+ // state it differently — a note names the `.md` it was parsed from, an asset
650
+ // the file it *is* — and both are paths within the package, so one order
651
+ // covers them.
631
652
  records.sort(
632
653
  (a, b) =>
633
- String(a.file.path).localeCompare(String(b.file.path), "en") ||
654
+ recordPath(a).localeCompare(recordPath(b), "en") ||
634
655
  String(a.address?.canonical ?? "").localeCompare(
635
656
  String(b.address?.canonical ?? ""),
636
657
  "en",
@@ -664,6 +685,10 @@ export function serializeContentIndex(records) {
664
685
  *
665
686
  * @param {object} [opts]
666
687
  * @param {string} [opts.contentBase] - The tree, defaulting to the configured one.
688
+ * @param {string} [opts.assetsBase] - The asset roots' parent, defaulting to
689
+ * the configured one. Stated separately from `contentBase` because the two
690
+ * move independently — a caller walking an assembled fixture tree says where
691
+ * that fixture's files are.
667
692
  * @param {object} [opts.config] - Resolved configuration, defaulting to ambient.
668
693
  * @param {readonly string[]} [opts.skipDirectories] - The walk's scope, for a
669
694
  * caller that resolved one of its own; defaults to the resolved
@@ -677,13 +702,20 @@ export function serializeContentIndex(records) {
677
702
  * exist.
678
703
  * @returns {object[]} One record per note, plus one per documentation entry.
679
704
  */
680
- export function indexRecordsFor({ contentBase, config, skipDirectories, problems } = {}) {
705
+ export function indexRecordsFor({
706
+ contentBase,
707
+ assetsBase,
708
+ config,
709
+ skipDirectories,
710
+ problems,
711
+ } = {}) {
681
712
  const resolved = config ?? loadPackConfig();
682
713
  const tree = contentBase ?? resolved.paths.content;
683
714
  if (!fs.existsSync(tree)) throw new Error(`no content tree at ${tree}`);
684
715
  return collectContentIndex(tree, {
685
716
  contentPackage: resolved.contentPackage,
686
717
  skipDirectories: skipDirectories ?? resolved.skipDirectories,
718
+ assetsBase: assetsBase ?? resolved.paths.assets,
687
719
  // Only the identities a UUID is a function of — see emitContentIndex.
688
720
  manifest: foundryIdentities(resolved),
689
721
  problems,
@@ -699,8 +731,9 @@ export function indexRecordsFor({ contentBase, config, skipDirectories, problems
699
731
  * @param {string} [options.outDir] - Where to write; defaults to the configured
700
732
  * `paths.contentIndex`.
701
733
  * @param {object} [options.config] - A resolved configuration; loaded when omitted.
702
- * @returns {{file: string, notes: number, bytes: number}} Where it was written,
703
- * how many notes it holds, and its size.
734
+ * @returns {{file: string, notes: number, assets: number, records: number,
735
+ * bytes: number}} Where it was written, how many notes and how many assets it
736
+ * holds, how many records that is in all, and its size.
704
737
  * @throws {Error} When the content tree is absent, or when it yields no note at
705
738
  * all — an empty index is indistinguishable from a mis-pointed tree, and a
706
739
  * reader would take it as the authoritative statement that this package has
@@ -739,8 +772,10 @@ export function emitContentIndex({ contentBase, outDir, config } = {}) {
739
772
  fs.writeFileSync(file, text);
740
773
 
741
774
  // Counted separately because they are genuinely different numbers: an item
742
- // note yields a second record for its documentation journal, so reporting
743
- // records as notes would overstate how large the tree is.
744
- const notes = records.filter((r) => !r.documents).length;
745
- return { file, notes, records: records.length, bytes: Buffer.byteLength(text) };
775
+ // note yields a second record for its documentation journal and a file
776
+ // yields an asset record, so reporting records as notes would overstate how
777
+ // large the tree is.
778
+ const notes = records.filter(isNoteRecord).length;
779
+ const assets = records.filter(isAssetRecord).length;
780
+ return { file, notes, assets, records: records.length, bytes: Buffer.byteLength(text) };
746
781
  }
@@ -64,7 +64,16 @@ import { collectAnchors } from "./anchors.mjs";
64
64
  // derives it. Nothing in the index's own import graph reaches this
65
65
  // module, so this is a plain static import rather than the deferred one
66
66
  // `sql-tables` needs to keep out of the compilers' cycle.
67
- import { authoredFrontmatter, indexRecordsFor, isNoteRecord, noteFile } from "./content-index.mjs";
67
+ import {
68
+ authoredFrontmatter,
69
+ indexRecordsFor,
70
+ isAssetRecord,
71
+ isNoteRecord,
72
+ noteFile,
73
+ } from "./content-index.mjs";
74
+ import { ASSET_TYPE_NAMES } from "./asset-types.mjs";
75
+ import { resolveEmbeds } from "./content-embeds.mjs";
76
+ import { foundryAddressProblem, servesFoundry } from "./pathnames.mjs";
68
77
  import { hasDocEntry } from "./item-docs.mjs";
69
78
  import { NO_SYSTEM, systemOf } from "./document-subtypes.mjs";
70
79
  import { KNOWN_DOCUMENT_SUBTYPE_MAPS } from "./note-claims.mjs";
@@ -173,9 +182,20 @@ export function buildLinkIndex(
173
182
  records ?? indexRecordsFor({ contentBase, config: resolved, skipDirectories, problems });
174
183
 
175
184
  const byKey = new Map();
185
+ /** Canonical address to asset record, for the files this package ships. */
186
+ const byAssetKey = new Map();
176
187
  const anchors = new Map();
177
188
 
178
189
  for (const record of indexRecords) {
190
+ // An asset's record addresses a file rather than a note: there is no
191
+ // body to read links out of and no anchor to resolve one against, so it
192
+ // is keyed for resolution and nothing else. Keyed here rather than
193
+ // alongside the notes because `byKey` holds notes, and a caller that
194
+ // reaches for `.fm` or `.body` on one must not be handed a file.
195
+ if (isAssetRecord(record)) {
196
+ if (record.address?.canonical) byAssetKey.set(record.address.canonical, record);
197
+ continue;
198
+ }
179
199
  // A documentation journal has a record of its own but no file and no
180
200
  // authored frontmatter — it is a document this tree emits, not a note
181
201
  // in it. Its addresses are keyed below, from the note it documents.
@@ -228,7 +248,14 @@ export function buildLinkIndex(
228
248
  // documentation journal's `doc<type>` is deliberately not among them: it is
229
249
  // virtual, and `readQualifier` resolves it from the base type rather than
230
250
  // from a type any tree declares.
231
- const types = new Set(notes.map((n) => n.type));
251
+ //
252
+ // The **asset** types join unconditionally, whether or not this tree holds
253
+ // a file of each. They are a closed vocabulary rather than a census of what
254
+ // was found, and the difference is the whole diagnostic: an unknown type
255
+ // does not parse, so a tree with no `audio/` directory would read
256
+ // `audio-swoosh` as prose and say nothing, where an address the vocabulary
257
+ // knows and nothing answers is reported as the dead reference it is.
258
+ const types = new Set([...notes.map((n) => n.type), ...ASSET_TYPE_NAMES]);
232
259
 
233
260
  // A foreign package may use a type this tree has never seen, so its types
234
261
  // join `types` — otherwise `readQualifier` reads the link as prose and it
@@ -242,6 +269,17 @@ export function buildLinkIndex(
242
269
 
243
270
  const packages = new Set([...(byKey.size ? [pkg] : []), ...foreign.packages]);
244
271
 
272
+ // The address space an `![[…]]` embed resolves against, shaped as every
273
+ // other asset resolver reads one so the checker cannot answer an authored
274
+ // embed differently from the builds that emit it.
275
+ const assetIndex = {
276
+ types,
277
+ packages,
278
+ contentPackage: pkg,
279
+ assets: byAssetKey,
280
+ foreign: foreign.index,
281
+ };
282
+
245
283
  /** The searchable universe a `dataview` table draws its rows from. */
246
284
  const tableDocs = notes.map((n) => ({
247
285
  // Package present for a `WHERE … package = "…"` clause, synthesised
@@ -252,9 +290,93 @@ export function buildLinkIndex(
252
290
  folder: path.dirname(n.rel).split("/").pop(),
253
291
  }));
254
292
 
293
+ /**
294
+ * One note's body with its `dataview` and `sql` tables expanded.
295
+ *
296
+ * The body every body-level check reads, so a link and an embed in one note
297
+ * are found in the same text — a generated table is as free to carry either
298
+ * as prose is.
299
+ *
300
+ * @param {object} note - A note from this index.
301
+ * @returns {string} The markdown.
302
+ */
303
+ function expandedBody(note) {
304
+ const body = note.body;
305
+ if (!/^[ \t]*(?:`{3,}|~{3,})[ \t]*(?:dataview|sql)\b/im.test(body)) return body;
306
+ return expandContentTables(body, {
307
+ // Unfiltered: every note in the tree is this package's, so
308
+ // there is no other package's note to exclude.
309
+ docs: tableDocs,
310
+ linkable: (d) => Boolean(d.fm.shortcode),
311
+ source: note.file,
312
+ // A `sql` table's links are checked like an authored one's, so
313
+ // its rows are prepared ahead of this walk — see
314
+ // {@link module:engine/sql-tables.prepareTreeSqlTables}.
315
+ sqlTables: sqlTables?.get(note.file),
316
+ }).markdown;
317
+ }
318
+
319
+ /**
320
+ * Every `![[…]]` embed in a note body, resolved against the files this tree
321
+ * and its dependencies ship.
322
+ *
323
+ * @param {object} note - A note from this index.
324
+ * @returns {Array<{text: string, occurrence: number, reason?: string,
325
+ * target?: string, type?: string, message?: string}>} One entry per
326
+ * defect, in the shape the finding reporter reads.
327
+ */
328
+ function embedsOf(note) {
329
+ const { unresolved, problems, images } = resolveEmbeds(expandedBody(note), {
330
+ index: assetIndex,
331
+ });
332
+ // **The address an embed resolved to, held to the Foundry surface.** An
333
+ // embed becomes the ordinary image every surface renders, so a file
334
+ // the website serves and the book stages can still be one no Foundry
335
+ // install carries — and the journal would take the pathname as
336
+ // authored. Reported against the embed the note actually wrote, which
337
+ // is what a reader can open and edit. A build that installs nothing in
338
+ // Foundry has no such surface and is not asked.
339
+ const dead =
340
+ servesFoundry(resolved) ?
341
+ images
342
+ .map((image) => ({
343
+ text: image.link,
344
+ message: foundryAddressProblem(image.pathname, resolved),
345
+ }))
346
+ .filter((finding) => finding.message)
347
+ : [];
348
+ const seen = new Map();
349
+ /**
350
+ * @param {string} text - The embed exactly as authored.
351
+ * @returns {number} Its nth appearance in the note.
352
+ */
353
+ const at = (text) => {
354
+ const occurrence = (seen.get(text) ?? 0) + 1;
355
+ seen.set(text, occurrence);
356
+ return occurrence;
357
+ };
358
+ return [
359
+ ...unresolved.map((u) => ({
360
+ text: u.link,
361
+ target: u.target,
362
+ reason: u.reason,
363
+ ...(u.type ? { type: u.type } : {}),
364
+ })),
365
+ ...problems.map((problem) => ({
366
+ text: problem.link,
367
+ message: problem.message,
368
+ })),
369
+ ...dead,
370
+ ].map((finding) => ({ ...finding, occurrence: at(finding.text) }));
371
+ }
372
+
255
373
  /**
256
374
  * Every wikilink in a note body, with its `dataview` tables expanded.
257
375
  *
376
+ * An `![[…]]` embed is not one: it names a file rather than a note, and
377
+ * {@link module:engine/wikilink-syntax.WIKILINK} excludes it so that no
378
+ * reader can take one for the other.
379
+ *
258
380
  * @param {object} note - A note from this index.
259
381
  * @returns {Array<{target: string, anchor: string, text: string,
260
382
  * occurrence: number, labelled: boolean}>} `target` is `""` for a
@@ -262,26 +384,13 @@ export function buildLinkIndex(
262
384
  * `|` every link must have.
263
385
  */
264
386
  function linksOf(note) {
265
- let body = note.body;
266
- if (/^[ \t]*(?:`{3,}|~{3,})[ \t]*(?:dataview|sql)\b/im.test(body)) {
267
- body = expandContentTables(body, {
268
- // Unfiltered: every note in the tree is this package's, so
269
- // there is no other package's note to exclude.
270
- docs: tableDocs,
271
- linkable: (d) => Boolean(d.fm.shortcode),
272
- source: note.file,
273
- // A `sql` table's links are checked like an authored one's, so
274
- // its rows are prepared ahead of this walk — see
275
- // {@link module:engine/sql-tables.prepareTreeSqlTables}.
276
- sqlTables: sqlTables?.get(note.file),
277
- }).markdown;
278
- }
387
+ const body = expandedBody(note);
279
388
  const out = [];
280
389
  // How many times each authored link has been seen, so two identical
281
390
  // links in one note are reported at their own positions.
282
391
  const seen = new Map();
283
- // Code is verbatim, so a `[[…]]` inside a fence, an indented block or
284
- // an inline span is not a link — the compilers make none of it either.
392
+ // Code is verbatim, so a `[[…]]` inside a fence, an indented block or an
393
+ // inline span is not a link — the compilers make none of it either.
285
394
  for (const [all, rawInner] of matchAllOutsideCode(body, new RegExp(WIKILINK.source, "g"))) {
286
395
  const parsed = parseWikilink(rawInner);
287
396
  const { target, anchor } = parsed;
@@ -366,7 +475,11 @@ export function buildLinkIndex(
366
475
  // plain `Map.set` — so two notes in one package sharing a
367
476
  // `(type, shortcode)` across systems silently overwrote each other, and
368
477
  // a bare link resolved to whichever was indexed second.
369
- return byKey.get(expandAddress(qualified, { package: pkg, system: blockSystem(keyPath) }));
478
+ const canonical = expandAddress(qualified, { package: pkg, system: blockSystem(keyPath) });
479
+ // Assets are consulted after notes and never instead of them: the two
480
+ // namespaces cannot collide — an address carries its type — so the order
481
+ // is about which map holds the answer, not about precedence.
482
+ return byKey.get(canonical) ?? byAssetKey.get(canonical);
370
483
  }
371
484
 
372
485
  /**
@@ -431,13 +544,17 @@ export function buildLinkIndex(
431
544
  * to this one. That is the rule for a link, whose target is a document to
432
545
  * point at; a reference names an item to stand beside.
433
546
  *
547
+ * The files this package ships answer here too, and by the same rule: an
548
+ * asset's address is a `(type, shortcode)` pair like any other, so a field
549
+ * naming `icon-anvil` resolves to the record that carries the file's path.
550
+ *
434
551
  * @param {string} target - The reference as `type-shortcode`.
435
- * @returns {object|null} The note or foreign entry declaring it.
552
+ * @returns {object|null} The note, asset record or foreign entry declaring it.
436
553
  */
437
554
  function referenceHit(target) {
438
555
  const q = readQualifier(target, types, packages);
439
556
  if (!q || q.reason) return null;
440
- const local = matchAddress([...byKey], q);
557
+ const local = matchAddress([...byKey, ...byAssetKey], q);
441
558
  if (local.length) return local[0][1];
442
559
  const abroad = matchAddress([...foreign.index], q);
443
560
  return abroad.length ? abroad[0][1] : null;
@@ -449,6 +566,17 @@ export function buildLinkIndex(
449
566
  anchors,
450
567
  types,
451
568
  packages,
569
+ /**
570
+ * The files this package ships, by canonical address. Separate from the
571
+ * notes because the two record shapes are read differently, and exposed
572
+ * because a pass resolving art needs the address set without walking the
573
+ * index again.
574
+ */
575
+ assets: byAssetKey,
576
+ /**
577
+ * The address space an `![[…]]` embed resolves against.
578
+ */
579
+ assetIndex,
452
580
  /**
453
581
  * The one package this tree publishes. Distinct from `packages`, which
454
582
  * is the set an address may name and which a homepage-only tree leaves
@@ -457,6 +585,7 @@ export function buildLinkIndex(
457
585
  contentPackage: pkg,
458
586
  foreign,
459
587
  linksOf,
588
+ embedsOf,
460
589
  /**
461
590
  * Resolve a link target the way both builds do, or `undefined`. Every
462
591
  * link is an address, so this is {@link resolveAddress} under the name
@@ -797,7 +926,15 @@ export function auditHomepageLinks(index) {
797
926
  * the three resolvers agree on severity for every class.
798
927
  */
799
928
  export function auditLinks(index) {
800
- const { notes, anchors, linksOf, resolve, manifestHit, isAddress } = index;
929
+ const { notes, anchors, linksOf, embedsOf, resolve, manifestHit, isAddress } = index;
930
+
931
+ // An embed names a file, and is checked here rather than by the image pass
932
+ // because its grammar is the wikilink's: the same short-form ladder, the
933
+ // same package defaults and the same findings vocabulary.
934
+ const deadEmbeds = [];
935
+ for (const note of notes) {
936
+ for (const finding of embedsOf(note)) deadEmbeds.push({ note, ...finding });
937
+ }
801
938
 
802
939
  const deadAnchors = [];
803
940
  for (const note of notes) {
@@ -805,9 +942,11 @@ export function auditLinks(index) {
805
942
  if (!anchor || !labelled) continue;
806
943
  const dest = target ? resolve(target) : note;
807
944
  // An unresolvable target is reported by the pass below; its anchor
808
- // has nothing to be checked against.
945
+ // has nothing to be checked against. Neither has a file: an asset
946
+ // resolves but has no body, so it declares the empty set of anchors
947
+ // and every `#section` on one is dead.
809
948
  if (!dest) continue;
810
- if (!anchors.get(dest).has(slugify(anchor))) {
949
+ if (!(anchors.get(dest) ?? new Set()).has(slugify(anchor))) {
811
950
  deadAnchors.push({
812
951
  note,
813
952
  link: `${target}#${anchor}`,
@@ -876,6 +1015,7 @@ export function auditLinks(index) {
876
1015
  return {
877
1016
  deadAnchors,
878
1017
  deadAddresses,
1018
+ deadEmbeds,
879
1019
  unlabelledLinks,
880
1020
  frontmatterLinks: index.frontmatterLinks,
881
1021
  homepageLinks: auditHomepageLinks(index),
@@ -72,11 +72,14 @@ import { isAddressSegment } from "./address-charset.mjs";
72
72
  // reads is exactly the disagreement to avoid.
73
73
  import { DEFAULT_PARENT } from "./folder-notes.mjs";
74
74
  import {
75
+ dataFields,
75
76
  declaredTags,
76
77
  exclusiveTagGroups,
77
78
  subTypeCharsetMessage,
78
79
  typeCharsetMessage,
79
80
  } from "./note-vocabulary.mjs";
81
+ // The art slots, declared once. The linter states no art key of its own.
82
+ import { ART_SLOTS } from "./art-fields.mjs";
80
83
  import {
81
84
  RETIRED_FIELD_ALIASES,
82
85
  declaresRetiredAlias,
@@ -770,28 +773,25 @@ function checkExclusiveTags(note, { type }) {
770
773
  }
771
774
 
772
775
  /**
773
- * The frontmatter fields that name artwork, and so resolve through
774
- * {@link module:engine/helpers.resolveImg}.
775
- *
776
- * Both, always: a being carries `img` and `portrait` independently the token
777
- * art and the sheet portrait and a rule about how the translator reads an
778
- * empty value belongs to the translator, not to whichever key happens to be
779
- * more common. Eleven `sohl-kethira-basic` beings write `portrait: ""` and no
780
- * note in any tree writes `img: ""` on a being; a check keyed on `img` alone
781
- * would have called that tree clean.
782
- *
783
- * **Each carries where it is authored**, because the two no longer agree. The
784
- * specification puts an actor's portrait under `data:` and leaves its token art
785
- * at the note's top level, so `portrait` has a third position to read and `img`
786
- * does not — and a check that read only the two they share would pass a
787
- * `data.portrait: ""` it could not see.
788
- *
789
- * @type {readonly {key: string, inData: boolean}[]}
776
+ * The frontmatter fields that name artwork.
777
+ *
778
+ * {@link module:engine/art-fields.ART_SLOTS} is the declaration; this is that
779
+ * list in the shape the checks below read, so the linter states no art key of
780
+ * its own and a slot added there is checked here with no second edit. All four
781
+ * are authored under `data:`, which is what `inData` records.
782
+ *
783
+ * `document` separates the three that reach a compiled document from `banner`,
784
+ * which reaches none by design. The inert-art check reads it: a key that is
785
+ * *meant* to reach no document is not an inert key, and reporting every note
786
+ * that names a hero image would bury the finding that matters.
787
+ *
788
+ * @type {readonly {key: string, inData: boolean, document: boolean}[]}
790
789
  */
791
- export const ART_FIELDS = Object.freeze([
792
- Object.freeze({ key: "img", inData: false }),
793
- Object.freeze({ key: "portrait", inData: true }),
794
- ]);
790
+ export const ART_FIELDS = Object.freeze(
791
+ ART_SLOTS.map((slot) =>
792
+ Object.freeze({ key: slot.key, inData: true, document: slot.document }),
793
+ ),
794
+ );
795
795
 
796
796
  /**
797
797
  * The keys a field declaration is authored at **inside a system block**.
@@ -1159,7 +1159,11 @@ export function lintNote(
1159
1159
  const emitted = emittedArt ? emittedArt(currentType(type)) : null;
1160
1160
  /** The art fields this note's type reaches nothing through. */
1161
1161
  const inertArt = new Set(
1162
- emitted ? ART_FIELDS.filter(({ key }) => !emitted.art.includes(key)).map((f) => f.key) : [],
1162
+ emitted ?
1163
+ ART_FIELDS.filter(({ key, document }) => document && !emitted.art.includes(key)).map(
1164
+ (f) => f.key,
1165
+ )
1166
+ : [],
1163
1167
  );
1164
1168
  for (const { key, inData } of ART_FIELDS) {
1165
1169
  if (!inertArt.has(key)) continue;
@@ -1400,7 +1404,16 @@ export function lintNote(
1400
1404
  // to recognise it would be worse than not checking.
1401
1405
  const entry = vocabulary?.[current];
1402
1406
  if (entry) {
1403
- findings.push(...checkDataContainer(note, { type, fields: entry.data ?? [], packs }));
1407
+ findings.push(
1408
+ ...checkDataContainer(note, {
1409
+ type,
1410
+ // Read through `dataFields`, so the keys **every** type accepts
1411
+ // are part of what this type declares rather than a second list
1412
+ // the container check would have to be told about.
1413
+ fields: dataFields(current, vocabulary) ?? [],
1414
+ packs,
1415
+ }),
1416
+ );
1404
1417
  findings.push(...checkSubType(note, { type, entry }));
1405
1418
  }
1406
1419