@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.
- package/CHANGELOG.md +22 -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 +98 -0
- package/docs/api.md +18 -14
- package/docs/authoring/dates-and-calendars.md +29 -1
- package/docs/authoring/links-and-markup.md +442 -50
- package/docs/commands.md +127 -0
- package/docs/configuration.md +90 -3
- package/docs/content-format.md +3 -1
- package/docs/guides/book.md +11 -0
- package/docs/guides/module-project.md +17 -1
- package/docs/guides/site.md +19 -0
- package/docs/reference/format-details.md +434 -23
- package/docs/reference/note-types.md +45 -21
- 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 -2
- package/engine/content-index.mjs +33 -2
- package/engine/content-links.mjs +3 -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/event-rows.mjs +63 -18
- package/engine/event-views.mjs +28 -15
- package/engine/helpers.mjs +8 -2
- package/engine/index-records.mjs +2 -0
- package/engine/infobox.mjs +4 -8
- 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/markdown-expressions.mjs +72 -17
- package/engine/markup-validation.mjs +17 -3
- package/engine/names.mjs +1 -1
- package/engine/note-calendars.mjs +314 -0
- package/engine/note-vocabulary.mjs +124 -16
- package/engine/pdf-build.mjs +28 -10
- package/engine/pdf-render.mjs +116 -11
- package/engine/reckoning-markers.mjs +63 -3
- 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 +120 -4
- 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 +38 -0
- package/types/engine/compile-corpus.d.mts +2 -1
- package/types/engine/content-index.d.mts +17 -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/event-rows.d.mts +16 -1
- package/types/engine/event-views.d.mts +2 -1
- 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/note-calendars.d.mts +60 -0
- package/types/engine/pdf-build.d.mts +26 -3
- package/types/engine/pdf-render.d.mts +17 -2
- package/types/engine/reckoning-markers.d.mts +32 -1
- 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,762 @@
|
|
|
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 map reads its language's dictionary first.** The sound rules belong to
|
|
16
|
+
* the script: a mapping note's rows spell every language written in it. The
|
|
17
|
+
* exceptions belong to the language: a word of a language note's
|
|
18
|
+
* `data.words` states a `native` form for a map only where the rows would
|
|
19
|
+
* spell it differently.
|
|
20
|
+
*
|
|
21
|
+
* ```yaml
|
|
22
|
+
* words:
|
|
23
|
+
* - form: zaron
|
|
24
|
+
* class: n
|
|
25
|
+
* field: Law
|
|
26
|
+
* gloss: an oath
|
|
27
|
+
* native:
|
|
28
|
+
* - {map: semaron, text: "…"}
|
|
29
|
+
* - {map: semaron, font: semaron-carved, text: "…"}
|
|
30
|
+
* ```
|
|
31
|
+
*
|
|
32
|
+
* - **`languages.<name>.note`** in the configuration names the language note
|
|
33
|
+
* whose words are the language's lexicon. A run marked `lang=` with that
|
|
34
|
+
* name and a `map=` reads it; a language with no `note`, a bare BCP 47 tag
|
|
35
|
+
* and a run with no `lang=` are spelled by the rows alone.
|
|
36
|
+
* - **A native form with a `font`** applies in that face alone, and wins there
|
|
37
|
+
* over one with none.
|
|
38
|
+
* - **Derived forms take the rows**: a plural or a verb ending of an exception
|
|
39
|
+
* word is spelled by the rows, and only the word as cited is looked up.
|
|
40
|
+
*
|
|
41
|
+
* {@link mappingFor} is the one path from a map, a face and a language to the
|
|
42
|
+
* function that rewrites text: the build's runs, `package-build transliterate`
|
|
43
|
+
* and `package-build lexicon` all go through it.
|
|
44
|
+
*
|
|
45
|
+
* @module
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
import fs from "node:fs";
|
|
49
|
+
|
|
50
|
+
import { parseAddress, renderAddress } from "./address.mjs";
|
|
51
|
+
import { positionOfFrontmatterPath, positionOfYamlPath } from "./diagnostics.mjs";
|
|
52
|
+
import { isNoteRecord } from "./index-records.mjs";
|
|
53
|
+
import { nearest } from "./near-miss.mjs";
|
|
54
|
+
import { FONTS_PACKAGE } from "./script-fonts.mjs";
|
|
55
|
+
import { GENERIC_FAMILIES, applyMap } from "./text-maps.mjs";
|
|
56
|
+
import { isLanguageRecord, noteAddressOf } from "./word-rows.mjs";
|
|
57
|
+
import { LANGUAGE_SUBTYPE, glossText } from "./word-terms.mjs";
|
|
58
|
+
|
|
59
|
+
/** The configuration file a language is declared in. */
|
|
60
|
+
const CONFIG = "package-build.config.yaml";
|
|
61
|
+
|
|
62
|
+
/** Whether a value is a plain map. */
|
|
63
|
+
const isMap = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
64
|
+
|
|
65
|
+
/** A list value, or an empty one. */
|
|
66
|
+
const listOf = (value) => (Array.isArray(value) ? value : []);
|
|
67
|
+
|
|
68
|
+
/** `U+XXXX` for a code point. */
|
|
69
|
+
const codeName = (point) => `U+${point.toString(16).toUpperCase().padStart(4, "0")}`;
|
|
70
|
+
|
|
71
|
+
/** Names in backticks, joined as a list is said: `a`, `b` and `c`. */
|
|
72
|
+
function said(names) {
|
|
73
|
+
const quoted = names.map((name) => `\`${name}\``);
|
|
74
|
+
return quoted.length < 2 ?
|
|
75
|
+
quoted.join("")
|
|
76
|
+
: `${quoted.slice(0, -1).join(", ")} and ${quoted.at(-1)}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* One native form, read.
|
|
81
|
+
*
|
|
82
|
+
* @typedef {object} NativeForm
|
|
83
|
+
* @property {string} map - The map it is written for.
|
|
84
|
+
* @property {string|undefined} font - The one face it is for.
|
|
85
|
+
* @property {string} text - The word in the script.
|
|
86
|
+
*/
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* One word of a lexicon.
|
|
90
|
+
*
|
|
91
|
+
* @typedef {object} LexiconWord
|
|
92
|
+
* @property {string} form - The word as cited.
|
|
93
|
+
* @property {string|null} class - Its class.
|
|
94
|
+
* @property {string|null} gloss - Its senses, joined by `; `.
|
|
95
|
+
* @property {readonly NativeForm[]} natives - Its native forms.
|
|
96
|
+
*/
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* A language's lexicon.
|
|
100
|
+
*
|
|
101
|
+
* @typedef {object} Lexicon
|
|
102
|
+
* @property {string} language - The language's configured name.
|
|
103
|
+
* @property {readonly LexiconWord[]} words - Its words, in `data.words` order.
|
|
104
|
+
* @property {ReadonlyMap<string, readonly NativeForm[]>} byForm - The native
|
|
105
|
+
* forms by the word's form, NFC; the first word of a form keeps it.
|
|
106
|
+
*/
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* A word's native forms as written, keeping those that read.
|
|
110
|
+
*
|
|
111
|
+
* @param {unknown} value - A word's `native`.
|
|
112
|
+
* @returns {NativeForm[]} The forms.
|
|
113
|
+
*/
|
|
114
|
+
function readNatives(value) {
|
|
115
|
+
return listOf(value)
|
|
116
|
+
.filter(
|
|
117
|
+
(entry) =>
|
|
118
|
+
isMap(entry) && typeof entry.map === "string" && typeof entry.text === "string",
|
|
119
|
+
)
|
|
120
|
+
.map((entry) => ({
|
|
121
|
+
map: entry.map,
|
|
122
|
+
font: typeof entry.font === "string" ? entry.font : undefined,
|
|
123
|
+
text: entry.text,
|
|
124
|
+
}));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* A language note's words as a lexicon.
|
|
129
|
+
*
|
|
130
|
+
* @param {string} language - The language's configured name.
|
|
131
|
+
* @param {Record<string, any>} data - The note's `data`.
|
|
132
|
+
* @returns {Lexicon} The lexicon.
|
|
133
|
+
*/
|
|
134
|
+
function lexiconOf(language, data) {
|
|
135
|
+
const words = [];
|
|
136
|
+
const byForm = new Map();
|
|
137
|
+
for (const word of listOf(data?.words)) {
|
|
138
|
+
if (!isMap(word) || typeof word.form !== "string") continue;
|
|
139
|
+
const natives = readNatives(word.native);
|
|
140
|
+
words.push(
|
|
141
|
+
Object.freeze({
|
|
142
|
+
form: word.form,
|
|
143
|
+
class: typeof word.class === "string" ? word.class : null,
|
|
144
|
+
gloss: glossText(word.gloss),
|
|
145
|
+
natives,
|
|
146
|
+
}),
|
|
147
|
+
);
|
|
148
|
+
const key = word.form.normalize("NFC");
|
|
149
|
+
if (!byForm.has(key)) byForm.set(key, natives);
|
|
150
|
+
}
|
|
151
|
+
return Object.freeze({ language, words: Object.freeze(words), byForm });
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The canonical Address `languages.<name>.note` names, or `undefined` where
|
|
156
|
+
* the value is no Address.
|
|
157
|
+
*
|
|
158
|
+
* @param {unknown} note - The configured value.
|
|
159
|
+
* @param {string|undefined} contentPackage - The package it is read in.
|
|
160
|
+
* @param {Iterable<string>} types - The types a qualified value may name.
|
|
161
|
+
* @returns {string|undefined} The Address.
|
|
162
|
+
*/
|
|
163
|
+
function noteKey(note, contentPackage, types) {
|
|
164
|
+
if (typeof note !== "string" || !contentPackage) return undefined;
|
|
165
|
+
const tuple = parseAddress(
|
|
166
|
+
note,
|
|
167
|
+
{ package: contentPackage, system: "note", type: "skill", types: new Set(types) },
|
|
168
|
+
{ declared: true },
|
|
169
|
+
);
|
|
170
|
+
return "reason" in tuple ? undefined : renderAddress(tuple);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Each record by the Address a reference to it is written as.
|
|
175
|
+
*
|
|
176
|
+
* @param {Iterable<Record<string, any>>} records - Index records.
|
|
177
|
+
* @param {string|undefined} contentPackage - The package a record with no
|
|
178
|
+
* `package` belongs to.
|
|
179
|
+
* @returns {Map<string, Record<string, any>>} The records.
|
|
180
|
+
*/
|
|
181
|
+
function recordsByKey(records, contentPackage) {
|
|
182
|
+
const byKey = new Map();
|
|
183
|
+
for (const record of records ?? []) {
|
|
184
|
+
if (!record || !isNoteRecord(record) || !record.type || !record.shortcode) continue;
|
|
185
|
+
const pkg = record.package ?? contentPackage;
|
|
186
|
+
if (!pkg) continue;
|
|
187
|
+
const key = renderAddress({
|
|
188
|
+
package: String(pkg),
|
|
189
|
+
system: "note",
|
|
190
|
+
type: String(record.type),
|
|
191
|
+
shortcode: String(record.shortcode),
|
|
192
|
+
});
|
|
193
|
+
if (!byKey.has(key)) byKey.set(key, record);
|
|
194
|
+
}
|
|
195
|
+
return byKey;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Whether a record is a language note's. */
|
|
199
|
+
const isLanguage = (record) =>
|
|
200
|
+
record?.type === "skill" && String(record.subType ?? "").toLowerCase() === LANGUAGE_SUBTYPE;
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* The language note each configured language names.
|
|
204
|
+
*
|
|
205
|
+
* @param {Readonly<Record<string, {tag: string, note?: string}>>} languages -
|
|
206
|
+
* The configured languages.
|
|
207
|
+
* @param {string|undefined} contentPackage - The package.
|
|
208
|
+
* @param {Iterable<Record<string, any>>} records - Index records.
|
|
209
|
+
* @returns {Map<string, {key: string|undefined, record: Record<string, any>|undefined}>}
|
|
210
|
+
* Each language stating a `note`, with what it resolves to.
|
|
211
|
+
*/
|
|
212
|
+
function languageNotes(languages, contentPackage, records) {
|
|
213
|
+
const list = [...(records ?? [])];
|
|
214
|
+
const byKey = recordsByKey(list, contentPackage);
|
|
215
|
+
const types = new Set(list.map((record) => record?.type).filter(Boolean));
|
|
216
|
+
types.add("skill");
|
|
217
|
+
const out = new Map();
|
|
218
|
+
for (const [name, spec] of Object.entries(languages ?? {})) {
|
|
219
|
+
if (spec?.note === undefined) continue;
|
|
220
|
+
const key = noteKey(spec.note, contentPackage, types);
|
|
221
|
+
const record = key ? byKey.get(key) : undefined;
|
|
222
|
+
out.set(name, { key, record: isLanguage(record) ? record : undefined });
|
|
223
|
+
}
|
|
224
|
+
return out;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Each configured language's lexicon, read from the language note its `note`
|
|
229
|
+
* names. A language with no `note`, or one naming no language note, has none.
|
|
230
|
+
*
|
|
231
|
+
* @param {Iterable<Record<string, any>>|undefined} records - Index records.
|
|
232
|
+
* @param {{languages?: Readonly<Record<string, {tag: string, note?: string}>>, contentPackage?: string}} config -
|
|
233
|
+
* The resolved configuration.
|
|
234
|
+
* @returns {Map<string, Lexicon>} The lexicons by language name.
|
|
235
|
+
*/
|
|
236
|
+
export function lexiconsFromRecords(records, config = {}) {
|
|
237
|
+
const lexicons = new Map();
|
|
238
|
+
for (const [name, record] of languageRecordsByName(records, config))
|
|
239
|
+
lexicons.set(name, lexiconOf(name, record.data));
|
|
240
|
+
return lexicons;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* The language note each configured language's `note` names, by language
|
|
245
|
+
* name. A language with no `note`, or one naming no language note, is absent.
|
|
246
|
+
*
|
|
247
|
+
* @param {Iterable<Record<string, any>>|undefined} records - Index records.
|
|
248
|
+
* @param {{languages?: Readonly<Record<string, {tag: string, note?: string}>>, contentPackage?: string}} config -
|
|
249
|
+
* The resolved configuration.
|
|
250
|
+
* @returns {Map<string, Record<string, any>>} The records.
|
|
251
|
+
*/
|
|
252
|
+
export function languageRecordsByName(records, { languages, contentPackage } = {}) {
|
|
253
|
+
const out = new Map();
|
|
254
|
+
for (const [name, { record }] of languageNotes(languages, contentPackage, records))
|
|
255
|
+
if (record) out.set(name, record);
|
|
256
|
+
return out;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Each `languages.<name>.note` that names no language note, at its key.
|
|
261
|
+
*
|
|
262
|
+
* @param {{languages?: Readonly<Record<string, {tag: string, note?: string}>>, contentPackage?: string, configFile?: string, configText?: string}} config -
|
|
263
|
+
* The resolved configuration, and where it was read from.
|
|
264
|
+
* @param {Iterable<Record<string, any>>} records - Index records.
|
|
265
|
+
* @returns {object[]} The findings.
|
|
266
|
+
*/
|
|
267
|
+
export function languageNoteFindings(config, records) {
|
|
268
|
+
const { languages, contentPackage, configFile, configText } = config ?? {};
|
|
269
|
+
let text = configText;
|
|
270
|
+
if (text === undefined && configFile) {
|
|
271
|
+
try {
|
|
272
|
+
text = fs.readFileSync(configFile, "utf8");
|
|
273
|
+
} catch {
|
|
274
|
+
text = undefined;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
const findings = [];
|
|
278
|
+
for (const [name, { record }] of languageNotes(languages, contentPackage, records)) {
|
|
279
|
+
if (record) continue;
|
|
280
|
+
findings.push({
|
|
281
|
+
file: configFile ?? CONFIG,
|
|
282
|
+
...(text ? positionOfYamlPath(text, ["languages", name, "note"], { key: true }) : {}),
|
|
283
|
+
severity: "error",
|
|
284
|
+
message:
|
|
285
|
+
`\`languages.${name}.note\` is \`${String(languages[name].note)}\`, which names no ` +
|
|
286
|
+
`language note in ${contentPackage}; it names a skill note whose subType is language`,
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
return findings;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* A lookup of a word's native form under one map in one face: the form stated
|
|
294
|
+
* for the face, else the one stated for no face.
|
|
295
|
+
*
|
|
296
|
+
* @param {Lexicon|undefined} lexicon - The lexicon.
|
|
297
|
+
* @param {string} map - The map's name.
|
|
298
|
+
* @param {string|undefined} font - The face.
|
|
299
|
+
* @returns {((word: string) => string|undefined)|undefined} The lookup, or
|
|
300
|
+
* `undefined` with no lexicon.
|
|
301
|
+
*/
|
|
302
|
+
export function nativeLookup(lexicon, map, font) {
|
|
303
|
+
if (!lexicon) return undefined;
|
|
304
|
+
return (word) => {
|
|
305
|
+
const natives = lexicon.byForm.get(word.normalize("NFC"));
|
|
306
|
+
if (!natives) return undefined;
|
|
307
|
+
const own =
|
|
308
|
+
font === undefined ? undefined : natives.find((n) => n.map === map && n.font === font);
|
|
309
|
+
return (own ?? natives.find((n) => n.map === map && n.font === undefined))?.text;
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* What a run, a passage or a listing is mapped by.
|
|
315
|
+
*
|
|
316
|
+
* @typedef {object} Mapping
|
|
317
|
+
* @property {import("./text-maps.mjs").TextMap} map - The map.
|
|
318
|
+
* @property {string|undefined} font - The face, whose own rows come first.
|
|
319
|
+
* @property {((word: string) => string|undefined)|undefined} native - The
|
|
320
|
+
* language's native forms under the map in the face.
|
|
321
|
+
*/
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* The one path from a map, a face and a language to what rewrites the text.
|
|
325
|
+
*
|
|
326
|
+
* @param {{maps?: ReadonlyMap<string, import("./text-maps.mjs").TextMap>, lexicons?: ReadonlyMap<string, Lexicon>}} ctx -
|
|
327
|
+
* The run context.
|
|
328
|
+
* @param {{map: string, font?: string, lang?: string}} keys - The map, the face
|
|
329
|
+
* and the language.
|
|
330
|
+
* @returns {Mapping|undefined} The mapping, or `undefined` for an unknown map.
|
|
331
|
+
*/
|
|
332
|
+
export function mappingFor(ctx, { map, font, lang }) {
|
|
333
|
+
const textMap = ctx?.maps?.get(map);
|
|
334
|
+
if (!textMap) return undefined;
|
|
335
|
+
const lexicon = lang === undefined ? undefined : ctx.lexicons?.get(lang);
|
|
336
|
+
return { map: textMap, font, native: nativeLookup(lexicon, textMap.name, font) };
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Why a face is not one a map is for, or `undefined` where it is.
|
|
341
|
+
*
|
|
342
|
+
* @param {import("./text-maps.mjs").TextMap} map - The map.
|
|
343
|
+
* @param {string} font - The face's slug.
|
|
344
|
+
* @returns {string|undefined} The problem.
|
|
345
|
+
*/
|
|
346
|
+
export function fontProblem(map, font) {
|
|
347
|
+
if (!map.fonts.size || map.fonts.has(font)) return undefined;
|
|
348
|
+
return `\`${font}\` is not a font of the map \`${map.name}\`; it is for ${said([...map.fonts.keys()])}`;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* The mapping a command uses, or what is wrong with what it was given. A map
|
|
353
|
+
* listing one face defaults to it; one listing more needs `font`.
|
|
354
|
+
*
|
|
355
|
+
* @param {object} ctx - The run context.
|
|
356
|
+
* @param {{map: string, font?: string, lang?: string}} keys - What was asked for.
|
|
357
|
+
* @returns {{mapping: Mapping}|{problem: string, about: "map"|"lang"|"font"}} The mapping or the problem.
|
|
358
|
+
*/
|
|
359
|
+
function commandMapping(ctx, { map, font, lang }) {
|
|
360
|
+
const textMap = ctx?.maps?.get(map);
|
|
361
|
+
if (!textMap) {
|
|
362
|
+
const names = [...(ctx?.maps?.keys() ?? [])];
|
|
363
|
+
const near = nearest(map, names);
|
|
364
|
+
return {
|
|
365
|
+
about: "map",
|
|
366
|
+
problem:
|
|
367
|
+
`\`${map}\` is not a map: a map is named by the \`data.map.name\` of a mapping note` +
|
|
368
|
+
(near ? `; did you mean \`${near}\`?`
|
|
369
|
+
: names.length ? `; the maps are ${said(names)}`
|
|
370
|
+
: "; no note declares one"),
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
if (lang !== undefined && !Object.hasOwn(ctx.languages ?? {}, lang)) {
|
|
374
|
+
const near = nearest(lang, Object.keys(ctx.languages ?? {}));
|
|
375
|
+
return {
|
|
376
|
+
about: "lang",
|
|
377
|
+
problem:
|
|
378
|
+
`\`${lang}\` is not a declared language (\`languages\` in ${CONFIG})` +
|
|
379
|
+
(near ? `; did you mean \`${near}\`?` : ""),
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
let face = font;
|
|
383
|
+
if (face === undefined && textMap.fonts.size === 1) face = [...textMap.fonts.keys()][0];
|
|
384
|
+
if (face === undefined && textMap.fonts.size > 1)
|
|
385
|
+
return {
|
|
386
|
+
about: "font",
|
|
387
|
+
problem: `the map \`${map}\` is for ${said([...textMap.fonts.keys()])}; name one with \`--font\``,
|
|
388
|
+
};
|
|
389
|
+
if (face !== undefined) {
|
|
390
|
+
const problem = fontProblem(textMap, face);
|
|
391
|
+
if (problem) return { about: "font", problem };
|
|
392
|
+
}
|
|
393
|
+
return { mapping: /** @type {Mapping} */ (mappingFor(ctx, { map, font: face, lang })) };
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* What is wrong with a capitalised word whose native form the rows cannot
|
|
398
|
+
* capitalise.
|
|
399
|
+
*
|
|
400
|
+
* @param {string} word - The word as written.
|
|
401
|
+
* @param {string} lower - The lowercase form the lexicon states.
|
|
402
|
+
* @param {string|undefined} lang - The language.
|
|
403
|
+
* @param {string} map - The map's name.
|
|
404
|
+
* @returns {string} The message.
|
|
405
|
+
*/
|
|
406
|
+
export function uncapitalisedMessage(word, lower, lang, map) {
|
|
407
|
+
return (
|
|
408
|
+
`\`${word}\` is capitalised, and the native form the lexicon of \`${lang}\` states for ` +
|
|
409
|
+
`\`${lower}\` under the map \`${map}\` does not begin as the rows spell its first letter, ` +
|
|
410
|
+
`so the rows cannot capitalise it; state \`${word}\` as a word of its own with its native form`
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Map a passage exactly as the build maps a run with the same keys.
|
|
416
|
+
*
|
|
417
|
+
* @param {object} ctx - The run context.
|
|
418
|
+
* @param {{map: string, font?: string, lang?: string}} keys - The map, the face
|
|
419
|
+
* and the language.
|
|
420
|
+
* @param {string} text - The passage, with no escapes.
|
|
421
|
+
* @returns {{text: string}|{problem: string, about: string}} The mapped text, or the problem.
|
|
422
|
+
*/
|
|
423
|
+
export function transliterate(ctx, keys, text) {
|
|
424
|
+
const found = commandMapping(ctx, keys);
|
|
425
|
+
if ("problem" in found) return { problem: found.problem, about: found.about };
|
|
426
|
+
const { map, font, native } = found.mapping;
|
|
427
|
+
const mapped = applyMap(map, text, { font, native });
|
|
428
|
+
const piece = mapped.pieces.find((one) => one.uncapitalised !== undefined);
|
|
429
|
+
if (piece)
|
|
430
|
+
return {
|
|
431
|
+
about: "word",
|
|
432
|
+
problem: uncapitalisedMessage(
|
|
433
|
+
piece.from,
|
|
434
|
+
/** @type {string} */ (piece.uncapitalised),
|
|
435
|
+
keys.lang,
|
|
436
|
+
map.name,
|
|
437
|
+
),
|
|
438
|
+
};
|
|
439
|
+
return { text: mapped.text };
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Every word of a language's lexicon with its native form under a map: the
|
|
444
|
+
* stated exception, else the rows' spelling, each exactly as the build gives
|
|
445
|
+
* it.
|
|
446
|
+
*
|
|
447
|
+
* @param {object} ctx - The run context.
|
|
448
|
+
* @param {{lang: string, map: string, font?: string}} keys - The language, the
|
|
449
|
+
* map and the face.
|
|
450
|
+
* @returns {{entries: Array<{form: string, native: string, exception: boolean, class: string|null, gloss: string|null}>}|{problem: string, about: string}}
|
|
451
|
+
* The words in `data.words` order, or the problem.
|
|
452
|
+
*/
|
|
453
|
+
export function lexiconEntries(ctx, { lang, map, font }) {
|
|
454
|
+
const found = commandMapping(ctx, { map, font, lang });
|
|
455
|
+
if ("problem" in found) return { problem: found.problem, about: found.about };
|
|
456
|
+
const lexicon = ctx.lexicons?.get(lang);
|
|
457
|
+
if (!lexicon)
|
|
458
|
+
return {
|
|
459
|
+
about: "lang",
|
|
460
|
+
problem:
|
|
461
|
+
`\`${lang}\` has no lexicon: \`languages.${lang}\` names no language note ` +
|
|
462
|
+
"in its `note`",
|
|
463
|
+
};
|
|
464
|
+
const { map: textMap, font: face, native } = found.mapping;
|
|
465
|
+
const mapped = lexicon.words.map((word) =>
|
|
466
|
+
applyMap(textMap, word.form, { font: face, native }),
|
|
467
|
+
);
|
|
468
|
+
const unsound = mapped.flatMap((one) => one.pieces).find((one) => one.uncapitalised);
|
|
469
|
+
if (unsound)
|
|
470
|
+
return {
|
|
471
|
+
about: "word",
|
|
472
|
+
problem: uncapitalisedMessage(
|
|
473
|
+
unsound.from,
|
|
474
|
+
/** @type {string} */ (unsound.uncapitalised),
|
|
475
|
+
lang,
|
|
476
|
+
textMap.name,
|
|
477
|
+
),
|
|
478
|
+
};
|
|
479
|
+
return {
|
|
480
|
+
entries: lexicon.words.map((word, i) => ({
|
|
481
|
+
form: word.form,
|
|
482
|
+
native: mapped[i].text,
|
|
483
|
+
exception:
|
|
484
|
+
native?.(word.form) !== undefined ||
|
|
485
|
+
(word.form.toLowerCase() !== word.form &&
|
|
486
|
+
native?.(word.form.toLowerCase()) !== undefined),
|
|
487
|
+
class: word.class,
|
|
488
|
+
gloss: word.gloss,
|
|
489
|
+
})),
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Whether a shared row can be reached in a face: no plain row of the face's
|
|
495
|
+
* own matches at the start of its `from`, which would win there first.
|
|
496
|
+
*
|
|
497
|
+
* @param {import("./text-maps.mjs").MapRow} row - A shared row.
|
|
498
|
+
* @param {readonly import("./text-maps.mjs").MapRow[]} own - The face's rows.
|
|
499
|
+
* @returns {boolean} Whether the row can apply.
|
|
500
|
+
*/
|
|
501
|
+
function reachable(row, own) {
|
|
502
|
+
return !own.some((mine) => !mine.before && !mine.at && row.from.startsWith(mine.from));
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* Check a mapping note's `fonts` against the installed faces: an unknown face
|
|
507
|
+
* and a face listed twice, at the entry, and each code point a listed face
|
|
508
|
+
* lacks, at the row that writes it.
|
|
509
|
+
*
|
|
510
|
+
* @param {{file: string, raw?: string, fm?: Record<string, any>}} note - The note.
|
|
511
|
+
* @param {{faces?: ReadonlyMap<string, import("./script-fonts.mjs").Face>, maps?: ReadonlyMap<string, import("./text-maps.mjs").TextMap>}} ctx -
|
|
512
|
+
* The run context.
|
|
513
|
+
* @returns {object[]} The findings.
|
|
514
|
+
*/
|
|
515
|
+
export function checkMapFonts(note, ctx) {
|
|
516
|
+
const value = note?.fm?.data?.map;
|
|
517
|
+
if (!isMap(value) || !Array.isArray(value.fonts)) return [];
|
|
518
|
+
const raw = note.raw ?? "";
|
|
519
|
+
const finding = (keyPath, message, key = false) => ({
|
|
520
|
+
file: note.file,
|
|
521
|
+
...positionOfFrontmatterPath(raw, ["data", "map", ...keyPath], { key }),
|
|
522
|
+
severity: "error",
|
|
523
|
+
message,
|
|
524
|
+
});
|
|
525
|
+
const faces = ctx?.faces ?? new Map();
|
|
526
|
+
const findings = [];
|
|
527
|
+
const first = new Map();
|
|
528
|
+
const listed = [];
|
|
529
|
+
value.fonts.forEach((entry, at) => {
|
|
530
|
+
if (!isMap(entry) || typeof entry.font !== "string") return;
|
|
531
|
+
const slug = entry.font;
|
|
532
|
+
if (GENERIC_FAMILIES.includes(slug)) return;
|
|
533
|
+
if (first.has(slug)) {
|
|
534
|
+
findings.push(
|
|
535
|
+
finding(
|
|
536
|
+
["fonts", at],
|
|
537
|
+
`\`data.map.fonts[${at}]\` lists the font \`${slug}\` a second time; the first is ` +
|
|
538
|
+
`\`data.map.fonts[${first.get(slug)}]\``,
|
|
539
|
+
),
|
|
540
|
+
);
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
first.set(slug, at);
|
|
544
|
+
if (!faces.has(slug)) {
|
|
545
|
+
const near = nearest(slug, [...faces.keys()]);
|
|
546
|
+
findings.push(
|
|
547
|
+
finding(
|
|
548
|
+
["fonts", at, "font"],
|
|
549
|
+
`\`${slug}\` is not a font: \`data.map.fonts[${at}].font\` names ` +
|
|
550
|
+
(faces.size ? "an installed face" : (
|
|
551
|
+
`a face of ${FONTS_PACKAGE}, which this package has not installed`
|
|
552
|
+
)) +
|
|
553
|
+
(near ? `; did you mean \`${near}\`?` : ""),
|
|
554
|
+
),
|
|
555
|
+
);
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
listed.push({ slug, at });
|
|
559
|
+
});
|
|
560
|
+
const compiled = ctx?.maps?.get(value.name);
|
|
561
|
+
if (!compiled) return findings;
|
|
562
|
+
for (const { slug, at } of listed) {
|
|
563
|
+
const face = /** @type {import("./script-fonts.mjs").Face} */ (faces.get(slug));
|
|
564
|
+
const own = compiled.fonts.get(slug)?.rows ?? [];
|
|
565
|
+
const gaps = (rows, keyPath, label, message) =>
|
|
566
|
+
listOf(rows).forEach((authored, j) => {
|
|
567
|
+
const to = Array.isArray(authored) ? authored[1] : authored?.to;
|
|
568
|
+
if (typeof to !== "string") return;
|
|
569
|
+
const from = Array.isArray(authored) ? authored[0] : authored?.from;
|
|
570
|
+
if (keyPath[0] === "rows" && !reachable({ from: String(from), to }, own)) return;
|
|
571
|
+
const seen = new Set();
|
|
572
|
+
for (const char of to) {
|
|
573
|
+
const point = /** @type {number} */ (char.codePointAt(0));
|
|
574
|
+
if (/\s/u.test(char) || face.draws.has(point) || seen.has(point)) continue;
|
|
575
|
+
seen.add(point);
|
|
576
|
+
findings.push(
|
|
577
|
+
finding(
|
|
578
|
+
[...keyPath, j],
|
|
579
|
+
`\`${label}[${j}]\` writes ${codeName(point)}, which the font \`${slug}\`${message} does not draw`,
|
|
580
|
+
),
|
|
581
|
+
);
|
|
582
|
+
}
|
|
583
|
+
});
|
|
584
|
+
gaps(value.fonts[at].rows, ["fonts", at, "rows"], `data.map.fonts[${at}].rows`, "");
|
|
585
|
+
gaps(value.rows, ["rows"], "data.map.rows", ", listed in `data.map.fonts`,");
|
|
586
|
+
}
|
|
587
|
+
return findings;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* Check a language note's native forms against the maps and faces: an unknown
|
|
592
|
+
* map, a face the map is not for, a second form for one map and face, a form
|
|
593
|
+
* that states what the rows already give, and a code point a face it applies
|
|
594
|
+
* in does not draw. The shape of each entry is the data-key check's.
|
|
595
|
+
*
|
|
596
|
+
* @param {{file: string, raw?: string, fm?: Record<string, any>}} note - The note.
|
|
597
|
+
* @param {{faces?: ReadonlyMap<string, import("./script-fonts.mjs").Face>, maps?: ReadonlyMap<string, import("./text-maps.mjs").TextMap>}} ctx -
|
|
598
|
+
* The run context.
|
|
599
|
+
* @returns {object[]} The findings.
|
|
600
|
+
*/
|
|
601
|
+
export function checkLexiconNatives(note, ctx) {
|
|
602
|
+
const fm = note?.fm ?? {};
|
|
603
|
+
if (!isLanguage(fm) || !isMap(fm.data)) return [];
|
|
604
|
+
const raw = note.raw ?? "";
|
|
605
|
+
const finding = (keyPath, message, key = false) => ({
|
|
606
|
+
file: note.file,
|
|
607
|
+
...positionOfFrontmatterPath(raw, ["data", "words", ...keyPath], { key }),
|
|
608
|
+
severity: "error",
|
|
609
|
+
message,
|
|
610
|
+
});
|
|
611
|
+
const maps = ctx?.maps ?? new Map();
|
|
612
|
+
const faces = ctx?.faces ?? new Map();
|
|
613
|
+
const findings = [];
|
|
614
|
+
listOf(fm.data.words).forEach((word, i) => {
|
|
615
|
+
if (!isMap(word) || !Array.isArray(word.native)) return;
|
|
616
|
+
const form = typeof word.form === "string" ? word.form : "";
|
|
617
|
+
const stated = new Map();
|
|
618
|
+
const entries = word.native.map((entry) =>
|
|
619
|
+
isMap(entry) && typeof entry.map === "string" && typeof entry.text === "string" ?
|
|
620
|
+
{
|
|
621
|
+
map: entry.map,
|
|
622
|
+
font: typeof entry.font === "string" ? entry.font : undefined,
|
|
623
|
+
text: entry.text,
|
|
624
|
+
}
|
|
625
|
+
: undefined,
|
|
626
|
+
);
|
|
627
|
+
entries.forEach((entry, j) => {
|
|
628
|
+
if (!entry) return;
|
|
629
|
+
const label = `data.words[${i}].native[${j}]`;
|
|
630
|
+
const map = maps.get(entry.map);
|
|
631
|
+
if (!map) {
|
|
632
|
+
const near = nearest(entry.map, [...maps.keys()]);
|
|
633
|
+
findings.push(
|
|
634
|
+
finding(
|
|
635
|
+
[i, "native", j, "map"],
|
|
636
|
+
`\`${label}.map\` is \`${entry.map}\`, which is not a map: a native form names ` +
|
|
637
|
+
"the `data.map.name` of a mapping note" +
|
|
638
|
+
(near ? `; did you mean \`${near}\`?` : ""),
|
|
639
|
+
),
|
|
640
|
+
);
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
if (entry.font !== undefined && map.fonts.size && !map.fonts.has(entry.font)) {
|
|
644
|
+
findings.push(
|
|
645
|
+
finding(
|
|
646
|
+
[i, "native", j, "font"],
|
|
647
|
+
`\`${label}.font\` is \`${entry.font}\`, which is not a font of the map ` +
|
|
648
|
+
`\`${map.name}\`; it is for ${said([...map.fonts.keys()])}`,
|
|
649
|
+
),
|
|
650
|
+
);
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
const slot = `${entry.map}\u0000${entry.font ?? ""}`;
|
|
654
|
+
if (stated.has(slot))
|
|
655
|
+
findings.push(
|
|
656
|
+
finding(
|
|
657
|
+
[i, "native", j],
|
|
658
|
+
`\`${label}\` states a second native form for the map \`${map.name}\`` +
|
|
659
|
+
(entry.font === undefined ? "" : ` in the font \`${entry.font}\``) +
|
|
660
|
+
`; the first is \`data.words[${i}].native[${stated.get(slot)}]\``,
|
|
661
|
+
),
|
|
662
|
+
);
|
|
663
|
+
else stated.set(slot, j);
|
|
664
|
+
const fonts =
|
|
665
|
+
entry.font !== undefined ? [entry.font]
|
|
666
|
+
: map.fonts.size ? [...map.fonts.keys()]
|
|
667
|
+
: [undefined];
|
|
668
|
+
if (fonts.every((font) => applyMap(map, form, { font }).text === entry.text))
|
|
669
|
+
findings.push(
|
|
670
|
+
finding(
|
|
671
|
+
[i, "native", j],
|
|
672
|
+
`\`${label}\` states what the rows already give: the map \`${map.name}\` ` +
|
|
673
|
+
`spells \`${form}\` so ` +
|
|
674
|
+
(entry.font !== undefined ? `in the font \`${entry.font}\``
|
|
675
|
+
: map.fonts.size > 1 ? "in every font it is for"
|
|
676
|
+
: map.fonts.size ? `in the font \`${[...map.fonts.keys()][0]}\``
|
|
677
|
+
: "by its rows") +
|
|
678
|
+
"; a native form is stated only where the rows spell the word differently",
|
|
679
|
+
),
|
|
680
|
+
);
|
|
681
|
+
// The faces the form is written in: its own, or every listed face
|
|
682
|
+
// no form stated for that face overrides.
|
|
683
|
+
const applies =
|
|
684
|
+
entry.font !== undefined ?
|
|
685
|
+
[entry.font]
|
|
686
|
+
: [...map.fonts.keys()].filter(
|
|
687
|
+
(font) =>
|
|
688
|
+
!entries.some(
|
|
689
|
+
(other) => other && other.map === entry.map && other.font === font,
|
|
690
|
+
),
|
|
691
|
+
);
|
|
692
|
+
for (const slug of applies) {
|
|
693
|
+
const face = faces.get(slug);
|
|
694
|
+
if (!face) continue;
|
|
695
|
+
const seen = new Set();
|
|
696
|
+
for (const char of entry.text) {
|
|
697
|
+
const point = /** @type {number} */ (char.codePointAt(0));
|
|
698
|
+
if (/\s/u.test(char) || face.draws.has(point) || seen.has(point)) continue;
|
|
699
|
+
seen.add(point);
|
|
700
|
+
findings.push(
|
|
701
|
+
finding(
|
|
702
|
+
[i, "native", j],
|
|
703
|
+
`\`${label}\` holds ${codeName(point)}, which the font \`${slug}\`, ` +
|
|
704
|
+
`listed in the map \`${map.name}\`, does not draw`,
|
|
705
|
+
),
|
|
706
|
+
);
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
});
|
|
710
|
+
});
|
|
711
|
+
return findings;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/** Every column of the `natives` relation, in order. */
|
|
715
|
+
export const NATIVE_COLUMNS = Object.freeze([
|
|
716
|
+
"language",
|
|
717
|
+
"word",
|
|
718
|
+
"form",
|
|
719
|
+
"native",
|
|
720
|
+
"map",
|
|
721
|
+
"font",
|
|
722
|
+
"text",
|
|
723
|
+
]);
|
|
724
|
+
|
|
725
|
+
/**
|
|
726
|
+
* One row per native form of a language note's word, for the `natives`
|
|
727
|
+
* relation: a text is a value of a row, never a struct key, so forms that
|
|
728
|
+
* differ only in case stay apart.
|
|
729
|
+
*
|
|
730
|
+
* @param {Iterable<Record<string, any>>} records - Index records.
|
|
731
|
+
* @returns {Array<Record<string, string|number|null>>} The rows; `word` is the
|
|
732
|
+
* word's 0-based place in `data.words`, `native` the form's in its `native`.
|
|
733
|
+
*/
|
|
734
|
+
export function nativeRows(records) {
|
|
735
|
+
const rows = [];
|
|
736
|
+
for (const record of records) {
|
|
737
|
+
if (!isLanguageRecord(record)) continue;
|
|
738
|
+
const language = noteAddressOf(record);
|
|
739
|
+
if (!language) continue;
|
|
740
|
+
listOf(record.data?.words).forEach((word, position) => {
|
|
741
|
+
if (!isMap(word) || typeof word.form !== "string") return;
|
|
742
|
+
listOf(word.native).forEach((entry, at) => {
|
|
743
|
+
if (
|
|
744
|
+
!isMap(entry) ||
|
|
745
|
+
typeof entry.map !== "string" ||
|
|
746
|
+
typeof entry.text !== "string"
|
|
747
|
+
)
|
|
748
|
+
return;
|
|
749
|
+
rows.push({
|
|
750
|
+
language,
|
|
751
|
+
word: position,
|
|
752
|
+
form: word.form,
|
|
753
|
+
native: at,
|
|
754
|
+
map: entry.map,
|
|
755
|
+
font: typeof entry.font === "string" ? entry.font : null,
|
|
756
|
+
text: entry.text,
|
|
757
|
+
});
|
|
758
|
+
});
|
|
759
|
+
});
|
|
760
|
+
}
|
|
761
|
+
return rows;
|
|
762
|
+
}
|