@heroiclands/package-build 20.0.0 → 20.2.1

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.
@@ -0,0 +1,434 @@
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
+ * The charset authored content is held to, so a book can choose its face (#377).
16
+ *
17
+ * The packs and the website render in whatever font the reader's browser or
18
+ * Foundry supplies, and a glyph nobody has is somebody else's problem. **A book
19
+ * is not that.** A PDF embeds the faces it sets, so every character in the
20
+ * corpus is a claim on the book's typeface — and the claim is silent, which is
21
+ * what makes it expensive.
22
+ *
23
+ * **Typst does not warn about a missing glyph.** It falls back to whatever
24
+ * system font happens to carry one and exits 0. A single page asking for
25
+ * Libertinus Serif was observed to embed six fonts — Libertinus, Geneva, Arial,
26
+ * STSong, SF NS, and macOS LastResort, which draws a literal tofu box — with no
27
+ * diagnostic of any kind. So a rules table can set in three unrelated faces and
28
+ * the build still reports success. Nothing downstream will catch that; it has
29
+ * to be caught where the character is written.
30
+ *
31
+ * **The tiers are measured, not chosen.** Eight candidate book faces were
32
+ * probed over every non-ASCII character in the five content trees — Charis SIL,
33
+ * Libertinus Serif, EB Garamond, Georgia, Palatino, Times New Roman, Hoefler
34
+ * Text and Iowan Old Style — by setting one codepoint per page and reading back
35
+ * which font each page actually embedded. What survives below is what enough of
36
+ * them carry:
37
+ *
38
+ * - **Letters and typography: 8 of 8.** Punctuation is the safest thing in the
39
+ * corpus, which is worth saying because it looks exotic and is not: the em
40
+ * dash alone runs to 24,622 occurrences.
41
+ * - **Latin Extended Additional: 7 of 8.** Only Hoefler Text lacks the
42
+ * dot-unders the transliterated notes are built on, and one face is a price
43
+ * worth paying for `Ādānaśreṇī`.
44
+ * - **IPA Extensions: 5 of 8.** Considered for the allowlist and *rejected* —
45
+ * requiring it would have cost font freedom rather than bought it, which is
46
+ * the opposite of this module's purpose.
47
+ *
48
+ * **What is banned is banned by category, not by glyph**, because the next
49
+ * emoji nobody has thought of yet should fail on arrival rather than after it
50
+ * ships. The invisible characters are listed even though the corpus contains
51
+ * none of them: a zero-width space or a bidi override is the one class of
52
+ * defect a proofreader cannot see, and the moment to refuse it is before it
53
+ * arrives.
54
+ *
55
+ * **Two rules an allowlist cannot express** ride along here:
56
+ *
57
+ * - Content must be **NFC**. Canonically-equivalent spellings are different
58
+ * strings to every byte comparison, and DuckDB's `=` is one — so a book leaf
59
+ * filtering `name.full = 'Fývria'` typed in NFC selects nothing from a note
60
+ * stored decomposed, and reports nothing, because a clause that matches *some*
61
+ * rows looks like a clause that worked.
62
+ * - Diagram characters are permitted **inside a fenced code block only**, where
63
+ * the mono face sets them. Verified against DejaVu Sans Mono, which Typst
64
+ * embeds: it carries the box-drawing, geometric and arrow repertoire that no
65
+ * candidate serif reliably has.
66
+ *
67
+ * **This module reports and does not exit**, as every gate in this engine does.
68
+ * The command decides what a finding is worth.
69
+ *
70
+ * @module
71
+ */
72
+
73
+ import fs from "node:fs";
74
+ import path from "node:path";
75
+
76
+ /**
77
+ * Tier 1 — the letters, and the two whitespace characters a file is made of.
78
+ *
79
+ * The Latin-1 range is split around `U+00D7` and `U+00F7` deliberately: `×` and
80
+ * `÷` sit inside the letter block but are operators, and they are admitted
81
+ * below in Tier 3 on their own merits rather than smuggled in as letters.
82
+ *
83
+ * @param {number} cp - A Unicode code point.
84
+ * @returns {boolean} Whether Tier 1 admits it.
85
+ */
86
+ export function isLetterTier(cp) {
87
+ return (
88
+ cp === 0x09 ||
89
+ cp === 0x0a ||
90
+ (cp >= 0x20 && cp <= 0x7e) || // ASCII printable
91
+ (cp >= 0xc0 && cp <= 0xd6) || // Latin-1 letters, excluding ×
92
+ (cp >= 0xd8 && cp <= 0xf6) || // ... and excluding ÷
93
+ (cp >= 0xf8 && cp <= 0xff) ||
94
+ (cp >= 0x100 && cp <= 0x17f) || // Latin Extended-A
95
+ (cp >= 0x180 && cp <= 0x24f) || // Latin Extended-B
96
+ (cp >= 0x1e00 && cp <= 0x1eff) // Latin Extended Additional
97
+ );
98
+ }
99
+
100
+ /**
101
+ * Tier 2 — typography, enumerated one codepoint at a time.
102
+ *
103
+ * **Not the General Punctuation block.** `U+2000`–`U+206F` carries `U+200B`
104
+ * ZERO WIDTH SPACE, the `U+200E`/`U+200F` direction marks, the `U+2028`/`U+2029`
105
+ * separators, the `U+202A`–`U+202E` bidi overrides and `U+2060` WORD JOINER —
106
+ * precisely the invisibles this charset exists to refuse. Admitting the block
107
+ * to reach the em dash would admit all of them, so the ten that are wanted are
108
+ * named and the block is not.
109
+ *
110
+ * @type {ReadonlySet<number>}
111
+ */
112
+ export const TYPOGRAPHY = Object.freeze(
113
+ new Set([
114
+ 0x2013, // – en dash
115
+ 0x2014, // — em dash
116
+ 0x2018, // ' left single quote
117
+ 0x2019, // ' right single quote / apostrophe
118
+ 0x201c, // " left double quote
119
+ 0x201d, // " right double quote
120
+ 0x2026, // … ellipsis
121
+ 0x00b7, // · middle dot
122
+ 0x00a7, // § section sign
123
+ 0x00b4, // ´ acute accent, as a character discussed in prose
124
+ ]),
125
+ );
126
+
127
+ /**
128
+ * Tier 3 — the notation the rules and price tables are written in.
129
+ *
130
+ * Every one of these is carried by seven or eight of the eight probed faces, so
131
+ * the tier costs nothing in font freedom. It is a separate tier from the
132
+ * typography above only because it is a separate argument: these earn their
133
+ * place by being *needed* — a Shock threshold reads `≥ 10`, a wall is `10′ ×
134
+ * 11′` — where the typography earns it by being unavoidable.
135
+ *
136
+ * @type {ReadonlySet<number>}
137
+ */
138
+ export const NOTATION = Object.freeze(
139
+ new Set([
140
+ // Operators: − × ÷ ± °
141
+ 0x2212, 0x00d7, 0x00f7, 0x00b1, 0x00b0,
142
+ // Relations: ≤ ≥ ≈ ∞
143
+ 0x2264, 0x2265, 0x2248, 0x221e,
144
+ // Fractions and superscripts: ¼ ½ ¾ ² ³ ¹ ⁰ ⁴
145
+ 0x00bc, 0x00bd, 0x00be, 0x00b2, 0x00b3, 0x00b9, 0x2070, 0x2074,
146
+ // Currency, legal marks and the prime pair: £ © ® ′ ″
147
+ 0x00a3, 0x00a9, 0x00ae, 0x2032, 0x2033,
148
+ ]),
149
+ );
150
+
151
+ /**
152
+ * Whether the charset admits a code point anywhere in a note.
153
+ *
154
+ * @param {number} cp - A Unicode code point.
155
+ * @returns {boolean} Whether it is allowed outside a code fence.
156
+ */
157
+ export function isAllowedCodePoint(cp) {
158
+ return isLetterTier(cp) || TYPOGRAPHY.has(cp) || NOTATION.has(cp);
159
+ }
160
+
161
+ /**
162
+ * Whether a code point is diagram furniture, admitted inside a fence only.
163
+ *
164
+ * A fenced block is set in the mono face, and the mono face is not the book
165
+ * face — so the question "does the text font have this" is the wrong question
166
+ * to ask about a character in an ASCII-art org chart. All three ranges were
167
+ * confirmed present in DejaVu Sans Mono, the mono face Typst embeds.
168
+ *
169
+ * @param {number} cp - A Unicode code point.
170
+ * @returns {boolean} Whether a fence may carry it.
171
+ */
172
+ export function isDiagramCodePoint(cp) {
173
+ return (
174
+ (cp >= 0x2500 && cp <= 0x257f) || // box drawing
175
+ (cp >= 0x25a0 && cp <= 0x25ff) || // geometric shapes
176
+ (cp >= 0x2190 && cp <= 0x21ff) // arrows
177
+ );
178
+ }
179
+
180
+ /**
181
+ * Why a given code point is refused, in the words a diagnostic should use.
182
+ *
183
+ * Ordered most specific first, so `U+FE0F` is reported as a variation selector
184
+ * rather than as an unnamed character in a high plane. A range with no entry
185
+ * falls through to a generic message: the list explains the categories the
186
+ * corpus actually grew, and inventing prose for every unassigned block would be
187
+ * guessing at a reason.
188
+ *
189
+ * @type {ReadonlyArray<{from: number, to: number, why: string}>}
190
+ */
191
+ const REFUSALS = Object.freeze([
192
+ { from: 0x0300, to: 0x036f, why: "a combining mark; write the precomposed letter instead" },
193
+ {
194
+ from: 0x0250,
195
+ to: 0x02af,
196
+ why: "IPA, which three of eight candidate book faces lack — describe the sound instead",
197
+ },
198
+ {
199
+ from: 0x02b0,
200
+ to: 0x02ff,
201
+ why: "a spacing modifier or tone letter, carried by two of eight candidate book faces",
202
+ },
203
+ { from: 0x0370, to: 0x03ff, why: "Greek; spell the sound out rather than citing the letter" },
204
+ {
205
+ from: 0xa720,
206
+ to: 0xa7ff,
207
+ why: "Latin Extended-D, carried by one of eight candidate book faces",
208
+ },
209
+ {
210
+ from: 0x2500,
211
+ to: 0x257f,
212
+ why: "box drawing, which is allowed inside a fenced code block and nowhere else",
213
+ },
214
+ {
215
+ from: 0x25a0,
216
+ to: 0x25ff,
217
+ why: "a geometric shape, which is allowed inside a fenced code block and nowhere else",
218
+ },
219
+ { from: 0x2190, to: 0x21ff, why: "an arrow; write `>` for a menu path or a derivation" },
220
+ {
221
+ from: 0x2700,
222
+ to: 0x27bf,
223
+ why: "a dingbat, which no candidate book face carries — use the icon role",
224
+ },
225
+ {
226
+ from: 0x2600,
227
+ to: 0x26ff,
228
+ why: "a miscellaneous symbol, which no candidate book face carries — use the icon role",
229
+ },
230
+ { from: 0x1f000, to: 0x1faff, why: "an emoji, which no book face sets — use the icon role" },
231
+ { from: 0xfe00, to: 0xfe0f, why: "an invisible variation selector" },
232
+ { from: 0xff00, to: 0xffef, why: "a fullwidth form; write the ASCII character" },
233
+ {
234
+ from: 0x2000,
235
+ to: 0x206f,
236
+ why: "an unlisted General Punctuation character, several of which are invisible",
237
+ },
238
+ ]);
239
+
240
+ /** Invisible characters named individually, because their reason is their name. */
241
+ const INVISIBLES = Object.freeze(
242
+ new Map([
243
+ [0x00a0, "a no-break space"],
244
+ [0x00ad, "a soft hyphen"],
245
+ [0xfeff, "a byte order mark"],
246
+ [0x000d, "a carriage return; this tree uses LF line endings"],
247
+ ]),
248
+ );
249
+
250
+ /**
251
+ * The reason a code point is refused.
252
+ *
253
+ * @param {number} cp - A Unicode code point.
254
+ * @returns {string} A clause naming what it is and what to do instead.
255
+ */
256
+ export function refusalFor(cp) {
257
+ const named = INVISIBLES.get(cp);
258
+ if (named) return named;
259
+ for (const { from, to, why } of REFUSALS) if (cp >= from && cp <= to) return why;
260
+ return "outside the content charset";
261
+ }
262
+
263
+ /** `U+XXXX`, in the spelling every Unicode reference uses. */
264
+ const hex = (cp) => `U+${cp.toString(16).toUpperCase().padStart(4, "0")}`;
265
+
266
+ /**
267
+ * Every non-NFC run in a string, with the composed form it should have been.
268
+ *
269
+ * Reported as **runs** rather than as bare combining marks, because `y` plus
270
+ * `U+0301` is one authoring mistake and pointing at the accent alone would name
271
+ * the half the author did not type.
272
+ *
273
+ * @param {string} text - File contents.
274
+ * @returns {Array<{sequence: string, composed: string, index: number}>} Each
275
+ * offending run, in the order it appears.
276
+ */
277
+ export function decomposedRuns(text) {
278
+ const out = [];
279
+ for (const m of text.matchAll(/(\P{M})(\p{M}+)/gu)) {
280
+ const sequence = m[0];
281
+ const composed = sequence.normalize("NFC");
282
+ if (composed === sequence) continue;
283
+ out.push({ sequence, composed, index: m.index ?? 0 });
284
+ }
285
+ return out;
286
+ }
287
+
288
+ /**
289
+ * Check one file's text against the charset and the normalization rule.
290
+ *
291
+ * Findings are **deduplicated per character per line**: a 60-cell table of
292
+ * `━` is one mistake made once, and sixty findings would bury the other
293
+ * fifty-nine things wrong with the tree.
294
+ *
295
+ * @param {string} text - The file's contents.
296
+ * @param {string} file - Path to report, relative to the tree.
297
+ * @returns {Array<{file: string, line: number, column: number,
298
+ * severity: "error", message: string}>} What is wrong, in file order.
299
+ */
300
+ export function checkText(text, file) {
301
+ const findings = [];
302
+ const runs = decomposedRuns(text);
303
+
304
+ // A decomposed letter is one mistake. Its combining mark would otherwise be
305
+ // refused by the charset *and* reported as a normalization error, which
306
+ // tells the author twice and offers two different fixes for one edit. The
307
+ // normalization finding wins because it names the precomposed replacement,
308
+ // so the offsets its runs cover are struck from the character scan. A
309
+ // combining mark that composes to nothing is in no run, and is still
310
+ // refused below on its own account.
311
+ const composedAway = new Set();
312
+ for (const run of runs) {
313
+ for (let k = 0; k < run.sequence.length; k++) composedAway.add(run.index + k);
314
+ }
315
+
316
+ const lines = text.split("\n");
317
+ let inFence = false;
318
+ let offset = 0;
319
+
320
+ for (let i = 0; i < lines.length; i++) {
321
+ const line = lines[i];
322
+ const lineStart = offset;
323
+ offset += line.length + 1; // the newline `split` removed
324
+
325
+ if (/^\s*(```|~~~)/.test(line)) {
326
+ inFence = !inFence;
327
+ continue;
328
+ }
329
+
330
+ const reportedOnThisLine = new Set();
331
+ let column = 0;
332
+ let within = 0;
333
+ for (const ch of line) {
334
+ column += 1;
335
+ const at = lineStart + within;
336
+ within += ch.length;
337
+ const cp = ch.codePointAt(0) ?? 0;
338
+ if (isAllowedCodePoint(cp)) continue;
339
+ if (composedAway.has(at)) continue;
340
+ // Inside a fence the mono face sets the text, so the diagram
341
+ // repertoire is judged against that font rather than the book's.
342
+ if (inFence && isDiagramCodePoint(cp)) continue;
343
+ if (reportedOnThisLine.has(cp)) continue;
344
+ reportedOnThisLine.add(cp);
345
+
346
+ findings.push({
347
+ file,
348
+ line: i + 1,
349
+ column,
350
+ severity: /** @type {const} */ ("error"),
351
+ message: `\`${ch}\` ${hex(cp)} is ${refusalFor(cp)}`,
352
+ });
353
+ }
354
+ }
355
+
356
+ // Normalization is a property of the whole file, so it is checked once
357
+ // rather than per line — but reported at the run, which is where the fix is.
358
+ for (const run of runs) {
359
+ const before = text.slice(0, run.index);
360
+ const line = before.split("\n").length;
361
+ const column = run.index - (before.lastIndexOf("\n") + 1) + 1;
362
+ const points = [...run.sequence].map((c) => hex(c.codePointAt(0) ?? 0)).join(" ");
363
+ findings.push({
364
+ file,
365
+ line,
366
+ column,
367
+ severity: /** @type {const} */ ("error"),
368
+ message:
369
+ `\`${run.sequence}\` is written decomposed as ${points}; write the ` +
370
+ `precomposed \`${run.composed}\` (${[...run.composed]
371
+ .map((c) => hex(c.codePointAt(0) ?? 0))
372
+ .join(" ")}) — the two are the same letter and different strings, ` +
373
+ `so an exact-match filter finds one and not the other`,
374
+ });
375
+ }
376
+
377
+ return findings.sort((a, b) => a.line - b.line || a.column - b.column);
378
+ }
379
+
380
+ /**
381
+ * Walk a content tree and check every authored file in it.
382
+ *
383
+ * Dot-directories are skipped: `.obsidian` carries editor state, and a plugin
384
+ * manifest's CRLF line endings are not this tree's prose. That is not a
385
+ * theoretical exclusion — it was the first thing a run over `sohl-thalorna`
386
+ * reported before the skip existed.
387
+ *
388
+ * @param {string} contentBase - Root of the content tree.
389
+ * @param {object} [opts]
390
+ * @param {readonly string[]} [opts.skipDirectories] - Directory names to ignore
391
+ * in addition to the dot-directories always skipped.
392
+ * @param {readonly string[]} [opts.extensions] - File extensions to read.
393
+ * @returns {{findings: Array<{file: string, line: number, column: number,
394
+ * severity: "error", message: string}>, files: number}} The findings, and how
395
+ * many files produced them.
396
+ */
397
+ export function lintContentCharset(contentBase, { skipDirectories = [], extensions } = {}) {
398
+ const exts = new Set(extensions ?? [".md", ".markdown", ".yaml", ".yml", ".json"]);
399
+ const skip = new Set(skipDirectories);
400
+ /** @type {Array<{file: string, line: number, column: number, severity: "error", message: string}>} */
401
+ const findings = [];
402
+ let files = 0;
403
+
404
+ /** @param {string} dir - Directory to descend into. */
405
+ const walk = (dir) => {
406
+ /** @type {import("node:fs").Dirent[]} */
407
+ let entries;
408
+ try {
409
+ entries = fs.readdirSync(dir, { withFileTypes: true });
410
+ } catch {
411
+ return;
412
+ }
413
+ for (const entry of entries) {
414
+ if (entry.name.startsWith(".") || skip.has(entry.name)) continue;
415
+ const full = path.join(dir, entry.name);
416
+ if (entry.isDirectory()) {
417
+ walk(full);
418
+ continue;
419
+ }
420
+ if (!exts.has(path.extname(entry.name).toLowerCase())) continue;
421
+ let text;
422
+ try {
423
+ text = fs.readFileSync(full, "utf8");
424
+ } catch {
425
+ continue;
426
+ }
427
+ files += 1;
428
+ findings.push(...checkText(text, path.relative(contentBase, full)));
429
+ }
430
+ };
431
+
432
+ walk(contentBase);
433
+ return { findings, files };
434
+ }