@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
@@ -199,6 +199,34 @@ export class BasePackCompiler {
199
199
  */
200
200
  static requiresSystemBlock = false;
201
201
 
202
+ /**
203
+ * The **art fields** this pass reads off a note and writes onto its
204
+ * document — `img`, `portrait`, whichever of them reaches the output.
205
+ *
206
+ * Empty by default, and every shipped pass states its own, for the reason
207
+ * {@link BasePackCompiler.readsPackOutputOf} does: the fact belongs to the
208
+ * class that does the writing, and a second list of it somewhere else is a
209
+ * list free to disagree with what is actually emitted.
210
+ *
211
+ * The reader is the frontmatter lint. `img` is a *shared top-level* field —
212
+ * legal on every note whatever its type, because
213
+ * `BLOCK_DOCUMENT_PROPERTIES` maps it onto `document.img` — so a note whose
214
+ * document has no such property authors it, validates, compiles, and loses
215
+ * the value with nothing said. That is #349: `Parrot` in `sohl-thalorna`
216
+ * had declared `img:` since long before the art rule existed and compiled
217
+ * `img: null` exactly as a note declaring nothing does. Naming the fields
218
+ * here is what lets the lint tell an inert key from a live one.
219
+ *
220
+ * A pass that emits art **anywhere** in its document declares it, not only
221
+ * one that writes a top-level `img`: the scenes pass puts the path on the
222
+ * scene's background rather than on a property called `img`, and the value
223
+ * is no less live for it. The question this answers is whether the authored
224
+ * path reaches the output at all.
225
+ *
226
+ * @type {readonly string[]}
227
+ */
228
+ static emitsArt = Object.freeze([]);
229
+
202
230
  /** @type {string} */
203
231
  contentBase;
204
232
  /** @type {string} */
@@ -271,8 +299,9 @@ export class BasePackCompiler {
271
299
  * @param {string} options.dest - Where this pass writes its JSON.
272
300
  * @param {readonly string[]} options.skipDirectories - Directories the walk
273
301
  * never descends into. Required: see {@link assertStatedScope}.
274
- * @param {(path: string|null) => string|null} [options.folderResolver] -
275
- * Resolves a `sohl.folder` id against this pack's folder hierarchy.
302
+ * @param {(address: string|null) => string|null} [options.folderResolver] -
303
+ * Resolves a `packFolder` a folder note's address — to the Foundry
304
+ * folder id it materialises as in this pack (#255, #257).
276
305
  * @param {string} [options.packName] - The pack this pass writes.
277
306
  * @param {string} [options.docType] - The Foundry document type it holds.
278
307
  * @param {{resolve: Function}} [options.router] - The pack router. Omit it
@@ -415,6 +444,31 @@ export class BasePackCompiler {
415
444
  );
416
445
  }
417
446
 
447
+ /**
448
+ * A refusal only this pass can make, because its subject is the note's
449
+ * **type** (#330).
450
+ *
451
+ * The `assertNo*Field` family above it in the walk is type-agnostic by
452
+ * construction: it runs before `selects`, so that a note declaring a
453
+ * retired field is answered whichever pass would have claimed it. A rule
454
+ * about what a *`trauma`* may write cannot live there — it needs the type's
455
+ * field declaration, which only the pass that compiles the type can reach.
456
+ *
457
+ * So it is a hook, called once the note is known to be this pass's, and its
458
+ * throw is counted and located exactly as the family's is: the note is
459
+ * declined rather than skipped, and the build fails naming the line.
460
+ *
461
+ * The default refuses nothing, which is the honest position for a pass
462
+ * whose documents have no schema to have opinions about.
463
+ *
464
+ * @param {object} fm - The note's frontmatter.
465
+ * @returns {void}
466
+ * @throws {Error} When the note authors something its type forbids. The
467
+ * error may carry a `position` for the diagnostic.
468
+ */
469
+ // eslint-disable-next-line no-unused-vars
470
+ assertAuthorable(fm) {}
471
+
418
472
  /**
419
473
  * Whether this pass claims a note. **Required.**
420
474
  *
@@ -967,6 +1021,13 @@ export class BasePackCompiler {
967
1021
  stats.skippedOther++;
968
1022
  continue;
969
1023
  }
1024
+ // The type-specific half of the retired-field family (#330):
1025
+ // what a note of *this* type may not write, which needs the
1026
+ // type's own field declaration and so cannot be asked before
1027
+ // `selects`. Counted as a declined note for the same reason
1028
+ // they are — the alternative is a tree that compiles fewer
1029
+ // documents than it has notes and exits 0.
1030
+ this.assertAuthorable(fm);
970
1031
  } catch (err) {
971
1032
  stats.declined++;
972
1033
  this.errorCount++;
@@ -135,6 +135,15 @@ export class Bundles extends BasePackCompiler {
135
135
  */
136
136
  static readsPackOutputOf = Object.freeze(["Actor", "Item", "JournalEntry", "Macro", "Scene"]);
137
137
 
138
+ /**
139
+ * An Adventure carries an `img` — what Foundry shows on the import card.
140
+ * There is no default for it: a bundle naming none ships a blank tile,
141
+ * deliberately, since no stand-in artwork means "a set of documents".
142
+ *
143
+ * @type {readonly string[]}
144
+ */
145
+ static emitsArt = Object.freeze(["img"]);
146
+
138
147
  /**
139
148
  * The JSON directories this pass reads its members from, by document type.
140
149
  *
@@ -45,7 +45,9 @@ import { DEFAULT_ADDRESS_SCHEME } from "../content-config.mjs";
45
45
  // The system vocabulary is the `<system>` segment's own registry, and
46
46
  // `engine/systems.mjs` imports nothing but `engine/address-charset.mjs`, so
47
47
  // the direction is toward the leaf and cannot close a cycle.
48
- import { NO_SYSTEM, assertSystemSegment } from "./systems.mjs";
48
+ import { NO_SYSTEM, assertSystemSegment, isSystemSegment } from "./systems.mjs";
49
+ import { systemOf } from "./document-subtypes.mjs";
50
+ import { KNOWN_DOCUMENT_SUBTYPE_MAPS } from "./subtype-registry.mjs";
49
51
 
50
52
  // `ids.mjs` is a leaf with no local imports — the module note there says why —
51
53
  // so an address may hash itself without any risk of closing a cycle.
@@ -203,6 +205,95 @@ export function canonicalKey(pkg, system, type, shortcode) {
203
205
  return `${pkg}-${system}-${type}-${shortcode}`.toLowerCase();
204
206
  }
205
207
 
208
+ /**
209
+ * Which system a frontmatter key path is written under.
210
+ *
211
+ * The **enclosing system block** decides, at any depth within it, and nothing
212
+ * else does: `sohl.items[3].model` and `sohl.system.body.structure` are both
213
+ * `sohl` because both sit under `sohl:`. Everywhere else is {@link NO_SYSTEM} —
214
+ * top-level frontmatter, the shared `data:` container, and body prose, which has
215
+ * no key path at all and passes `undefined`.
216
+ *
217
+ * It is the block rather than the field, so a `WikiLink` field needs no opinion
218
+ * about systems and no per-field table has to be kept in step with the schema.
219
+ *
220
+ * The first segment must **be** a declared system, not merely look like one:
221
+ * `sohlish.items` is a key called `sohlish`, and `notes.sohl.thing` names no
222
+ * block at all.
223
+ *
224
+ * @param {string} [keyPath] - The dotted frontmatter key path, or `undefined`
225
+ * for body prose.
226
+ * @returns {string} The system id, or `none`.
227
+ */
228
+ export function blockSystem(keyPath) {
229
+ if (typeof keyPath !== "string" || !keyPath) return NO_SYSTEM;
230
+ const first = keyPath.split(".")[0].trim().toLowerCase();
231
+ return isSystemSegment(first) && first !== NO_SYSTEM ? first : NO_SYSTEM;
232
+ }
233
+
234
+ /**
235
+ * Expand a written address to the one canonical address it names.
236
+ *
237
+ * **An omitted segment defaults from where the link is written** (#336) — it is
238
+ * not a wildcard, and resolution is not a search. Package omitted means the
239
+ * citing note's own; system omitted means {@link blockSystem} of the key path it
240
+ * was written under. So every short form has exactly one expansion, computed
241
+ * before anything is looked up, and there is no candidate set to disambiguate.
242
+ *
243
+ * **Under `none`, a system-bearing type addresses its documentation journal.**
244
+ * A note's `none` address *is* its `doc<type>` entry — the Item is the one with
245
+ * a system — so a prose `[[affiliation-sirvadar|…]]` names the page, which is
246
+ * almost always what prose means. A link that means the Item states the system
247
+ * and gets it. This is the defaulting rule applied, not an exception carved out
248
+ * of it.
249
+ *
250
+ * **Only a type whose own document carries a system is redirected.** A `macro`
251
+ * and the map types have documentation journals too, but their own documents
252
+ * are core ones and already live at `none` — so `<pkg>-none-macro-x` names the
253
+ * Macro and `<pkg>-none-docmacro-x` its journal, two live addresses that the
254
+ * redirect would collapse into one. The test is the note type's own system,
255
+ * not merely whether it has a doc entry.
256
+ *
257
+ * A `doc<type>` written explicitly is `none` **wherever** it appears, even
258
+ * inside a system block: no game system defines a JournalEntry, so there is no
259
+ * other system for one to belong to.
260
+ *
261
+ * @param {{type: string, shortcode: string, package?: string, system?: string,
262
+ * itemDoc?: boolean}} read - A qualifier, as `readQualifier` returns one.
263
+ * @param {{package: string, system?: string}} where - The citing context: the
264
+ * tree's own content package, and the system of the block the link sits in.
265
+ * @returns {string} The canonical `package-system-type-shortcode`.
266
+ */
267
+ export function expandAddress(read, where) {
268
+ const pkg = read.package ?? where.package;
269
+ // A documentation journal is a core document, so it is `none` however it was
270
+ // reached; otherwise the block's system, which body prose reports as `none`.
271
+ const system = read.itemDoc ? NO_SYSTEM : (read.system ?? where.system ?? NO_SYSTEM);
272
+ const redirected = system === NO_SYSTEM && isSystemBearing(read.type);
273
+ const type = read.itemDoc || redirected ? `doc${read.type}` : read.type;
274
+ return canonicalKey(pkg, system, type, read.shortcode);
275
+ }
276
+
277
+ /**
278
+ * Whether a note type's **own** document carries a game system.
279
+ *
280
+ * True for the types some shipped map compiles into an Item or an Actor; false
281
+ * for the core-document types — `doc`, `lore`, `place`, `scenario`, `macro` and
282
+ * the map types — whose documents Foundry itself defines and which therefore
283
+ * already live at `none`.
284
+ *
285
+ * It is what {@link expandAddress} tests rather than {@link hasDocEntry}: a
286
+ * `macro` has a documentation journal *and* a `none` address of its own, so
287
+ * redirecting on "has a doc entry" would collapse two live addresses into one
288
+ * and a `[[macro-autoattack|]]` would stop naming the Macro.
289
+ *
290
+ * @param {string} type - The note type.
291
+ * @returns {boolean} True when the type compiles into a system document.
292
+ */
293
+ function isSystemBearing(type) {
294
+ return systemOf(type, KNOWN_DOCUMENT_SUBTYPE_MAPS) !== NO_SYSTEM;
295
+ }
296
+
206
297
  /**
207
298
  * How many segments a canonical key has, and therefore how many the reader
208
299
  * below counts.
@@ -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
+ }