@heroiclands/package-build 23.12.5 → 23.12.7

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 (95) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/assets/styles/content-base.css +25 -0
  3. package/bin/content-commands.mjs +264 -3
  4. package/bin/package-build.mjs +25 -0
  5. package/content-config.mjs +98 -0
  6. package/docs/api.md +18 -14
  7. package/docs/authoring/dates-and-calendars.md +29 -1
  8. package/docs/authoring/links-and-markup.md +442 -50
  9. package/docs/commands.md +127 -0
  10. package/docs/configuration.md +90 -3
  11. package/docs/content-format.md +3 -1
  12. package/docs/guides/book.md +11 -0
  13. package/docs/guides/module-project.md +17 -1
  14. package/docs/guides/site.md +19 -0
  15. package/docs/reference/format-details.md +434 -23
  16. package/docs/reference/note-types.md +45 -21
  17. package/engine/base-compiler.mjs +2 -0
  18. package/engine/bundles.mjs +7 -2
  19. package/engine/compile-corpus.mjs +3 -1
  20. package/engine/content-charset.mjs +21 -2
  21. package/engine/content-format.yaml +1 -2
  22. package/engine/content-index.mjs +33 -2
  23. package/engine/content-links.mjs +3 -0
  24. package/engine/content-poetry.mjs +22 -4
  25. package/engine/content-scripts.mjs +1219 -0
  26. package/engine/content-session.mjs +2 -0
  27. package/engine/data-keys.mjs +5 -1
  28. package/engine/dictionary-layout.mjs +18 -12
  29. package/engine/dictionary-rows.mjs +11 -2
  30. package/engine/event-rows.mjs +63 -18
  31. package/engine/event-views.mjs +28 -15
  32. package/engine/helpers.mjs +8 -2
  33. package/engine/index-records.mjs +2 -0
  34. package/engine/infobox.mjs +4 -8
  35. package/engine/journals.mjs +14 -3
  36. package/engine/language-data.mjs +137 -1
  37. package/engine/language-tag.mjs +103 -0
  38. package/engine/language-terms.mjs +16 -4
  39. package/engine/map-lexicon.mjs +762 -0
  40. package/engine/markdown-expressions.mjs +72 -17
  41. package/engine/markup-validation.mjs +17 -3
  42. package/engine/names.mjs +1 -1
  43. package/engine/note-calendars.mjs +314 -0
  44. package/engine/note-vocabulary.mjs +124 -16
  45. package/engine/pdf-build.mjs +28 -10
  46. package/engine/pdf-render.mjs +116 -11
  47. package/engine/reckoning-markers.mjs +63 -3
  48. package/engine/run-rows.mjs +119 -0
  49. package/engine/run-words.mjs +266 -0
  50. package/engine/scenes.mjs +1 -0
  51. package/engine/script-font-delivery.mjs +321 -0
  52. package/engine/script-fonts.mjs +172 -0
  53. package/engine/site-build.mjs +10 -3
  54. package/engine/site-config.mjs +5 -0
  55. package/engine/site-root.mjs +19 -5
  56. package/engine/sql-tables.mjs +120 -4
  57. package/engine/text-maps.mjs +607 -0
  58. package/engine/verb-pattern.mjs +99 -0
  59. package/engine/word-pattern.mjs +27 -0
  60. package/engine/word-rows.mjs +8 -2
  61. package/engine/word-terms.mjs +44 -0
  62. package/hugo-theme/layouts/_default/baseof.html +3 -0
  63. package/hugo-theme/static/css/style.css +10 -0
  64. package/manifest.mjs +9 -1
  65. package/package.json +1 -1
  66. package/types/content-config.d.mts +38 -0
  67. package/types/engine/compile-corpus.d.mts +2 -1
  68. package/types/engine/content-index.d.mts +17 -3
  69. package/types/engine/content-poetry.d.mts +7 -0
  70. package/types/engine/content-scripts.d.mts +226 -0
  71. package/types/engine/data-keys.d.mts +2 -2
  72. package/types/engine/dictionary-rows.d.mts +15 -0
  73. package/types/engine/event-rows.d.mts +16 -1
  74. package/types/engine/event-views.d.mts +2 -1
  75. package/types/engine/helpers.d.mts +5 -1
  76. package/types/engine/journals.d.mts +7 -2
  77. package/types/engine/language-data.d.mts +2 -0
  78. package/types/engine/language-tag.d.mts +8 -0
  79. package/types/engine/language-terms.d.mts +2 -1
  80. package/types/engine/map-lexicon.d.mts +274 -0
  81. package/types/engine/markup-validation.d.mts +7 -2
  82. package/types/engine/note-calendars.d.mts +60 -0
  83. package/types/engine/pdf-build.d.mts +26 -3
  84. package/types/engine/pdf-render.d.mts +17 -2
  85. package/types/engine/reckoning-markers.d.mts +32 -1
  86. package/types/engine/run-rows.d.mts +89 -0
  87. package/types/engine/run-words.d.mts +86 -0
  88. package/types/engine/script-font-delivery.d.mts +134 -0
  89. package/types/engine/script-fonts.d.mts +168 -0
  90. package/types/engine/site-build.d.mts +2 -1
  91. package/types/engine/site-root.d.mts +11 -3
  92. package/types/engine/text-maps.d.mts +175 -0
  93. package/types/engine/verb-pattern.d.mts +65 -0
  94. package/types/engine/word-pattern.d.mts +13 -0
  95. package/types/engine/word-terms.d.mts +6 -0
