@heroiclands/package-build 20.3.1 → 20.4.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 (178) hide show
  1. package/CHANGELOG.md +75 -0
  2. package/CONTENT.md +111 -92
  3. package/README.md +2 -9
  4. package/bin/content-build.mjs +70 -61
  5. package/bin/package-build.mjs +2 -2
  6. package/config.mjs +6 -8
  7. package/content-config.mjs +187 -64
  8. package/docs/content-format.md +57 -68
  9. package/e2e.mjs +4 -4
  10. package/engine/actor-compiler.mjs +36 -36
  11. package/engine/address-charset.mjs +6 -6
  12. package/engine/address-diff.mjs +12 -11
  13. package/engine/anchored-sections.mjs +1 -1
  14. package/engine/anchors.mjs +1 -1
  15. package/engine/base-compiler.mjs +36 -38
  16. package/engine/bundle-notes.mjs +4 -4
  17. package/engine/bundles.mjs +8 -8
  18. package/engine/code-fences.mjs +3 -3
  19. package/engine/compendiums.mjs +8 -8
  20. package/engine/compile-corpus.mjs +4 -5
  21. package/engine/content-address.mjs +15 -15
  22. package/engine/content-charset.mjs +1 -1
  23. package/engine/content-format-check.mjs +24 -25
  24. package/engine/content-format.mjs +8 -8
  25. package/engine/content-html.mjs +202 -0
  26. package/engine/content-icons.mjs +196 -165
  27. package/engine/content-index.mjs +17 -17
  28. package/engine/content-links.mjs +30 -30
  29. package/engine/content-lint.mjs +20 -24
  30. package/engine/content-package.mjs +7 -9
  31. package/engine/content-slug.mjs +2 -2
  32. package/engine/content-tables.mjs +7 -8
  33. package/engine/content-tree.mjs +1 -1
  34. package/engine/derived-fields.mjs +174 -0
  35. package/engine/diagnostics.mjs +3 -4
  36. package/engine/document-subtypes.mjs +6 -7
  37. package/engine/field-reference.mjs +8 -8
  38. package/engine/field-spec.mjs +29 -30
  39. package/engine/folder-notes.mjs +14 -14
  40. package/engine/foreign-catalog.mjs +8 -9
  41. package/engine/foundry-entries.mjs +19 -20
  42. package/engine/frontmatter-lint.mjs +64 -69
  43. package/engine/frontmatter.mjs +11 -11
  44. package/engine/generate.mjs +39 -39
  45. package/engine/helpers.mjs +63 -54
  46. package/engine/homepage.mjs +20 -22
  47. package/engine/ids.mjs +15 -15
  48. package/engine/index-records.mjs +4 -4
  49. package/engine/index.mjs +14 -8
  50. package/engine/item-compiler.mjs +54 -29
  51. package/engine/item-docs.mjs +11 -13
  52. package/engine/item-registry.mjs +13 -13
  53. package/engine/journals.mjs +15 -15
  54. package/engine/macros.mjs +3 -3
  55. package/engine/map-notes.mjs +6 -6
  56. package/engine/metadata-index.mjs +5 -5
  57. package/engine/note-claims.mjs +36 -34
  58. package/engine/note-ids.mjs +5 -5
  59. package/engine/note-package.mjs +4 -4
  60. package/engine/note-renames.mjs +3 -3
  61. package/engine/note-schemas.mjs +3 -3
  62. package/engine/note-vocabulary.mjs +27 -26
  63. package/engine/pack-config.mjs +13 -13
  64. package/engine/pack-router.mjs +0 -0
  65. package/engine/prose-config.mjs +9 -9
  66. package/engine/prose-lint.mjs +12 -13
  67. package/engine/region-events.mjs +2 -2
  68. package/engine/retired-fields.mjs +24 -22
  69. package/engine/runtime-only-fields.mjs +1 -1
  70. package/engine/scene-levels.mjs +1 -1
  71. package/engine/scenes.mjs +19 -20
  72. package/engine/schema-check.mjs +20 -20
  73. package/engine/schema-extract.mjs +1 -1
  74. package/engine/site-build.mjs +45 -47
  75. package/engine/site-index.mjs +17 -18
  76. package/engine/sql-tables.mjs +21 -13
  77. package/engine/subtype-registry.mjs +5 -4
  78. package/engine/system-block.mjs +18 -19
  79. package/engine/systems.mjs +2 -2
  80. package/engine/web-wikilinks.mjs +35 -35
  81. package/engine/wikilink-syntax.mjs +16 -16
  82. package/engine/wikilinks.mjs +39 -39
  83. package/hm3/actors.mjs +22 -22
  84. package/hm3/document-subtypes.mjs +5 -5
  85. package/hm3/index.mjs +2 -2
  86. package/hm3/item-builders.mjs +1 -1
  87. package/hm3/item-fields.mjs +3 -3
  88. package/hm3/items.mjs +20 -8
  89. package/hm3/template-priority.mjs +2 -2
  90. package/lang.mjs +3 -3
  91. package/manifest.mjs +17 -20
  92. package/package.json +1 -2
  93. package/release.mjs +3 -3
  94. package/sohl/actors.mjs +28 -28
  95. package/sohl/affiliation-standings.mjs +2 -2
  96. package/sohl/being-info.mjs +5 -5
  97. package/sohl/default-item-art.mjs +5 -5
  98. package/sohl/document-subtypes.mjs +5 -5
  99. package/sohl/index.mjs +3 -3
  100. package/sohl/item-builders.mjs +7 -7
  101. package/sohl/item-fields.mjs +8 -9
  102. package/sohl/items.mjs +20 -6
  103. package/sohl/kb-passes.mjs +5 -5
  104. package/sohl/note-schemas.mjs +7 -7
  105. package/sohl/skill-base.mjs +1 -1
  106. package/types/content-config.d.mts +25 -24
  107. package/types/e2e.d.mts +3 -3
  108. package/types/engine/actor-compiler.d.mts +14 -13
  109. package/types/engine/address-charset.d.mts +6 -6
  110. package/types/engine/address-diff.d.mts +3 -3
  111. package/types/engine/base-compiler.d.mts +17 -19
  112. package/types/engine/bundle-notes.d.mts +3 -3
  113. package/types/engine/bundles.d.mts +1 -1
  114. package/types/engine/code-fences.d.mts +3 -3
  115. package/types/engine/compendiums.d.mts +3 -3
  116. package/types/engine/compile-corpus.d.mts +1 -1
  117. package/types/engine/content-address.d.mts +11 -11
  118. package/types/engine/content-format-check.d.mts +6 -6
  119. package/types/engine/content-format.d.mts +2 -2
  120. package/types/engine/content-html.d.mts +78 -0
  121. package/types/engine/content-icons.d.mts +139 -92
  122. package/types/engine/content-index.d.mts +2 -2
  123. package/types/engine/content-links.d.mts +8 -8
  124. package/types/engine/content-lint.d.mts +2 -2
  125. package/types/engine/content-package.d.mts +6 -8
  126. package/types/engine/derived-fields.d.mts +101 -0
  127. package/types/engine/diagnostics.d.mts +2 -2
  128. package/types/engine/document-subtypes.d.mts +3 -3
  129. package/types/engine/field-spec.d.mts +37 -39
  130. package/types/engine/folder-notes.d.mts +4 -5
  131. package/types/engine/foreign-catalog.d.mts +3 -3
  132. package/types/engine/foundry-entries.d.mts +6 -7
  133. package/types/engine/frontmatter-lint.d.mts +7 -7
  134. package/types/engine/frontmatter.d.mts +7 -7
  135. package/types/engine/generate.d.mts +7 -7
  136. package/types/engine/helpers.d.mts +25 -27
  137. package/types/engine/homepage.d.mts +16 -18
  138. package/types/engine/ids.d.mts +13 -13
  139. package/types/engine/index-records.d.mts +3 -3
  140. package/types/engine/index.d.mts +2 -0
  141. package/types/engine/item-compiler.d.mts +21 -5
  142. package/types/engine/item-docs.d.mts +2 -2
  143. package/types/engine/item-registry.d.mts +6 -6
  144. package/types/engine/journals.d.mts +3 -3
  145. package/types/engine/map-notes.d.mts +2 -2
  146. package/types/engine/metadata-index.d.mts +4 -4
  147. package/types/engine/note-claims.d.mts +16 -15
  148. package/types/engine/note-ids.d.mts +4 -4
  149. package/types/engine/note-package.d.mts +1 -1
  150. package/types/engine/note-renames.d.mts +3 -3
  151. package/types/engine/note-vocabulary.d.mts +9 -8
  152. package/types/engine/pack-config.d.mts +3 -3
  153. package/types/engine/pack-router.d.mts +1 -1
  154. package/types/engine/prose-config.d.mts +9 -9
  155. package/types/engine/prose-lint.d.mts +3 -4
  156. package/types/engine/region-events.d.mts +2 -2
  157. package/types/engine/retired-fields.d.mts +10 -9
  158. package/types/engine/schema-check.d.mts +11 -11
  159. package/types/engine/site-build.d.mts +18 -19
  160. package/types/engine/site-index.d.mts +3 -4
  161. package/types/engine/sql-tables.d.mts +11 -5
  162. package/types/engine/subtype-registry.d.mts +3 -3
  163. package/types/engine/system-block.d.mts +3 -3
  164. package/types/engine/web-wikilinks.d.mts +7 -7
  165. package/types/engine/wikilink-syntax.d.mts +17 -17
  166. package/types/engine/wikilinks.d.mts +13 -12
  167. package/types/hm3/actors.d.mts +1 -1
  168. package/types/hm3/document-subtypes.d.mts +1 -1
  169. package/types/hm3/items.d.mts +1 -2
  170. package/types/hm3/template-priority.d.mts +1 -1
  171. package/types/manifest.d.mts +8 -8
  172. package/types/sohl/actors.d.mts +2 -2
  173. package/types/sohl/affiliation-standings.d.mts +2 -2
  174. package/types/sohl/being-info.d.mts +2 -2
  175. package/types/sohl/document-subtypes.d.mts +1 -1
  176. package/types/sohl/items.d.mts +1 -2
  177. package/types/sohl/note-schemas.d.mts +1 -1
  178. package/MIGRATING.md +0 -608
