@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,340 @@
|
|
|
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
|
+
* An `sql` fence's result set as a dictionary of words, `layout=dictionary`,
|
|
16
|
+
* or as a glossary of terms, `layout=glossary`.
|
|
17
|
+
*
|
|
18
|
+
* A dictionary reads the `dictionary` relation's columns and a glossary the
|
|
19
|
+
* `terms` relation's, or any query returning them. Each needs `name`, `senses`
|
|
20
|
+
* and `usage`, and a result missing one is refused naming it; every other
|
|
21
|
+
* column is read where the query selects it. The output is Markdown, so every
|
|
22
|
+
* surface sets it as it sets a note:
|
|
23
|
+
*
|
|
24
|
+
* - **the key**, once, at the head: the abbreviations this rendering uses,
|
|
25
|
+
* each with its expansion, then the common mark where an entry carries it;
|
|
26
|
+
* - **a heading per letter**, from `letter`, at the fence's `section-level`;
|
|
27
|
+
* - **one paragraph per entry**: the common mark, the bold headword, the
|
|
28
|
+
* pronunciation between slashes, the language tag (and for a word its
|
|
29
|
+
* class) in italics, a word's etymology in brackets, the senses, and the
|
|
30
|
+
* citations as links.
|
|
31
|
+
*
|
|
32
|
+
* A glossary sets the rows that share a headword and a language as one entry:
|
|
33
|
+
* their senses numbered in the order the rows arrive, each tagged with the
|
|
34
|
+
* bodies it comes from, a sense several rows give printed once with all of
|
|
35
|
+
* them. A word's senses carry no bodies, and a glossary's always do.
|
|
36
|
+
*
|
|
37
|
+
* The entries are ordered here rather than by the query: by letter, then by
|
|
38
|
+
* `name_sort` with its accents taken off and its case folded, then as written.
|
|
39
|
+
* A language tag prints where the entry's language is not the language of the
|
|
40
|
+
* page the fence is on, and an etymology never repeats the entry's own.
|
|
41
|
+
*
|
|
42
|
+
* @module
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
import { parseAddress, renderAddress } from "./address.mjs";
|
|
46
|
+
import { dictionaryLetter } from "./dictionary-rows.mjs";
|
|
47
|
+
import {
|
|
48
|
+
WORD_CLASS_BASES,
|
|
49
|
+
WORD_CLASS_MODIFIERS,
|
|
50
|
+
abbreviateWordClass,
|
|
51
|
+
parseWordClass,
|
|
52
|
+
} from "./word-classes.mjs";
|
|
53
|
+
|
|
54
|
+
/** The columns a dictionary or a glossary cannot be set without. */
|
|
55
|
+
export const LAYOUT_REQUIRED_COLUMNS = Object.freeze({
|
|
56
|
+
dictionary: Object.freeze(["name", "senses", "usage"]),
|
|
57
|
+
glossary: Object.freeze(["name", "senses", "usage"]),
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
/** The mark a common entry opens with. */
|
|
61
|
+
export const COMMON_MARK = "•";
|
|
62
|
+
|
|
63
|
+
/** What the key says of the common mark. */
|
|
64
|
+
const COMMON_KEY = "a common word, which a player is expected to know by heart";
|
|
65
|
+
|
|
66
|
+
/** Code-point order. */
|
|
67
|
+
const compare = (a, b) =>
|
|
68
|
+
a < b ? -1
|
|
69
|
+
: a > b ? 1
|
|
70
|
+
: 0;
|
|
71
|
+
|
|
72
|
+
/** A sort key with its accents off and its case folded. */
|
|
73
|
+
const fold = (text) =>
|
|
74
|
+
String(text ?? "")
|
|
75
|
+
.normalize("NFD")
|
|
76
|
+
.replace(/\p{M}/gu, "")
|
|
77
|
+
.toLowerCase();
|
|
78
|
+
|
|
79
|
+
/** A sense as a sentence: a capital first, and a closing stop where none is. */
|
|
80
|
+
function sentence(text) {
|
|
81
|
+
const value = String(text ?? "").trim();
|
|
82
|
+
if (!value) return value;
|
|
83
|
+
const opened = value[0].toLocaleUpperCase() + value.slice(1);
|
|
84
|
+
return /[.!?…]$/u.test(opened) ? opened : `${opened}.`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* @typedef {object} LayoutOptions
|
|
89
|
+
* @property {(ref: object) => boolean} [linkable] - Whether an Address can be
|
|
90
|
+
* linked to; a citation that cannot prints as its label.
|
|
91
|
+
* @property {number} [sectionLevel=2] - The level of a letter heading.
|
|
92
|
+
* @property {object} [addressContext] - Address resolution context.
|
|
93
|
+
* @property {(address: string) => string|undefined} [labelOf] - A note's
|
|
94
|
+
* `name.full`, by its Address.
|
|
95
|
+
* @property {string} [pageLanguage] - The language of the page the fence is
|
|
96
|
+
* on, which no entry is tagged with.
|
|
97
|
+
*/
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Render a result of words as a dictionary.
|
|
101
|
+
*
|
|
102
|
+
* @param {{columns: string[], rows: object[]}} result - From
|
|
103
|
+
* {@link module:engine/sql-tables.runSqlQuery}.
|
|
104
|
+
* @param {LayoutOptions} [opts]
|
|
105
|
+
* @returns {string} The Markdown.
|
|
106
|
+
* @throws {Error} When the result lacks a required column.
|
|
107
|
+
*/
|
|
108
|
+
export function renderDictionary(result, opts = {}) {
|
|
109
|
+
requireColumns(result, "dictionary");
|
|
110
|
+
return render(
|
|
111
|
+
result.rows.map((row) => ({ ...row, rows: [row] })),
|
|
112
|
+
opts,
|
|
113
|
+
true,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Render a result of terms as a glossary.
|
|
119
|
+
*
|
|
120
|
+
* @param {{columns: string[], rows: object[]}} result - From
|
|
121
|
+
* {@link module:engine/sql-tables.runSqlQuery}.
|
|
122
|
+
* @param {LayoutOptions} [opts]
|
|
123
|
+
* @returns {string} The Markdown.
|
|
124
|
+
* @throws {Error} When the result lacks a required column.
|
|
125
|
+
*/
|
|
126
|
+
export function renderGlossary(result, opts = {}) {
|
|
127
|
+
requireColumns(result, "glossary");
|
|
128
|
+
const entries = new Map();
|
|
129
|
+
for (const row of result.rows) {
|
|
130
|
+
const key = `${row.language ?? ""}\0${row.name}`;
|
|
131
|
+
if (!entries.has(key)) entries.set(key, { ...row, rows: [] });
|
|
132
|
+
entries.get(key).rows.push(row);
|
|
133
|
+
}
|
|
134
|
+
return render(
|
|
135
|
+
[...entries.values()].map((entry) => {
|
|
136
|
+
const senses = [];
|
|
137
|
+
for (const row of entry.rows)
|
|
138
|
+
for (const sense of Array.isArray(row.senses) ? row.senses : []) {
|
|
139
|
+
const same = senses.find((one) => one.text === sense?.text);
|
|
140
|
+
const bodies = Array.isArray(sense?.bodies) ? sense.bodies : [];
|
|
141
|
+
if (!same) senses.push({ text: sense?.text, bodies: [...bodies] });
|
|
142
|
+
else
|
|
143
|
+
for (const body of bodies)
|
|
144
|
+
if (!same.bodies.includes(body)) same.bodies.push(body);
|
|
145
|
+
}
|
|
146
|
+
for (const sense of senses) sense.bodies.sort(compare);
|
|
147
|
+
const first = (column) =>
|
|
148
|
+
entry.rows.map((row) => row[column]).find((value) => value != null && value !== "");
|
|
149
|
+
return {
|
|
150
|
+
...entry,
|
|
151
|
+
senses,
|
|
152
|
+
usage: [...new Set(entry.rows.flatMap((row) => row.usage ?? []))].sort(compare),
|
|
153
|
+
common: entry.rows.some((row) => row.common === true),
|
|
154
|
+
pronunciation: first("pronunciation"),
|
|
155
|
+
name_sort: first("name_sort"),
|
|
156
|
+
letter: first("letter"),
|
|
157
|
+
class: undefined,
|
|
158
|
+
etymology: undefined,
|
|
159
|
+
};
|
|
160
|
+
}),
|
|
161
|
+
opts,
|
|
162
|
+
false,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Refuse a result missing a column its layout needs, naming the column. */
|
|
167
|
+
function requireColumns(result, layout) {
|
|
168
|
+
const columns = new Set(result.columns);
|
|
169
|
+
for (const column of LAYOUT_REQUIRED_COLUMNS[layout])
|
|
170
|
+
if (!columns.has(column))
|
|
171
|
+
throw new Error(
|
|
172
|
+
`layout=${layout} needs the column "${column}", and the query does not select it`,
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Set entries as Markdown.
|
|
178
|
+
*
|
|
179
|
+
* @param {object[]} entries - One per entry, with the row columns.
|
|
180
|
+
* @param {LayoutOptions} opts
|
|
181
|
+
* @param {boolean} words - Whether the entries are words, with a class and an
|
|
182
|
+
* etymology.
|
|
183
|
+
* @returns {string}
|
|
184
|
+
*/
|
|
185
|
+
function render(
|
|
186
|
+
entries,
|
|
187
|
+
{
|
|
188
|
+
linkable = () => true,
|
|
189
|
+
sectionLevel = 2,
|
|
190
|
+
addressContext,
|
|
191
|
+
labelOf = () => undefined,
|
|
192
|
+
pageLanguage,
|
|
193
|
+
},
|
|
194
|
+
words,
|
|
195
|
+
) {
|
|
196
|
+
const label = (address) => labelOf(address) ?? address;
|
|
197
|
+
const usedTags = new Map();
|
|
198
|
+
const usedBases = new Set();
|
|
199
|
+
const usedModifiers = new Set();
|
|
200
|
+
let anyCommon = false;
|
|
201
|
+
|
|
202
|
+
const cite = (address) => {
|
|
203
|
+
const text = label(address);
|
|
204
|
+
const ref = parseAddress(address, addressContext ?? {}, { declared: true });
|
|
205
|
+
if (ref.reason || !linkable(ref)) return text;
|
|
206
|
+
return `[[${renderAddress(ref)}|${String(text).replace(/\|/g, "/")}]]`;
|
|
207
|
+
};
|
|
208
|
+
const tag = (language, text) => {
|
|
209
|
+
if (!text) return "";
|
|
210
|
+
if (!usedTags.has(text)) usedTags.set(text, language);
|
|
211
|
+
return text;
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const sorted = entries.map((row) => {
|
|
215
|
+
const sortKey = String(row.name_sort ?? row.name ?? "");
|
|
216
|
+
return { row, letter: String(row.letter ?? dictionaryLetter(sortKey)), sortKey };
|
|
217
|
+
});
|
|
218
|
+
sorted.sort(
|
|
219
|
+
(a, b) =>
|
|
220
|
+
compare(a.letter, b.letter) ||
|
|
221
|
+
compare(fold(a.sortKey), fold(b.sortKey)) ||
|
|
222
|
+
compare(a.sortKey, b.sortKey) ||
|
|
223
|
+
compare(String(a.row.name), String(b.row.name)) ||
|
|
224
|
+
compare(String(a.row.language_tag ?? ""), String(b.row.language_tag ?? "")),
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
const paragraphs = [];
|
|
228
|
+
let letter;
|
|
229
|
+
for (const { row, letter: current } of sorted) {
|
|
230
|
+
if (current !== letter) {
|
|
231
|
+
letter = current;
|
|
232
|
+
paragraphs.push(`${"#".repeat(sectionLevel)} ${letter}`);
|
|
233
|
+
}
|
|
234
|
+
const parts = [];
|
|
235
|
+
if (row.common === true) {
|
|
236
|
+
anyCommon = true;
|
|
237
|
+
parts.push(COMMON_MARK);
|
|
238
|
+
}
|
|
239
|
+
parts.push(`**${row.name}**`);
|
|
240
|
+
if (row.pronunciation) parts.push(`/${row.pronunciation}/`);
|
|
241
|
+
|
|
242
|
+
const italic = [];
|
|
243
|
+
if (row.language_tag && row.language !== pageLanguage)
|
|
244
|
+
italic.push(tag(row.language, row.language_tag));
|
|
245
|
+
if (words && row.class) {
|
|
246
|
+
const parsed = parseWordClass(row.class);
|
|
247
|
+
if (!parsed.reason) {
|
|
248
|
+
usedBases.add(parsed.base);
|
|
249
|
+
for (const token of parsed.modifiers) usedModifiers.add(token);
|
|
250
|
+
italic.push(abbreviateWordClass(parsed));
|
|
251
|
+
} else italic.push(String(row.class));
|
|
252
|
+
}
|
|
253
|
+
if (italic.length) parts.push(`_${italic.join(" ")}_`);
|
|
254
|
+
|
|
255
|
+
if (words) {
|
|
256
|
+
const bracket = etymologyText(row.etymology, row.language, tag);
|
|
257
|
+
if (bracket) parts.push(`[${bracket}]`);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const senses = Array.isArray(row.senses) ? row.senses : [];
|
|
261
|
+
const senseText = (sense) => {
|
|
262
|
+
const bodies = Array.isArray(sense?.bodies) ? sense.bodies : [];
|
|
263
|
+
const by = bodies.length ? `(_${bodies.map(label).join(", ")}_) ` : "";
|
|
264
|
+
return `${by}${sentence(sense?.text)}`;
|
|
265
|
+
};
|
|
266
|
+
if (senses.length === 1) parts.push(senseText(senses[0]));
|
|
267
|
+
else senses.forEach((sense, i) => parts.push(`**${i + 1}** ${senseText(sense)}`));
|
|
268
|
+
|
|
269
|
+
const usage = Array.isArray(row.usage) ? row.usage : [];
|
|
270
|
+
let line = parts.join(" ");
|
|
271
|
+
if (usage.length) line += ` — ${usage.map(cite).join(", ")}`;
|
|
272
|
+
paragraphs.push(line);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const key = [];
|
|
276
|
+
for (const [text, language] of [...usedTags].sort((a, b) => compare(a[0], b[0]))) {
|
|
277
|
+
const expansion = language ? labelOf(language) : undefined;
|
|
278
|
+
if (expansion && expansion !== text) key.push(`_${text}_ ${expansion}`);
|
|
279
|
+
}
|
|
280
|
+
for (const base of WORD_CLASS_BASES)
|
|
281
|
+
if (usedBases.has(base.token))
|
|
282
|
+
key.push(`_${base.abbreviation ?? base.token}_ ${base.expansion}`);
|
|
283
|
+
for (const modifier of WORD_CLASS_MODIFIERS)
|
|
284
|
+
if (usedModifiers.has(modifier.token))
|
|
285
|
+
key.push(`_${modifier.abbreviation}_ ${modifier.expansion}`);
|
|
286
|
+
if (anyCommon) key.push(`${COMMON_MARK} ${COMMON_KEY}`);
|
|
287
|
+
if (key.length) paragraphs.unshift(`**Key:** ${key.join("; ")}.`);
|
|
288
|
+
return paragraphs.join("\n\n");
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* An etymology as the bracket prints it, without the brackets, or the empty
|
|
293
|
+
* string where it says nothing.
|
|
294
|
+
*
|
|
295
|
+
* @param {object|null|undefined} etymology - The row's `etymology`.
|
|
296
|
+
* @param {string|null|undefined} own - The entry's own language.
|
|
297
|
+
* @param {(language: string, text: string) => string} tag - Records and
|
|
298
|
+
* returns a language tag.
|
|
299
|
+
* @returns {string}
|
|
300
|
+
*/
|
|
301
|
+
function etymologyText(etymology, own, tag) {
|
|
302
|
+
if (!etymology || typeof etymology !== "object") return "";
|
|
303
|
+
const pieces = [];
|
|
304
|
+
const tongue =
|
|
305
|
+
etymology.language && etymology.language !== own ?
|
|
306
|
+
tag(etymology.language, etymology.language_tag)
|
|
307
|
+
: "";
|
|
308
|
+
const form = etymology.form ? `_${etymology.form}_` : "";
|
|
309
|
+
switch (etymology.kind) {
|
|
310
|
+
case "borrowed":
|
|
311
|
+
pieces.push(["fr.", tongue, form].filter(Boolean).join(" "));
|
|
312
|
+
break;
|
|
313
|
+
case "inherited":
|
|
314
|
+
case "kept":
|
|
315
|
+
case "worn":
|
|
316
|
+
if (form || tongue) pieces.push([tongue, form && `*${form}`].filter(Boolean).join(" "));
|
|
317
|
+
break;
|
|
318
|
+
case "coined":
|
|
319
|
+
case "substrate":
|
|
320
|
+
case "island":
|
|
321
|
+
pieces.push(etymology.kind);
|
|
322
|
+
break;
|
|
323
|
+
default:
|
|
324
|
+
break;
|
|
325
|
+
}
|
|
326
|
+
if (Array.isArray(etymology.parts) && etymology.parts.length)
|
|
327
|
+
pieces.push(
|
|
328
|
+
etymology.parts
|
|
329
|
+
.map((part) => `_${part.form}_${part.gloss ? ` "${part.gloss}"` : ""}`)
|
|
330
|
+
.join(" + "),
|
|
331
|
+
);
|
|
332
|
+
const skeleton = [
|
|
333
|
+
etymology.root ? `root _${etymology.root}_` : "",
|
|
334
|
+
etymology.frame ? `pattern _${etymology.frame}_` : "",
|
|
335
|
+
]
|
|
336
|
+
.filter(Boolean)
|
|
337
|
+
.join(", ");
|
|
338
|
+
if (skeleton) pieces.push(skeleton);
|
|
339
|
+
return pieces.filter(Boolean).join("; ");
|
|
340
|
+
}
|