@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
package/engine/base-compiler.mjs
CHANGED
|
@@ -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 {(
|
|
275
|
-
* Resolves a `
|
|
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++;
|
package/engine/bundles.mjs
CHANGED
|
@@ -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
|
+
}
|