@heroiclands/package-build 25.0.0 → 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 +14 -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 +137 -6
- package/docs/authoring/links-and-markup.md +260 -481
- package/docs/commands.md +110 -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 +450 -574
- package/docs/reference/format-details.md +289 -404
- package/docs/reference/note-types.md +80 -63
- package/docs/source-preview.md +8 -0
- package/e2e.mjs +1 -1
- package/engine/base-compiler.mjs +27 -9
- package/engine/content-charset.mjs +13 -1
- package/engine/content-format.yaml +6 -2
- package/engine/content-index.mjs +6 -2
- package/engine/content-links.mjs +1 -0
- package/engine/content-scripts.mjs +614 -459
- 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 +7 -0
- package/engine/language-terms.mjs +17 -16
- 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 +135 -16
- 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-index.d.mts +4 -1
- package/types/engine/content-scripts.d.mts +103 -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 +4 -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 +2 -2
- 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 -1045
- 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/text-maps.mjs
DELETED
|
@@ -1,654 +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
|
-
* **Mapping notes: code point sequences rewritten into code points.** A `doc`
|
|
16
|
-
* note's `data.map` names a mapping and lists its rows; a run marked `map=`
|
|
17
|
-
* with that name has its text rewritten by them before it is set.
|
|
18
|
-
*
|
|
19
|
-
* ```yaml
|
|
20
|
-
* data:
|
|
21
|
-
* map:
|
|
22
|
-
* name: vyl-semaron
|
|
23
|
-
* vowels: "aeiouAEIOU"
|
|
24
|
-
* rows:
|
|
25
|
-
* - [a, "\U000F0230"]
|
|
26
|
-
* - [aa, "\U000F0248"]
|
|
27
|
-
* - {from: y, to: "\U000F0237", before: consonant}
|
|
28
|
-
* ```
|
|
29
|
-
*
|
|
30
|
-
* - **A row** is `[from, to]`, or `{from, to, before?, at?}`. `from` is one or
|
|
31
|
-
* more characters and `to` any number, none included.
|
|
32
|
-
* - **The longest `from` wins.** Among rows of one length, a row with a
|
|
33
|
-
* condition that holds comes before a row with none, and otherwise the
|
|
34
|
-
* earlier row wins. A row whose condition fails is skipped.
|
|
35
|
-
* - **`before: vowel`** holds when the next character is one of the map's
|
|
36
|
-
* `vowels`; **`before: consonant`** when it is any other letter. Neither
|
|
37
|
-
* holds at the end of the text.
|
|
38
|
-
* - **`at: start`** holds at the first character of the text, or after
|
|
39
|
-
* whitespace or punctuation; **`at: end`** mirrors it at the match's end.
|
|
40
|
-
* - **A character no row matches passes through unchanged.**
|
|
41
|
-
*
|
|
42
|
-
* - **A word with a native form.** Given a lookup from a language's lexicon,
|
|
43
|
-
* each word of the text (see {@link module:engine/word-pattern.WORD}) is
|
|
44
|
-
* looked up as written and then in lowercase; a word found is written by its
|
|
45
|
-
* native form as one piece, and every other word, and everything between
|
|
46
|
-
* words, is spelled by the rows.
|
|
47
|
-
* - **A capital on a word found in lowercase** is the rows' own: the word's
|
|
48
|
-
* first sound is mapped as written and in lowercase, and where the native
|
|
49
|
-
* form begins with the lowercase result, that beginning is replaced by the
|
|
50
|
-
* capital one. Where the native form begins otherwise, the piece is marked
|
|
51
|
-
* `uncapitalised`, which a run reports; the capitalised word is then stated
|
|
52
|
-
* as a lexicon word of its own.
|
|
53
|
-
*
|
|
54
|
-
* - **A logographic map** (`logographic: true`) writes every word, a word
|
|
55
|
-
* being a match of the word pattern that holds a letter, by the writing a
|
|
56
|
-
* lookup gives it (see {@link module:engine/logographic}), and its rows
|
|
57
|
-
* write only the text between words: spaces, points, and anything else
|
|
58
|
-
* with no letter. A word the lookup gives no writing is written as typed
|
|
59
|
-
* and marked `unwritten`, which a run reports. A row of such a map whose
|
|
60
|
-
* `from` holds a letter could never apply, and is refused.
|
|
61
|
-
*
|
|
62
|
-
* `rows` is a list, never a keyed map, so `A` and `a` are two rows: the index
|
|
63
|
-
* carries them as rows of the `maps` relation, never as struct keys.
|
|
64
|
-
*
|
|
65
|
-
* @module
|
|
66
|
-
*/
|
|
67
|
-
|
|
68
|
-
import { positionOfFrontmatterPath } from "./diagnostics.mjs";
|
|
69
|
-
import { isNoteRecord } from "./index-records.mjs";
|
|
70
|
-
import { WORD } from "./word-pattern.mjs";
|
|
71
|
-
|
|
72
|
-
/** What a map's `name` may be. */
|
|
73
|
-
export const MAP_NAME = /^[a-z0-9-]+$/;
|
|
74
|
-
|
|
75
|
-
/** Each condition a row may carry, and the values it takes. */
|
|
76
|
-
export const MAP_CONDITIONS = Object.freeze({
|
|
77
|
-
before: Object.freeze(["vowel", "consonant"]),
|
|
78
|
-
at: Object.freeze(["start", "end"]),
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
/** The generic families, which a map's `fonts` may not list. */
|
|
82
|
-
export const GENERIC_FAMILIES = Object.freeze(["mono", "serif", "sans"]);
|
|
83
|
-
|
|
84
|
-
/** The keys a row written as a map takes. */
|
|
85
|
-
const ROW_KEYS = Object.freeze(["from", "to", "before", "at"]);
|
|
86
|
-
|
|
87
|
-
/** A letter, for `before: consonant`. */
|
|
88
|
-
const LETTER = /\p{L}/u;
|
|
89
|
-
|
|
90
|
-
/** What `at:` reads as a boundary beside a match. */
|
|
91
|
-
const BOUNDARY = /[\s\p{P}\p{S}]/u;
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
* One row, read.
|
|
95
|
-
*
|
|
96
|
-
* @typedef {object} MapRow
|
|
97
|
-
* @property {string} from - The characters it matches.
|
|
98
|
-
* @property {string} to - What it writes in their place.
|
|
99
|
-
* @property {"vowel"|"consonant"} [before] - What must follow the match.
|
|
100
|
-
* @property {"start"|"end"} [at] - Where the match must sit.
|
|
101
|
-
*/
|
|
102
|
-
|
|
103
|
-
/**
|
|
104
|
-
* A map ready to apply.
|
|
105
|
-
*
|
|
106
|
-
* @typedef {object} TextMap
|
|
107
|
-
* @property {string} name - Its name.
|
|
108
|
-
* @property {boolean} logographic - Whether it writes words from the lexicon
|
|
109
|
-
* alone, its rows only between them.
|
|
110
|
-
* @property {ReadonlySet<string>} vowels - The characters `before: vowel` reads.
|
|
111
|
-
* @property {readonly MapRow[]} rows - Its well-formed shared rows, in authored order.
|
|
112
|
-
* @property {ReadonlyMap<string, readonly MapRow[]>} byFirst - Its shared rows
|
|
113
|
-
* by the first character of `from`, longest first, conditioned before plain.
|
|
114
|
-
* @property {ReadonlyMap<string, {rows: readonly MapRow[], byFirst: ReadonlyMap<string, readonly MapRow[]>}>} fonts -
|
|
115
|
-
* Each listed face by slug, in listed order, with its own rows.
|
|
116
|
-
*/
|
|
117
|
-
|
|
118
|
-
/**
|
|
119
|
-
* How a text is mapped beyond the rows.
|
|
120
|
-
*
|
|
121
|
-
* @typedef {object} MapOptions
|
|
122
|
-
* @property {string} [font] - The face the text is set in, whose own rows are
|
|
123
|
-
* tried first.
|
|
124
|
-
* @property {(word: string) => string|undefined} [native] - A word's native
|
|
125
|
-
* form, for the word exactly as given, or `undefined` where it states none.
|
|
126
|
-
* @property {(word: string) => import("./logographic.mjs").Writing} [writing] -
|
|
127
|
-
* A word's writing under a logographic map; without one, no word has one.
|
|
128
|
-
*/
|
|
129
|
-
|
|
130
|
-
/**
|
|
131
|
-
* Whether a value is a plain map.
|
|
132
|
-
*
|
|
133
|
-
* @param {unknown} value - Anything.
|
|
134
|
-
* @returns {boolean} Whether it is an object that is not a list.
|
|
135
|
-
*/
|
|
136
|
-
function isMap(value) {
|
|
137
|
-
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
/**
|
|
141
|
-
* Read one authored row.
|
|
142
|
-
*
|
|
143
|
-
* @param {unknown} row - The row as YAML gave it.
|
|
144
|
-
* @returns {{row: MapRow}|{problem: string, key?: string}} The row, or what is
|
|
145
|
-
* wrong with it and the key the fault sits at.
|
|
146
|
-
*/
|
|
147
|
-
export function readMapRow(row) {
|
|
148
|
-
if (Array.isArray(row)) {
|
|
149
|
-
if (row.length !== 2 || typeof row[0] !== "string" || typeof row[1] !== "string")
|
|
150
|
-
return {
|
|
151
|
-
problem: "is a list, and a row written as a list is two strings, `[from, to]`",
|
|
152
|
-
};
|
|
153
|
-
if (row[0] === "")
|
|
154
|
-
return { problem: "has an empty `from`; a row matches one or more characters" };
|
|
155
|
-
return { row: { from: row[0], to: row[1] } };
|
|
156
|
-
}
|
|
157
|
-
if (!isMap(row)) return { problem: "is neither `[from, to]` nor `{from, to, before?, at?}`" };
|
|
158
|
-
const map = /** @type {Record<string, unknown>} */ (row);
|
|
159
|
-
for (const key of Object.keys(map))
|
|
160
|
-
if (!ROW_KEYS.includes(key))
|
|
161
|
-
return {
|
|
162
|
-
problem: `has \`${key}\`, which is not a row key; a row takes \`from\`, \`to\`, \`before\` and \`at\``,
|
|
163
|
-
key,
|
|
164
|
-
};
|
|
165
|
-
if (typeof map.from !== "string" || map.from === "")
|
|
166
|
-
return {
|
|
167
|
-
problem: "needs `from`, the one or more characters the row matches",
|
|
168
|
-
...(map.from === undefined ? {} : { key: "from" }),
|
|
169
|
-
};
|
|
170
|
-
if (typeof map.to !== "string")
|
|
171
|
-
return {
|
|
172
|
-
problem: "needs `to`, the characters the row writes, which may be empty",
|
|
173
|
-
...(map.to === undefined ? {} : { key: "to" }),
|
|
174
|
-
};
|
|
175
|
-
const read = /** @type {MapRow} */ ({ from: map.from, to: map.to });
|
|
176
|
-
for (const [condition, allowed] of Object.entries(MAP_CONDITIONS)) {
|
|
177
|
-
const value = map[condition];
|
|
178
|
-
if (value === undefined) continue;
|
|
179
|
-
if (typeof value !== "string" || !allowed.includes(value))
|
|
180
|
-
return {
|
|
181
|
-
problem: `has \`${condition}: ${String(value)}\`; \`${condition}\` is ${allowed.map((word) => `\`${word}\``).join(" or ")}`,
|
|
182
|
-
key: condition,
|
|
183
|
-
};
|
|
184
|
-
read[condition] = value;
|
|
185
|
-
}
|
|
186
|
-
return { row: read };
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
/**
|
|
190
|
-
* Rows by the first character of `from`, longest first, conditioned before
|
|
191
|
-
* plain, then in authored order.
|
|
192
|
-
*
|
|
193
|
-
* @param {readonly MapRow[]} rows - Well-formed rows.
|
|
194
|
-
* @returns {ReadonlyMap<string, readonly MapRow[]>} The index.
|
|
195
|
-
*/
|
|
196
|
-
function indexRows(rows) {
|
|
197
|
-
const byFirst = new Map();
|
|
198
|
-
rows.forEach((row, order) => {
|
|
199
|
-
const first = String.fromCodePoint(/** @type {number} */ (row.from.codePointAt(0)));
|
|
200
|
-
if (!byFirst.has(first)) byFirst.set(first, []);
|
|
201
|
-
byFirst.get(first).push({ row, order });
|
|
202
|
-
});
|
|
203
|
-
for (const [first, list] of byFirst) {
|
|
204
|
-
list.sort(
|
|
205
|
-
(a, b) =>
|
|
206
|
-
b.row.from.length - a.row.from.length ||
|
|
207
|
-
Number(Boolean(b.row.before || b.row.at)) -
|
|
208
|
-
Number(Boolean(a.row.before || a.row.at)) ||
|
|
209
|
-
a.order - b.order,
|
|
210
|
-
);
|
|
211
|
-
byFirst.set(first, Object.freeze(list.map(({ row }) => row)));
|
|
212
|
-
}
|
|
213
|
-
return byFirst;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
/**
|
|
217
|
-
* The well-formed rows of an authored list.
|
|
218
|
-
*
|
|
219
|
-
* @param {unknown} list - The rows as YAML gave them.
|
|
220
|
-
* @returns {readonly MapRow[]} The rows that read.
|
|
221
|
-
*/
|
|
222
|
-
function readRows(list) {
|
|
223
|
-
const rows = [];
|
|
224
|
-
for (const authored of Array.isArray(list) ? list : []) {
|
|
225
|
-
const read = readMapRow(authored);
|
|
226
|
-
if ("row" in read) rows.push(Object.freeze(read.row));
|
|
227
|
-
}
|
|
228
|
-
return Object.freeze(rows);
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
/**
|
|
232
|
-
* Compile a `data.map` value, keeping its well-formed rows.
|
|
233
|
-
*
|
|
234
|
-
* @param {unknown} value - The note's `data.map`.
|
|
235
|
-
* @returns {TextMap|undefined} The map, or `undefined` when it has no valid name.
|
|
236
|
-
*/
|
|
237
|
-
export function compileMap(value) {
|
|
238
|
-
if (!isMap(value)) return undefined;
|
|
239
|
-
const map = /** @type {Record<string, unknown>} */ (value);
|
|
240
|
-
if (typeof map.name !== "string" || !MAP_NAME.test(map.name)) return undefined;
|
|
241
|
-
const vowels = new Set(typeof map.vowels === "string" ? Array.from(map.vowels) : []);
|
|
242
|
-
const rows = readRows(map.rows);
|
|
243
|
-
const fonts = new Map();
|
|
244
|
-
for (const entry of Array.isArray(map.fonts) ? map.fonts : []) {
|
|
245
|
-
if (!isMap(entry) || typeof entry.font !== "string" || fonts.has(entry.font)) continue;
|
|
246
|
-
const own = readRows(entry.rows);
|
|
247
|
-
fonts.set(entry.font, Object.freeze({ rows: own, byFirst: indexRows(own) }));
|
|
248
|
-
}
|
|
249
|
-
return Object.freeze({
|
|
250
|
-
name: map.name,
|
|
251
|
-
logographic: map.logographic === true,
|
|
252
|
-
vowels,
|
|
253
|
-
rows,
|
|
254
|
-
byFirst: indexRows(rows),
|
|
255
|
-
fonts,
|
|
256
|
-
});
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
/**
|
|
260
|
-
* Whether a row's conditions hold for a match.
|
|
261
|
-
*
|
|
262
|
-
* @param {MapRow} row - The row.
|
|
263
|
-
* @param {string} text - The whole text.
|
|
264
|
-
* @param {number} from - The match's first UTF-16 offset.
|
|
265
|
-
* @param {number} to - The offset past the match.
|
|
266
|
-
* @param {ReadonlySet<string>} vowels - The map's vowels.
|
|
267
|
-
* @returns {boolean} Whether every condition holds.
|
|
268
|
-
*/
|
|
269
|
-
function holds(row, text, from, to, vowels) {
|
|
270
|
-
if (row.before) {
|
|
271
|
-
const next = text.codePointAt(to);
|
|
272
|
-
if (next === undefined) return false;
|
|
273
|
-
const char = String.fromCodePoint(next);
|
|
274
|
-
const vowel = vowels.has(char);
|
|
275
|
-
if (row.before === "vowel" ? !vowel : vowel || !LETTER.test(char)) return false;
|
|
276
|
-
}
|
|
277
|
-
if (row.at === "start" && from > 0 && !BOUNDARY.test(text[from - 1])) return false;
|
|
278
|
-
if (row.at === "end" && to < text.length && !BOUNDARY.test(text[to])) return false;
|
|
279
|
-
return true;
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
/**
|
|
283
|
-
* The row that matches at an offset: the font's own rows first, then the
|
|
284
|
-
* shared ones.
|
|
285
|
-
*
|
|
286
|
-
* @param {TextMap} map - The map.
|
|
287
|
-
* @param {string|undefined} font - The face.
|
|
288
|
-
* @param {string} text - The whole text.
|
|
289
|
-
* @param {number} at - The offset.
|
|
290
|
-
* @param {number} limit - The offset no match may run past.
|
|
291
|
-
* @returns {MapRow|undefined} The row.
|
|
292
|
-
*/
|
|
293
|
-
function rowAt(map, font, text, at, limit) {
|
|
294
|
-
const char = String.fromCodePoint(/** @type {number} */ (text.codePointAt(at)));
|
|
295
|
-
const tiers = [font === undefined ? undefined : map.fonts?.get(font)?.byFirst, map.byFirst];
|
|
296
|
-
for (const byFirst of tiers) {
|
|
297
|
-
const row = byFirst
|
|
298
|
-
?.get(char)
|
|
299
|
-
?.find(
|
|
300
|
-
(candidate) =>
|
|
301
|
-
at + candidate.from.length <= limit &&
|
|
302
|
-
text.startsWith(candidate.from, at) &&
|
|
303
|
-
holds(candidate, text, at, at + candidate.from.length, map.vowels),
|
|
304
|
-
);
|
|
305
|
-
if (row) return row;
|
|
306
|
-
}
|
|
307
|
-
return undefined;
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
/**
|
|
311
|
-
* Apply the rows alone.
|
|
312
|
-
*
|
|
313
|
-
* @param {TextMap|undefined} map - The map.
|
|
314
|
-
* @param {string} text - The text.
|
|
315
|
-
* @param {string|undefined} font - The face.
|
|
316
|
-
* @param {ReadonlyMap<number, {end: number, to: string, uncapitalised?: string, unwritten?: object}>} words -
|
|
317
|
-
* Words written whole, by offset.
|
|
318
|
-
* @returns {{text: string, pieces: Array<{at: number, from: string, to: string, row: boolean, native?: boolean, uncapitalised?: string, unwritten?: object}>}}
|
|
319
|
-
* As {@link applyMap}.
|
|
320
|
-
*/
|
|
321
|
-
function applyRows(map, text, font, words) {
|
|
322
|
-
const pieces = [];
|
|
323
|
-
let out = "";
|
|
324
|
-
const starts = [...words.keys()].sort((a, b) => a - b);
|
|
325
|
-
let next = 0;
|
|
326
|
-
for (let at = 0; at < text.length;) {
|
|
327
|
-
const word = words.get(at);
|
|
328
|
-
if (word) {
|
|
329
|
-
pieces.push({
|
|
330
|
-
at,
|
|
331
|
-
from: text.slice(at, word.end),
|
|
332
|
-
to: word.to,
|
|
333
|
-
...(word.unwritten === undefined ?
|
|
334
|
-
{ row: true, native: true }
|
|
335
|
-
: { row: false, unwritten: word.unwritten }),
|
|
336
|
-
...(word.uncapitalised === undefined ? {} : { uncapitalised: word.uncapitalised }),
|
|
337
|
-
});
|
|
338
|
-
out += word.to;
|
|
339
|
-
at = word.end;
|
|
340
|
-
continue;
|
|
341
|
-
}
|
|
342
|
-
while (next < starts.length && starts[next] <= at) next++;
|
|
343
|
-
const limit = next < starts.length ? starts[next] : text.length;
|
|
344
|
-
const row = map ? rowAt(map, font, text, at, limit) : undefined;
|
|
345
|
-
if (row) {
|
|
346
|
-
pieces.push({ at, from: row.from, to: row.to, row: true });
|
|
347
|
-
out += row.to;
|
|
348
|
-
at += row.from.length;
|
|
349
|
-
} else {
|
|
350
|
-
const char = String.fromCodePoint(/** @type {number} */ (text.codePointAt(at)));
|
|
351
|
-
pieces.push({ at, from: char, to: char, row: false });
|
|
352
|
-
out += char;
|
|
353
|
-
at += char.length;
|
|
354
|
-
}
|
|
355
|
-
}
|
|
356
|
-
return { text: out, pieces };
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
/**
|
|
360
|
-
* A native form found for a word in lowercase, given the word's capital as the
|
|
361
|
-
* rows write it: the rows map the word's first sound as written and in
|
|
362
|
-
* lowercase, and where the native form begins with the lowercase result, that
|
|
363
|
-
* beginning becomes the capital one.
|
|
364
|
-
*
|
|
365
|
-
* @param {TextMap} map - The map.
|
|
366
|
-
* @param {string|undefined} font - The face.
|
|
367
|
-
* @param {string} word - The word as written.
|
|
368
|
-
* @param {string} native - The native form of its lowercase.
|
|
369
|
-
* @returns {string|undefined} The native form, capitalised; `undefined` where
|
|
370
|
-
* it does not begin as the rows begin the word, so the rows cannot say how.
|
|
371
|
-
*/
|
|
372
|
-
export function capitalNative(map, font, word, native) {
|
|
373
|
-
const none = new Map();
|
|
374
|
-
const lower = applyRows(map, word.toLowerCase(), font, none).pieces[0];
|
|
375
|
-
const upper = applyRows(map, word, font, none).pieces[0];
|
|
376
|
-
if (!lower || !upper || lower.from.length !== upper.from.length) return undefined;
|
|
377
|
-
if (!native.startsWith(lower.to)) return undefined;
|
|
378
|
-
return upper.to + native.slice(lower.to.length);
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
/**
|
|
382
|
-
* Apply a map to text.
|
|
383
|
-
*
|
|
384
|
-
* @param {TextMap|undefined} map - The map; without one the text is returned
|
|
385
|
-
* as it is.
|
|
386
|
-
* @param {string} text - The text, with no escapes in it.
|
|
387
|
-
* @param {MapOptions} [options] - The face, and the native forms of words.
|
|
388
|
-
* @returns {{text: string, pieces: Array<{at: number, from: string, to: string, row: boolean, native?: boolean, unwritten?: object}>}}
|
|
389
|
-
* The rewritten text, and each piece of it with the UTF-16 offset in `text`
|
|
390
|
-
* it came from: a matched row's `from` and what it wrote, a word and its
|
|
391
|
-
* native form (`native: true`), or one character that passed through. A
|
|
392
|
-
* capitalised word whose native form the rows cannot capitalise is written
|
|
393
|
-
* as stated and carries `uncapitalised`, the lowercase form it was found by.
|
|
394
|
-
* Under a logographic map, a word with no writing is written as typed and
|
|
395
|
-
* carries `unwritten`, why it has none.
|
|
396
|
-
*/
|
|
397
|
-
export function applyMap(map, text, { font, native, writing } = {}) {
|
|
398
|
-
const words = new Map();
|
|
399
|
-
if (map?.logographic) {
|
|
400
|
-
for (const match of text.matchAll(WORD)) {
|
|
401
|
-
const word = match[0];
|
|
402
|
-
if (!LETTER.test(word)) continue;
|
|
403
|
-
const found = writing ? writing(word) : { missing: true, noLexicon: true };
|
|
404
|
-
words.set(
|
|
405
|
-
match.index,
|
|
406
|
-
"text" in found ?
|
|
407
|
-
{ end: match.index + word.length, to: found.text }
|
|
408
|
-
: { end: match.index + word.length, to: word, unwritten: found },
|
|
409
|
-
);
|
|
410
|
-
}
|
|
411
|
-
return applyRows(map, text, font, words);
|
|
412
|
-
}
|
|
413
|
-
if (map && native)
|
|
414
|
-
for (const match of text.matchAll(WORD)) {
|
|
415
|
-
const word = match[0];
|
|
416
|
-
const exact = native(word);
|
|
417
|
-
if (exact !== undefined) {
|
|
418
|
-
words.set(match.index, { end: match.index + word.length, to: exact });
|
|
419
|
-
continue;
|
|
420
|
-
}
|
|
421
|
-
const lower = word.toLowerCase();
|
|
422
|
-
const found = lower === word ? undefined : native(lower);
|
|
423
|
-
if (found === undefined) continue;
|
|
424
|
-
const first = String.fromCodePoint(/** @type {number} */ (word.codePointAt(0)));
|
|
425
|
-
const capital = first !== first.toLowerCase();
|
|
426
|
-
const capitalised = capital ? capitalNative(map, font, word, found) : found;
|
|
427
|
-
words.set(match.index, {
|
|
428
|
-
end: match.index + word.length,
|
|
429
|
-
to: capitalised ?? found,
|
|
430
|
-
...(capitalised === undefined ? { uncapitalised: lower } : {}),
|
|
431
|
-
});
|
|
432
|
-
}
|
|
433
|
-
return applyRows(map, text, font, words);
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
/**
|
|
437
|
-
* Every map the index records declare, by name. The first note to declare a
|
|
438
|
-
* name keeps it; the duplicate is the lint's to report.
|
|
439
|
-
*
|
|
440
|
-
* @param {Iterable<Record<string, any>>|undefined} records - Index records.
|
|
441
|
-
* @returns {Map<string, TextMap>} The maps.
|
|
442
|
-
*/
|
|
443
|
-
export function mapsFromRecords(records) {
|
|
444
|
-
const maps = new Map();
|
|
445
|
-
for (const record of records ?? []) {
|
|
446
|
-
if (!record || !isNoteRecord(record)) continue;
|
|
447
|
-
const map = compileMap(record.data?.map);
|
|
448
|
-
if (map && !maps.has(map.name)) maps.set(map.name, map);
|
|
449
|
-
}
|
|
450
|
-
return maps;
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
/** Every column of the `maps` relation, in order. */
|
|
454
|
-
export const MAP_COLUMNS = Object.freeze([
|
|
455
|
-
"owner",
|
|
456
|
-
"name",
|
|
457
|
-
"font",
|
|
458
|
-
"row",
|
|
459
|
-
"from",
|
|
460
|
-
"to",
|
|
461
|
-
"before",
|
|
462
|
-
"at",
|
|
463
|
-
]);
|
|
464
|
-
|
|
465
|
-
/**
|
|
466
|
-
* One row per well-formed map row, for the `maps` relation: the shared rows
|
|
467
|
-
* with no `font`, then each listed face's own rows with its slug.
|
|
468
|
-
*
|
|
469
|
-
* @param {Iterable<Record<string, any>>} records - Index records.
|
|
470
|
-
* @param {(record: Record<string, any>) => string|null} ownerOf - A record's
|
|
471
|
-
* page Address.
|
|
472
|
-
* @returns {Array<Record<string, string|number|null>>} The rows, in record
|
|
473
|
-
* then authored order; `row` is the row's 1-based place in its list.
|
|
474
|
-
*/
|
|
475
|
-
export function mapRows(records, ownerOf) {
|
|
476
|
-
const out = [];
|
|
477
|
-
for (const record of records) {
|
|
478
|
-
if (!record || !isNoteRecord(record)) continue;
|
|
479
|
-
const value = record.data?.map;
|
|
480
|
-
if (!isMap(value) || typeof value.name !== "string") continue;
|
|
481
|
-
const owner = ownerOf(record);
|
|
482
|
-
const lists = [
|
|
483
|
-
[null, value.rows],
|
|
484
|
-
...(Array.isArray(value.fonts) ? value.fonts : [])
|
|
485
|
-
.filter((entry) => isMap(entry) && typeof entry.font === "string")
|
|
486
|
-
.map((entry) => [entry.font, entry.rows]),
|
|
487
|
-
];
|
|
488
|
-
for (const [font, rows] of lists) {
|
|
489
|
-
if (!Array.isArray(rows)) continue;
|
|
490
|
-
rows.forEach((authored, index) => {
|
|
491
|
-
const read = readMapRow(authored);
|
|
492
|
-
if (!("row" in read)) return;
|
|
493
|
-
out.push({
|
|
494
|
-
owner,
|
|
495
|
-
name: value.name,
|
|
496
|
-
font,
|
|
497
|
-
row: index + 1,
|
|
498
|
-
from: read.row.from,
|
|
499
|
-
to: read.row.to,
|
|
500
|
-
before: read.row.before ?? null,
|
|
501
|
-
at: read.row.at ?? null,
|
|
502
|
-
});
|
|
503
|
-
});
|
|
504
|
-
}
|
|
505
|
-
}
|
|
506
|
-
return out;
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
/** The notes declaring each map name, read once per lint index. */
|
|
510
|
-
const declaredNames = new WeakMap();
|
|
511
|
-
|
|
512
|
-
/**
|
|
513
|
-
* Each map name the index's notes declare, and the notes declaring it.
|
|
514
|
-
*
|
|
515
|
-
* @param {{notes?: Iterable<{file: string, fm?: Record<string, any>}>}} index - The lint index.
|
|
516
|
-
* @returns {Map<string, string[]>} Name → the files that declare it.
|
|
517
|
-
*/
|
|
518
|
-
function namesIn(index) {
|
|
519
|
-
const known = declaredNames.get(index);
|
|
520
|
-
if (known) return known;
|
|
521
|
-
const names = new Map();
|
|
522
|
-
for (const note of index.notes ?? []) {
|
|
523
|
-
const name = note?.fm?.data?.map?.name;
|
|
524
|
-
if (typeof name !== "string") continue;
|
|
525
|
-
if (!names.has(name)) names.set(name, []);
|
|
526
|
-
names.get(name).push(note.file);
|
|
527
|
-
}
|
|
528
|
-
declaredNames.set(index, names);
|
|
529
|
-
return names;
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
/**
|
|
533
|
-
* Check a note's `data.map`: its name, that no other note declares it, each
|
|
534
|
-
* row written as a list, a `before` condition on a map with no `vowels`, an
|
|
535
|
-
* empty `fonts`, a generic family in it, and a row of a logographic map that
|
|
536
|
-
* matches a letter. The data-key check owns every
|
|
537
|
-
* key, its kind and the words a condition takes; whether a listed face is
|
|
538
|
-
* installed, and draws what the map writes, is
|
|
539
|
-
* {@link module:engine/map-lexicon.checkMapFonts}'s.
|
|
540
|
-
*
|
|
541
|
-
* @param {{file: string, raw?: string, fm?: Record<string, any>}} note - The note.
|
|
542
|
-
* @param {{index?: {notes?: Iterable<object>}}} [context] - The lint index.
|
|
543
|
-
* @returns {object[]} The findings, each at its key.
|
|
544
|
-
*/
|
|
545
|
-
export function checkMapField(note, { index } = {}) {
|
|
546
|
-
const value = note?.fm?.data?.map;
|
|
547
|
-
if (!isMap(value)) return [];
|
|
548
|
-
const raw = note.raw ?? "";
|
|
549
|
-
/**
|
|
550
|
-
* @param {Array<string|number>} keyPath - Where the fault is.
|
|
551
|
-
* @param {string} message - What is wrong.
|
|
552
|
-
* @param {boolean} [key] - Locate the key rather than its value.
|
|
553
|
-
*/
|
|
554
|
-
const finding = (keyPath, message, key = false) => ({
|
|
555
|
-
file: note.file,
|
|
556
|
-
...positionOfFrontmatterPath(raw, ["data", "map", ...keyPath], { key }),
|
|
557
|
-
severity: "error",
|
|
558
|
-
message,
|
|
559
|
-
});
|
|
560
|
-
const findings = [];
|
|
561
|
-
const { name, vowels, rows, fonts, logographic } = /** @type {Record<string, unknown>} */ (
|
|
562
|
-
value
|
|
563
|
-
);
|
|
564
|
-
if (typeof name === "string" && !MAP_NAME.test(name))
|
|
565
|
-
findings.push(
|
|
566
|
-
finding(
|
|
567
|
-
["name"],
|
|
568
|
-
`\`data.map.name\` is \`${name}\`; a map's name is lowercase letters, digits and \`-\``,
|
|
569
|
-
),
|
|
570
|
-
);
|
|
571
|
-
else if (typeof name === "string" && index) {
|
|
572
|
-
const others = (namesIn(index).get(name) ?? []).filter((file) => file !== note.file);
|
|
573
|
-
if (others.length)
|
|
574
|
-
findings.push(
|
|
575
|
-
finding(
|
|
576
|
-
["name"],
|
|
577
|
-
`\`data.map.name\` is \`${name}\`, which ${others.join(", ")} also declares; a map's name is unique`,
|
|
578
|
-
),
|
|
579
|
-
);
|
|
580
|
-
}
|
|
581
|
-
if (Array.isArray(fonts)) {
|
|
582
|
-
if (!fonts.length)
|
|
583
|
-
findings.push(
|
|
584
|
-
finding(
|
|
585
|
-
["fonts"],
|
|
586
|
-
"`data.map.fonts` is empty; a map lists each face it is drawn in",
|
|
587
|
-
),
|
|
588
|
-
);
|
|
589
|
-
fonts.forEach((entry, at) => {
|
|
590
|
-
if (isMap(entry) && GENERIC_FAMILIES.includes(entry.font))
|
|
591
|
-
findings.push(
|
|
592
|
-
finding(
|
|
593
|
-
["fonts", at, "font"],
|
|
594
|
-
`\`data.map.fonts[${at}].font\` is \`${entry.font}\`, a generic family; a map lists the faces it is drawn in`,
|
|
595
|
-
),
|
|
596
|
-
);
|
|
597
|
-
});
|
|
598
|
-
}
|
|
599
|
-
// A row's keys, their kinds and the words a condition takes are the
|
|
600
|
-
// data-key check's; what it cannot say is a list row's arity, an empty
|
|
601
|
-
// `from` in one, a condition the map has no vowels for, and a letter a
|
|
602
|
-
// logographic map's row could never meet.
|
|
603
|
-
const hasVowels = typeof vowels === "string" && vowels !== "";
|
|
604
|
-
const rowFindings = (list, keyPath) => {
|
|
605
|
-
if (!Array.isArray(list)) return;
|
|
606
|
-
const label = `data.map.${keyPath.join(".").replace(/\.(\d+)/g, "[$1]")}`;
|
|
607
|
-
list.forEach((row, at) => {
|
|
608
|
-
const from =
|
|
609
|
-
Array.isArray(row) ? row[0]
|
|
610
|
-
: isMap(row) ? row.from
|
|
611
|
-
: undefined;
|
|
612
|
-
const letter = typeof from === "string" ? from.match(LETTER)?.[0] : undefined;
|
|
613
|
-
if (logographic === true && letter !== undefined)
|
|
614
|
-
findings.push(
|
|
615
|
-
finding(
|
|
616
|
-
[...keyPath, at],
|
|
617
|
-
`\`${label}[${at}]\` matches \`${letter}\`, a letter, and the rows of a ` +
|
|
618
|
-
"logographic map write only the text between words",
|
|
619
|
-
),
|
|
620
|
-
);
|
|
621
|
-
if (Array.isArray(row)) {
|
|
622
|
-
if (row.length !== 2)
|
|
623
|
-
findings.push(
|
|
624
|
-
finding(
|
|
625
|
-
[...keyPath, at],
|
|
626
|
-
`\`${label}[${at}]\` is a list, and a row written as a list is two strings, \`[from, to]\``,
|
|
627
|
-
),
|
|
628
|
-
);
|
|
629
|
-
else if (row[0] === "")
|
|
630
|
-
findings.push(
|
|
631
|
-
finding(
|
|
632
|
-
[...keyPath, at],
|
|
633
|
-
`\`${label}[${at}]\` has an empty \`from\`; a row matches one or more characters`,
|
|
634
|
-
),
|
|
635
|
-
);
|
|
636
|
-
return;
|
|
637
|
-
}
|
|
638
|
-
if (isMap(row) && row.before !== undefined && !hasVowels)
|
|
639
|
-
findings.push(
|
|
640
|
-
finding(
|
|
641
|
-
[...keyPath, at, "before"],
|
|
642
|
-
`\`${label}[${at}]\` has \`before: ${String(row.before)}\`, and the map states no \`vowels\` to read it by`,
|
|
643
|
-
true,
|
|
644
|
-
),
|
|
645
|
-
);
|
|
646
|
-
});
|
|
647
|
-
};
|
|
648
|
-
rowFindings(rows, ["rows"]);
|
|
649
|
-
if (Array.isArray(fonts))
|
|
650
|
-
fonts.forEach((entry, at) => {
|
|
651
|
-
if (isMap(entry)) rowFindings(entry.rows, ["fonts", at, "rows"]);
|
|
652
|
-
});
|
|
653
|
-
return findings;
|
|
654
|
-
}
|