@heroiclands/package-build 19.0.0 → 20.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 (85) hide show
  1. package/CHANGELOG.md +1100 -0
  2. package/CONTENT.md +264 -33
  3. package/README.md +43 -4
  4. package/bin/content-build.mjs +94 -3
  5. package/config.mjs +9 -1
  6. package/content-config.mjs +99 -19
  7. package/docs/content-format.md +394 -72
  8. package/e2e.mjs +297 -3
  9. package/engine/actor-compiler.mjs +197 -7
  10. package/engine/address-charset.mjs +23 -5
  11. package/engine/base-compiler.mjs +63 -2
  12. package/engine/bundles.mjs +9 -0
  13. package/engine/content-address.mjs +92 -1
  14. package/engine/content-charset.mjs +434 -0
  15. package/engine/content-format.mjs +102 -0
  16. package/engine/content-icons.mjs +388 -0
  17. package/engine/content-index.mjs +11 -8
  18. package/engine/content-links.mjs +37 -21
  19. package/engine/field-reference.mjs +57 -5
  20. package/engine/field-spec.mjs +214 -7
  21. package/engine/folder-notes.mjs +24 -1
  22. package/engine/foreign-catalog.mjs +112 -7
  23. package/engine/foundry-entries.mjs +14 -0
  24. package/engine/frontmatter-lint.mjs +377 -56
  25. package/engine/frontmatter.mjs +11 -11
  26. package/engine/generate.mjs +72 -12
  27. package/engine/helpers.mjs +96 -10
  28. package/engine/index.mjs +9 -0
  29. package/engine/item-compiler.mjs +37 -0
  30. package/engine/journals.mjs +21 -4
  31. package/engine/macros.mjs +8 -0
  32. package/engine/map-notes.mjs +7 -7
  33. package/engine/note-claims.mjs +208 -5
  34. package/engine/note-ids.mjs +25 -1
  35. package/engine/note-vocabulary.mjs +76 -9
  36. package/engine/pack-config.mjs +102 -12
  37. package/engine/pack-router.mjs +0 -0
  38. package/engine/prose-config.mjs +42 -0
  39. package/engine/prose-lint.mjs +126 -0
  40. package/engine/retired-fields.mjs +57 -16
  41. package/engine/runtime-only-fields.mjs +204 -0
  42. package/engine/scenes.mjs +12 -19
  43. package/engine/schema-check.mjs +23 -1
  44. package/engine/schema-extract.mjs +13 -0
  45. package/engine/site-index.mjs +17 -0
  46. package/engine/subtype-registry.mjs +30 -0
  47. package/engine/system-block.mjs +81 -3
  48. package/engine/web-wikilinks.mjs +33 -27
  49. package/engine/wikilink-syntax.mjs +7 -0
  50. package/engine/wikilinks.mjs +74 -16
  51. package/hm3/actors.mjs +63 -13
  52. package/package.json +2 -2
  53. package/sohl/actors.mjs +106 -7
  54. package/sohl/item-fields.mjs +203 -0
  55. package/sohl/note-schemas.mjs +6 -3
  56. package/types/config.d.mts +7 -0
  57. package/types/e2e.d.mts +130 -3
  58. package/types/engine/actor-compiler.d.mts +83 -3
  59. package/types/engine/address-charset.d.mts +22 -4
  60. package/types/engine/base-compiler.d.mts +54 -3
  61. package/types/engine/content-address.d.mts +64 -0
  62. package/types/engine/content-charset.d.mts +127 -0
  63. package/types/engine/content-format.d.mts +9 -0
  64. package/types/engine/content-icons.d.mts +151 -0
  65. package/types/engine/field-spec.d.mts +271 -3
  66. package/types/engine/folder-notes.d.mts +20 -0
  67. package/types/engine/foreign-catalog.d.mts +38 -2
  68. package/types/engine/foundry-entries.d.mts +6 -0
  69. package/types/engine/frontmatter-lint.d.mts +164 -30
  70. package/types/engine/frontmatter.d.mts +11 -11
  71. package/types/engine/generate.d.mts +27 -0
  72. package/types/engine/helpers.d.mts +45 -9
  73. package/types/engine/index.d.mts +3 -0
  74. package/types/engine/map-notes.d.mts +2 -2
  75. package/types/engine/note-claims.d.mts +67 -0
  76. package/types/engine/note-ids.d.mts +14 -0
  77. package/types/engine/pack-config.d.mts +35 -0
  78. package/types/engine/prose-config.d.mts +41 -0
  79. package/types/engine/prose-lint.d.mts +36 -0
  80. package/types/engine/retired-fields.d.mts +29 -13
  81. package/types/engine/runtime-only-fields.d.mts +102 -0
  82. package/types/engine/schema-check.d.mts +10 -1
  83. package/types/engine/subtype-registry.d.mts +21 -0
  84. package/types/engine/system-block.d.mts +28 -2
  85. package/types/sohl/actors.d.mts +3 -3
