@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.
- package/CHANGELOG.md +495 -0
- package/CONTENT.md +185 -25
- package/README.md +43 -4
- package/bin/content-build.mjs +87 -2
- package/config.mjs +9 -1
- package/content-config.mjs +89 -18
- package/e2e.mjs +297 -3
- package/engine/content-charset.mjs +434 -0
- package/engine/content-icons.mjs +388 -0
- package/engine/foreign-catalog.mjs +109 -7
- package/engine/frontmatter-lint.mjs +191 -29
- package/engine/generate.mjs +5 -2
- package/engine/helpers.mjs +10 -1
- package/engine/index.mjs +6 -0
- package/engine/note-claims.mjs +208 -5
- 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/schema-extract.mjs +13 -0
- package/package.json +1 -1
- package/types/config.d.mts +7 -0
- package/types/e2e.d.mts +130 -3
- package/types/engine/content-charset.d.mts +127 -0
- package/types/engine/content-icons.d.mts +151 -0
- package/types/engine/foreign-catalog.d.mts +38 -2
- package/types/engine/frontmatter-lint.d.mts +146 -28
- package/types/engine/helpers.d.mts +8 -0
- package/types/engine/index.d.mts +2 -0
- package/types/engine/note-claims.d.mts +67 -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
|
@@ -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
|
+
}
|