@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.
- package/CHANGELOG.md +1100 -0
- package/CONTENT.md +264 -33
- package/README.md +43 -4
- package/bin/content-build.mjs +94 -3
- package/config.mjs +9 -1
- package/content-config.mjs +99 -19
- package/docs/content-format.md +394 -72
- package/e2e.mjs +297 -3
- package/engine/actor-compiler.mjs +197 -7
- package/engine/address-charset.mjs +23 -5
- package/engine/base-compiler.mjs +63 -2
- package/engine/bundles.mjs +9 -0
- package/engine/content-address.mjs +92 -1
- package/engine/content-charset.mjs +434 -0
- package/engine/content-format.mjs +102 -0
- package/engine/content-icons.mjs +388 -0
- package/engine/content-index.mjs +11 -8
- package/engine/content-links.mjs +37 -21
- package/engine/field-reference.mjs +57 -5
- package/engine/field-spec.mjs +214 -7
- package/engine/folder-notes.mjs +24 -1
- package/engine/foreign-catalog.mjs +112 -7
- package/engine/foundry-entries.mjs +14 -0
- package/engine/frontmatter-lint.mjs +377 -56
- package/engine/frontmatter.mjs +11 -11
- package/engine/generate.mjs +72 -12
- package/engine/helpers.mjs +96 -10
- package/engine/index.mjs +9 -0
- package/engine/item-compiler.mjs +37 -0
- package/engine/journals.mjs +21 -4
- package/engine/macros.mjs +8 -0
- package/engine/map-notes.mjs +7 -7
- package/engine/note-claims.mjs +208 -5
- package/engine/note-ids.mjs +25 -1
- package/engine/note-vocabulary.mjs +76 -9
- package/engine/pack-config.mjs +102 -12
- package/engine/pack-router.mjs +0 -0
- package/engine/prose-config.mjs +42 -0
- package/engine/prose-lint.mjs +126 -0
- package/engine/retired-fields.mjs +57 -16
- package/engine/runtime-only-fields.mjs +204 -0
- package/engine/scenes.mjs +12 -19
- package/engine/schema-check.mjs +23 -1
- package/engine/schema-extract.mjs +13 -0
- package/engine/site-index.mjs +17 -0
- package/engine/subtype-registry.mjs +30 -0
- package/engine/system-block.mjs +81 -3
- package/engine/web-wikilinks.mjs +33 -27
- package/engine/wikilink-syntax.mjs +7 -0
- package/engine/wikilinks.mjs +74 -16
- package/hm3/actors.mjs +63 -13
- package/package.json +2 -2
- package/sohl/actors.mjs +106 -7
- package/sohl/item-fields.mjs +203 -0
- package/sohl/note-schemas.mjs +6 -3
- package/types/config.d.mts +7 -0
- package/types/e2e.d.mts +130 -3
- package/types/engine/actor-compiler.d.mts +83 -3
- package/types/engine/address-charset.d.mts +22 -4
- package/types/engine/base-compiler.d.mts +54 -3
- package/types/engine/content-address.d.mts +64 -0
- package/types/engine/content-charset.d.mts +127 -0
- package/types/engine/content-format.d.mts +9 -0
- package/types/engine/content-icons.d.mts +151 -0
- package/types/engine/field-spec.d.mts +271 -3
- package/types/engine/folder-notes.d.mts +20 -0
- package/types/engine/foreign-catalog.d.mts +38 -2
- package/types/engine/foundry-entries.d.mts +6 -0
- package/types/engine/frontmatter-lint.d.mts +164 -30
- package/types/engine/frontmatter.d.mts +11 -11
- package/types/engine/generate.d.mts +27 -0
- package/types/engine/helpers.d.mts +45 -9
- package/types/engine/index.d.mts +3 -0
- package/types/engine/map-notes.d.mts +2 -2
- package/types/engine/note-claims.d.mts +67 -0
- package/types/engine/note-ids.d.mts +14 -0
- package/types/engine/pack-config.d.mts +35 -0
- package/types/engine/prose-config.d.mts +41 -0
- package/types/engine/prose-lint.d.mts +36 -0
- package/types/engine/retired-fields.d.mts +29 -13
- package/types/engine/runtime-only-fields.d.mts +102 -0
- package/types/engine/schema-check.d.mts +10 -1
- package/types/engine/subtype-registry.d.mts +21 -0
- package/types/engine/system-block.d.mts +28 -2
- 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, "&").replace(/</g, "<").replace(/"/g, """);
|
|
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
|
+
}
|
package/engine/content-index.mjs
CHANGED
|
@@ -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
|
|
494
|
-
* — plus `documents`, naming the record it is the documentation
|
|
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
|
|
606
|
-
//
|
|
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") ||
|
package/engine/content-links.mjs
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
199
|
-
//
|
|
200
|
-
//
|
|
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
|
-
//
|
|
348
|
-
//
|
|
349
|
-
//
|
|
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
|
-
//
|
|
352
|
-
//
|
|
353
|
-
//
|
|
354
|
-
//
|
|
355
|
-
|
|
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
|
-
|
|
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
|
/**
|