@heroiclands/package-build 23.12.6 → 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.
- package/CHANGELOG.md +12 -0
- package/assets/styles/content-base.css +25 -0
- package/bin/content-commands.mjs +264 -3
- package/bin/package-build.mjs +25 -0
- package/content-config.mjs +72 -0
- package/docs/api.md +10 -9
- package/docs/authoring/links-and-markup.md +439 -47
- package/docs/commands.md +127 -0
- package/docs/configuration.md +66 -3
- package/docs/content-format.md +3 -1
- package/docs/guides/book.md +11 -0
- package/docs/guides/module-project.md +16 -0
- package/docs/guides/site.md +19 -0
- package/docs/reference/format-details.md +372 -6
- package/docs/reference/note-types.md +36 -11
- package/engine/base-compiler.mjs +2 -0
- package/engine/bundles.mjs +7 -2
- package/engine/compile-corpus.mjs +3 -1
- package/engine/content-charset.mjs +21 -2
- package/engine/content-format.yaml +1 -0
- package/engine/content-index.mjs +12 -0
- package/engine/content-poetry.mjs +22 -4
- package/engine/content-scripts.mjs +1219 -0
- package/engine/content-session.mjs +2 -0
- package/engine/data-keys.mjs +5 -1
- package/engine/dictionary-layout.mjs +18 -12
- package/engine/dictionary-rows.mjs +11 -2
- package/engine/helpers.mjs +8 -2
- package/engine/index-records.mjs +1 -0
- package/engine/journals.mjs +14 -3
- package/engine/language-data.mjs +137 -1
- package/engine/language-tag.mjs +103 -0
- package/engine/language-terms.mjs +16 -4
- package/engine/map-lexicon.mjs +762 -0
- package/engine/markup-validation.mjs +17 -3
- package/engine/names.mjs +1 -1
- package/engine/note-vocabulary.mjs +113 -0
- package/engine/pdf-build.mjs +28 -10
- package/engine/pdf-render.mjs +116 -11
- package/engine/run-rows.mjs +119 -0
- package/engine/run-words.mjs +266 -0
- package/engine/scenes.mjs +1 -0
- package/engine/script-font-delivery.mjs +321 -0
- package/engine/script-fonts.mjs +172 -0
- package/engine/site-build.mjs +10 -3
- package/engine/site-config.mjs +5 -0
- package/engine/site-root.mjs +19 -5
- package/engine/sql-tables.mjs +111 -1
- package/engine/text-maps.mjs +607 -0
- package/engine/verb-pattern.mjs +99 -0
- package/engine/word-pattern.mjs +27 -0
- package/engine/word-rows.mjs +8 -2
- package/engine/word-terms.mjs +44 -0
- package/hugo-theme/layouts/_default/baseof.html +3 -0
- package/hugo-theme/static/css/style.css +10 -0
- package/manifest.mjs +9 -1
- package/package.json +1 -1
- package/types/content-config.d.mts +26 -0
- package/types/engine/compile-corpus.d.mts +2 -1
- package/types/engine/content-index.d.mts +11 -3
- package/types/engine/content-poetry.d.mts +7 -0
- package/types/engine/content-scripts.d.mts +226 -0
- package/types/engine/data-keys.d.mts +2 -2
- package/types/engine/dictionary-rows.d.mts +15 -0
- package/types/engine/helpers.d.mts +5 -1
- package/types/engine/journals.d.mts +7 -2
- package/types/engine/language-data.d.mts +2 -0
- package/types/engine/language-tag.d.mts +8 -0
- package/types/engine/language-terms.d.mts +2 -1
- package/types/engine/map-lexicon.d.mts +274 -0
- package/types/engine/markup-validation.d.mts +7 -2
- package/types/engine/pdf-build.d.mts +26 -3
- package/types/engine/pdf-render.d.mts +17 -2
- package/types/engine/run-rows.d.mts +89 -0
- package/types/engine/run-words.d.mts +86 -0
- package/types/engine/script-font-delivery.d.mts +134 -0
- package/types/engine/script-fonts.d.mts +168 -0
- package/types/engine/site-build.d.mts +2 -1
- package/types/engine/site-root.d.mts +11 -3
- package/types/engine/text-maps.d.mts +175 -0
- package/types/engine/verb-pattern.d.mts +65 -0
- package/types/engine/word-pattern.d.mts +13 -0
- 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
|
+
}
|