@@ -46,6 +46,16 @@
46
46
  * A mapping table's remaining header cells name the systems (`→ sohl`,
47
47
  * `→ hm3`), so the system vocabulary comes from the document too.
48
48
  *
49
+ * **The other half of a type's vocabulary is a bullet list, not a table.** A
50
+ * type's `subType` values are stated as `**subType**:` followed by one bullet
51
+ * per value, `- <value>` or `- <value>: <definition>`, and that is read here
52
+ * for the same reason the tables are: so the specification and
53
+ * `note-vocabulary.mjs` cannot disagree about which genres exist (#345). The
54
+ * one shape is enforced rather than guessed at — the document wrote them five
55
+ * ways, and a reader that accepted every spelling would accept the sixth by
56
+ * reading the section as declaring nothing, which is the drift it exists to
57
+ * catch. An unrecognised shape throws.
58
+ *
49
59
  * **A mapping table before the first `### type:` heading is the shared one.**
50
60
  * The document states the rows every type maps identically once, at the top,
51
61
  * and omits them from all sixteen per-type tables — so a parser that only ever
@@ -92,6 +102,9 @@ export const CONTENT_FORMAT_PATH = path.join(
92
102
  * property — what a note actually writes. `appearance.eye_color` is authored
93
103
  * as `appearance`, so that is the key recorded.
94
104
  * @property {Set<string>} dataPaths - The declared paths, whole.
105
+ * @property {string[]} subTypes - The `subType` values the section enumerates,
106
+ * in document order — empty when it states none, which is the ordinary case
107
+ * for a type that has no `subType` at all.
95
108
  */
96
109
 
97
110
  /**
@@ -178,6 +191,79 @@ function columnOfCell(line, index) {
178
191
  return at + lead + 2;
179
192
  }
180
193
 
194
+ /** The one shape the specification states a type's `subType` values in. */
195
+ const SUBTYPE_MARKER = "**subType**:";
196
+
197
+ /** Any line that reads as a `subType` marker, canonical or not. */
198
+ const SUBTYPE_MARKER_ISH = /^\s*\**\s*subTypes?\s*\**\s*:?\s*$/i;
199
+
200
+ /** One bullet of a values list: `- <value>` or `- <value>: <definition>`. */
201
+ const SUBTYPE_BULLET = /^-\s+(\S+?)\s*(?::|$)/;
202
+
203
+ /**
204
+ * A parse failure, positioned where the document went wrong.
205
+ *
206
+ * Thrown rather than collected, because there is nothing partial to report: a
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
210
+ * toolchain's diagnostics use.
211
+ *
212
+ * @param {string} file - The document being read.
213
+ * @param {number} line - 1-based line the fault is on.
214
+ * @param {string} message - What is wrong, and what to write instead.
215
+ * @returns {Error} The failure to throw.
216
+ */
217
+ function specError(file, line, message) {
218
+ return new Error(`${file}:${line}:1: error: ${message}`);
219
+ }
220
+
221
+ /**
222
+ * The `subType` values a section enumerates under its marker.
223
+ *
224
+ * Reads the one contiguous bullet list directly below the marker and stops
225
+ * there: several sections state another closed vocabulary of their own a blank
226
+ * line later — `TransmissionTypes`, `GovernanceModel` — and reading on would
227
+ * quietly attribute its values to `subType`.
228
+ *
229
+ * @param {string[]} lines - The document's lines.
230
+ * @param {number} at - Index of the marker line.
231
+ * @param {string} file - The document, for the failure message.
232
+ * @returns {string[]} The values, in document order.
233
+ */
234
+ function subTypeValues(lines, at, file) {
235
+ /** @type {string[]} */
236
+ const values = [];
237
+ let i = at + 1;
238
+ while (i < lines.length && lines[i].trim() === "") i += 1;
239
+ for (; i < lines.length; i += 1) {
240
+ const line = lines[i];
241
+ // A wrapped definition is indented under its own bullet.
242
+ if (values.length && /^\s+\S/.test(line)) continue;
243
+ if (!line.startsWith("-")) break;
244
+ const bullet = SUBTYPE_BULLET.exec(line);
245
+ const value = bullet?.[1].replace(/`/g, "");
246
+ if (!value || !/^[A-Za-z0-9]+$/.test(value)) {
247
+ throw specError(
248
+ file,
249
+ i + 1,
250
+ `\`${SUBTYPE_MARKER}\` takes one bullet per value, ` +
251
+ "`- <value>` or `- <value>: <definition>`, and this bullet states none.",
252
+ );
253
+ }
254
+ values.push(value);
255
+ }
256
+ if (!values.length) {
257
+ throw specError(
258
+ file,
259
+ at + 1,
260
+ `\`${SUBTYPE_MARKER}\` enumerates no values. A type whose subType values the ` +
261
+ "specification does not state omits the marker.",
262
+ );
263
+ }
264
+ return values;
265
+ }
266
+
181
267
  /**
182
268
  * Parse the specification's tables.
183
269
  *
@@ -211,12 +297,28 @@ export function parseContentFormat(text, { file = CONTENT_FORMAT_PATH } = {}) {
211
297
  line: i + 1,
212
298
  dataKeys: new Set(),
213
299
  dataPaths: new Set(),
300
+ subTypes: [],
214
301
  };
215
302
  types.set(current.name, current);
216
303
  table = undefined;
217
304
  continue;
218
305
  }
219
306
 
307
+ if (current && SUBTYPE_MARKER_ISH.test(line)) {
308
+ if (line.trim() !== SUBTYPE_MARKER) {
309
+ throw specError(
310
+ file,
311
+ i + 1,
312
+ `a type's subType values are stated as \`${SUBTYPE_MARKER}\`, ` +
313
+ `not \`${line.trim()}\`. The specification had five spellings and ` +
314
+ "converged on one, so that a section is never read as declaring none.",
315
+ );
316
+ }
317
+ current.subTypes = subTypeValues(lines, i, file);
318
+ table = undefined;
319
+ continue;
320
+ }
321
+
220
322
  const cells = cellsOf(line);
221
323
  if (!cells) {
222
324
  // Any non-table line ends the table. A blank line between two
@@ -0,0 +1,388 @@
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
+ * Naming an interface icon in a note, without drawing it there (#378).
16
+ *
17
+ * The user guide describes Foundry's interface, and it did so by pasting
18
+ * Unicode lookalikes of the icons the sheets actually draw: `☆` for the improve
19
+ * flag, `✎` for the formula editor, `◆` in the success-value table, `★★★` for
20
+ * mastery. The system renders every one of those with **Font Awesome** — a
21
+ * `fa-regular fa-star`, a `fa-solid fa-pen-to-square` — so the note and the
22
+ * screen it describes were drawing different pictures, and drifting apart with
23
+ * every sheet change.
24
+ *
25
+ * They are also the worst characters in the corpus to typeset. Of the eight
26
+ * book faces probed for #377, **none** carries `✕ ✗ ✎ ☆ ⚗ ➕`; in a Libertinus
27
+ * setting `✕` resolves to macOS LastResort, which draws a tofu box.
28
+ *
29
+ * **Neither obvious fix works.** Keeping the dingbats pins the book to some
30
+ * icon-capable font forever, which is the coupling #377 exists to remove.
31
+ * Pasting Font Awesome's own codepoints is worse: they live in the Private Use
32
+ * Area, which is unassigned by definition, so they break search, copy-paste and
33
+ * screen readers, and no charset check can validate them.
34
+ *
35
+ * So a note **names** an icon and never contains one. `:icon-star-outline:` is
36
+ * ASCII, it is greppable, it survives a charset check, and it degrades to
37
+ * visible literal text on any surface that has not been taught to render it —
38
+ * which is the failure mode you want, because you can see it.
39
+ *
40
+ * **Why a registry rather than the Font Awesome classes.** Three surfaces need
41
+ * three different artefacts from one name: the journals and the website want
42
+ * `<i class="fa-solid fa-star">`, and the PDF wants a font file and a glyph.
43
+ * Only a mapping serves both. It also means a Font Awesome major version that
44
+ * renames an icon — `fa-trash-o` became `fa-trash-can` — costs one line here
45
+ * rather than a sweep of the corpus, and it lets an unknown name be *reported*
46
+ * instead of passing silently through as literal text.
47
+ *
48
+ * **The codepoint is deliberately not here.** A renderer that embeds Font
49
+ * Awesome has to read the font to subset it, and the font's own `cmap` is the
50
+ * only trustworthy source for which glyph a name resolves to. Writing the
51
+ * codepoints out by hand would be a second copy of that table, wrong the first
52
+ * time Font Awesome renumbers anything, and wrong silently. This module states
53
+ * the style and the name; the renderer resolves them against the file it ships.
54
+ *
55
+ * **Licence.** Font Awesome Free's icons are CC BY 4.0 and its fonts SIL OFL
56
+ * 1.1, so a distributed PDF may embed the subset it uses. Attribution belongs
57
+ * in the book's colophon, not in every note.
58
+ *
59
+ * @module
60
+ */
61
+
62
+ import fs from "node:fs";
63
+ import path from "node:path";
64
+
65
+ /**
66
+ * The Font Awesome styles a registry entry may name.
67
+ *
68
+ * Free ships these three and no others, so a `light` or `duotone` entry would
69
+ * name a glyph the shipped font does not contain — refused here rather than
70
+ * discovered as a blank space in a printed book.
71
+ *
72
+ * @type {readonly string[]}
73
+ */
74
+ export const ICON_STYLES = Object.freeze(["solid", "regular", "brands"]);
75
+
76
+ /**
77
+ * The icons the user guide already depicts, under the names it should call them.
78
+ *
79
+ * Each entry was read off the interface it describes rather than invented: the
80
+ * `star`/`star-outline` pair is the filled and hollow star the mastery row and
81
+ * the improve flag draw, and `edit` is the pencil the formula editor opens
82
+ * from. The names are what a *writer* would reach for — `delete`, not
83
+ * `trash-can` — because the writer is the one typing them; the Font Awesome
84
+ * spelling is this table's business, not theirs.
85
+ *
86
+ * @type {Readonly<Record<string, {style: string, icon: string, label: string}>>}
87
+ */
88
+ export const DEFAULT_ICONS = Object.freeze({
89
+ star: { style: "solid", icon: "star", label: "star" },
90
+ "star-outline": { style: "regular", icon: "star", label: "hollow star" },
91
+ diamond: { style: "solid", icon: "diamond", label: "diamond" },
92
+ edit: { style: "solid", icon: "pen-to-square", label: "edit" },
93
+ delete: { style: "solid", icon: "trash-can", label: "delete" },
94
+ add: { style: "solid", icon: "plus", label: "add" },
95
+ remove: { style: "solid", icon: "xmark", label: "remove" },
96
+ "not-applicable": { style: "solid", icon: "xmark", label: "not applicable" },
97
+ menu: { style: "solid", icon: "ellipsis-vertical", label: "actions menu" },
98
+ expand: { style: "solid", icon: "caret-right", label: "expand" },
99
+ shield: { style: "solid", icon: "shield-halved", label: "armour" },
100
+ compass: { style: "solid", icon: "compass", label: "guided tour" },
101
+ flask: { style: "solid", icon: "flask", label: "under construction" },
102
+ });
103
+
104
+ /**
105
+ * The shape a note writes, and the one this module claims.
106
+ *
107
+ * The `icon-` prefix is what keeps it out of the way of an emoji shortcode: a
108
+ * surface that also renders `:smile:` can tell the two apart without a lookup,
109
+ * and a reader can tell what `:icon-star:` is without knowing this module
110
+ * exists. Names are lowercase, digits and hyphens — the charset an address
111
+ * segment already uses (#59), so nothing new has to be explained.
112
+ *
113
+ * Not `:name[content]`. That is remark-directive syntax, and this toolchain
114
+ * parses with markdown-it; a directive would render as its own literal text.
115
+ *
116
+ * @type {RegExp}
117
+ */
118
+ export const ICON_PATTERN = /:icon-([a-z0-9]+(?:-[a-z0-9]+)*):/g;
119
+
120
+ /**
121
+ * Look one name up.
122
+ *
123
+ * @param {string} name - The name written between the colons, without `icon-`.
124
+ * @param {Record<string, object>} [registry] - Defaults to {@link DEFAULT_ICONS}.
125
+ * @returns {{style: string, icon: string, label: string}|null} The entry, or
126
+ * `null` when the registry does not declare it.
127
+ */
128
+ export function resolveIcon(name, registry = DEFAULT_ICONS) {
129
+ return Object.prototype.hasOwnProperty.call(registry, name) ? registry[name] : null;
130
+ }
131
+
132
+ /** HTML-escape a value going into an attribute. */
133
+ const attr = (value) =>
134
+ String(value).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/"/g, "&quot;");
135
+
136
+ /**
137
+ * The HTML the journals and the website emit — what the system already renders.
138
+ *
139
+ * Carries an accessible name rather than `aria-hidden`. The system's own
140
+ * templates hide their icons because a labelled parent element speaks for them;
141
+ * an icon dropped into a sentence has no such parent, and "the ☆ toggles it"
142
+ * read aloud as "the toggles it" is a sentence with a hole in it.
143
+ *
144
+ * @param {{style: string, icon: string, label: string}} entry - A registry entry.
145
+ * @returns {string} An `<i>` element.
146
+ */
147
+ export function iconHtml(entry) {
148
+ return (
149
+ `<i class="fa-${attr(entry.style)} fa-${attr(entry.icon)}" ` +
150
+ `role="img" aria-label="${attr(entry.label)}"></i>`
151
+ );
152
+ }
153
+
154
+ /**
155
+ * Every icon a string names, in the order written.
156
+ *
157
+ * @param {string} text - Markdown source.
158
+ * @returns {Array<{name: string, index: number, raw: string}>} What it names.
159
+ */
160
+ export function iconsIn(text) {
161
+ const out = [];
162
+ for (const m of text.matchAll(ICON_PATTERN)) {
163
+ out.push({ name: m[1], index: m.index ?? 0, raw: m[0] });
164
+ }
165
+ return out;
166
+ }
167
+
168
+ /**
169
+ * Report every icon a tree names that its registry does not declare.
170
+ *
171
+ * The whole point of a registry is that a typo is answerable, so this is the
172
+ * half that makes `:icon-stra:` a finding rather than three words of literal
173
+ * text nobody notices in a rendered page.
174
+ *
175
+ * @param {string} text - The file's contents.
176
+ * @param {string} file - Path to report.
177
+ * @param {Record<string, object>} [registry] - Defaults to {@link DEFAULT_ICONS}.
178
+ * @returns {Array<{file: string, line: number, column: number,
179
+ * severity: "error", message: string}>} The unknown names.
180
+ */
181
+ export function lintIcons(text, file, registry = DEFAULT_ICONS) {
182
+ const findings = [];
183
+ for (const { name, index, raw } of iconsIn(text)) {
184
+ if (resolveIcon(name, registry)) continue;
185
+ const before = text.slice(0, index);
186
+ const line = before.split("\n").length;
187
+ const column = index - (before.lastIndexOf("\n") + 1) + 1;
188
+ // Nearest declared name, when there is an obvious one: a typo is the
189
+ // common case and the registry is short enough to say what was meant.
190
+ const suggestion = nearestName(name, Object.keys(registry));
191
+ findings.push({
192
+ file,
193
+ line,
194
+ column,
195
+ severity: /** @type {const} */ ("error"),
196
+ message:
197
+ `\`${raw}\` names an icon the registry does not declare` +
198
+ (suggestion ? `; did you mean \`:icon-${suggestion}:\`?` : "") +
199
+ ` — an undeclared name renders as its own literal text`,
200
+ });
201
+ }
202
+ return findings;
203
+ }
204
+
205
+ /**
206
+ * The closest declared name within one edit, or nothing.
207
+ *
208
+ * Deliberately strict: a suggestion that is merely the alphabetically nearest
209
+ * string is worse than none, because it sends the author to look at an icon
210
+ * they never meant.
211
+ *
212
+ * @param {string} name - What was written.
213
+ * @param {readonly string[]} known - The declared names.
214
+ * @returns {string|undefined} The suggestion, when one is close enough.
215
+ */
216
+ function nearestName(name, known) {
217
+ let best;
218
+ let bestScore = Infinity;
219
+ for (const candidate of known) {
220
+ const score = editDistance(name, candidate);
221
+ if (score < bestScore) {
222
+ bestScore = score;
223
+ best = candidate;
224
+ }
225
+ }
226
+ // Two edits on a short name is already a different word.
227
+ return bestScore <= Math.min(2, Math.floor(name.length / 3) + 1) ? best : undefined;
228
+ }
229
+
230
+ /** Levenshtein distance, on the two short strings a registry lookup compares. */
231
+ function editDistance(a, b) {
232
+ /** @type {number[]} */
233
+ let previous = Array.from({ length: b.length + 1 }, (_, i) => i);
234
+ for (let i = 1; i <= a.length; i++) {
235
+ const current = [i];
236
+ for (let j = 1; j <= b.length; j++) {
237
+ current[j] = Math.min(
238
+ previous[j] + 1,
239
+ current[j - 1] + 1,
240
+ previous[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1),
241
+ );
242
+ }
243
+ previous = current;
244
+ }
245
+ return previous[b.length];
246
+ }
247
+
248
+ /**
249
+ * Refuse a registry that names a style Font Awesome Free does not ship.
250
+ *
251
+ * @param {Record<string, object>} registry - A package's icon table.
252
+ * @param {string} [where="icons"] - Where to say the fault is.
253
+ * @returns {Array<{severity: "error", message: string}>} What is wrong with it.
254
+ */
255
+ export function checkIconRegistry(registry, where = "icons") {
256
+ const findings = [];
257
+ for (const [name, entry] of Object.entries(registry ?? {})) {
258
+ const at = `\`${where}.${name}\``;
259
+ if (!entry || typeof entry !== "object") {
260
+ findings.push({
261
+ severity: /** @type {const} */ ("error"),
262
+ message: `${at} is not an icon entry — it takes \`style\`, \`icon\` and \`label\``,
263
+ });
264
+ continue;
265
+ }
266
+ if (!ICON_STYLES.includes(entry.style)) {
267
+ findings.push({
268
+ severity: /** @type {const} */ ("error"),
269
+ message:
270
+ `${at} names style \`${entry.style}\`, and Font Awesome Free ships ` +
271
+ `only ${ICON_STYLES.join(", ")} — a glyph in any other style is ` +
272
+ `absent from the font a book would embed`,
273
+ });
274
+ }
275
+ if (typeof entry.icon !== "string" || !entry.icon) {
276
+ findings.push({
277
+ severity: /** @type {const} */ ("error"),
278
+ message: `${at} declares no \`icon\`, so nothing names the glyph to draw`,
279
+ });
280
+ }
281
+ if (typeof entry.label !== "string" || !entry.label) {
282
+ findings.push({
283
+ severity: /** @type {const} */ ("error"),
284
+ message:
285
+ `${at} declares no \`label\`, and an icon with no accessible name ` +
286
+ `is read aloud as a gap in the sentence`,
287
+ });
288
+ }
289
+ }
290
+ return findings;
291
+ }
292
+
293
+ /**
294
+ * Walk a content tree and report every icon name its registry does not declare.
295
+ *
296
+ * Its own walk rather than the charset check's, so both modules stay leaves
297
+ * with nothing imported between them. The cost is one extra pass over the tree,
298
+ * which is the cheaper half of a lint that already parses every note.
299
+ *
300
+ * @param {string} contentBase - Root of the content tree.
301
+ * @param {object} [opts]
302
+ * @param {readonly string[]} [opts.skipDirectories] - Directory names to ignore.
303
+ * @param {Record<string, object>} [opts.registry] - The package's icon table.
304
+ * @returns {{findings: Array<{file: string, line: number, column: number,
305
+ * severity: "error", message: string}>, files: number}} What it found.
306
+ */
307
+ export function lintContentIcons(contentBase, { skipDirectories = [], registry } = {}) {
308
+ const skip = new Set(skipDirectories);
309
+ const findings = [];
310
+ let files = 0;
311
+
312
+ /** @param {string} dir - Directory to descend into. */
313
+ const walk = (dir) => {
314
+ /** @type {import("node:fs").Dirent[]} */
315
+ let entries;
316
+ try {
317
+ entries = fs.readdirSync(dir, { withFileTypes: true });
318
+ } catch {
319
+ return;
320
+ }
321
+ for (const entry of entries) {
322
+ if (entry.name.startsWith(".") || skip.has(entry.name)) continue;
323
+ const full = path.join(dir, entry.name);
324
+ if (entry.isDirectory()) {
325
+ walk(full);
326
+ continue;
327
+ }
328
+ if (!/\.(md|markdown)$/i.test(entry.name)) continue;
329
+ let text;
330
+ try {
331
+ text = fs.readFileSync(full, "utf8");
332
+ } catch {
333
+ continue;
334
+ }
335
+ files += 1;
336
+ findings.push(...lintIcons(text, path.relative(contentBase, full), registry));
337
+ }
338
+ };
339
+
340
+ walk(contentBase);
341
+ return { findings, files };
342
+ }
343
+
344
+ /**
345
+ * A markdown-it plugin rendering `:icon-name:` inline.
346
+ *
347
+ * An unknown name is left **exactly as written** rather than dropped. The name
348
+ * is reported by {@link lintIcons}, and a rendered page that still shows
349
+ * `:icon-stra:` is how the author finds it without reading a log.
350
+ *
351
+ * @param {Record<string, object>} [registry] - Defaults to {@link DEFAULT_ICONS}.
352
+ * @returns {(md: object) => void} A markdown-it plugin.
353
+ */
354
+ export function iconPlugin(registry = DEFAULT_ICONS) {
355
+ return (md) => {
356
+ /** @type {any} */ (md).inline.ruler.before("emphasis", "heroiclands_icon", iconRule);
357
+ /** @type {any} */ (md).renderer.rules.heroiclands_icon = (tokens, idx) =>
358
+ iconHtml(tokens[idx].meta.entry);
359
+
360
+ /**
361
+ * @param {any} state - markdown-it inline state.
362
+ * @param {boolean} silent - Validation pass, which emits no token.
363
+ * @returns {boolean} Whether the rule consumed anything.
364
+ */
365
+ function iconRule(state, silent) {
366
+ const start = state.pos;
367
+ if (state.src.charCodeAt(start) !== 0x3a /* : */) return false;
368
+ // Anchored at the cursor, so the scan is O(token) rather than a
369
+ // search of the remaining source at every colon in the paragraph.
370
+ const re = /^:icon-([a-z0-9]+(?:-[a-z0-9]+)*):/;
371
+ const m = re.exec(state.src.slice(start));
372
+ if (!m) return false;
373
+
374
+ const entry = resolveIcon(m[1], registry);
375
+ // Not ours to consume: leaving the source untouched is what makes an
376
+ // unrecognised name visible on the page instead of vanishing.
377
+ if (!entry) return false;
378
+
379
+ if (!silent) {
380
+ const token = state.push("heroiclands_icon", "", 0);
381
+ token.meta = { name: m[1], entry };
382
+ token.markup = m[0];
383
+ }
384
+ state.pos += m[0].length;
385
+ return true;
386
+ }
387
+ };
388
+ }
@@ -490,9 +490,9 @@ export function buildIndexRecord({
490
490
  * **Lean, and deliberately not the note's frontmatter.** The item's `sohl:`
491
491
  * block describes the *item*; copying it onto the journal would assert things
492
492
  * about the journal that are not true, and double the file to do it. What the
493
- * journal has of its own is its addresses, its name, and the file it came from
494
- * — plus `documents`, naming the record it is the documentation for, so the
495
- * link is navigable in both directions.
493
+ * journal has of its own is its addresses, its **id**, its name, and the file
494
+ * it came from — plus `documents`, naming the record it is the documentation
495
+ * for, so the link is navigable in both directions.
496
496
  *
497
497
  * On the web both addresses resolve to one page — the item note renders as the
498
498
  * page that *is* its documentation — so the slug is shared and only the
@@ -514,6 +514,11 @@ function buildDocRecord({ frontmatter, address, entry, file, contentPackage, anc
514
514
  type: `doc${frontmatter.type}`,
515
515
  shortcode: frontmatter.shortcode,
516
516
  name: frontmatter.name,
517
+ // The journal's own `_id`, taken from the entry rather than
518
+ // re-derived: every entry the index gives an identity to publishes
519
+ // both the id and the UUID, computed once by whatever owns that
520
+ // entry's derivation (#310).
521
+ id: entry.id,
517
522
  nameAscii: asciiName(frontmatter?.name?.full),
518
523
  address: { slug: address.slug, canonical: entry.key },
519
524
  // The record this is the documentation *for*. `documentation` is
@@ -595,15 +600,13 @@ export function collectContentIndex(
595
600
  }
596
601
  }
597
602
 
598
- // Content path, then the note id. The walk yields in directory-read order,
599
- // which is not a fact about the content, and a rebuild that reorders lines
600
- // would make every regeneration look like a change.
601
603
  // Content path, then the canonical address, then the note id. The walk
602
604
  // yields in directory-read order, which is not a fact about the content,
603
605
  // and a rebuild that reordered lines would make every regeneration look
604
606
  // like a change. The address comes before the id because an item note's two
605
- // records share a file and only one of them carries an id — ordering on the
606
- // id first would put the documentation ahead of the item it documents.
607
+ // records share a file and carry two different ids — ordering on the id
608
+ // first would sort the documentation against the item it documents by a
609
+ // pair of hashes, which is no order at all.
607
610
  records.sort(
608
611
  (a, b) =>
609
612
  String(a.file.path).localeCompare(String(b.file.path), "en") ||
@@ -70,7 +70,13 @@ import { NO_SYSTEM, systemOf } from "./document-subtypes.mjs";
70
70
  import { KNOWN_DOCUMENT_SUBTYPE_MAPS } from "./note-claims.mjs";
71
71
  import { loadPackConfig } from "./pack-config.mjs";
72
72
  import { searchableFrontmatter } from "./note-package.mjs";
73
- import { canonicalKey, PACKAGE_BASE, readCanonicalKey } from "./content-address.mjs";
73
+ import {
74
+ blockSystem,
75
+ canonicalKey,
76
+ expandAddress,
77
+ PACKAGE_BASE,
78
+ readCanonicalKey,
79
+ } from "./content-address.mjs";
74
80
  import { loadForeignIndexes } from "./metadata-index.mjs";
75
81
  import { frontmatterWikilinks, slugify } from "./web-wikilinks.mjs";
76
82
  import { homepageAddresses, isHomepage } from "./homepage.mjs";
@@ -195,9 +201,14 @@ export function buildLinkIndex(
195
201
  anchors.set(note, new Set((record.anchors ?? []).map((a) => a.slug)));
196
202
 
197
203
  if (typeof fm.shortcode === "string" && fm.shortcode) {
198
- byKey.set(`${type}/${fm.shortcode}`.toLowerCase(), note);
199
- // The canonical, fully qualified address alongside the short one,
200
- // so a package-qualified link checks the same way a bare one does.
204
+ // Canonical addresses only. Every written target expands to one
205
+ // before lookup (#336), so there is nothing left for a short key to
206
+ // answer and the short key was harmful: `type/shortcode` is
207
+ // system-blind, set with a plain `Map.set`, so two notes in one
208
+ // package sharing a `(type, shortcode)` across systems silently
209
+ // overwrote each other in that slot while both canonical keys sat
210
+ // correctly beside it.
211
+ //
201
212
  // Taken from the record, which is where the address rule is applied
202
213
  // once for the whole build.
203
214
  const canonical =
@@ -205,7 +216,6 @@ export function buildLinkIndex(
205
216
  canonicalKey(pkg, systemOf(type, KNOWN_DOCUMENT_SUBTYPE_MAPS), type, fm.shortcode);
206
217
  byKey.set(canonical, note);
207
218
  if (hasDocEntry(type)) {
208
- byKey.set(`doc${type}/${fm.shortcode}`.toLowerCase(), note);
209
219
  // A documentation journal is `none`: no game system defines a
210
220
  // JournalEntry, and one note has one of them however many
211
221
  // system blocks it carries.
@@ -341,23 +351,19 @@ export function buildLinkIndex(
341
351
  return matchAddress([...byKey], q).map(([, v]) => v);
342
352
  }
343
353
 
344
- function resolveAddress(target) {
354
+ function resolveAddress(target, keyPath) {
345
355
  const qualified = readQualifier(target, types, packages);
346
356
  if (!qualified || qualified.reason) return undefined;
347
- // A target naming neither package nor system stays the system-blind
348
- // short key, which is already the wildcard an author writing
349
- // `[[skill-melee]]` means.
357
+ // Every omitted segment defaults from where the link is written (#336),
358
+ // so the target expands to exactly one canonical address and this is a
359
+ // plain lookup. There is no candidate set, and therefore no single-hit
360
+ // rule and no ambiguity to report.
350
361
  //
351
- // Anything that *does* state one is matched by the segments it supplied
352
- // rather than by an exact key (#59), and exactly one hit is required —
353
- // two systems' documents legitimately share a
354
- // `(package, type, shortcode)`, so a target that names no system may
355
- // name two notes, and naming two is not resolving.
356
- if (!qualified.package && !qualified.system) {
357
- return byKey.get(`${qualified.type}/${qualified.shortcode}`.toLowerCase());
358
- }
359
- const hits = matchLocal(qualified);
360
- return hits.length === 1 ? hits[0] : undefined;
362
+ // It replaced a system-blind short key, `type/shortcode`, populated by
363
+ // plain `Map.set` so two notes in one package sharing a
364
+ // `(type, shortcode)` across systems silently overwrote each other, and
365
+ // a bare link resolved to whichever was indexed second.
366
+ return byKey.get(expandAddress(qualified, { package: pkg, system: blockSystem(keyPath) }));
361
367
  }
362
368
 
363
369
  /**
@@ -375,10 +381,20 @@ export function buildLinkIndex(
375
381
  * @param {string} target - The link target.
376
382
  * @returns {object[]} The foreign entries, each carrying its `package`.
377
383
  */
378
- function foreignHits(target) {
384
+ function foreignHits(target, keyPath) {
379
385
  const q = readQualifier(target, types, packages);
380
386
  if (!q || q.reason) return [];
381
- return matchAddress([...foreign.index], q).map(([, v]) => v);
387
+ // An omitted package means *this* package (#336), so a short form
388
+ // addresses nothing foreign and never reaches a dependency's index.
389
+ // Reaching another package is the fully qualified form's job, and
390
+ // saying so is the whole point: a link that resolved into `sohl` only
391
+ // because no local note claimed the address was resolving by accident,
392
+ // and would have retargeted silently the day one did.
393
+ if (!q.package) return [];
394
+ const hit = foreign.index.get(
395
+ expandAddress(q, { package: q.package, system: blockSystem(keyPath) }),
396
+ );
397
+ return hit ? [hit] : [];
382
398
  }
383
399
 
384
400
  /**