@@ -19,13 +19,13 @@
19
19
  * address other packages link to. Stating it twice is how a manifest comes to
20
20
  * assert a URL that resolves at build time and 404s for the reader.
21
21
  *
22
- * **A page's URL is its address** — `<package>/<type>-<shortcode>/` (#181), and
23
- * it carries **no `<system>` segment** even though the canonical address does
24
- * (#59). That is not an omission: a note publishes one page however many
22
+ * **A page's URL is its address** — `<package>/<type>-<shortcode>/`, and
23
+ * it carries **no `<system>` segment** even though the canonical address does.
24
+ * That is not an omission: a note publishes one page however many
25
25
  * systems' documents it compiles into, so there is nothing for the segment to
26
26
  * distinguish, and adding it would split one page's URL in two. The canonical
27
27
  * address names a *document*; this names a *page*. It
28
- * used to be derived from `name.full`, which made a display string load-bearing
28
+ * is not derived from `name.full`, which would make a display string load-bearing
29
29
  * in three separate ways: a rename moved the URL and nothing redirected, two
30
30
  * notes in one section could derive the same URL so a uniqueness check had to
31
31
  * run, and a long name had to be abbreviated through a word table to keep the
@@ -62,7 +62,7 @@ export { DEFAULT_ADDRESS_SCHEME };
62
62
  */
63
63
  export { NO_SYSTEM };
64
64
 
65
- /** The knowledgebase's mount within this package's site (#1470). */
65
+ /** The knowledgebase's mount within this package's site. */
66
66
  export const KB_PREFIX = "kb/";
67
67
 
68
68
  /**
@@ -73,7 +73,7 @@ export const KB_PREFIX = "kb/";
73
73
  * key's **last two segments** — which is what makes a manifest entry's `path`
74
74
  * derivable from the key it is filed under rather than transported beside it.
75
75
  *
76
- * It was once the key's whole tail, and #59 ended that: the key gained a
76
+ * It is not the key's whole tail: the key carries a
77
77
  * `<system>` segment, so its tail is now `system-type-shortcode` and a slug is
78
78
  * the tail with that segment dropped. The behaviour here is unchanged, and
79
79
  * deliberately — a page has no system to name (see the module note above), so
@@ -131,9 +131,9 @@ export function addressSlug(fm) {
131
131
  *
132
132
  * **It is a pure function of the frontmatter**, and takes no options. Nothing
133
133
  * about the file the note was read from reaches it: the `README.md` convention
134
- * that made one note address a whole section is retired with the section itself
135
- * (#204), so every note is addressed alike and there is one rule and no branch.
136
- * It took an address scheme until #215, to validate a `landing` rule it then
134
+ * that made one note address a whole section is retired with the section itself,
135
+ * so every note is addressed alike and there is one rule and no branch.
136
+ * It takes no address scheme; the `landing` rule it once validated is
137
137
  * discarded; with that key retired, `prefix` was the only thing left in the
138
138
  * scheme and the paragraph above is the reason it never applied.
139
139
  *
@@ -169,9 +169,9 @@ export function packageAddress(fm) {
169
169
  * merge is a real conflict rather than an artefact of two packages sharing a
170
170
  * namespace. `(type, shortcode)` alone is unique only *within* a package, and
171
171
  * two independently authored packages reaching for the same short string is a
172
- * matter of time (#1499).
172
+ * matter of time.
173
173
  *
174
- * **The system segment (#59).** A package may ship content for more than one
174
+ * **The system segment.** A package may ship content for more than one
175
175
  * system, and one note then compiles into a document per system — an actor in
176
176
  * `actors-sohl` *and* an actor in `actors-hm3`. Without a system segment both
177
177
  * land on one key, so the address cannot name either of them. `harn-ensemble`
@@ -234,7 +234,7 @@ export function blockSystem(keyPath) {
234
234
  /**
235
235
  * Expand a written address to the one canonical address it names.
236
236
  *
237
- * **An omitted segment defaults from where the link is written** (#336) — it is
237
+ * **An omitted segment defaults from where the link is written** — it is
238
238
  * not a wildcard, and resolution is not a search. Package omitted means the
239
239
  * citing note's own; system omitted means {@link blockSystem} of the key path it
240
240
  * was written under. So every short form has exactly one expansion, computed
@@ -315,8 +315,8 @@ export const CANONICAL_KEY_SEGMENTS = 4;
315
315
  * `^[A-Za-z0-9]+$` (`ADDRESS_SEGMENT_PATTERN` in `engine/address-charset.mjs`),
316
316
  * so the hyphen is purely a separator and the count alone determines every
317
317
  * field. That is enforced at each of the three sources rather than assumed of
318
- * the data: shortcodes by `content-lint.mjs` (#1397), `contentPackage` by
319
- * `defineConfig` (#59), and types are bare words. Were any of them free to
318
+ * the data: shortcodes by `content-lint.mjs`, `contentPackage` by
319
+ * `defineConfig`, and types are bare words. Were any of them free to
320
320
  * carry a hyphen, no amount of counting would recover the fields and the reader
321
321
  * would need a vocabulary to match against instead.
322
322
  *
@@ -440,7 +440,7 @@ export const DOCUMENT_ID_NAMESPACE = "document";
440
440
 
441
441
  /**
442
442
  * The Foundry `_id` of the document a note compiles into, derived from its
443
- * canonical address (#270).
443
+ * canonical address.
444
444
  *
445
445
  * A note used to author this — an opaque 16-character string, one per note,
446
446
  * that said nothing its address did not and that no check guaranteed. The
@@ -12,7 +12,7 @@
12
12
  */
13
13
 
14
14
  /**
15
- * The charset authored content is held to, so a book can choose its face (#377).
15
+ * The charset authored content is held to, so a book can choose its face.
16
16
  *
17
17
  * The packs and the website render in whatever font the reader's browser or
18
18
  * Foundry supplies, and a glyph nobody has is somebody else's problem. **A book
@@ -12,7 +12,7 @@
12
12
  */
13
13
 
14
14
  /**
15
- * The things the content format asserts that can be checked (#130, #136).
15
+ * The things the content format asserts that can be checked.
16
16
  *
17
17
  * `content-format.mjs` reads the specification as data; this compares it
18
18
  * against the three worlds it makes claims about — the systems it maps onto,
@@ -22,7 +22,7 @@
22
22
  *
23
23
  * **The format does not define the `sohl:` or `hm3:` schemas.** Each system
24
24
  * defines its own, and its published `schema.json` is the authoritative
25
- * statement of it (#127). So a mapping row is a *claim*: `data.weight` reaches
25
+ * statement of it. So a mapping row is a *claim*: `data.weight` reaches
26
26
  * `system.weightBase` in SoHL. If SoHL declares no such field the two disagree,
27
27
  * and which of them is wrong is a question for a person — but that they
28
28
  * disagree is a fact a build can establish.
@@ -34,17 +34,16 @@
34
34
  *
35
35
  * **A target is resolved against the union of the system's subtypes.** The
36
36
  * mapping tables say which system field a shared source reaches; *which
37
- * document subtype receives it* is the note-type → subtype map, which is #79's
38
- * to declare and does not exist yet. Resolving per subtype before that map
39
- * exists would mean inferring it from the prose around each table, which is
40
- * precisely the transcription this whole module avoids. So the question asked
41
- * here is the one #130 states "does any schema declare this field?" — and it
42
- * narrows to the subtype when #79 lands.
37
+ * document subtype receives it* is the note-type → subtype map, which does not
38
+ * exist yet. Resolving per subtype before that map exists would mean inferring
39
+ * it from the prose around each table, which is precisely the transcription
40
+ * this whole module avoids. So the question asked here is "does any schema
41
+ * declare this field?", and it narrows to the subtype once the map lands.
43
42
  *
44
43
  * ## The corpus against the declared vocabulary
45
44
  *
46
45
  * Every authored note is measured against the per-type `data` tables. Three
47
- * classes of finding come out of them, and each corresponds to a slice of #127:
46
+ * classes of finding come out of them, one per slice of the migration:
48
47
  *
49
48
  * | class | what it means |
50
49
  * | --- | --- |
@@ -57,8 +56,8 @@
57
56
  * format, so a failing check would be red on day one in every repository and
58
57
  * would stay red for the length of the epic — which is a check nobody can act
59
58
  * on and everybody learns to skip. The counts are the migration's progress bar
60
- * instead, and `--strict` turns them fatal. #127 turns the flag on slice by
61
- * slice, as each class reaches zero.
59
+ * instead, and `--strict` turns them fatal turned on slice by slice, as each
60
+ * class reaches zero.
62
61
  *
63
62
  * **What it deliberately does not check.** A key inside a `sohl:` or `hm3:`
64
63
  * block that the format says nothing about is left alone: those regions are
@@ -71,7 +70,7 @@
71
70
  * The specification hand-writes a `data` table under most of its type sections,
72
71
  * which is the ground {@link module:engine/field-reference} already generates
73
72
  * from the `fields` on each `itemBuilders` entry — the duplication that module's
74
- * docstring exists to prevent, one document over (#136).
73
+ * docstring exists to prevent, one document over.
75
74
  *
76
75
  * **Checked rather than generated, because a merge is not available.** The
77
76
  * document's vocabulary spans note types that produce Scenes, Macros and
@@ -89,7 +88,7 @@
89
88
  * set and are not meant to be: the document names the *shared* source a field is
90
89
  * written as, while a declaration names every key the system's own block accepts
91
90
  * — including the system-specific ones (`heft`, `strikeModes`) that the document
92
- * correctly never maps. Until #127 has moved the corpus into `data:`, holding the
91
+ * correctly never maps. Until the corpus has moved into `data:`, holding the
93
92
  * sets equal would report the migration itself as a defect on every run. So the
94
93
  * fields only one side names come back as *coverage*, and the types only one side
95
94
  * describes come back **named** rather than skipped in silence — a check that
@@ -138,7 +137,7 @@ export function declaredPaths(artifact) {
138
137
  *
139
138
  * A shared row names no type, so it is placed rather than typed: "in the shared
140
139
  * mappings" instead of "on a `weapongear`". Reading `on a \`the shared
141
- * mappings\`` would be the alternative, and a diagnostic is prose (#275).
140
+ * mappings\`` would be the alternative, and a diagnostic is prose.
142
141
  *
143
142
  * @param {object} finding - `{system, systemVersion, noteType, source, target, shared}`.
144
143
  * @returns {string} The message.
@@ -342,8 +341,8 @@ export function measureNote(note, format, { severity = "warning" } = {}) {
342
341
 
343
342
  // The specification's sections are headed by the current spelling of a note
344
343
  // type, so a note still on a renamed one is measured against the section it
345
- // will move to rather than reported as a type the format does not declare
346
- // (#78). The rename itself is the frontmatter lint's finding.
344
+ // will move to rather than reported as a type the format does not declare.
345
+ // The rename itself is the frontmatter lint's finding.
347
346
  const spec = format.types.get(currentType(type));
348
347
  if (!spec) {
349
348
  add(
@@ -403,16 +402,16 @@ export function measureNote(note, format, { severity = "warning" } = {}) {
403
402
  /**
404
403
  * Measure a corpus, and count what it finds by class.
405
404
  *
406
- * The counts are the point as much as the findings: #127 promotes a class to
407
- * fatal when its count reaches zero, so a run that prints them is the epic's
408
- * progress bar.
405
+ * The counts are the point as much as the findings: a class is promoted to
406
+ * fatal when its count reaches zero, so a run that prints them is the
407
+ * migration's progress bar.
409
408
  *
410
409
  * @param {Iterable<object>} notes - `{file, raw, fm}` for each authored note.
411
410
  * @param {import("./content-format.mjs").ContentFormat} format - The parsed
412
411
  * specification.
413
412
  * @param {object} [opts]
414
413
  * @param {boolean} [opts.strict=false] - Report the findings as errors rather
415
- * than warnings. #127 turns this on one slice at a time.
414
+ * than warnings. Turned on one slice at a time.
416
415
  * @returns {{findings: object[], notes: number, byClass: Record<string, number>}}
417
416
  */
418
417
  export function measureCorpus(notes, format, { strict = false } = {}) {
@@ -475,9 +474,9 @@ export function fieldDriftMessage({ noteType, source, target, name, to }) {
475
474
  * A shared source, as the two sides spell it.
476
475
  *
477
476
  * The specification writes every type-specific row `data.<key>` — the container
478
- * `data:` (#128) put those facts in — while a declaration writes either: the
477
+ * `data:` put those facts in — while a declaration writes either: the
479
478
  * bare key it has always named, or the same dotted path now that a field can
480
- * declare its shared source and its legacy in-block key separately (#305).
479
+ * declare its shared source and its legacy in-block key separately.
481
480
  * Both are the same source, so both are normalized before they are compared;
482
481
  * matching one spelling against the other would report every moved field as
483
482
  * unmapped, which is the opposite of what this check is for.
@@ -507,7 +506,7 @@ function claimsFor(format, noteType, system) {
507
506
 
508
507
  /**
509
508
  * Check the specification's per-type tables against the field declarations that
510
- * compile them (#136).
509
+ * compile them.
511
510
  *
512
511
  * @param {object} opts
513
512
  * @param {import("./content-format.mjs").ContentFormat} opts.format - The
@@ -563,7 +562,7 @@ export function checkDeclaredFields({ format, itemFields, system, severity = "er
563
562
  }
564
563
  // No declaration names it. That is coverage, not a contradiction —
565
564
  // the specification maps fields no builder emits yet, which is the
566
- // ordinary mid-migration state (#127) and what `schema-check.mjs`
565
+ // ordinary mid-migration state and what `schema-check.mjs`
567
566
  // already reports as unemitted.
568
567
  if (!match) continue;
569
568
  fields += 1;
@@ -587,7 +586,7 @@ export function checkDeclaredFields({ format, itemFields, system, severity = "er
587
586
  // The two vocabularies, side by side. Reported rather than asserted
588
587
  // equal: the document names the *shared* source a field is written as,
589
588
  // and a declaration names every key the system's own block accepts, so
590
- // the sets legitimately differ until #127 has moved the corpus.
589
+ // the sets legitimately differ until the corpus has moved.
591
590
  const registryKeys = new Set(
592
591
  authored.map((field) => sharedSource(field.name).split(".")[0]),
593
592
  );
@@ -12,7 +12,7 @@
12
12
  */
13
13
 
14
14
  /**
15
- * Reading `docs/content-format.md` as data (#130).
15
+ * Reading `docs/content-format.md` as data.
16
16
  *
17
17
  * The content format — three frontmatter regions, a note vocabulary with its
18
18
  * own `type` and `subType`, and a declared map from each note type onto each
@@ -23,7 +23,7 @@
23
23
  * - **every `system.*` target it names** must exist in the naming system's
24
24
  * published `schema.json`, or the specification and the system disagree; and
25
25
  * - **every authored note** should carry only the keys the format declares for
26
- * its type, which during the migration (#127) is a progress bar as much as a
26
+ * its type, which during the migration is a progress bar as much as a
27
27
  * check.
28
28
  *
29
29
  * Both need the document as data, and this is the module that supplies it.
@@ -50,7 +50,7 @@
50
50
  * type's `subType` values are stated as `**subType**:` followed by one bullet
51
51
  * per value, `- <value>` or `- <value>: <definition>`, and that is read here
52
52
  * for the same reason the tables are: so the specification and
53
- * `note-vocabulary.mjs` cannot disagree about which genres exist (#345). The
53
+ * `note-vocabulary.mjs` cannot disagree about which genres exist. The
54
54
  * one shape is enforced rather than guessed at — the document wrote them five
55
55
  * ways, and a reader that accepted every spelling would accept the sixth by
56
56
  * reading the section as declaring nothing, which is the drift it exists to
@@ -60,7 +60,7 @@
60
60
  * The document states the rows every type maps identically once, at the top,
61
61
  * and omits them from all sixteen per-type tables — so a parser that only ever
62
62
  * looked inside a type's section could not see them, and the eight rows they
63
- * cover were checked by nothing (#275). Position is the whole distinction:
63
+ * cover were checked by nothing. Position is the whole distinction:
64
64
  * there is no marker to read and none is wanted, since the document's own
65
65
  * argument for stating them once is that they belong to no type in particular. A cell that
66
66
  * names no field — `NA`, `**see above**`, a `flags.*` path — is not a claim,
@@ -115,7 +115,7 @@ export const CONTENT_FORMAT_PATH = path.join(
115
115
  * `the shared mappings` for a row of the shared tables — see `shared`.
116
116
  * @property {boolean} [shared] - Whether the row came from a **shared** mapping
117
117
  * table, which stands before the first `### type:` heading and states what
118
- * every type maps identically (#275). Absent on a per-type row, so the two
118
+ * every type maps identically. Absent on a per-type row, so the two
119
119
  * never mix: only a per-type row has a field declaration to be checked
120
120
  * against.
121
121
  * @property {string} system - The system column it sits under, from the header.
@@ -205,8 +205,8 @@ const SUBTYPE_BULLET = /^-\s+(\S+?)\s*(?::|$)/;
205
205
  *
206
206
  * Thrown rather than collected, because there is nothing partial to report: a
207
207
  * marker the reader does not understand yields a section that appears to
208
- * declare no subTypes, and every comparison against it then passes vacuously
209
- * (#345). The message carries the compiler-parseable position the rest of the
208
+ * declare no subTypes, and every comparison against it then passes vacuously.
209
+ * The message carries the compiler-parseable position the rest of the
210
210
  * toolchain's diagnostics use.
211
211
  *
212
212
  * @param {string} file - The document being read.
@@ -338,7 +338,7 @@ export function parseContentFormat(text, { file = CONTENT_FORMAT_PATH } = {}) {
338
338
  table = {
339
339
  kind: "mapping",
340
340
  systems: cells.slice(1).map((cell) => cell.replace(/^→\s*/, "").trim()),
341
- // Before any type section, so the rows are every type's (#275).
341
+ // Before any type section, so the rows are every type's.
342
342
  ...(current ? {} : { shared: true }),
343
343
  };
344
344
  continue;
@@ -0,0 +1,202 @@
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
+ * Raw HTML in a note's prose, reported.
16
+ *
17
+ * **A note is markdown.** What markdown cannot say, a note does not say — it
18
+ * gets a construct every surface can render, the way `:icon-…:` replaces a
19
+ * pasted glyph.
20
+ *
21
+ * ## There is no route from `<p>` to a book
22
+ *
23
+ * The packs and the website both pass raw HTML through, so a tag written in a
24
+ * note reaches them intact and looks correct. Typst is handed markdown and
25
+ * knows nothing of HTML, so the same tag reaches the book as nothing at all, or
26
+ * as literal angle brackets. Closing that gap would mean writing an
27
+ * HTML-to-Typst translator — a renderer nobody wants to own for the sake of a
28
+ * `<strong>` that markdown already spells.
29
+ *
30
+ * So this is the character rule one level up: the charset check refuses a glyph
31
+ * no book face can set, and this refuses markup no book renderer can read.
32
+ *
33
+ * ## What it does not look at
34
+ *
35
+ * - **Fenced blocks and code spans.** HTML shown as an example is prose *about*
36
+ * HTML, and a rule that could not tell the difference would make it
37
+ * impossible to document any of this — including this module. Which runs
38
+ * count as code is {@link module:engine/code-fences.codeRegions}' rule rather
39
+ * than a second copy of it.
40
+ * - **Frontmatter.** A structured value is not prose, and a field the compiler
41
+ * derives is its own question — see {@link module:engine/derived-fields}.
42
+ *
43
+ * ## A warning, like the checks around it
44
+ *
45
+ * A note that renders correctly on two of three surfaces today should not fail
46
+ * a build while the third is still being built. The finding is what makes the
47
+ * work visible; the refusal follows when there is somewhere for the content to
48
+ * go.
49
+ *
50
+ * @module
51
+ */
52
+
53
+ import fs from "node:fs";
54
+ import path from "node:path";
55
+
56
+ import { matchAllOutsideCode } from "./code-fences.mjs";
57
+ import { positionInBody } from "./diagnostics.mjs";
58
+
59
+ /**
60
+ * A raw HTML tag, opening, closing or self-closing.
61
+ *
62
+ * The name must start a tag for the match to begin, which is what keeps
63
+ * markdown's own angle brackets out of it: an autolink is `<https://…>`, and
64
+ * `https` is followed by `:` rather than whitespace or `>`, so the pattern
65
+ * stops. A comparison written in prose — `a < b` — has no name after the
66
+ * bracket at all.
67
+ *
68
+ * Attributes are consumed as "anything but a bracket", deliberately loosely: a
69
+ * finding names the tag, and a pattern that tried to parse attribute syntax
70
+ * would be a second HTML parser with its own bugs, in a module whose whole
71
+ * point is that nothing here should be parsing HTML.
72
+ *
73
+ * @type {RegExp}
74
+ */
75
+ export const HTML_TAG = /<\/?[A-Za-z][A-Za-z0-9-]*(?:\s[^<>]*)?\/?>/g;
76
+
77
+ /**
78
+ * What a note carrying raw HTML is told.
79
+ *
80
+ * It names the tag, because a file with several is fixed one at a time, and it
81
+ * says why rather than only what: an author who does not know the book cannot
82
+ * render it will read the finding as pedantry about a tag that plainly works.
83
+ *
84
+ * @param {string} tag - The matched markup, as written.
85
+ * @returns {string} The message, unpunctuated at the end as a finding is.
86
+ */
87
+ export function htmlMessage(tag) {
88
+ return (
89
+ `\`${tag}\` is raw HTML in a note's prose — write it in markdown. ` +
90
+ `The packs and the website pass HTML through, so this renders on both ` +
91
+ `and reaches the book as nothing: Typst is handed markdown and knows ` +
92
+ `no HTML. Inside a fence or a code span it is an example, and not reported`
93
+ );
94
+ }
95
+
96
+ /**
97
+ * Every raw HTML tag in one note's body.
98
+ *
99
+ * @param {string} body - The note's markdown, without its frontmatter.
100
+ * @param {string} file - The note's path, for the finding.
101
+ * @param {object} [opts]
102
+ * @param {number} [opts.bodyLine=1] - The 1-based file line the body starts on.
103
+ * @param {number} [opts.bodyColumn=1] - The 1-based file column it starts at.
104
+ * @returns {Array<{file: string, line: number, column: number,
105
+ * severity: "warning", message: string}>} One finding per tag, in source
106
+ * order.
107
+ */
108
+ export function checkHtml(body, file, { bodyLine = 1, bodyColumn = 1 } = {}) {
109
+ const text = String(body ?? "");
110
+ if (!text) return [];
111
+
112
+ return matchAllOutsideCode(text, HTML_TAG).map((match) => ({
113
+ file,
114
+ ...positionInBody(text, /** @type {number} */ (match.index), { bodyLine, bodyColumn }),
115
+ severity: /** @type {"warning"} */ ("warning"),
116
+ message: htmlMessage(match[0]),
117
+ }));
118
+ }
119
+
120
+ /**
121
+ * Walk a content tree and report raw HTML in every note's prose.
122
+ *
123
+ * The frontmatter fence is taken off first, so what is scanned is the body and
124
+ * the positions are still the file's. A file with no frontmatter is scanned
125
+ * whole: it is not a note, but a stray `.md` in the tree carrying markup is the
126
+ * same problem for the same reason.
127
+ *
128
+ * @param {string} contentBase - Root of the content tree.
129
+ * @param {object} [opts]
130
+ * @param {readonly string[]} [opts.skipDirectories] - Directory names to ignore
131
+ * in addition to the dot-directories always skipped.
132
+ * @returns {{findings: Array<{file: string, line: number, column: number,
133
+ * severity: "warning", message: string}>, files: number}} The findings, and
134
+ * how many files were read.
135
+ */
136
+ export function lintContentHtml(contentBase, { skipDirectories = [] } = {}) {
137
+ const skip = new Set(skipDirectories);
138
+ /** @type {Array<{file: string, line: number, column: number, severity: "warning", message: string}>} */
139
+ const findings = [];
140
+ let files = 0;
141
+
142
+ /** @param {string} dir - Directory to descend into. */
143
+ const walk = (dir) => {
144
+ /** @type {import("node:fs").Dirent[]} */
145
+ let entries;
146
+ try {
147
+ entries = fs.readdirSync(dir, { withFileTypes: true });
148
+ } catch {
149
+ return;
150
+ }
151
+ for (const entry of entries) {
152
+ if (entry.name.startsWith(".") || skip.has(entry.name)) continue;
153
+ const full = path.join(dir, entry.name);
154
+ if (entry.isDirectory()) {
155
+ walk(full);
156
+ continue;
157
+ }
158
+ if (!/\.(md|markdown)$/i.test(entry.name)) continue;
159
+ let content;
160
+ try {
161
+ content = fs.readFileSync(full, "utf8");
162
+ } catch {
163
+ continue;
164
+ }
165
+ files += 1;
166
+ findings.push(...checkHtml(...bodyOf(content, path.relative(contentBase, full))));
167
+ }
168
+ };
169
+
170
+ walk(contentBase);
171
+ return { findings, files };
172
+ }
173
+
174
+ /**
175
+ * A file's body, its path, and where the body starts in the file.
176
+ *
177
+ * The same split {@link module:engine/helpers.parseMarkdownFile} makes, without
178
+ * parsing the YAML: this check has no use for the frontmatter's *values*, and
179
+ * reading them would make an unparseable note silently unscanned.
180
+ *
181
+ * @param {string} content - The whole file.
182
+ * @param {string} file - Its path, for the finding.
183
+ * @returns {[string, string, {bodyLine: number, bodyColumn: number}]} The
184
+ * arguments {@link checkHtml} takes.
185
+ */
186
+ function bodyOf(content, file) {
187
+ const match = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
188
+ if (!match) return [content, file, { bodyLine: 1, bodyColumn: 1 }];
189
+
190
+ const raw = match[2];
191
+ const body = raw.trim();
192
+ const bodyStart = content.length - raw.length + (raw.length - raw.trimStart().length);
193
+ const before = content.slice(0, bodyStart);
194
+ return [
195
+ body,
196
+ file,
197
+ {
198
+ bodyLine: before.split("\n").length,
199
+ bodyColumn: bodyStart - before.lastIndexOf("\n"),
200
+ },
201
+ ];
202
+ }