@heroiclands/package-build 23.5.0 → 23.6.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 +33 -0
- package/bin/content-commands.mjs +23 -5
- package/bin/package-build.mjs +1 -74
- package/docs/api.md +9 -0
- package/docs/authoring/links-and-markup.md +77 -12
- package/docs/commands.md +4 -48
- package/docs/content-format.md +16 -0
- package/docs/diagnostics.md +1 -2
- package/docs/guides/module-project.md +1 -1
- package/docs/guides/reference-pages.md +89 -0
- package/docs/guides/release-and-deploy.md +3 -3
- package/docs/project-setup.md +16 -59
- package/docs/reference/format-details.md +533 -84
- package/docs/reference/note-types.md +191 -149
- package/engine/base-compiler.mjs +21 -0
- package/engine/cdn-publish.mjs +30 -5
- package/engine/content-format.yaml +9 -0
- package/engine/content-index.mjs +19 -5
- package/engine/culture-reach.mjs +330 -0
- package/engine/dictionary-layout.mjs +340 -0
- package/engine/dictionary-rows.mjs +608 -0
- package/engine/glossary-entries.mjs +182 -0
- package/engine/glossary-language.mjs +99 -0
- package/engine/index.mjs +1 -0
- package/engine/infobox.mjs +8 -0
- package/engine/language-words.mjs +360 -0
- package/engine/literature-notes.mjs +10 -8
- package/engine/note-addresses.mjs +25 -0
- package/engine/note-vocabulary.mjs +161 -1
- package/engine/path-charset.mjs +120 -0
- package/engine/project-init.mjs +4 -29
- package/engine/sql-tables.mjs +311 -21
- package/engine/word-classes.mjs +167 -0
- package/engine/word-rows.mjs +233 -0
- package/engine/word-terms.mjs +306 -0
- package/package.json +2 -4
- package/types/engine/cdn-publish.d.mts +26 -25
- package/types/engine/content-index.d.mts +15 -5
- package/types/engine/culture-reach.d.mts +66 -0
- package/types/engine/dictionary-layout.d.mts +69 -0
- package/types/engine/dictionary-rows.d.mts +39 -0
- package/types/engine/glossary-entries.d.mts +12 -0
- package/types/engine/glossary-language.d.mts +42 -0
- package/types/engine/index.d.mts +1 -0
- package/types/engine/language-words.d.mts +33 -0
- package/types/engine/literature-notes.d.mts +2 -2
- package/types/engine/note-addresses.d.mts +17 -0
- package/types/engine/path-charset.d.mts +38 -0
- package/types/engine/sql-tables.d.mts +7 -2
- package/types/engine/word-classes.d.mts +87 -0
- package/types/engine/word-rows.d.mts +64 -0
- package/types/engine/word-terms.d.mts +68 -0
- package/labels.mjs +0 -191
- package/types/labels.d.mts +0 -40
|
@@ -0,0 +1,608 @@
|
|
|
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
|
+
* The rows of the SQL `dictionary` and `terms` relations: the words a
|
|
16
|
+
* dictionary sets, and the terms a glossary sets.
|
|
17
|
+
*
|
|
18
|
+
* **`dictionary` is one row per word per language it is listed under.** A
|
|
19
|
+
* language lists its own words at `depth` 0 and the words it inherits through
|
|
20
|
+
* `data.family` above that, nearest first, so `WHERE language = X` is a
|
|
21
|
+
* language's whole word list and `WHERE depth = 0` lists every word once,
|
|
22
|
+
* under the language that states it. A daughter's own word is the entry where
|
|
23
|
+
* a parent states the same form. Words of class `name`, `stem` and `pref` are
|
|
24
|
+
* no entry.
|
|
25
|
+
*
|
|
26
|
+
* **`terms` is one row per term per note that owns it**: a culture's or an
|
|
27
|
+
* affiliation's `data.glossary` entry, or an affiliation's rank or office. A
|
|
28
|
+
* rank and an office of one title in one body are one row, the rank's sense
|
|
29
|
+
* first; identical descriptions are one sense. Each row lists in `cultures`
|
|
30
|
+
* every culture it reaches: a culture's own terms reach the culture, and an
|
|
31
|
+
* affiliation's reach its culture and the cultures of every affiliation above
|
|
32
|
+
* it through `data.parents`.
|
|
33
|
+
*
|
|
34
|
+
* **A rank or office title of one word** (Subject, Member) is a term only
|
|
35
|
+
* where a glossary entry of its body, or of a culture it reaches, names it, or
|
|
36
|
+
* where it is a word of its language compared without case. A title of
|
|
37
|
+
* several words, joined by a space or a hyphen, always is.
|
|
38
|
+
*
|
|
39
|
+
* **A glossary entry with a `definition`** replaces the senses of its owner's
|
|
40
|
+
* own rank or office of that name, and otherwise is a term of its owner's own,
|
|
41
|
+
* cited from the owner. **An entry without one** marks what it names: the term
|
|
42
|
+
* of its name in the language it resolves to
|
|
43
|
+
* ({@link module:engine/glossary-language}), among the words and the ranks and
|
|
44
|
+
* offices that reach its note, and failing that the term of its name in any
|
|
45
|
+
* language that reaches it. Marking sets `common`, and replaces `name_sort`
|
|
46
|
+
* and `pronunciation`, on the word's row in that language or on the rank's or
|
|
47
|
+
* office's row.
|
|
48
|
+
*
|
|
49
|
+
* @module
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
import { parseAddress, renderAddress } from "./address.mjs";
|
|
53
|
+
import { glossaryLanguage } from "./glossary-language.mjs";
|
|
54
|
+
import { isNoteRecord } from "./index-records.mjs";
|
|
55
|
+
import { parseWordClass, WORD_CLASS_BASES } from "./word-classes.mjs";
|
|
56
|
+
import { isLanguageRecord, pieceGlosses } from "./word-rows.mjs";
|
|
57
|
+
|
|
58
|
+
/** Every column of the `dictionary` relation, in order. */
|
|
59
|
+
export const DICTIONARY_COLUMNS = Object.freeze([
|
|
60
|
+
"language",
|
|
61
|
+
"language_tag",
|
|
62
|
+
"name",
|
|
63
|
+
"name_sort",
|
|
64
|
+
"letter",
|
|
65
|
+
"pronunciation",
|
|
66
|
+
"class",
|
|
67
|
+
"etymology",
|
|
68
|
+
"senses",
|
|
69
|
+
"usage",
|
|
70
|
+
"common",
|
|
71
|
+
"depth",
|
|
72
|
+
]);
|
|
73
|
+
|
|
74
|
+
/** Every column of the `terms` relation, in order. */
|
|
75
|
+
export const TERM_COLUMNS = Object.freeze([
|
|
76
|
+
"owner",
|
|
77
|
+
"owner_kind",
|
|
78
|
+
"language",
|
|
79
|
+
"language_tag",
|
|
80
|
+
"name",
|
|
81
|
+
"name_sort",
|
|
82
|
+
"letter",
|
|
83
|
+
"pronunciation",
|
|
84
|
+
"senses",
|
|
85
|
+
"usage",
|
|
86
|
+
"common",
|
|
87
|
+
"origins",
|
|
88
|
+
"cultures",
|
|
89
|
+
]);
|
|
90
|
+
|
|
91
|
+
/** The keys of a word's `etymology`, in order. */
|
|
92
|
+
export const ETYMOLOGY_KEYS = Object.freeze([
|
|
93
|
+
"kind",
|
|
94
|
+
"language",
|
|
95
|
+
"language_tag",
|
|
96
|
+
"form",
|
|
97
|
+
"parts",
|
|
98
|
+
"root",
|
|
99
|
+
"frame",
|
|
100
|
+
]);
|
|
101
|
+
|
|
102
|
+
/** The bases whose words stand in a dictionary. */
|
|
103
|
+
const GLOSSARY_BASES = new Set(
|
|
104
|
+
WORD_CLASS_BASES.filter((item) => item.glossary).map((item) => item.token),
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
/** A rank's sense before an office's, within one row. */
|
|
108
|
+
const KIND_ORDER = Object.freeze({ rank: 0, office: 1 });
|
|
109
|
+
|
|
110
|
+
/** Whether a value is a plain map. */
|
|
111
|
+
const isMap = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
112
|
+
|
|
113
|
+
/** A headword as entries are matched on it: NFC, case kept. */
|
|
114
|
+
const nfc = (text) => String(text ?? "").normalize("NFC");
|
|
115
|
+
|
|
116
|
+
/** Code-point order, so a sort is the same on every machine. */
|
|
117
|
+
const compare = (a, b) =>
|
|
118
|
+
a < b ? -1
|
|
119
|
+
: a > b ? 1
|
|
120
|
+
: 0;
|
|
121
|
+
|
|
122
|
+
/** Whether a title is one word: no space and no hyphen in it. */
|
|
123
|
+
const isSingleWord = (title) => !/[\s\-‐‑]/u.test(title);
|
|
124
|
+
|
|
125
|
+
/** Whether a glossary entry states a definition. */
|
|
126
|
+
const defines = (entry) => typeof entry.definition === "string" && entry.definition.trim() !== "";
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The heading letter of a sort key: its first letter or digit, accents taken
|
|
130
|
+
* off and apostrophes skipped, in capitals; `#` where it has none.
|
|
131
|
+
*
|
|
132
|
+
* @param {unknown} text - The sort key.
|
|
133
|
+
* @returns {string} The letter.
|
|
134
|
+
*/
|
|
135
|
+
export function dictionaryLetter(text) {
|
|
136
|
+
const folded = String(text ?? "")
|
|
137
|
+
.normalize("NFD")
|
|
138
|
+
.replace(/\p{M}/gu, "");
|
|
139
|
+
const first = folded.match(/[\p{L}\p{N}]/u);
|
|
140
|
+
return first ? first[0].toUpperCase() : "#";
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* A record's Address as a reference to it is written: in the `note` system.
|
|
145
|
+
*
|
|
146
|
+
* @param {Record<string, any>} record - An index record.
|
|
147
|
+
* @returns {string|undefined}
|
|
148
|
+
*/
|
|
149
|
+
function noteKey(record) {
|
|
150
|
+
if (!record?.package || !record.type || !record.shortcode) return undefined;
|
|
151
|
+
return renderAddress({
|
|
152
|
+
package: String(record.package),
|
|
153
|
+
system: "note",
|
|
154
|
+
type: String(record.type),
|
|
155
|
+
shortcode: String(record.shortcode),
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* A written reference's canonical Address in the `note` system.
|
|
161
|
+
*
|
|
162
|
+
* @param {unknown} value - The value as the record holds it.
|
|
163
|
+
* @param {string} pkg - The package a short form defaults to.
|
|
164
|
+
* @param {string} type - The type a bare shortcode defaults to.
|
|
165
|
+
* @param {Set<string>} types - The note types a written type segment is read against.
|
|
166
|
+
* @returns {string|undefined}
|
|
167
|
+
*/
|
|
168
|
+
function refKey(value, pkg, type, types) {
|
|
169
|
+
if (typeof value !== "string" || !value) return undefined;
|
|
170
|
+
const tuple = parseAddress(
|
|
171
|
+
value,
|
|
172
|
+
{ package: pkg, system: "note", type, types },
|
|
173
|
+
{ declared: true },
|
|
174
|
+
);
|
|
175
|
+
if (tuple.reason) return undefined;
|
|
176
|
+
return renderAddress({ ...tuple, system: "note", anchor: undefined });
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* The `dictionary` and `terms` rows of a set of index records.
|
|
181
|
+
*
|
|
182
|
+
* @param {Iterable<Record<string, any>>} records - Index records, every
|
|
183
|
+
* Address a string, already narrowed to the audience being published to.
|
|
184
|
+
* @param {Iterable<Record<string, any>>} [all] - Every record of the package,
|
|
185
|
+
* for resolving a language or a culture through a note the audience does not
|
|
186
|
+
* see.
|
|
187
|
+
* @returns {{dictionary: object[], terms: object[]}} The rows: `dictionary`
|
|
188
|
+
* sorted by language, `name_sort` and name, `terms` by owner, `name_sort` and
|
|
189
|
+
* name, each carrying every column of {@link DICTIONARY_COLUMNS} or
|
|
190
|
+
* {@link TERM_COLUMNS}.
|
|
191
|
+
*/
|
|
192
|
+
export function dictionaryAndTermRows(records, all = records) {
|
|
193
|
+
const byKey = new Map();
|
|
194
|
+
for (const record of all) {
|
|
195
|
+
if (!isNoteRecord(record)) continue;
|
|
196
|
+
const key = noteKey(record);
|
|
197
|
+
if (key && !byKey.has(key)) byKey.set(key, record);
|
|
198
|
+
}
|
|
199
|
+
const visible = new Map();
|
|
200
|
+
for (const record of records) {
|
|
201
|
+
if (!isNoteRecord(record)) continue;
|
|
202
|
+
const key = noteKey(record);
|
|
203
|
+
if (key && !visible.has(key)) visible.set(key, record);
|
|
204
|
+
}
|
|
205
|
+
const types = new Set(["affiliation", "being", "lore", "skill"]);
|
|
206
|
+
for (const record of byKey.values())
|
|
207
|
+
if (typeof record.type === "string") types.add(record.type);
|
|
208
|
+
const ref = (value, pkg, type) => refKey(value, pkg, type, types);
|
|
209
|
+
/** An Address a row may cite: not a note of this package the audience does not see. */
|
|
210
|
+
const citable = (key) => visible.has(key) || !byKey.has(key);
|
|
211
|
+
const reader = {
|
|
212
|
+
keyOf: (value) => (typeof value === "string" && value ? value : undefined),
|
|
213
|
+
lookup: (key) => byKey.get(key),
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
// ── Languages ────────────────────────────────────────────────────────────
|
|
217
|
+
const languages = new Map();
|
|
218
|
+
for (const [key, record] of byKey) if (isLanguageRecord(record)) languages.set(key, record);
|
|
219
|
+
const tagOf = (language) => {
|
|
220
|
+
const record = language ? languages.get(language) : undefined;
|
|
221
|
+
if (!record) return null;
|
|
222
|
+
const abbreviation = record.data?.abbreviation;
|
|
223
|
+
if (typeof abbreviation === "string" && abbreviation.trim()) return abbreviation.trim();
|
|
224
|
+
const full = record.name?.full;
|
|
225
|
+
return typeof full === "string" && full ? full : null;
|
|
226
|
+
};
|
|
227
|
+
const lineage = (language) => {
|
|
228
|
+
const chain = [];
|
|
229
|
+
const seen = new Set();
|
|
230
|
+
for (let current = language; current && languages.has(current) && !seen.has(current);) {
|
|
231
|
+
seen.add(current);
|
|
232
|
+
chain.push(current);
|
|
233
|
+
const family = languages.get(current).data?.family;
|
|
234
|
+
current = typeof family === "string" && family ? family : undefined;
|
|
235
|
+
}
|
|
236
|
+
return chain;
|
|
237
|
+
};
|
|
238
|
+
const ownWords = new Map();
|
|
239
|
+
for (const [key, record] of languages) {
|
|
240
|
+
const list = [];
|
|
241
|
+
for (const word of Array.isArray(record.data?.words) ? record.data.words : []) {
|
|
242
|
+
if (!isMap(word) || typeof word.form !== "string") continue;
|
|
243
|
+
const parsed = parseWordClass(word.class);
|
|
244
|
+
if (parsed.reason || !GLOSSARY_BASES.has(parsed.base)) continue;
|
|
245
|
+
list.push(word);
|
|
246
|
+
}
|
|
247
|
+
ownWords.set(key, list);
|
|
248
|
+
}
|
|
249
|
+
// Each language's words by form, and by form without case, the first
|
|
250
|
+
// stated winning, so a lookup costs nothing however many words there are.
|
|
251
|
+
const byForm = new Map();
|
|
252
|
+
const byLowerForm = new Map();
|
|
253
|
+
for (const [key, list] of ownWords) {
|
|
254
|
+
const exact = new Map();
|
|
255
|
+
const lower = new Set();
|
|
256
|
+
for (const word of list) {
|
|
257
|
+
const form = nfc(word.form);
|
|
258
|
+
if (!exact.has(form)) exact.set(form, word);
|
|
259
|
+
lower.add(form.toLowerCase());
|
|
260
|
+
}
|
|
261
|
+
byForm.set(key, exact);
|
|
262
|
+
byLowerForm.set(key, lower);
|
|
263
|
+
}
|
|
264
|
+
/** The word of a form as a language lists it, nearest in its lineage first. */
|
|
265
|
+
const wordIn = (language, name) => {
|
|
266
|
+
const chain = lineage(language);
|
|
267
|
+
for (let depth = 0; depth < chain.length; depth++) {
|
|
268
|
+
const word = byForm.get(chain[depth]).get(name);
|
|
269
|
+
if (word) return { word, statedIn: chain[depth], depth };
|
|
270
|
+
}
|
|
271
|
+
return undefined;
|
|
272
|
+
};
|
|
273
|
+
const hasWordIgnoringCase = (language, name) => {
|
|
274
|
+
const lower = name.toLowerCase();
|
|
275
|
+
return lineage(language).some((key) => byLowerForm.get(key).has(lower));
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
// ── Cultures and affiliations ────────────────────────────────────────────
|
|
279
|
+
const isCulture = (record) => record?.type === "lore" && record?.subType === "culture";
|
|
280
|
+
const isAffiliation = (record) => record?.type === "affiliation";
|
|
281
|
+
const languageOf = (record, key, entry) =>
|
|
282
|
+
glossaryLanguage(entry, record, { ...reader, self: key }) ?? null;
|
|
283
|
+
const cultureOf = (key) => {
|
|
284
|
+
const record = byKey.get(key);
|
|
285
|
+
return record ? (ref(record.data?.culture, record.package, "lore") ?? null) : null;
|
|
286
|
+
};
|
|
287
|
+
/** Every culture an affiliation's terms reach: its own, and every parent's. */
|
|
288
|
+
const culturesOf = (body) => {
|
|
289
|
+
const out = new Set();
|
|
290
|
+
const seen = new Set();
|
|
291
|
+
const queue = [body];
|
|
292
|
+
while (queue.length) {
|
|
293
|
+
const key = queue.shift();
|
|
294
|
+
if (seen.has(key)) continue;
|
|
295
|
+
seen.add(key);
|
|
296
|
+
const record = byKey.get(key);
|
|
297
|
+
if (!isAffiliation(record)) continue;
|
|
298
|
+
const culture = cultureOf(key);
|
|
299
|
+
if (culture) out.add(culture);
|
|
300
|
+
for (const parent of Array.isArray(record.data?.parents) ? record.data.parents : []) {
|
|
301
|
+
const parentKey = ref(parent, record.package, "affiliation");
|
|
302
|
+
if (parentKey) queue.push(parentKey);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return [...out].sort(compare);
|
|
306
|
+
};
|
|
307
|
+
const beings = [...visible].filter(([, record]) => record.type === "being");
|
|
308
|
+
const membersOf = (body, test) =>
|
|
309
|
+
beings
|
|
310
|
+
.filter(([, record]) => {
|
|
311
|
+
const standing =
|
|
312
|
+
isMap(record.data?.affiliations) ? record.data.affiliations[body] : undefined;
|
|
313
|
+
return isMap(standing) && test(standing);
|
|
314
|
+
})
|
|
315
|
+
.map(([key]) => key);
|
|
316
|
+
const glossaryOf = (key) => {
|
|
317
|
+
const entries = byKey.get(key)?.data?.glossary;
|
|
318
|
+
return (Array.isArray(entries) ? entries : []).filter(
|
|
319
|
+
(entry) => isMap(entry) && typeof entry.name === "string" && entry.name.trim(),
|
|
320
|
+
);
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
// ── Ranks and offices ────────────────────────────────────────────────────
|
|
324
|
+
/** Every rank and office row, keyed by owner then name, whether or not it enters. */
|
|
325
|
+
const governance = new Map();
|
|
326
|
+
for (const [body, record] of byKey) {
|
|
327
|
+
if (!isAffiliation(record) || !visible.has(body)) continue;
|
|
328
|
+
const language = languageOf(record, body, undefined);
|
|
329
|
+
const cultures = culturesOf(body);
|
|
330
|
+
const rows = new Map();
|
|
331
|
+
const add = (kind, title, text, usage) => {
|
|
332
|
+
if (typeof title !== "string" || !title.trim()) return;
|
|
333
|
+
const name = nfc(title);
|
|
334
|
+
if (!rows.has(name))
|
|
335
|
+
rows.set(name, {
|
|
336
|
+
owner: body,
|
|
337
|
+
ownerKind: "affiliation",
|
|
338
|
+
language,
|
|
339
|
+
name,
|
|
340
|
+
cultures,
|
|
341
|
+
pieces: [],
|
|
342
|
+
usage: new Set(),
|
|
343
|
+
marks: [],
|
|
344
|
+
definition: undefined,
|
|
345
|
+
named: false,
|
|
346
|
+
});
|
|
347
|
+
const row = rows.get(name);
|
|
348
|
+
row.pieces.push({ kind, text: typeof text === "string" ? text : null });
|
|
349
|
+
for (const value of [body, ...usage]) row.usage.add(value);
|
|
350
|
+
};
|
|
351
|
+
const gov = isMap(record.data?.governance) ? record.data.governance : {};
|
|
352
|
+
for (const rung of Array.isArray(gov.ranks) ? gov.ranks : []) {
|
|
353
|
+
if (!isMap(rung)) continue;
|
|
354
|
+
add(
|
|
355
|
+
"rank",
|
|
356
|
+
rung.title,
|
|
357
|
+
rung.description,
|
|
358
|
+
membersOf(body, (standing) => standing.rank === rung.level),
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
if (isMap(gov.offices))
|
|
362
|
+
for (const [title, office] of Object.entries(gov.offices)) {
|
|
363
|
+
const holders =
|
|
364
|
+
isMap(office) && Array.isArray(office.holders) ?
|
|
365
|
+
office.holders
|
|
366
|
+
.map((holder) =>
|
|
367
|
+
isMap(holder) ?
|
|
368
|
+
ref(holder.being, record.package, "being")
|
|
369
|
+
: undefined,
|
|
370
|
+
)
|
|
371
|
+
.filter(Boolean)
|
|
372
|
+
: [];
|
|
373
|
+
add("office", title, isMap(office) ? office.description : office, [
|
|
374
|
+
...holders,
|
|
375
|
+
...membersOf(body, (standing) => standing.office === title),
|
|
376
|
+
]);
|
|
377
|
+
}
|
|
378
|
+
governance.set(body, rows);
|
|
379
|
+
}
|
|
380
|
+
const allGovernance = [...governance.values()].flatMap((rows) => [...rows.values()]);
|
|
381
|
+
|
|
382
|
+
// ── Word rows, before marks ──────────────────────────────────────────────
|
|
383
|
+
const wordRows = new Map();
|
|
384
|
+
const wordKey = (language, name) => `${language}\0${name}`;
|
|
385
|
+
for (const [language] of languages) {
|
|
386
|
+
if (!visible.has(language)) continue;
|
|
387
|
+
for (const ancestor of lineage(language)) {
|
|
388
|
+
if (ancestor !== language && !visible.has(ancestor)) continue;
|
|
389
|
+
for (const word of ownWords.get(ancestor)) {
|
|
390
|
+
const name = nfc(word.form);
|
|
391
|
+
const key = wordKey(language, name);
|
|
392
|
+
if (wordRows.has(key)) continue;
|
|
393
|
+
wordRows.set(key, { language, name, found: wordIn(language, name), marks: [] });
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// ── Glossary entries ─────────────────────────────────────────────────────
|
|
399
|
+
const owned = [];
|
|
400
|
+
const owners = [...visible]
|
|
401
|
+
.filter(([, record]) => isCulture(record) || isAffiliation(record))
|
|
402
|
+
.sort((a, b) => compare(a[0], b[0]));
|
|
403
|
+
for (const [note, record] of owners) {
|
|
404
|
+
const culture = isCulture(record);
|
|
405
|
+
const reach =
|
|
406
|
+
culture ?
|
|
407
|
+
allGovernance.filter((row) => row.cultures.includes(note))
|
|
408
|
+
: [...(governance.get(note)?.values() ?? [])];
|
|
409
|
+
const reachLanguages = new Set([
|
|
410
|
+
languageOf(record, note, undefined),
|
|
411
|
+
...(culture ? reach.map((row) => row.language) : []),
|
|
412
|
+
]);
|
|
413
|
+
reachLanguages.delete(null);
|
|
414
|
+
glossaryOf(note).forEach((entry, position) => {
|
|
415
|
+
const name = nfc(entry.name);
|
|
416
|
+
const language = languageOf(record, note, entry);
|
|
417
|
+
const mark = { note, position, entry };
|
|
418
|
+
if (defines(entry)) {
|
|
419
|
+
const own = !culture ? governance.get(note)?.get(name) : undefined;
|
|
420
|
+
if (own) {
|
|
421
|
+
own.definition = mark;
|
|
422
|
+
own.marks.push(mark);
|
|
423
|
+
own.named = true;
|
|
424
|
+
} else
|
|
425
|
+
owned.push({
|
|
426
|
+
owner: note,
|
|
427
|
+
ownerKind: culture ? "culture" : "affiliation",
|
|
428
|
+
language,
|
|
429
|
+
name,
|
|
430
|
+
cultures: culture ? [note] : culturesOf(note),
|
|
431
|
+
mark,
|
|
432
|
+
});
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
const stated = typeof entry.language === "string" && entry.language;
|
|
436
|
+
const among = stated ? new Set([language]) : reachLanguages;
|
|
437
|
+
const found = [];
|
|
438
|
+
for (const candidate of among)
|
|
439
|
+
if (wordIn(candidate, name)) {
|
|
440
|
+
const row = wordRows.get(wordKey(candidate, name));
|
|
441
|
+
if (row) found.push({ language: candidate, row });
|
|
442
|
+
}
|
|
443
|
+
for (const row of reach)
|
|
444
|
+
if (row.name === name && among.has(row.language))
|
|
445
|
+
found.push({ language: row.language, row });
|
|
446
|
+
const preferred = found.filter((one) => one.language === language);
|
|
447
|
+
for (const { row } of preferred.length ? preferred : found) {
|
|
448
|
+
row.marks.push(mark);
|
|
449
|
+
row.named = true;
|
|
450
|
+
}
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// ── Finishing ────────────────────────────────────────────────────────────
|
|
455
|
+
const marked = (marks, key) => {
|
|
456
|
+
const hit = marks.find(
|
|
457
|
+
(one) => typeof one.entry[key] === "string" && one.entry[key] !== "",
|
|
458
|
+
);
|
|
459
|
+
return hit ? hit.entry[key] : undefined;
|
|
460
|
+
};
|
|
461
|
+
const etymologyOf = ({ word, statedIn, depth }) => {
|
|
462
|
+
const blank = Object.fromEntries(ETYMOLOGY_KEYS.map((key) => [key, null]));
|
|
463
|
+
if (depth > 0)
|
|
464
|
+
return {
|
|
465
|
+
...blank,
|
|
466
|
+
kind: "inherited",
|
|
467
|
+
language: statedIn,
|
|
468
|
+
language_tag: tagOf(statedIn),
|
|
469
|
+
form: word.form,
|
|
470
|
+
};
|
|
471
|
+
const source = isMap(word.source) ? word.source : {};
|
|
472
|
+
const built = Array.isArray(word.built) ? word.built.map(String) : [];
|
|
473
|
+
const glossOf = pieceGlosses(languages.get(statedIn)?.data);
|
|
474
|
+
const out = {
|
|
475
|
+
...blank,
|
|
476
|
+
kind: typeof source.kind === "string" ? source.kind : null,
|
|
477
|
+
language: typeof source.language === "string" ? source.language : null,
|
|
478
|
+
form: typeof source.form === "string" ? source.form : null,
|
|
479
|
+
parts: built.length ? built.map((form) => ({ form, gloss: glossOf(form) })) : null,
|
|
480
|
+
root: typeof word.root === "string" ? word.root : null,
|
|
481
|
+
frame: typeof word.frame === "string" ? word.frame : null,
|
|
482
|
+
};
|
|
483
|
+
out.language_tag = tagOf(out.language);
|
|
484
|
+
return ETYMOLOGY_KEYS.some((key) => out[key] !== null) ? out : null;
|
|
485
|
+
};
|
|
486
|
+
|
|
487
|
+
const dictionary = [];
|
|
488
|
+
for (const { language, name, found, marks } of wordRows.values()) {
|
|
489
|
+
const { word, depth } = found;
|
|
490
|
+
const nameSort =
|
|
491
|
+
marked(marks, "name_sort") ??
|
|
492
|
+
(typeof word.name_sort === "string" && word.name_sort ? word.name_sort : name);
|
|
493
|
+
dictionary.push({
|
|
494
|
+
language,
|
|
495
|
+
language_tag: tagOf(language),
|
|
496
|
+
name,
|
|
497
|
+
name_sort: nameSort,
|
|
498
|
+
letter: dictionaryLetter(nameSort),
|
|
499
|
+
pronunciation:
|
|
500
|
+
marked(marks, "pronunciation") ??
|
|
501
|
+
(typeof word.pronunciation === "string" ? word.pronunciation : null),
|
|
502
|
+
class: typeof word.class === "string" ? word.class : null,
|
|
503
|
+
etymology: etymologyOf(found),
|
|
504
|
+
senses:
|
|
505
|
+
typeof word.gloss === "string" && word.gloss.trim() ?
|
|
506
|
+
[{ text: word.gloss, bodies: [] }]
|
|
507
|
+
: [],
|
|
508
|
+
usage: [
|
|
509
|
+
...new Set(
|
|
510
|
+
(Array.isArray(word.attested) ? word.attested : []).filter(
|
|
511
|
+
(value) => typeof value === "string" && citable(value),
|
|
512
|
+
),
|
|
513
|
+
),
|
|
514
|
+
].sort(compare),
|
|
515
|
+
common: marks.some((one) => one.entry.common === true),
|
|
516
|
+
depth,
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const terms = [];
|
|
521
|
+
const termRow = (
|
|
522
|
+
{ owner, ownerKind, language, name, cultures },
|
|
523
|
+
senses,
|
|
524
|
+
usage,
|
|
525
|
+
marks,
|
|
526
|
+
origins,
|
|
527
|
+
) => {
|
|
528
|
+
const nameSort = marked(marks, "name_sort") ?? name;
|
|
529
|
+
terms.push({
|
|
530
|
+
owner,
|
|
531
|
+
owner_kind: ownerKind,
|
|
532
|
+
language,
|
|
533
|
+
language_tag: tagOf(language),
|
|
534
|
+
name,
|
|
535
|
+
name_sort: nameSort,
|
|
536
|
+
letter: dictionaryLetter(nameSort),
|
|
537
|
+
pronunciation: marked(marks, "pronunciation") ?? null,
|
|
538
|
+
senses,
|
|
539
|
+
usage: [...new Set(usage)].filter(citable).sort(compare),
|
|
540
|
+
common: marks.some((one) => one.entry.common === true),
|
|
541
|
+
origins: [...new Set(origins)].sort(compare),
|
|
542
|
+
cultures,
|
|
543
|
+
});
|
|
544
|
+
};
|
|
545
|
+
for (const row of allGovernance) {
|
|
546
|
+
const enters =
|
|
547
|
+
!isSingleWord(row.name) ||
|
|
548
|
+
row.named ||
|
|
549
|
+
(row.language !== null && hasWordIgnoringCase(row.language, row.name));
|
|
550
|
+
if (!enters) continue;
|
|
551
|
+
const senses = [];
|
|
552
|
+
if (row.definition)
|
|
553
|
+
senses.push({ text: row.definition.entry.definition, bodies: [row.owner] });
|
|
554
|
+
else
|
|
555
|
+
for (const piece of [...row.pieces].sort(
|
|
556
|
+
(a, b) => KIND_ORDER[a.kind] - KIND_ORDER[b.kind],
|
|
557
|
+
))
|
|
558
|
+
if (piece.text?.trim() && !senses.some((sense) => sense.text === piece.text))
|
|
559
|
+
senses.push({ text: piece.text, bodies: [row.owner] });
|
|
560
|
+
termRow(row, senses, [...row.usage], row.marks, [
|
|
561
|
+
...row.pieces.map((piece) => piece.kind),
|
|
562
|
+
...(row.marks.length ? ["glossary"] : []),
|
|
563
|
+
]);
|
|
564
|
+
}
|
|
565
|
+
for (const one of owned)
|
|
566
|
+
termRow(
|
|
567
|
+
one,
|
|
568
|
+
[{ text: one.mark.entry.definition, bodies: [one.owner] }],
|
|
569
|
+
[one.owner],
|
|
570
|
+
[one.mark],
|
|
571
|
+
["glossary"],
|
|
572
|
+
);
|
|
573
|
+
|
|
574
|
+
dictionary.sort(
|
|
575
|
+
(a, b) =>
|
|
576
|
+
compare(a.language, b.language) ||
|
|
577
|
+
compare(a.name_sort, b.name_sort) ||
|
|
578
|
+
compare(a.name, b.name),
|
|
579
|
+
);
|
|
580
|
+
terms.sort(
|
|
581
|
+
(a, b) =>
|
|
582
|
+
compare(a.owner, b.owner) ||
|
|
583
|
+
compare(a.name_sort, b.name_sort) ||
|
|
584
|
+
compare(a.name, b.name),
|
|
585
|
+
);
|
|
586
|
+
return { dictionary, terms };
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* Rows as a datafile holds them: one JSON object per line, keys sorted at
|
|
591
|
+
* every depth, newline-terminated; the empty string for none.
|
|
592
|
+
*
|
|
593
|
+
* @param {readonly object[]} rows - From {@link dictionaryAndTermRows}.
|
|
594
|
+
* @returns {string} The JSON Lines.
|
|
595
|
+
*/
|
|
596
|
+
export function serializeRows(rows) {
|
|
597
|
+
if (!rows.length) return "";
|
|
598
|
+
const sorted = (value) => {
|
|
599
|
+
if (Array.isArray(value)) return value.map(sorted);
|
|
600
|
+
if (!isMap(value)) return value;
|
|
601
|
+
return Object.fromEntries(
|
|
602
|
+
Object.keys(value)
|
|
603
|
+
.sort(compare)
|
|
604
|
+
.map((key) => [key, sorted(value[key])]),
|
|
605
|
+
);
|
|
606
|
+
};
|
|
607
|
+
return `${rows.map((row) => JSON.stringify(sorted(row))).join("\n")}\n`;
|
|
608
|
+
}
|