@@ -0,0 +1,266 @@
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
+ * **A mapped run's words are words of its language.** In a run marked with
16
+ * `lang=`, whose language's `note` names its language note, and with `map=`,
17
+ * each word (see {@link module:engine/word-pattern.WORD}) is accepted when,
18
+ * case folded, it is:
19
+ *
20
+ * - a `data.words[].form` of the language, or a word of one;
21
+ * - a word of a name in the language note's own `data.register` whose `tongue`
22
+ * is not `translation`, so a foreign name the run uses is listed there first,
23
+ * as `other:<tongue>` or under another tag the note declares;
24
+ * - a form one rule the language states derives from a lexicon form:
25
+ * - the noun plural `data.grammar.plural` gives, or the word's own `plural`;
26
+ * - a verb's citation form with one ending of `data.grammar.verbs.persons`,
27
+ * which takes the place of a closing `data.grammar.verbs.replaces` where
28
+ * the pattern states one (see {@link module:engine/verb-pattern.verbForm});
29
+ * - a suffix of `data.affixes` that states `replaces`, or its `front` form:
30
+ * `vowel` takes the place of a closing vowel (a letter whose base is `a`,
31
+ * `e`, `i`, `o` or `u`), letters take the place of those letters, and a
32
+ * stem closing otherwise takes the suffix whole. An affix that states no
33
+ * `replaces` derives nothing.
34
+ *
35
+ * A word not accepted whole is split at its hyphens and en dashes, and each
36
+ * part is checked. An apostrophe joins a word, so `k'aralzon` is one word, and
37
+ * `’` reads as `'`. A word with no letter is not checked.
38
+ *
39
+ * Nothing here requires a word to take a particular form: a lexicon word
40
+ * stated with its own plural, and the plural the rule gives, both pass.
41
+ *
42
+ * @module
43
+ */
44
+
45
+ import { pluralOf } from "./dictionary-rows.mjs";
46
+ import { languageRecordsByName } from "./map-lexicon.mjs";
47
+ import { RESERVED_TONGUE } from "./language-terms.mjs";
48
+ import { replaceClosing, verbForm, verbPattern } from "./verb-pattern.mjs";
49
+ import { parseWordClass } from "./word-classes.mjs";
50
+ import { WORD } from "./word-pattern.mjs";
51
+
52
+ /** Whether a value is a plain map. */
53
+ const isMap = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
54
+
55
+ /** A list value, or an empty one. */
56
+ const listOf = (value) => (Array.isArray(value) ? value : []);
57
+
58
+ /** The characters a compound is split at. */
59
+ const JOINS = /[–-]/u;
60
+
61
+ /** A letter, which a checked word holds at least one of. */
62
+ const LETTER = /\p{L}/u;
63
+
64
+ /**
65
+ * A word as it is compared: NFC, lowercase, `’` read as `'`.
66
+ *
67
+ * @param {string} word - The word.
68
+ * @returns {string} Its folded form.
69
+ */
70
+ export function foldWord(word) {
71
+ return word.normalize("NFC").toLowerCase().replace(/’/gu, "'");
72
+ }
73
+
74
+ /** Whether a character is a vowel: its base letter is a, e, i, o or u. */
75
+ const isVowel = (char) => /^[aeiou]/iu.test(char.normalize("NFD"));
76
+
77
+ /**
78
+ * Add a text's words to a set, each whole and each part of a compound.
79
+ *
80
+ * @param {Set<string>} set - The set.
81
+ * @param {string} text - A form or a name.
82
+ */
83
+ function addWords(set, text) {
84
+ for (const [word] of text.matchAll(WORD)) {
85
+ const folded = foldWord(word);
86
+ set.add(folded);
87
+ for (const part of folded.split(JOINS)) if (part) set.add(part);
88
+ }
89
+ }
90
+
91
+ /**
92
+ * The form a suffix stating `replaces` makes of a stem.
93
+ *
94
+ * @param {string} stem - The stem, folded.
95
+ * @param {string} suffix - The suffix, folded, with no hyphen.
96
+ * @param {string} replaces - The affix's `replaces`.
97
+ * @returns {string} The derived form.
98
+ */
99
+ function attach(stem, suffix, replaces) {
100
+ if (replaces === "vowel") {
101
+ const last = Array.from(stem).at(-1);
102
+ return last !== undefined && isVowel(last) ?
103
+ stem.slice(0, -last.length) + suffix
104
+ : stem + suffix;
105
+ }
106
+ return replaceClosing(stem, suffix, foldWord(replaces));
107
+ }
108
+
109
+ /**
110
+ * The suffixes a language's affixes state `replaces` for: each `form`, and
111
+ * its `front` form, written `-x`.
112
+ *
113
+ * @param {Record<string, any>} data - The language note's `data`.
114
+ * @returns {Array<{suffix: string, replaces: string}>} The suffixes, folded.
115
+ */
116
+ function replacingSuffixes(data) {
117
+ const out = [];
118
+ for (const affix of listOf(data.affixes)) {
119
+ if (!isMap(affix) || typeof affix.replaces !== "string") continue;
120
+ for (const written of [affix.form, affix.front]) {
121
+ if (typeof written !== "string") continue;
122
+ const form = foldWord(written.trim());
123
+ if (!/^[-–]/u.test(form) || /[-–]$/u.test(form)) continue;
124
+ const suffix = form.replace(/^[-–]+/u, "");
125
+ if (suffix) out.push({ suffix, replaces: affix.replaces });
126
+ }
127
+ }
128
+ return out;
129
+ }
130
+
131
+ /**
132
+ * The words a language note accepts of its own: its forms, and what its
133
+ * plural rows, verb pattern and replacing suffixes derive from them.
134
+ *
135
+ * @param {Record<string, any>} data - The language note's `data`.
136
+ * @returns {Set<string>} The words, folded.
137
+ */
138
+ export function lexiconWords(data) {
139
+ const accepted = new Set();
140
+ const grammar = isMap(data?.grammar) ? data.grammar : undefined;
141
+ const verbs = verbPattern(grammar?.verbs);
142
+ const replaces = verbs?.replaces === undefined ? undefined : foldWord(verbs.replaces);
143
+ const suffixes = replacingSuffixes(data ?? {});
144
+ for (const word of listOf(data?.words)) {
145
+ if (!isMap(word) || typeof word.form !== "string") continue;
146
+ addWords(accepted, word.form);
147
+ const plural = pluralOf(word, grammar);
148
+ if (plural) addWords(accepted, plural);
149
+ if (verbs && parseWordClass(word.class).base === "v")
150
+ for (const { ending } of verbs.endings)
151
+ addWords(accepted, verbForm(foldWord(word.form), foldWord(ending), replaces));
152
+ if (suffixes.length)
153
+ for (const [stem] of word.form.matchAll(WORD)) {
154
+ const folded = foldWord(stem);
155
+ for (const { suffix, replaces } of suffixes)
156
+ accepted.add(attach(folded, suffix, replaces));
157
+ }
158
+ }
159
+ return accepted;
160
+ }
161
+
162
+ /**
163
+ * The words of every name a language note registers under a tongue other than
164
+ * `translation`.
165
+ *
166
+ * @param {Record<string, any>|undefined} data - The language note's `data`.
167
+ * @returns {Set<string>} The words, folded.
168
+ */
169
+ export function registeredNameWords(data) {
170
+ const words = new Set();
171
+ for (const row of listOf(data?.register)) {
172
+ if (!isMap(row) || typeof row.name !== "string") continue;
173
+ if (row.tongue === RESERVED_TONGUE) continue;
174
+ addWords(words, row.name);
175
+ }
176
+ return words;
177
+ }
178
+
179
+ /**
180
+ * What a mapped run's words are checked against, for one language.
181
+ *
182
+ * @typedef {object} WordBook
183
+ * @property {string} language - The language's configured name, as `lang=`
184
+ * writes it.
185
+ * @property {(word: string) => boolean} accepts - Whether a folded word is
186
+ * one of the language's.
187
+ */
188
+
189
+ /**
190
+ * Each configured language's word book: one per language whose `note` names a
191
+ * language note.
192
+ *
193
+ * @param {Iterable<Record<string, any>>|undefined} records - Index records.
194
+ * @param {{languages?: Readonly<Record<string, {tag: string, note?: string}>>, contentPackage?: string}} config -
195
+ * The resolved configuration.
196
+ * @returns {Map<string, WordBook>} The books by language name.
197
+ */
198
+ export function wordBooksFromRecords(records, config = {}) {
199
+ const list = [...(records ?? [])];
200
+ const books = new Map();
201
+ const languages = languageRecordsByName(list, config);
202
+ if (!languages.size) return books;
203
+ for (const [name, record] of languages) {
204
+ const own = lexiconWords(record.data);
205
+ const names = registeredNameWords(record.data);
206
+ books.set(
207
+ name,
208
+ Object.freeze({
209
+ language: name,
210
+ accepts: (word) => own.has(word) || names.has(word),
211
+ }),
212
+ );
213
+ }
214
+ return books;
215
+ }
216
+
217
+ /**
218
+ * The words of a text its book does not accept, each once, at the offset of
219
+ * its first character: a compound not accepted whole gives each part that is
220
+ * not.
221
+ *
222
+ * @param {string} text - The text, with no escapes.
223
+ * @param {WordBook} book - The language's book.
224
+ * @param {Set<string>} [said] - The folded words already reported in this
225
+ * run, which are not reported again; updated.
226
+ * @returns {Array<{offset: number, word: string}>} The words, in order.
227
+ */
228
+ export function unknownWords(text, book, said = new Set()) {
229
+ const out = [];
230
+ const check = (word, offset) => {
231
+ if (!LETTER.test(word)) return;
232
+ const folded = foldWord(word);
233
+ if (book.accepts(folded) || said.has(folded)) return;
234
+ said.add(folded);
235
+ out.push({ offset, word });
236
+ };
237
+ for (const match of text.matchAll(WORD)) {
238
+ const word = match[0];
239
+ const offset = /** @type {number} */ (match.index);
240
+ if (book.accepts(foldWord(word))) continue;
241
+ if (!JOINS.test(word)) {
242
+ check(word, offset);
243
+ continue;
244
+ }
245
+ let at = 0;
246
+ for (const part of word.split(JOINS)) {
247
+ check(part, offset + at);
248
+ at += part.length + 1;
249
+ }
250
+ }
251
+ return out;
252
+ }
253
+
254
+ /**
255
+ * The finding for a word of a mapped run that is not a word of its language.
256
+ *
257
+ * @param {string} word - The word as written.
258
+ * @param {string} language - The language as `lang=` names it.
259
+ * @returns {string} The message.
260
+ */
261
+ export function unknownWordMessage(word, language) {
262
+ return (
263
+ `\`${word}\` is not a word of the \`${language}\` lexicon, a registered name, or a regular ` +
264
+ "form of one; coin it in the lexicon (or register the name) first"
265
+ );
266
+ }
package/engine/scenes.mjs CHANGED
@@ -176,6 +176,7 @@ export class Scenes extends BasePackCompiler {
176
176
  markdown,
177
177
  leadName: name,
178
178
  resolveRole,
179
+ runs: this.corpus?.runs,
179
180
  // As in the journals pass: an address resolves in the pack
180
181
  // that emits it, which is what makes the folder
181
182
  // materialise there too.
@@ -0,0 +1,321 @@
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
+ * **Delivering the installed faces to each target.** A consumer that installs
16
+ * `@heroiclands/fonts` and sets text in one of its faces with `font=` gets the
17
+ * fonts on the site, in the Foundry module and in its books; without both,
18
+ * nothing ships and nothing links to it.
19
+ *
20
+ * - **Site and Foundry** receive `fonts.css`, listing only the WOFF2 sources,
21
+ * the WOFF2 files and `LICENSE`.
22
+ * - **Books** search the package's `fonts/` directory, which holds the TTFs.
23
+ *
24
+ * @module
25
+ */
26
+
27
+ import fs from "node:fs";
28
+ import path from "node:path";
29
+
30
+ import { baseStyleLayer } from "./base-styles.mjs";
31
+ import { scanRuns } from "./content-scripts.mjs";
32
+ import { FONTS_MANIFEST, resolveScriptFonts } from "./script-fonts.mjs";
33
+
34
+ /**
35
+ * Where the files land in the staged Foundry module, relative to its root.
36
+ *
37
+ * @type {string}
38
+ */
39
+ export const STAGED_FONTS_DIR = "fonts/heroiclands";
40
+
41
+ /**
42
+ * Where the files land in the Hugo static tree, relative to `build/hugo/static`,
43
+ * beneath a directory per version.
44
+ *
45
+ * @type {string}
46
+ */
47
+ export const SITE_FONTS_DIR = "fonts/heroiclands";
48
+
49
+ /** The stylesheet the fonts package publishes at its root. */
50
+ const STYLESHEET = "fonts.css";
51
+
52
+ /** The licence the fonts package publishes at its root. */
53
+ const LICENSE = "LICENSE";
54
+
55
+ /**
56
+ * The part of a resolved configuration delivery reads.
57
+ *
58
+ * @typedef {object} DeliveryConfig
59
+ * @property {string} [rootDir] - The consuming package's root.
60
+ * @property {{content?: string}} [paths] - Where its notes are; relative to
61
+ * `rootDir` unless absolute.
62
+ * @property {Readonly<Record<string, {tag: string}>>} [languages] - Its languages.
63
+ */
64
+
65
+ /** The faces each configuration's notes set text in, found once. */
66
+ const usedFaces = new WeakMap();
67
+
68
+ /**
69
+ * The faces the package's notes set text in with `font=`, read from the
70
+ * notes themselves: every Markdown file under the content tree that writes
71
+ * `font=` is scanned for its runs.
72
+ *
73
+ * @param {DeliveryConfig} config - The resolved configuration.
74
+ * @param {import("./script-fonts.mjs").ScriptFonts} installed - The fonts package.
75
+ * @returns {ReadonlySet<string>} The slugs of the faces used.
76
+ */
77
+ export function facesUsed(config, installed) {
78
+ const known = usedFaces.get(config);
79
+ if (known) return known;
80
+ const used = new Set();
81
+ const content =
82
+ config.paths?.content && config.rootDir ?
83
+ path.resolve(config.rootDir, config.paths.content)
84
+ : undefined;
85
+ /** @param {string} dir */
86
+ const walk = (dir) => {
87
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
88
+ if (entry.name.startsWith(".")) continue;
89
+ const full = path.join(dir, entry.name);
90
+ if (entry.isDirectory()) walk(full);
91
+ else if (entry.isFile() && entry.name.endsWith(".md")) {
92
+ const source = fs.readFileSync(full, "utf8");
93
+ if (!/\bfont=/.test(source)) continue;
94
+ const body = source.replace(
95
+ /^\uFEFF?---\r?\n[\s\S]*?\r?\n---(?:\r?\n|$)/,
96
+ (frontmatter) => frontmatter.replace(/[^\r\n]/g, " "),
97
+ );
98
+ const ctx = { languages: config.languages ?? {}, faces: installed.faces };
99
+ for (const run of scanRuns(body, ctx).runs)
100
+ if (run.font !== undefined && installed.faces.has(run.font)) used.add(run.font);
101
+ }
102
+ }
103
+ };
104
+ if (content && fs.existsSync(content)) walk(content);
105
+ usedFaces.set(config, used);
106
+ return used;
107
+ }
108
+
109
+ /**
110
+ * The installed package, when this consumer is to ship it.
111
+ *
112
+ * @param {DeliveryConfig} config - The resolved configuration.
113
+ * @returns {import("./script-fonts.mjs").ScriptFonts|undefined} The package, or
114
+ * `undefined` without a root, an install, a manifest, or a note that sets
115
+ * text in one of its faces.
116
+ */
117
+ function delivered(config) {
118
+ if (!config?.rootDir) return undefined;
119
+ const installed = resolveScriptFonts(config.rootDir);
120
+ if (!installed?.manifest) return undefined;
121
+ return facesUsed(config, installed).size ? installed : undefined;
122
+ }
123
+
124
+ /**
125
+ * The files a target receives, as paths relative to the fonts package root.
126
+ *
127
+ * @param {DeliveryConfig} config - The resolved configuration.
128
+ * @param {string[]} formats - The font formats the target uses: `woff2`, `ttf`.
129
+ * @returns {{root: string, version: string, files: string[]}|undefined} The
130
+ * files, sorted: `fonts.css`, `LICENSE` and every file in `fonts/` of a
131
+ * listed format. `undefined` when the consumer ships none.
132
+ */
133
+ export function scriptFontFiles(config, formats) {
134
+ const installed = delivered(config);
135
+ if (!installed) return undefined;
136
+ const fontsDir = path.join(installed.root, "fonts");
137
+ const fonts =
138
+ fs.existsSync(fontsDir) ?
139
+ fs
140
+ .readdirSync(fontsDir)
141
+ .filter((name) => formats.some((format) => name.endsWith(`.${format}`)))
142
+ .map((name) => `fonts/${name}`)
143
+ : [];
144
+ const files = [STYLESHEET, LICENSE, ...fonts].filter((file) =>
145
+ fs.existsSync(path.join(installed.root, file)),
146
+ );
147
+ return { root: installed.root, version: installed.version, files: files.sort() };
148
+ }
149
+
150
+ /**
151
+ * The source list of a `src` declaration, split at its top-level commas.
152
+ *
153
+ * @param {string} list - The value, without `src:` and the closing `;`.
154
+ * @returns {string[]} The sources, each with its leading whitespace.
155
+ */
156
+ function splitSources(list) {
157
+ const out = [];
158
+ let depth = 0;
159
+ let quote = "";
160
+ let start = 0;
161
+ for (let i = 0; i < list.length; i++) {
162
+ const char = list[i];
163
+ if (quote) {
164
+ if (char === "\\") i++;
165
+ else if (char === quote) quote = "";
166
+ } else if (char === '"' || char === "'") quote = char;
167
+ else if (char === "(") depth++;
168
+ else if (char === ")") depth--;
169
+ else if (char === "," && depth === 0) {
170
+ out.push(list.slice(start, i));
171
+ start = i + 1;
172
+ }
173
+ }
174
+ out.push(list.slice(start));
175
+ return out;
176
+ }
177
+
178
+ /** A source's `url(…)` address, unquoted, or `undefined` for a `local(…)`. */
179
+ const sourceUrl = (source) => {
180
+ const match = /url\(\s*(?:"([^"]*)"|'([^']*)'|([^)\s]*))\s*\)/u.exec(source);
181
+ return match ? (match[1] ?? match[2] ?? match[3]).replace(/^\.\//u, "") : undefined;
182
+ };
183
+
184
+ /**
185
+ * The fonts package's stylesheet as a target stages it: each `@font-face`
186
+ * `src` lists only the files staged beside it, so no browser asks for a
187
+ * format the target does not carry. A `local(…)` source stays, and a face
188
+ * left with no source is dropped.
189
+ *
190
+ * @param {string} css - The stylesheet as the package publishes it.
191
+ * @param {ReadonlySet<string>} staged - The staged files, relative to the
192
+ * stylesheet: `fonts/SemaronKuriale-Regular.woff2`.
193
+ * @returns {string} The stylesheet to stage.
194
+ */
195
+ export function stagedStylesheet(css, staged) {
196
+ return css.replace(/@font-face\s*\{[^}]*\}(?:[ \t]*\r?\n)?/gu, (face) => {
197
+ let empty = false;
198
+ const kept = face.replace(/(\bsrc\s*:)([^;}]*)(;?)/u, (_all, key, list, end) => {
199
+ const sources = splitSources(list).filter((source) => {
200
+ const url = sourceUrl(source);
201
+ return url === undefined ? source.trim() !== "" : staged.has(url);
202
+ });
203
+ if (!sources.length) empty = true;
204
+ return `${key} ${sources.map((source) => source.trim()).join(", ")}${end}`;
205
+ });
206
+ return empty ? "" : kept;
207
+ });
208
+ }
209
+
210
+ /**
211
+ * Copy the delivered files into a directory, keeping their relative layout so
212
+ * the stylesheet's `url("fonts/…")` resolves from where it lands. The
213
+ * stylesheet is rewritten by {@link stagedStylesheet} to name only the files
214
+ * copied with it.
215
+ *
216
+ * @param {DeliveryConfig} config - The resolved configuration.
217
+ * @param {string} dest - The directory to fill; created when there is
218
+ * something to put in it.
219
+ * @param {string[]} formats - The font formats the target uses.
220
+ * @returns {number} How many files were written; 0 when the consumer ships none.
221
+ */
222
+ export function copyScriptFonts(config, dest, formats) {
223
+ const found = scriptFontFiles(config, formats);
224
+ if (!found) return 0;
225
+ const staged = new Set(found.files);
226
+ for (const file of found.files) {
227
+ const to = path.join(dest, file);
228
+ fs.mkdirSync(path.dirname(to), { recursive: true });
229
+ if (file === STYLESHEET)
230
+ fs.writeFileSync(
231
+ to,
232
+ stagedStylesheet(fs.readFileSync(path.join(found.root, file), "utf8"), staged),
233
+ );
234
+ else fs.copyFileSync(path.join(found.root, file), to);
235
+ }
236
+ return found.files.length;
237
+ }
238
+
239
+ /**
240
+ * Whether the package ships what its manifest and stylesheet promise: the
241
+ * stylesheet, the licence and every file the manifest lists.
242
+ *
243
+ * @param {DeliveryConfig} config - The resolved configuration.
244
+ * @returns {{file: string, severity: "error", message: string}[]} One finding
245
+ * per missing file, located at the manifest; empty when the consumer ships none.
246
+ */
247
+ export function checkScriptFonts(config) {
248
+ const installed = delivered(config);
249
+ if (!installed) return [];
250
+ const manifest = path.join(installed.root, FONTS_MANIFEST);
251
+ const wanted = new Set([STYLESHEET, LICENSE]);
252
+ for (const entry of installed.manifest?.families ?? []) {
253
+ for (const format of ["woff2", "ttf"]) {
254
+ const file = entry.files?.[format];
255
+ if (file) wanted.add(file);
256
+ }
257
+ }
258
+ return [...wanted]
259
+ .filter((file) => !fs.existsSync(path.join(installed.root, file)))
260
+ .map((file) => ({
261
+ file: manifest,
262
+ severity: /** @type {const} */ ("error"),
263
+ message:
264
+ `@heroiclands/fonts ${installed.version} does not ship \`${file}\`, ` +
265
+ "which the package's fonts depend on",
266
+ }));
267
+ }
268
+
269
+ /**
270
+ * The site param naming the stylesheet, relative to the site root.
271
+ *
272
+ * @param {DeliveryConfig} config - The resolved configuration.
273
+ * @returns {string|undefined} The versioned address, or `undefined` when the
274
+ * site ships no fonts.
275
+ */
276
+ export function siteFontsParam(config) {
277
+ const found = scriptFontFiles(config, ["woff2"]);
278
+ return found ? `${SITE_FONTS_DIR}/${found.version}/${STYLESHEET}` : undefined;
279
+ }
280
+
281
+ /**
282
+ * The directory under the Hugo static tree that holds this version's fonts.
283
+ *
284
+ * @param {string} version - The fonts package's version.
285
+ * @returns {string} The POSIX path, relative to `build/hugo/static`.
286
+ */
287
+ export function siteFontsDestination(version) {
288
+ return `${SITE_FONTS_DIR}/${version}`;
289
+ }
290
+
291
+ /**
292
+ * The manifest `styles`, with the fonts' stylesheet named after the base sheet.
293
+ *
294
+ * @param {object} options
295
+ * @param {unknown} options.styles - The `styles` so far, base sheet included.
296
+ * @param {"system"|"module"} options.artifact - Which artifact is shipped.
297
+ * @param {boolean} options.delivered - Whether the module carries the fonts.
298
+ * @returns {unknown} The `styles` to emit.
299
+ */
300
+ export function withScriptFontStyle({ styles, artifact, delivered: carried }) {
301
+ if (!carried) return styles;
302
+ const list = Array.isArray(styles) ? styles : [];
303
+ const src = `${STAGED_FONTS_DIR}/${STYLESHEET}`;
304
+ const names = (entry) => (typeof entry === "string" ? entry : entry?.src);
305
+ if (list.some((entry) => names(entry) === src)) return list;
306
+ const entry = { src, layer: baseStyleLayer(artifact) };
307
+ const baseAt = list.findIndex((item) => names(item) === "styles/package-build-base.css");
308
+ return [...list.slice(0, baseAt + 1), entry, ...list.slice(baseAt + 1)];
309
+ }
310
+
311
+ /**
312
+ * The directory of TTFs a book's compiler searches.
313
+ *
314
+ * @param {DeliveryConfig} config - The resolved configuration.
315
+ * @returns {string|undefined} The package's `fonts/` directory, or `undefined`
316
+ * when the consumer ships no fonts.
317
+ */
318
+ export function typstScriptFontPath(config) {
319
+ const installed = delivered(config);
320
+ return installed ? path.join(installed.root, "fonts") : undefined;
321
+ }