@heroiclands/package-build 24.1.1 → 25.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +36 -0
- package/README.md +1 -0
- package/bin/content-commands.mjs +30 -96
- package/bin/encoding-commands.mjs +146 -0
- package/bin/package-build.mjs +35 -24
- package/config.mjs +25 -0
- package/convert.mjs +182 -0
- package/docs/api.md +154 -11
- package/docs/authoring/links-and-markup.md +260 -473
- package/docs/commands.md +118 -119
- package/docs/configuration.md +31 -13
- package/docs/content-format.md +11 -4
- package/docs/diagnostics.md +43 -16
- package/docs/encoding-core.md +135 -0
- package/docs/guides/book.md +2 -2
- package/docs/guides/module-project.md +23 -1
- package/docs/guides/site.md +2 -2
- package/docs/guides/writing-in-invented-scripts.md +453 -569
- package/docs/reference/format-details.md +290 -380
- package/docs/reference/note-types.md +80 -62
- package/docs/source-preview.md +8 -0
- package/e2e.mjs +1 -1
- package/engine/base-compiler.mjs +27 -9
- package/engine/content-charset.mjs +21 -1
- package/engine/content-format.yaml +7 -2
- package/engine/content-index.mjs +6 -2
- package/engine/content-links.mjs +1 -0
- package/engine/content-scripts.mjs +658 -423
- package/engine/content-scroll.mjs +4 -4
- package/engine/content-spans.mjs +40 -9
- package/engine/content-verse.mjs +1 -1
- package/engine/converter-data.mjs +70 -0
- package/engine/dictionary-rows.mjs +2 -34
- package/engine/encoding-integrity.mjs +94 -0
- package/engine/encoding-language.mjs +506 -0
- package/engine/encoding-lexicon.mjs +699 -0
- package/engine/encoding-notes.mjs +186 -0
- package/engine/encoding-standard.mjs +42 -0
- package/engine/encoding-template.mjs +313 -0
- package/engine/encoding-terms.mjs +141 -0
- package/engine/encoding.mjs +946 -0
- package/engine/extension-attributes.mjs +2 -0
- package/engine/helpers.mjs +1 -1
- package/engine/index.mjs +10 -0
- package/engine/language-data.mjs +4 -1
- package/engine/language-terms.mjs +23 -12
- package/engine/{logographic.mjs → language-writings.mjs} +29 -38
- package/engine/markup-validation.mjs +1 -1
- package/engine/note-vocabulary.mjs +4 -158
- package/engine/pdf-render.mjs +31 -3
- package/engine/roman-characters.mjs +232 -0
- package/engine/roman-run.mjs +149 -0
- package/engine/roman-verse.mjs +95 -0
- package/engine/run-rows.mjs +29 -31
- package/engine/run-words.mjs +2 -2
- package/engine/script-emphasis-terms.mjs +3 -0
- package/engine/script-emphasis.mjs +3 -10
- package/engine/script-font-delivery.mjs +9 -12
- package/engine/script-fonts.mjs +2 -1
- package/engine/site-build.mjs +14 -5
- package/engine/site-preview.mjs +12 -2
- package/engine/sql-tables.mjs +36 -31
- package/engine/verse-metre.mjs +1 -1
- package/engine/word-forms.mjs +1 -1
- package/engine/word-inflections.mjs +48 -0
- package/engine/word-rows.mjs +1 -1
- package/engine/word-terms.mjs +16 -17
- package/package.json +11 -5
- package/types/config.d.mts +5 -0
- package/types/convert.d.mts +44 -0
- package/types/e2e.d.mts +1 -1
- package/types/engine/base-compiler.d.mts +4 -2
- package/types/engine/content-charset.d.mts +8 -0
- package/types/engine/content-index.d.mts +4 -1
- package/types/engine/content-scripts.d.mts +108 -42
- package/types/engine/converter-data.d.mts +11 -0
- package/types/engine/dictionary-rows.d.mts +1 -25
- package/types/engine/encoding-integrity.d.mts +14 -0
- package/types/engine/encoding-language.d.mts +36 -0
- package/types/engine/encoding-lexicon.d.mts +259 -0
- package/types/engine/encoding-notes.d.mts +39 -0
- package/types/engine/encoding-standard.d.mts +11 -0
- package/types/engine/encoding-template.d.mts +43 -0
- package/types/engine/encoding-terms.d.mts +105 -0
- package/types/engine/encoding.d.mts +155 -0
- package/types/engine/helpers.d.mts +1 -1
- package/types/engine/index.d.mts +5 -0
- package/types/engine/language-terms.d.mts +1 -1
- package/types/engine/{logographic.d.mts → language-writings.d.mts} +6 -6
- package/types/engine/markup-validation.d.mts +1 -1
- package/types/engine/pdf-render.d.mts +1 -1
- package/types/engine/roman-characters.d.mts +45 -0
- package/types/engine/roman-run.d.mts +101 -0
- package/types/engine/roman-verse.d.mts +23 -0
- package/types/engine/run-rows.d.mts +13 -20
- package/types/engine/script-emphasis-terms.d.mts +2 -0
- package/types/engine/script-emphasis.d.mts +1 -1
- package/types/engine/script-font-delivery.d.mts +2 -3
- package/types/engine/script-fonts.d.mts +5 -0
- package/types/engine/site-preview.d.mts +1 -1
- package/types/engine/word-inflections.d.mts +25 -0
- package/engine/map-lexicon.mjs +0 -1034
- package/engine/text-maps.mjs +0 -654
- package/engine/written-in.mjs +0 -204
- package/types/engine/map-lexicon.d.mts +0 -330
- package/types/engine/text-maps.d.mts +0 -189
- package/types/engine/written-in.d.mts +0 -76
package/engine/map-lexicon.mjs
DELETED
|
@@ -1,1034 +0,0 @@
|
|
|
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
|
-
* - **A logographic map** spells no word by its rows: every word is written
|
|
41
|
-
* from the lexicon, by its own writing or its parts' (see
|
|
42
|
-
* {@link module:engine/logographic}), and a word with neither is a finding.
|
|
43
|
-
*
|
|
44
|
-
* {@link mappingFor} is the one path from a map, a face and a language to the
|
|
45
|
-
* function that rewrites text: the build's runs, `package-build transliterate`
|
|
46
|
-
* and `package-build lexicon` all go through it.
|
|
47
|
-
*
|
|
48
|
-
* @module
|
|
49
|
-
*/
|
|
50
|
-
|
|
51
|
-
import fs from "node:fs";
|
|
52
|
-
|
|
53
|
-
import { parseAddress, renderAddress } from "./address.mjs";
|
|
54
|
-
import { positionOfFrontmatterPath, positionOfYamlPath } from "./diagnostics.mjs";
|
|
55
|
-
import { isNoteRecord } from "./index-records.mjs";
|
|
56
|
-
import { nearest } from "./near-miss.mjs";
|
|
57
|
-
import { WRITTEN_ENDINGS } from "./language-terms.mjs";
|
|
58
|
-
import { fromWrittenIn } from "./written-in.mjs";
|
|
59
|
-
import { writingsOf } from "./logographic.mjs";
|
|
60
|
-
import { FONTS_PACKAGE } from "./script-fonts.mjs";
|
|
61
|
-
import { GENERIC_FAMILIES, applyMap } from "./text-maps.mjs";
|
|
62
|
-
import { isLanguageRecord, lineageChain, noteAddressOf } from "./word-rows.mjs";
|
|
63
|
-
import { derivedForms } from "./word-forms.mjs";
|
|
64
|
-
import { LANGUAGE_SUBTYPE, glossText } from "./word-terms.mjs";
|
|
65
|
-
|
|
66
|
-
/** The configuration file a language is declared in. */
|
|
67
|
-
const CONFIG = "package-build.config.yaml";
|
|
68
|
-
|
|
69
|
-
/** Whether a value is a plain map. */
|
|
70
|
-
const isMap = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
71
|
-
|
|
72
|
-
/** A list value, or an empty one. */
|
|
73
|
-
const listOf = (value) => (Array.isArray(value) ? value : []);
|
|
74
|
-
|
|
75
|
-
/** `U+XXXX` for a code point. */
|
|
76
|
-
const codeName = (point) => `U+${point.toString(16).toUpperCase().padStart(4, "0")}`;
|
|
77
|
-
|
|
78
|
-
/** Names in backticks, joined as a list is said: `a`, `b` and `c`. */
|
|
79
|
-
function said(names) {
|
|
80
|
-
const quoted = names.map((name) => `\`${name}\``);
|
|
81
|
-
return quoted.length < 2 ?
|
|
82
|
-
quoted.join("")
|
|
83
|
-
: `${quoted.slice(0, -1).join(", ")} and ${quoted.at(-1)}`;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* One native form, read.
|
|
88
|
-
*
|
|
89
|
-
* @typedef {object} NativeForm
|
|
90
|
-
* @property {string} map - The map it is written for.
|
|
91
|
-
* @property {string|undefined} font - The one face it is for.
|
|
92
|
-
* @property {string} text - The word in the script.
|
|
93
|
-
*/
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
* One word of a lexicon.
|
|
97
|
-
*
|
|
98
|
-
* @typedef {object} LexiconWord
|
|
99
|
-
* @property {string} form - The word as cited.
|
|
100
|
-
* @property {string|null} class - Its class.
|
|
101
|
-
* @property {string|null} gloss - Its senses, joined by `; `.
|
|
102
|
-
* @property {readonly NativeForm[]} natives - Its native forms.
|
|
103
|
-
*/
|
|
104
|
-
|
|
105
|
-
/**
|
|
106
|
-
* A language's lexicon.
|
|
107
|
-
*
|
|
108
|
-
* @typedef {object} Lexicon
|
|
109
|
-
* @property {string} language - The language's configured name.
|
|
110
|
-
* @property {readonly LexiconWord[]} words - Its words, in `data.words` order.
|
|
111
|
-
* @property {ReadonlyMap<string, readonly NativeForm[]>} byForm - The native
|
|
112
|
-
* forms by the word's form, NFC; the first word of a form keeps it.
|
|
113
|
-
* @property {ReadonlyArray<{form: string, from: LexiconWord}>} derived - The
|
|
114
|
-
* forms the language's rules derive from its words, folded, as the mapped-run
|
|
115
|
-
* word check accepts them (see {@link module:engine/word-forms.derivedForms}).
|
|
116
|
-
* @property {Record<string, any>} data - The language note's `data`, which a
|
|
117
|
-
* logographic map writes words from.
|
|
118
|
-
*/
|
|
119
|
-
|
|
120
|
-
/**
|
|
121
|
-
* A word's native forms as written, keeping those that read.
|
|
122
|
-
*
|
|
123
|
-
* @param {unknown} value - A word's `native`.
|
|
124
|
-
* @returns {NativeForm[]} The forms.
|
|
125
|
-
*/
|
|
126
|
-
function readNatives(value) {
|
|
127
|
-
return listOf(value)
|
|
128
|
-
.filter(
|
|
129
|
-
(entry) =>
|
|
130
|
-
isMap(entry) && typeof entry.map === "string" && typeof entry.text === "string",
|
|
131
|
-
)
|
|
132
|
-
.map((entry) => ({
|
|
133
|
-
map: entry.map,
|
|
134
|
-
font: typeof entry.font === "string" ? entry.font : undefined,
|
|
135
|
-
text: entry.text,
|
|
136
|
-
}));
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
/**
|
|
140
|
-
* A language note's words as a lexicon.
|
|
141
|
-
*
|
|
142
|
-
* @param {string} language - The language's configured name.
|
|
143
|
-
* @param {Record<string, any>} data - The note's `data`.
|
|
144
|
-
* @returns {Lexicon} The lexicon.
|
|
145
|
-
*/
|
|
146
|
-
function lexiconOf(language, data) {
|
|
147
|
-
const words = [];
|
|
148
|
-
const byForm = new Map();
|
|
149
|
-
const bySource = new Map();
|
|
150
|
-
for (const word of listOf(data?.words)) {
|
|
151
|
-
if (!isMap(word) || typeof word.form !== "string") continue;
|
|
152
|
-
const natives = readNatives(word.native);
|
|
153
|
-
const read = Object.freeze({
|
|
154
|
-
form: word.form,
|
|
155
|
-
class: typeof word.class === "string" ? word.class : null,
|
|
156
|
-
gloss: glossText(word.gloss),
|
|
157
|
-
natives,
|
|
158
|
-
});
|
|
159
|
-
words.push(read);
|
|
160
|
-
bySource.set(word, read);
|
|
161
|
-
const key = word.form.normalize("NFC");
|
|
162
|
-
if (!byForm.has(key)) byForm.set(key, natives);
|
|
163
|
-
}
|
|
164
|
-
const derived = derivedForms(data).map(({ form, word }) =>
|
|
165
|
-
Object.freeze({ form, from: bySource.get(word) }),
|
|
166
|
-
);
|
|
167
|
-
return Object.freeze({
|
|
168
|
-
language,
|
|
169
|
-
words: Object.freeze(words),
|
|
170
|
-
byForm,
|
|
171
|
-
derived: Object.freeze(derived),
|
|
172
|
-
data: isMap(data) ? data : {},
|
|
173
|
-
});
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
/**
|
|
177
|
-
* The canonical Address `languages.<name>.note` names, or `undefined` where
|
|
178
|
-
* the value is no Address.
|
|
179
|
-
*
|
|
180
|
-
* @param {unknown} note - The configured value.
|
|
181
|
-
* @param {string|undefined} contentPackage - The package it is read in.
|
|
182
|
-
* @param {Iterable<string>} types - The types a qualified value may name.
|
|
183
|
-
* @returns {string|undefined} The Address.
|
|
184
|
-
*/
|
|
185
|
-
function noteKey(note, contentPackage, types) {
|
|
186
|
-
if (!contentPackage) return undefined;
|
|
187
|
-
const tuple = parseAddress(
|
|
188
|
-
note,
|
|
189
|
-
{ package: contentPackage, system: "note", type: "skill", types: new Set(types) },
|
|
190
|
-
{ declared: true },
|
|
191
|
-
);
|
|
192
|
-
return "reason" in tuple ? undefined : renderAddress(tuple);
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
/**
|
|
196
|
-
* Each record by the Address a reference to it is written as.
|
|
197
|
-
*
|
|
198
|
-
* @param {Iterable<Record<string, any>>} records - Index records.
|
|
199
|
-
* @param {string|undefined} contentPackage - The package a record with no
|
|
200
|
-
* `package` belongs to.
|
|
201
|
-
* @returns {Map<string, Record<string, any>>} The records.
|
|
202
|
-
*/
|
|
203
|
-
function recordsByKey(records, contentPackage) {
|
|
204
|
-
const byKey = new Map();
|
|
205
|
-
for (const record of records ?? []) {
|
|
206
|
-
if (!record || !isNoteRecord(record) || !record.type || !record.shortcode) continue;
|
|
207
|
-
const pkg = record.package ?? contentPackage;
|
|
208
|
-
if (!pkg) continue;
|
|
209
|
-
const key = renderAddress({
|
|
210
|
-
package: String(pkg),
|
|
211
|
-
system: "note",
|
|
212
|
-
type: String(record.type),
|
|
213
|
-
shortcode: String(record.shortcode),
|
|
214
|
-
});
|
|
215
|
-
if (!byKey.has(key)) byKey.set(key, record);
|
|
216
|
-
}
|
|
217
|
-
return byKey;
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
/** Whether a record is a language note's. */
|
|
221
|
-
const isLanguage = (record) =>
|
|
222
|
-
record?.type === "skill" && String(record.subType ?? "").toLowerCase() === LANGUAGE_SUBTYPE;
|
|
223
|
-
|
|
224
|
-
/**
|
|
225
|
-
* The language note each configured language names.
|
|
226
|
-
*
|
|
227
|
-
* @param {Readonly<Record<string, {tag: string, note?: string}>>} languages -
|
|
228
|
-
* The configured languages.
|
|
229
|
-
* @param {string|undefined} contentPackage - The package.
|
|
230
|
-
* @param {Iterable<Record<string, any>>} records - Index records.
|
|
231
|
-
* @returns {Map<string, {key: string|undefined, record: Record<string, any>|undefined}>}
|
|
232
|
-
* Each language stating a `note`, with what it resolves to.
|
|
233
|
-
*/
|
|
234
|
-
function languageNotes(languages, contentPackage, records) {
|
|
235
|
-
const list = [...(records ?? [])];
|
|
236
|
-
const byKey = recordsByKey(list, contentPackage);
|
|
237
|
-
const types = new Set(list.map((record) => record?.type).filter(Boolean));
|
|
238
|
-
types.add("skill");
|
|
239
|
-
const out = new Map();
|
|
240
|
-
for (const [name, spec] of Object.entries(languages ?? {})) {
|
|
241
|
-
if (spec?.note === undefined) continue;
|
|
242
|
-
const key = noteKey(spec.note, contentPackage, types);
|
|
243
|
-
const record = key ? byKey.get(key) : undefined;
|
|
244
|
-
out.set(name, { key, record: isLanguage(record) ? record : undefined });
|
|
245
|
-
}
|
|
246
|
-
return out;
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
/**
|
|
250
|
-
* Each configured language's lexicon, read from the language note its `note`
|
|
251
|
-
* names and its family chain. The nearest exact NFC form supplies the whole
|
|
252
|
-
* word; grammar, registers and other settings remain the daughter's own.
|
|
253
|
-
* A language with no `note`, or one naming no language note, has none.
|
|
254
|
-
*
|
|
255
|
-
* @param {Iterable<Record<string, any>>|undefined} records - Index records.
|
|
256
|
-
* @param {{languages?: Readonly<Record<string, {tag: string, note?: string}>>, contentPackage?: string}} config -
|
|
257
|
-
* The resolved configuration.
|
|
258
|
-
* @returns {Map<string, Lexicon>} The lexicons by language name.
|
|
259
|
-
*/
|
|
260
|
-
export function lexiconsFromRecords(records, config = {}) {
|
|
261
|
-
const list = [...(records ?? [])];
|
|
262
|
-
const byKey = recordsByKey(list, config.contentPackage);
|
|
263
|
-
const types = new Set(["skill", ...list.map((record) => record?.type).filter(Boolean)]);
|
|
264
|
-
const lineage = {
|
|
265
|
-
has: (key) => isLanguage(byKey.get(key)),
|
|
266
|
-
parentOf: (key) => {
|
|
267
|
-
const record = byKey.get(key);
|
|
268
|
-
return noteKey(record.data?.family, record.package ?? config.contentPackage, types);
|
|
269
|
-
},
|
|
270
|
-
};
|
|
271
|
-
const lexicons = new Map();
|
|
272
|
-
for (const [name, { key, record }] of languageNotes(
|
|
273
|
-
config.languages,
|
|
274
|
-
config.contentPackage,
|
|
275
|
-
list,
|
|
276
|
-
)) {
|
|
277
|
-
if (!record) continue;
|
|
278
|
-
const chain = lineageChain(key, lineage);
|
|
279
|
-
let data = record.data;
|
|
280
|
-
if (chain.length > 1) {
|
|
281
|
-
const words = new Map();
|
|
282
|
-
for (const ancestor of chain)
|
|
283
|
-
for (const word of listOf(byKey.get(ancestor).data?.words)) {
|
|
284
|
-
if (!isMap(word) || typeof word.form !== "string") continue;
|
|
285
|
-
// The nearest complete definition wins before word matching
|
|
286
|
-
// folds case or splits compounds. Only words are inherited.
|
|
287
|
-
const form = word.form.normalize("NFC");
|
|
288
|
-
if (!words.has(form)) words.set(form, word);
|
|
289
|
-
}
|
|
290
|
-
data = { ...data, words: [...words.values()] };
|
|
291
|
-
}
|
|
292
|
-
lexicons.set(name, lexiconOf(name, data));
|
|
293
|
-
}
|
|
294
|
-
return lexicons;
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
/**
|
|
298
|
-
* The language note each configured language's `note` names, by language
|
|
299
|
-
* name. A language with no `note`, or one naming no language note, is absent.
|
|
300
|
-
*
|
|
301
|
-
* @param {Iterable<Record<string, any>>|undefined} records - Index records.
|
|
302
|
-
* @param {{languages?: Readonly<Record<string, {tag: string, note?: string}>>, contentPackage?: string}} config -
|
|
303
|
-
* The resolved configuration.
|
|
304
|
-
* @returns {Map<string, Record<string, any>>} The records.
|
|
305
|
-
*/
|
|
306
|
-
export function languageRecordsByName(records, { languages, contentPackage } = {}) {
|
|
307
|
-
const out = new Map();
|
|
308
|
-
for (const [name, { record }] of languageNotes(languages, contentPackage, records))
|
|
309
|
-
if (record) out.set(name, record);
|
|
310
|
-
return out;
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
/**
|
|
314
|
-
* Each `languages.<name>.note` that names no language note, at its key.
|
|
315
|
-
*
|
|
316
|
-
* @param {{languages?: Readonly<Record<string, {tag: string, note?: string}>>, contentPackage?: string, configFile?: string, configText?: string}} config -
|
|
317
|
-
* The resolved configuration, and where it was read from.
|
|
318
|
-
* @param {Iterable<Record<string, any>>} records - Index records.
|
|
319
|
-
* @returns {object[]} The findings.
|
|
320
|
-
*/
|
|
321
|
-
export function languageNoteFindings(config, records) {
|
|
322
|
-
const { languages, contentPackage, configFile, configText } = config ?? {};
|
|
323
|
-
let text = configText;
|
|
324
|
-
if (text === undefined && configFile) {
|
|
325
|
-
try {
|
|
326
|
-
text = fs.readFileSync(configFile, "utf8");
|
|
327
|
-
} catch {
|
|
328
|
-
text = undefined;
|
|
329
|
-
}
|
|
330
|
-
}
|
|
331
|
-
const findings = [];
|
|
332
|
-
for (const [name, { record }] of languageNotes(languages, contentPackage, records)) {
|
|
333
|
-
if (record) continue;
|
|
334
|
-
findings.push({
|
|
335
|
-
file: configFile ?? CONFIG,
|
|
336
|
-
...(text ? positionOfYamlPath(text, ["languages", name, "note"], { key: true }) : {}),
|
|
337
|
-
severity: "error",
|
|
338
|
-
message:
|
|
339
|
-
`\`languages.${name}.note\` is \`${String(languages[name].note)}\`, which names no ` +
|
|
340
|
-
`language note in ${contentPackage}; it names a skill note whose subType is language`,
|
|
341
|
-
});
|
|
342
|
-
}
|
|
343
|
-
return findings;
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
/**
|
|
347
|
-
* A lookup of a word's native form under one map in one face: the form stated
|
|
348
|
-
* for the face, else the one stated for no face.
|
|
349
|
-
*
|
|
350
|
-
* @param {Lexicon|undefined} lexicon - The lexicon.
|
|
351
|
-
* @param {string} map - The map's name.
|
|
352
|
-
* @param {string|undefined} font - The face.
|
|
353
|
-
* @returns {((word: string) => string|undefined)|undefined} The lookup, or
|
|
354
|
-
* `undefined` with no lexicon.
|
|
355
|
-
*/
|
|
356
|
-
export function nativeLookup(lexicon, map, font) {
|
|
357
|
-
if (!lexicon) return undefined;
|
|
358
|
-
return (word) => {
|
|
359
|
-
const natives = lexicon.byForm.get(word.normalize("NFC"));
|
|
360
|
-
if (!natives) return undefined;
|
|
361
|
-
const own =
|
|
362
|
-
font === undefined ? undefined : natives.find((n) => n.map === map && n.font === font);
|
|
363
|
-
return (own ?? natives.find((n) => n.map === map && n.font === undefined))?.text;
|
|
364
|
-
};
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
/**
|
|
368
|
-
* What a run, a passage or a listing is mapped by.
|
|
369
|
-
*
|
|
370
|
-
* @typedef {object} Mapping
|
|
371
|
-
* @property {import("./text-maps.mjs").TextMap} map - The map.
|
|
372
|
-
* @property {string|undefined} font - The face, whose own rows come first.
|
|
373
|
-
* @property {((word: string) => string|undefined)|undefined} native - The
|
|
374
|
-
* language's native forms under the map in the face, for a map that spells
|
|
375
|
-
* by its rows.
|
|
376
|
-
* @property {((word: string) => import("./logographic.mjs").Writing)|undefined} writing -
|
|
377
|
-
* Each word's writing, for a logographic map.
|
|
378
|
-
*/
|
|
379
|
-
|
|
380
|
-
/**
|
|
381
|
-
* The one path from a map, a face and a language to what rewrites the text.
|
|
382
|
-
*
|
|
383
|
-
* @param {{maps?: ReadonlyMap<string, import("./text-maps.mjs").TextMap>, lexicons?: ReadonlyMap<string, Lexicon>}} ctx -
|
|
384
|
-
* The run context.
|
|
385
|
-
* @param {{map: string, font?: string, lang?: string}} keys - The map, the face
|
|
386
|
-
* and the language.
|
|
387
|
-
* @returns {Mapping|undefined} The mapping, or `undefined` for an unknown map.
|
|
388
|
-
*/
|
|
389
|
-
export function mappingFor(ctx, { map, font, lang }) {
|
|
390
|
-
const textMap = ctx?.maps?.get(map);
|
|
391
|
-
if (!textMap) return undefined;
|
|
392
|
-
const lexicon = lang === undefined ? undefined : ctx.lexicons?.get(lang);
|
|
393
|
-
if (!textMap.logographic)
|
|
394
|
-
return {
|
|
395
|
-
map: textMap,
|
|
396
|
-
font,
|
|
397
|
-
native: nativeLookup(lexicon, textMap.name, font),
|
|
398
|
-
writing: undefined,
|
|
399
|
-
};
|
|
400
|
-
const writings = lexicon ? writingsOf(lexicon.data, textMap.name, font) : undefined;
|
|
401
|
-
return {
|
|
402
|
-
map: textMap,
|
|
403
|
-
font,
|
|
404
|
-
native: undefined,
|
|
405
|
-
writing: writings ? writings.word : () => ({ missing: true, noLexicon: true }),
|
|
406
|
-
};
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
/**
|
|
410
|
-
* Why a face is not one a map is for, or `undefined` where it is.
|
|
411
|
-
*
|
|
412
|
-
* @param {import("./text-maps.mjs").TextMap} map - The map.
|
|
413
|
-
* @param {string} font - The face's slug.
|
|
414
|
-
* @returns {string|undefined} The problem.
|
|
415
|
-
*/
|
|
416
|
-
export function fontProblem(map, font) {
|
|
417
|
-
if (!map.fonts.size || map.fonts.has(font)) return undefined;
|
|
418
|
-
return `\`${font}\` is not a font of the map \`${map.name}\`; it is for ${said([...map.fonts.keys()])}`;
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
/**
|
|
422
|
-
* The mapping a command uses, or what is wrong with what it was given. The
|
|
423
|
-
* language's `data.writtenIn` fills the map and the face it leaves out, as it
|
|
424
|
-
* fills a run's; after that, a map listing one face defaults to it, and one
|
|
425
|
-
* listing more needs `font`.
|
|
426
|
-
*
|
|
427
|
-
* @param {object} ctx - The run context.
|
|
428
|
-
* @param {{map?: string, font?: string, lang?: string}} keys - What was asked for.
|
|
429
|
-
* @returns {{mapping: Mapping}|{problem: string, about: "map"|"lang"|"font"}} The mapping or the problem.
|
|
430
|
-
*/
|
|
431
|
-
function commandMapping(ctx, keys) {
|
|
432
|
-
const { lang } = keys;
|
|
433
|
-
const filled = fromWrittenIn(keys, ctx?.writtenIn, ctx?.maps);
|
|
434
|
-
const map = keys.map ?? filled.map;
|
|
435
|
-
const font = keys.font ?? filled.font;
|
|
436
|
-
if (map === undefined) {
|
|
437
|
-
if (lang !== undefined && !Object.hasOwn(ctx?.languages ?? {}, lang)) {
|
|
438
|
-
const near = nearest(lang, Object.keys(ctx?.languages ?? {}));
|
|
439
|
-
return {
|
|
440
|
-
about: "lang",
|
|
441
|
-
problem:
|
|
442
|
-
`\`${lang}\` is not a declared language (\`languages\` in ${CONFIG})` +
|
|
443
|
-
(near ? `; did you mean \`${near}\`?` : ""),
|
|
444
|
-
};
|
|
445
|
-
}
|
|
446
|
-
return {
|
|
447
|
-
about: "map",
|
|
448
|
-
problem:
|
|
449
|
-
`\`${lang}\` names no script: its note states no \`data.writtenIn\`, so name a ` +
|
|
450
|
-
"map with `--map`",
|
|
451
|
-
};
|
|
452
|
-
}
|
|
453
|
-
const textMap = ctx?.maps?.get(map);
|
|
454
|
-
if (!textMap) {
|
|
455
|
-
const names = [...(ctx?.maps?.keys() ?? [])];
|
|
456
|
-
const near = nearest(map, names);
|
|
457
|
-
return {
|
|
458
|
-
about: "map",
|
|
459
|
-
problem:
|
|
460
|
-
`\`${map}\` is not a map: a map is named by the \`data.map.name\` of a mapping note` +
|
|
461
|
-
(near ? `; did you mean \`${near}\`?`
|
|
462
|
-
: names.length ? `; the maps are ${said(names)}`
|
|
463
|
-
: "; no note declares one"),
|
|
464
|
-
};
|
|
465
|
-
}
|
|
466
|
-
if (lang !== undefined && !Object.hasOwn(ctx.languages ?? {}, lang)) {
|
|
467
|
-
const near = nearest(lang, Object.keys(ctx.languages ?? {}));
|
|
468
|
-
return {
|
|
469
|
-
about: "lang",
|
|
470
|
-
problem:
|
|
471
|
-
`\`${lang}\` is not a declared language (\`languages\` in ${CONFIG})` +
|
|
472
|
-
(near ? `; did you mean \`${near}\`?` : ""),
|
|
473
|
-
};
|
|
474
|
-
}
|
|
475
|
-
let face = font;
|
|
476
|
-
if (face === undefined && textMap.fonts.size === 1) face = [...textMap.fonts.keys()][0];
|
|
477
|
-
if (face === undefined && textMap.fonts.size > 1)
|
|
478
|
-
return {
|
|
479
|
-
about: "font",
|
|
480
|
-
problem: `the map \`${map}\` is for ${said([...textMap.fonts.keys()])}; name one with \`--font\``,
|
|
481
|
-
};
|
|
482
|
-
if (face !== undefined) {
|
|
483
|
-
const problem = fontProblem(textMap, face);
|
|
484
|
-
if (problem) return { about: "font", problem };
|
|
485
|
-
}
|
|
486
|
-
return { mapping: /** @type {Mapping} */ (mappingFor(ctx, { map, font: face, lang })) };
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
/**
|
|
490
|
-
* What is wrong with a capitalised word whose native form the rows cannot
|
|
491
|
-
* capitalise.
|
|
492
|
-
*
|
|
493
|
-
* @param {string} word - The word as written.
|
|
494
|
-
* @param {string} lower - The lowercase form the lexicon states.
|
|
495
|
-
* @param {string|undefined} lang - The language.
|
|
496
|
-
* @param {string} map - The map's name.
|
|
497
|
-
* @returns {string} The message.
|
|
498
|
-
*/
|
|
499
|
-
export function uncapitalisedMessage(word, lower, lang, map) {
|
|
500
|
-
return (
|
|
501
|
-
`\`${word}\` is capitalised, and the native form the lexicon of \`${lang}\` states for ` +
|
|
502
|
-
`\`${lower}\` under the map \`${map}\` does not begin as the rows spell its first letter, ` +
|
|
503
|
-
`so the rows cannot capitalise it; state \`${word}\` as a word of its own with its native form`
|
|
504
|
-
);
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
/**
|
|
508
|
-
* What is wrong with a word a logographic map gives no writing.
|
|
509
|
-
*
|
|
510
|
-
* @param {string} word - The word as written.
|
|
511
|
-
* @param {object} unwritten - Why it has none, as
|
|
512
|
-
* {@link module:engine/logographic.writingsOf} says.
|
|
513
|
-
* @param {string|undefined} lang - The run's language.
|
|
514
|
-
* @param {string} map - The map's name.
|
|
515
|
-
* @returns {string} The message.
|
|
516
|
-
*/
|
|
517
|
-
export function unwrittenMessage(word, unwritten, lang, map) {
|
|
518
|
-
const why = /** @type {{noLexicon?: true, ambiguous?: {form: string, glosses: string[]}}} */ (
|
|
519
|
-
unwritten
|
|
520
|
-
);
|
|
521
|
-
if (why.noLexicon)
|
|
522
|
-
return lang === undefined ?
|
|
523
|
-
`\`${word}\` has no \`${map}\` writing: the map \`${map}\` is logographic, and the run ` +
|
|
524
|
-
"names no `lang=` whose lexicon writes it; mark the run with its language, or " +
|
|
525
|
-
"write the word directly in signs"
|
|
526
|
-
: `\`${word}\` has no \`${map}\` writing: the map \`${map}\` is logographic, and \`${lang}\` ` +
|
|
527
|
-
`has no lexicon (\`languages.${lang}.note\`) to write it from; write the word ` +
|
|
528
|
-
"directly in signs";
|
|
529
|
-
if (why.ambiguous) {
|
|
530
|
-
const { form, glosses } = why.ambiguous;
|
|
531
|
-
const quoted = glosses.map((gloss) => `“${gloss}”`);
|
|
532
|
-
const listed =
|
|
533
|
-
quoted.length < 2 ?
|
|
534
|
-
quoted.join("")
|
|
535
|
-
: `${quoted.slice(0, -1).join(", ")} and ${quoted.at(-1)}`;
|
|
536
|
-
const stands =
|
|
537
|
-
`stands for ${glosses.length} words of the \`${lang}\` lexicon that the map ` +
|
|
538
|
-
`\`${map}\` writes differently, ${listed}`;
|
|
539
|
-
return (
|
|
540
|
-
(form === word ?
|
|
541
|
-
`\`${word}\` ${stands}`
|
|
542
|
-
: `\`${word}\` is written from its part \`${form}\`, which ${stands}`) +
|
|
543
|
-
"; write the word directly in signs, choosing by its sense in " +
|
|
544
|
-
`\`package-build lexicon ${lang} --map ${map}\``
|
|
545
|
-
);
|
|
546
|
-
}
|
|
547
|
-
return (
|
|
548
|
-
`\`${word}\` has no \`${map}\` writing in the \`${lang}\` lexicon, and its parts do not ` +
|
|
549
|
-
"give one; state one, or write the word directly in signs"
|
|
550
|
-
);
|
|
551
|
-
}
|
|
552
|
-
|
|
553
|
-
/**
|
|
554
|
-
* Map a passage exactly as the build maps a run with the same keys.
|
|
555
|
-
*
|
|
556
|
-
* @param {object} ctx - The run context.
|
|
557
|
-
* @param {{map: string, font?: string, lang?: string}} keys - The map, the face
|
|
558
|
-
* and the language.
|
|
559
|
-
* @param {string} text - The passage, with no escapes.
|
|
560
|
-
* @returns {{text: string}|{problem: string, about: string}} The mapped text, or the problem.
|
|
561
|
-
*/
|
|
562
|
-
export function transliterate(ctx, keys, text) {
|
|
563
|
-
const found = commandMapping(ctx, keys);
|
|
564
|
-
if ("problem" in found) return { problem: found.problem, about: found.about };
|
|
565
|
-
const { map, font, native, writing } = found.mapping;
|
|
566
|
-
const mapped = applyMap(map, text, { font, native, writing });
|
|
567
|
-
const unwritten = mapped.pieces.find((one) => one.unwritten !== undefined);
|
|
568
|
-
if (unwritten)
|
|
569
|
-
return {
|
|
570
|
-
about: "word",
|
|
571
|
-
problem: unwrittenMessage(unwritten.from, unwritten.unwritten, keys.lang, map.name),
|
|
572
|
-
};
|
|
573
|
-
const piece = mapped.pieces.find((one) => one.uncapitalised !== undefined);
|
|
574
|
-
if (piece)
|
|
575
|
-
return {
|
|
576
|
-
about: "word",
|
|
577
|
-
problem: uncapitalisedMessage(
|
|
578
|
-
piece.from,
|
|
579
|
-
/** @type {string} */ (piece.uncapitalised),
|
|
580
|
-
keys.lang,
|
|
581
|
-
map.name,
|
|
582
|
-
),
|
|
583
|
-
};
|
|
584
|
-
return { text: mapped.text };
|
|
585
|
-
}
|
|
586
|
-
|
|
587
|
-
/**
|
|
588
|
-
* Every word of a language's lexicon with its native form under a map: the
|
|
589
|
-
* stated exception, else the rows' spelling, each exactly as the build gives
|
|
590
|
-
* it. With `derived`, the forms the language's rules derive follow the words,
|
|
591
|
-
* each case folded and naming the word it comes from in `from`, so the list
|
|
592
|
-
* is what the mapped-run word check accepts of the lexicon.
|
|
593
|
-
*
|
|
594
|
-
* Under a logographic map an entry carries `source` in place of `exception`:
|
|
595
|
-
* `own` for a writing it states, `parts` for one its parts give, and `none`,
|
|
596
|
-
* with an empty `native`, where it has none. A word whose form a run cannot
|
|
597
|
-
* write, because the form stands for entries written differently, or a part
|
|
598
|
-
* does, also carries `ambiguous: true`; its `native` is still its own
|
|
599
|
-
* writing, the signs a run writes directly for that sense.
|
|
600
|
-
*
|
|
601
|
-
* @param {object} ctx - The run context.
|
|
602
|
-
* @param {{lang: string, map?: string, font?: string, derived?: boolean}} keys -
|
|
603
|
-
* The language, the map, the face, and whether to list derived forms. A map
|
|
604
|
-
* or a face left out is the one the language's `data.writtenIn` names.
|
|
605
|
-
* @returns {{entries: Array<{form: string, native: string, exception?: boolean, source?: "own"|"parts"|"none", ambiguous?: true, class: string|null, gloss: string|null, from?: string}>}|{problem: string, about: string}}
|
|
606
|
-
* The words in `data.words` order, then any derived forms, or the problem.
|
|
607
|
-
*/
|
|
608
|
-
export function lexiconEntries(ctx, { lang, map, font, derived = false }) {
|
|
609
|
-
const found = commandMapping(ctx, { map, font, lang });
|
|
610
|
-
if ("problem" in found) return { problem: found.problem, about: found.about };
|
|
611
|
-
const lexicon = ctx.lexicons?.get(lang);
|
|
612
|
-
if (!lexicon)
|
|
613
|
-
return {
|
|
614
|
-
about: "lang",
|
|
615
|
-
problem:
|
|
616
|
-
`\`${lang}\` has no lexicon: \`languages.${lang}\` names no language note ` +
|
|
617
|
-
"in its `note`",
|
|
618
|
-
};
|
|
619
|
-
const { map: textMap, font: face, native } = found.mapping;
|
|
620
|
-
if (textMap.logographic) return { entries: writtenEntries(lexicon, textMap, face, derived) };
|
|
621
|
-
const listed = [
|
|
622
|
-
...lexicon.words.map((word) => ({ word, form: word.form, from: undefined })),
|
|
623
|
-
...(derived ?
|
|
624
|
-
lexicon.derived.map((one) => ({ word: one.from, form: one.form, from: one.from.form }))
|
|
625
|
-
: []),
|
|
626
|
-
];
|
|
627
|
-
const mapped = listed.map((one) => applyMap(textMap, one.form, { font: face, native }));
|
|
628
|
-
const unsound = mapped.flatMap((one) => one.pieces).find((one) => one.uncapitalised);
|
|
629
|
-
if (unsound)
|
|
630
|
-
return {
|
|
631
|
-
about: "word",
|
|
632
|
-
problem: uncapitalisedMessage(
|
|
633
|
-
unsound.from,
|
|
634
|
-
/** @type {string} */ (unsound.uncapitalised),
|
|
635
|
-
lang,
|
|
636
|
-
textMap.name,
|
|
637
|
-
),
|
|
638
|
-
};
|
|
639
|
-
return {
|
|
640
|
-
entries: listed.map(({ word, form, from }, i) => ({
|
|
641
|
-
form,
|
|
642
|
-
native: mapped[i].text,
|
|
643
|
-
exception:
|
|
644
|
-
native?.(form) !== undefined ||
|
|
645
|
-
(form.toLowerCase() !== form && native?.(form.toLowerCase()) !== undefined),
|
|
646
|
-
class: word.class,
|
|
647
|
-
gloss: word.gloss,
|
|
648
|
-
...(from === undefined ? {} : { from }),
|
|
649
|
-
})),
|
|
650
|
-
};
|
|
651
|
-
}
|
|
652
|
-
|
|
653
|
-
/**
|
|
654
|
-
* Every word of a lexicon with its writing under a logographic map, as
|
|
655
|
-
* {@link lexiconEntries} lists them.
|
|
656
|
-
*
|
|
657
|
-
* @param {Lexicon} lexicon - The lexicon.
|
|
658
|
-
* @param {import("./text-maps.mjs").TextMap} map - The map.
|
|
659
|
-
* @param {string|undefined} font - The face.
|
|
660
|
-
* @param {boolean} derived - Whether to list derived forms.
|
|
661
|
-
* @returns {Array<Record<string, any>>} The entries.
|
|
662
|
-
*/
|
|
663
|
-
function writtenEntries(lexicon, map, font, derived) {
|
|
664
|
-
const writings = writingsOf(lexicon.data, map.name, font);
|
|
665
|
-
// The `data.words` index of each lexicon word, which reads the same entries.
|
|
666
|
-
const indexes = [];
|
|
667
|
-
listOf(lexicon.data.words).forEach((word, index) => {
|
|
668
|
-
if (isMap(word) && typeof word.form === "string") indexes.push(index);
|
|
669
|
-
});
|
|
670
|
-
const shaped = (form, writing, word, from) => {
|
|
671
|
-
// Whether a run of the form, written as the build writes it, meets an
|
|
672
|
-
// ambiguity: another entry of the form, or of a part, written otherwise.
|
|
673
|
-
const run = applyMap(map, form, { font, writing: writings.word }).pieces.some(
|
|
674
|
-
(piece) => piece.unwritten?.ambiguous !== undefined,
|
|
675
|
-
);
|
|
676
|
-
return {
|
|
677
|
-
form,
|
|
678
|
-
native: "text" in writing ? writing.text : "",
|
|
679
|
-
source:
|
|
680
|
-
"text" in writing ? writing.source
|
|
681
|
-
: "ambiguous" in writing ? "parts"
|
|
682
|
-
: "none",
|
|
683
|
-
...("ambiguous" in writing || run ? { ambiguous: true } : {}),
|
|
684
|
-
class: word.class,
|
|
685
|
-
gloss: word.gloss,
|
|
686
|
-
...(from === undefined ? {} : { from }),
|
|
687
|
-
};
|
|
688
|
-
};
|
|
689
|
-
const entries = lexicon.words.map((word, at) =>
|
|
690
|
-
shaped(word.form, writings.entry(indexes[at]), word, undefined),
|
|
691
|
-
);
|
|
692
|
-
if (derived)
|
|
693
|
-
for (const one of lexicon.derived)
|
|
694
|
-
entries.push(shaped(one.form, writings.word(one.form), one.from, one.from.form));
|
|
695
|
-
return entries;
|
|
696
|
-
}
|
|
697
|
-
|
|
698
|
-
/**
|
|
699
|
-
* Whether a shared row can be reached in a face: no plain row of the face's
|
|
700
|
-
* own matches at the start of its `from`, which would win there first.
|
|
701
|
-
*
|
|
702
|
-
* @param {import("./text-maps.mjs").MapRow} row - A shared row.
|
|
703
|
-
* @param {readonly import("./text-maps.mjs").MapRow[]} own - The face's rows.
|
|
704
|
-
* @returns {boolean} Whether the row can apply.
|
|
705
|
-
*/
|
|
706
|
-
function reachable(row, own) {
|
|
707
|
-
return !own.some((mine) => !mine.before && !mine.at && row.from.startsWith(mine.from));
|
|
708
|
-
}
|
|
709
|
-
|
|
710
|
-
/**
|
|
711
|
-
* Check a mapping note's `fonts` against the installed faces: an unknown face
|
|
712
|
-
* and a face listed twice, at the entry, and each code point a listed face
|
|
713
|
-
* lacks, at the row that writes it.
|
|
714
|
-
*
|
|
715
|
-
* @param {{file: string, raw?: string, fm?: Record<string, any>}} note - The note.
|
|
716
|
-
* @param {{faces?: ReadonlyMap<string, import("./script-fonts.mjs").Face>, maps?: ReadonlyMap<string, import("./text-maps.mjs").TextMap>}} ctx -
|
|
717
|
-
* The run context.
|
|
718
|
-
* @returns {object[]} The findings.
|
|
719
|
-
*/
|
|
720
|
-
export function checkMapFonts(note, ctx) {
|
|
721
|
-
const value = note?.fm?.data?.map;
|
|
722
|
-
if (!isMap(value) || !Array.isArray(value.fonts)) return [];
|
|
723
|
-
const raw = note.raw ?? "";
|
|
724
|
-
const finding = (keyPath, message, key = false) => ({
|
|
725
|
-
file: note.file,
|
|
726
|
-
...positionOfFrontmatterPath(raw, ["data", "map", ...keyPath], { key }),
|
|
727
|
-
severity: "error",
|
|
728
|
-
message,
|
|
729
|
-
});
|
|
730
|
-
const faces = ctx?.faces ?? new Map();
|
|
731
|
-
const findings = [];
|
|
732
|
-
const first = new Map();
|
|
733
|
-
const listed = [];
|
|
734
|
-
value.fonts.forEach((entry, at) => {
|
|
735
|
-
if (!isMap(entry) || typeof entry.font !== "string") return;
|
|
736
|
-
const slug = entry.font;
|
|
737
|
-
if (GENERIC_FAMILIES.includes(slug)) return;
|
|
738
|
-
if (first.has(slug)) {
|
|
739
|
-
findings.push(
|
|
740
|
-
finding(
|
|
741
|
-
["fonts", at],
|
|
742
|
-
`\`data.map.fonts[${at}]\` lists the font \`${slug}\` a second time; the first is ` +
|
|
743
|
-
`\`data.map.fonts[${first.get(slug)}]\``,
|
|
744
|
-
),
|
|
745
|
-
);
|
|
746
|
-
return;
|
|
747
|
-
}
|
|
748
|
-
first.set(slug, at);
|
|
749
|
-
if (!faces.has(slug)) {
|
|
750
|
-
const near = nearest(slug, [...faces.keys()]);
|
|
751
|
-
findings.push(
|
|
752
|
-
finding(
|
|
753
|
-
["fonts", at, "font"],
|
|
754
|
-
`\`${slug}\` is not a font: \`data.map.fonts[${at}].font\` names ` +
|
|
755
|
-
(faces.size ? "an installed face" : (
|
|
756
|
-
`a face of ${FONTS_PACKAGE}, which this package has not installed`
|
|
757
|
-
)) +
|
|
758
|
-
(near ? `; did you mean \`${near}\`?` : ""),
|
|
759
|
-
),
|
|
760
|
-
);
|
|
761
|
-
return;
|
|
762
|
-
}
|
|
763
|
-
listed.push({ slug, at });
|
|
764
|
-
});
|
|
765
|
-
const compiled = ctx?.maps?.get(value.name);
|
|
766
|
-
if (!compiled) return findings;
|
|
767
|
-
for (const { slug, at } of listed) {
|
|
768
|
-
const face = /** @type {import("./script-fonts.mjs").Face} */ (faces.get(slug));
|
|
769
|
-
const own = compiled.fonts.get(slug)?.rows ?? [];
|
|
770
|
-
const gaps = (rows, keyPath, label, message) =>
|
|
771
|
-
listOf(rows).forEach((authored, j) => {
|
|
772
|
-
const to = Array.isArray(authored) ? authored[1] : authored?.to;
|
|
773
|
-
if (typeof to !== "string") return;
|
|
774
|
-
const from = Array.isArray(authored) ? authored[0] : authored?.from;
|
|
775
|
-
if (keyPath[0] === "rows" && !reachable({ from: String(from), to }, own)) return;
|
|
776
|
-
const seen = new Set();
|
|
777
|
-
for (const char of to) {
|
|
778
|
-
const point = /** @type {number} */ (char.codePointAt(0));
|
|
779
|
-
if (/\s/u.test(char) || face.draws.has(point) || seen.has(point)) continue;
|
|
780
|
-
seen.add(point);
|
|
781
|
-
findings.push(
|
|
782
|
-
finding(
|
|
783
|
-
[...keyPath, j],
|
|
784
|
-
`\`${label}[${j}]\` writes ${codeName(point)}, which the font \`${slug}\`${message} does not draw`,
|
|
785
|
-
),
|
|
786
|
-
);
|
|
787
|
-
}
|
|
788
|
-
});
|
|
789
|
-
gaps(value.fonts[at].rows, ["fonts", at, "rows"], `data.map.fonts[${at}].rows`, "");
|
|
790
|
-
gaps(value.rows, ["rows"], "data.map.rows", ", listed in `data.map.fonts`,");
|
|
791
|
-
}
|
|
792
|
-
return findings;
|
|
793
|
-
}
|
|
794
|
-
|
|
795
|
-
/**
|
|
796
|
-
* Check a language note's native forms against the maps and faces: a word's
|
|
797
|
-
* `native`, an affix's, and the endings' in `data.grammar.native`. Each is
|
|
798
|
-
* checked for an unknown map, a face the map is not for, a second form for one
|
|
799
|
-
* map and face (and one ending), and a code point a face it applies in does
|
|
800
|
-
* not draw. A word's form under a map that spells by its rows is also checked
|
|
801
|
-
* for stating what the rows already give; under a logographic map the rows
|
|
802
|
-
* spell no word, so that check does not apply. An affix's and an ending's
|
|
803
|
-
* writings mean something only under a logographic map, and an ending of a
|
|
804
|
-
* person is written only where `data.grammar.verbs` states `persons`. The
|
|
805
|
-
* shape of each entry is the data-key check's.
|
|
806
|
-
*
|
|
807
|
-
* @param {{file: string, raw?: string, fm?: Record<string, any>}} note - The note.
|
|
808
|
-
* @param {{faces?: ReadonlyMap<string, import("./script-fonts.mjs").Face>, maps?: ReadonlyMap<string, import("./text-maps.mjs").TextMap>}} ctx -
|
|
809
|
-
* The run context.
|
|
810
|
-
* @returns {object[]} The findings.
|
|
811
|
-
*/
|
|
812
|
-
export function checkLexiconNatives(note, ctx) {
|
|
813
|
-
const fm = note?.fm ?? {};
|
|
814
|
-
if (!isLanguage(fm) || !isMap(fm.data)) return [];
|
|
815
|
-
const raw = note.raw ?? "";
|
|
816
|
-
const finding = (keyPath, message, key = false) => ({
|
|
817
|
-
file: note.file,
|
|
818
|
-
...positionOfFrontmatterPath(raw, ["data", ...keyPath], { key }),
|
|
819
|
-
severity: "error",
|
|
820
|
-
message,
|
|
821
|
-
});
|
|
822
|
-
const maps = ctx?.maps ?? new Map();
|
|
823
|
-
const faces = ctx?.faces ?? new Map();
|
|
824
|
-
const findings = [];
|
|
825
|
-
|
|
826
|
-
/**
|
|
827
|
-
* Check one `native` list.
|
|
828
|
-
*
|
|
829
|
-
* @param {unknown[]} list - The list as authored.
|
|
830
|
-
* @param {Array<string|number>} keyPath - Its path under `data`.
|
|
831
|
-
* @param {object} as - What it belongs to.
|
|
832
|
-
* @param {"word"|"affix"|"ending"} as.kind - A word's, an affix's or the
|
|
833
|
-
* endings' list.
|
|
834
|
-
* @param {string} [as.form] - The word's form, for the rows' check.
|
|
835
|
-
*/
|
|
836
|
-
const checkList = (list, keyPath, { kind, form = "" }) => {
|
|
837
|
-
const base = `data.${keyPath.join(".").replace(/\.(\d+)/g, "[$1]")}`;
|
|
838
|
-
const stated = new Map();
|
|
839
|
-
const entries = list.map((entry) =>
|
|
840
|
-
isMap(entry) && typeof entry.map === "string" && typeof entry.text === "string" ?
|
|
841
|
-
{
|
|
842
|
-
map: entry.map,
|
|
843
|
-
font: typeof entry.font === "string" ? entry.font : undefined,
|
|
844
|
-
text: entry.text,
|
|
845
|
-
ending: typeof entry.ending === "string" ? entry.ending : undefined,
|
|
846
|
-
}
|
|
847
|
-
: undefined,
|
|
848
|
-
);
|
|
849
|
-
const persons = isMap(fm.data.grammar?.verbs?.persons);
|
|
850
|
-
entries.forEach((entry, j) => {
|
|
851
|
-
if (!entry) return;
|
|
852
|
-
const label = `${base}[${j}]`;
|
|
853
|
-
const map = maps.get(entry.map);
|
|
854
|
-
if (!map) {
|
|
855
|
-
const near = nearest(entry.map, [...maps.keys()]);
|
|
856
|
-
findings.push(
|
|
857
|
-
finding(
|
|
858
|
-
[...keyPath, j, "map"],
|
|
859
|
-
`\`${label}.map\` is \`${entry.map}\`, which is not a map: a native form names ` +
|
|
860
|
-
"the `data.map.name` of a mapping note" +
|
|
861
|
-
(near ? `; did you mean \`${near}\`?` : ""),
|
|
862
|
-
),
|
|
863
|
-
);
|
|
864
|
-
return;
|
|
865
|
-
}
|
|
866
|
-
if (kind !== "word" && !map.logographic) {
|
|
867
|
-
findings.push(
|
|
868
|
-
finding(
|
|
869
|
-
[...keyPath, j],
|
|
870
|
-
`\`${label}\` names the map \`${map.name}\`, which is not logographic; ` +
|
|
871
|
-
`${kind === "affix" ? "an affix" : "an ending"} is written by a native ` +
|
|
872
|
-
"form only under a logographic map",
|
|
873
|
-
),
|
|
874
|
-
);
|
|
875
|
-
return;
|
|
876
|
-
}
|
|
877
|
-
if (entry.font !== undefined && map.fonts.size && !map.fonts.has(entry.font)) {
|
|
878
|
-
findings.push(
|
|
879
|
-
finding(
|
|
880
|
-
[...keyPath, j, "font"],
|
|
881
|
-
`\`${label}.font\` is \`${entry.font}\`, which is not a font of the map ` +
|
|
882
|
-
`\`${map.name}\`; it is for ${said([...map.fonts.keys()])}`,
|
|
883
|
-
),
|
|
884
|
-
);
|
|
885
|
-
return;
|
|
886
|
-
}
|
|
887
|
-
if (
|
|
888
|
-
kind === "ending" &&
|
|
889
|
-
entry.ending !== undefined &&
|
|
890
|
-
entry.ending !== WRITTEN_ENDINGS[0] &&
|
|
891
|
-
WRITTEN_ENDINGS.includes(entry.ending) &&
|
|
892
|
-
!persons
|
|
893
|
-
)
|
|
894
|
-
findings.push(
|
|
895
|
-
finding(
|
|
896
|
-
[...keyPath, j],
|
|
897
|
-
`\`${label}\` writes the ending \`${entry.ending}\`, and \`data.grammar.verbs\` ` +
|
|
898
|
-
"states no `persons`",
|
|
899
|
-
),
|
|
900
|
-
);
|
|
901
|
-
const slot = `${entry.ending ?? ""}\u0000${entry.map}\u0000${entry.font ?? ""}`;
|
|
902
|
-
if (stated.has(slot))
|
|
903
|
-
findings.push(
|
|
904
|
-
finding(
|
|
905
|
-
[...keyPath, j],
|
|
906
|
-
`\`${label}\` states a second native form` +
|
|
907
|
-
(kind === "ending" ? ` of the ending \`${entry.ending}\`` : "") +
|
|
908
|
-
` for the map \`${map.name}\`` +
|
|
909
|
-
(entry.font === undefined ? "" : ` in the font \`${entry.font}\``) +
|
|
910
|
-
`; the first is \`${base}[${stated.get(slot)}]\``,
|
|
911
|
-
),
|
|
912
|
-
);
|
|
913
|
-
else stated.set(slot, j);
|
|
914
|
-
const fonts =
|
|
915
|
-
entry.font !== undefined ? [entry.font]
|
|
916
|
-
: map.fonts.size ? [...map.fonts.keys()]
|
|
917
|
-
: [undefined];
|
|
918
|
-
if (
|
|
919
|
-
kind === "word" &&
|
|
920
|
-
!map.logographic &&
|
|
921
|
-
fonts.every((font) => applyMap(map, form, { font }).text === entry.text)
|
|
922
|
-
)
|
|
923
|
-
findings.push(
|
|
924
|
-
finding(
|
|
925
|
-
[...keyPath, j],
|
|
926
|
-
`\`${label}\` states what the rows already give: the map \`${map.name}\` ` +
|
|
927
|
-
`spells \`${form}\` so ` +
|
|
928
|
-
(entry.font !== undefined ? `in the font \`${entry.font}\``
|
|
929
|
-
: map.fonts.size > 1 ? "in every font it is for"
|
|
930
|
-
: map.fonts.size ? `in the font \`${[...map.fonts.keys()][0]}\``
|
|
931
|
-
: "by its rows") +
|
|
932
|
-
"; a native form is stated only where the rows spell the word differently",
|
|
933
|
-
),
|
|
934
|
-
);
|
|
935
|
-
// The faces the form is written in: its own, or every listed face
|
|
936
|
-
// no form stated for that face overrides.
|
|
937
|
-
const applies =
|
|
938
|
-
entry.font !== undefined ?
|
|
939
|
-
[entry.font]
|
|
940
|
-
: [...map.fonts.keys()].filter(
|
|
941
|
-
(font) =>
|
|
942
|
-
!entries.some(
|
|
943
|
-
(other) =>
|
|
944
|
-
other &&
|
|
945
|
-
other.map === entry.map &&
|
|
946
|
-
other.ending === entry.ending &&
|
|
947
|
-
other.font === font,
|
|
948
|
-
),
|
|
949
|
-
);
|
|
950
|
-
for (const slug of applies) {
|
|
951
|
-
const face = faces.get(slug);
|
|
952
|
-
if (!face) continue;
|
|
953
|
-
const seen = new Set();
|
|
954
|
-
for (const char of entry.text) {
|
|
955
|
-
const point = /** @type {number} */ (char.codePointAt(0));
|
|
956
|
-
if (/\s/u.test(char) || face.draws.has(point) || seen.has(point)) continue;
|
|
957
|
-
seen.add(point);
|
|
958
|
-
findings.push(
|
|
959
|
-
finding(
|
|
960
|
-
[...keyPath, j],
|
|
961
|
-
`\`${label}\` holds ${codeName(point)}, which the font \`${slug}\`, ` +
|
|
962
|
-
`listed in the map \`${map.name}\`, does not draw`,
|
|
963
|
-
),
|
|
964
|
-
);
|
|
965
|
-
}
|
|
966
|
-
}
|
|
967
|
-
});
|
|
968
|
-
};
|
|
969
|
-
|
|
970
|
-
listOf(fm.data.words).forEach((word, i) => {
|
|
971
|
-
if (!isMap(word) || !Array.isArray(word.native)) return;
|
|
972
|
-
checkList(word.native, ["words", i, "native"], {
|
|
973
|
-
kind: "word",
|
|
974
|
-
form: typeof word.form === "string" ? word.form : "",
|
|
975
|
-
});
|
|
976
|
-
});
|
|
977
|
-
listOf(fm.data.affixes).forEach((affix, i) => {
|
|
978
|
-
if (isMap(affix) && Array.isArray(affix.native))
|
|
979
|
-
checkList(affix.native, ["affixes", i, "native"], { kind: "affix" });
|
|
980
|
-
});
|
|
981
|
-
if (isMap(fm.data.grammar) && Array.isArray(fm.data.grammar.native))
|
|
982
|
-
checkList(fm.data.grammar.native, ["grammar", "native"], { kind: "ending" });
|
|
983
|
-
return findings;
|
|
984
|
-
}
|
|
985
|
-
|
|
986
|
-
/** Every column of the `natives` relation, in order. */
|
|
987
|
-
export const NATIVE_COLUMNS = Object.freeze([
|
|
988
|
-
"language",
|
|
989
|
-
"word",
|
|
990
|
-
"form",
|
|
991
|
-
"native",
|
|
992
|
-
"map",
|
|
993
|
-
"font",
|
|
994
|
-
"text",
|
|
995
|
-
]);
|
|
996
|
-
|
|
997
|
-
/**
|
|
998
|
-
* One row per native form of a language note's word, for the `natives`
|
|
999
|
-
* relation: a text is a value of a row, never a struct key, so forms that
|
|
1000
|
-
* differ only in case stay apart.
|
|
1001
|
-
*
|
|
1002
|
-
* @param {Iterable<Record<string, any>>} records - Index records.
|
|
1003
|
-
* @returns {Array<Record<string, string|number|null>>} The rows; `word` is the
|
|
1004
|
-
* word's 0-based place in `data.words`, `native` the form's in its `native`.
|
|
1005
|
-
*/
|
|
1006
|
-
export function nativeRows(records) {
|
|
1007
|
-
const rows = [];
|
|
1008
|
-
for (const record of records) {
|
|
1009
|
-
if (!isLanguageRecord(record)) continue;
|
|
1010
|
-
const language = noteAddressOf(record);
|
|
1011
|
-
if (!language) continue;
|
|
1012
|
-
listOf(record.data?.words).forEach((word, position) => {
|
|
1013
|
-
if (!isMap(word) || typeof word.form !== "string") return;
|
|
1014
|
-
listOf(word.native).forEach((entry, at) => {
|
|
1015
|
-
if (
|
|
1016
|
-
!isMap(entry) ||
|
|
1017
|
-
typeof entry.map !== "string" ||
|
|
1018
|
-
typeof entry.text !== "string"
|
|
1019
|
-
)
|
|
1020
|
-
return;
|
|
1021
|
-
rows.push({
|
|
1022
|
-
language,
|
|
1023
|
-
word: position,
|
|
1024
|
-
form: word.form,
|
|
1025
|
-
native: at,
|
|
1026
|
-
map: entry.map,
|
|
1027
|
-
font: typeof entry.font === "string" ? entry.font : null,
|
|
1028
|
-
text: entry.text,
|
|
1029
|
-
});
|
|
1030
|
-
});
|
|
1031
|
-
});
|
|
1032
|
-
}
|
|
1033
|
-
return rows;
|
|
1034
|
-
}
|