@heroiclands/package-build 23.12.8 → 23.13.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 +48 -0
- package/README.md +1 -1
- package/assets/preview/gitea.ini +2 -2
- package/assets/styles/content-base.css +143 -23
- package/bin/content-commands.mjs +15 -4
- package/bin/package-build-preview.mjs +2 -2
- package/docs/api.md +18 -18
- package/docs/authoring/links-and-markup.md +156 -22
- package/docs/commands.md +22 -7
- package/docs/configuration.md +1 -1
- package/docs/content-format.md +3 -3
- package/docs/diagnostics.md +42 -0
- package/docs/reference/format-details.md +99 -22
- package/docs/reference/note-types.md +8 -1
- package/docs/reference/theme-pages.md +51 -0
- package/docs/source-preview.md +1 -1
- package/engine/anchors.mjs +2 -2
- package/engine/code-fences.mjs +3 -3
- package/engine/content-blocks.mjs +3 -3
- package/engine/content-figures.mjs +5 -5
- package/engine/content-index.mjs +1 -1
- package/engine/content-scripts.mjs +55 -21
- package/engine/content-spans.mjs +2 -2
- package/engine/content-verse.mjs +750 -0
- package/engine/dictionary-rows.mjs +15 -4
- package/engine/field-reference.mjs +4 -4
- package/engine/first-use.mjs +17 -27
- package/engine/key-case-collisions.mjs +136 -0
- package/engine/language-data.mjs +203 -6
- package/engine/language-terms.mjs +74 -5
- package/engine/map-lexicon.mjs +42 -23
- package/engine/names.mjs +36 -9
- package/engine/note-matter.mjs +66 -0
- package/engine/pdf-build.mjs +1 -0
- package/engine/pdf-render.mjs +107 -18
- package/engine/read-stdin.mjs +29 -0
- package/engine/run-rows.mjs +2 -2
- package/engine/run-words.mjs +4 -111
- package/engine/site-build.mjs +4 -4
- package/engine/site-preview.mjs +14 -14
- package/engine/sql-tables.mjs +20 -3
- package/engine/web-wikilinks.mjs +1 -1
- package/engine/word-forms.mjs +349 -0
- package/engine/word-terms.mjs +9 -0
- package/hugo-theme/static/css/style.css +92 -14
- package/package.json +2 -2
- package/types/engine/code-fences.d.mts +3 -3
- package/types/engine/content-scripts.d.mts +8 -4
- package/types/engine/content-verse.d.mts +240 -0
- package/types/engine/dictionary-rows.d.mts +10 -0
- package/types/engine/field-reference.d.mts +1 -1
- package/types/engine/key-case-collisions.d.mts +28 -0
- package/types/engine/language-data.d.mts +0 -2
- package/types/engine/map-lexicon.d.mts +19 -6
- package/types/engine/names.d.mts +34 -3
- package/types/engine/note-matter.d.mts +48 -0
- package/types/engine/read-stdin.d.mts +11 -0
- package/types/engine/run-rows.d.mts +2 -2
- package/types/engine/run-words.d.mts +0 -15
- package/types/engine/sql-tables.d.mts +4 -1
- package/types/engine/web-wikilinks.d.mts +1 -1
- package/types/engine/word-forms.d.mts +82 -0
- package/engine/content-poetry.mjs +0 -240
- package/types/engine/content-poetry.d.mts +0 -58
package/engine/site-preview.mjs
CHANGED
|
@@ -18,10 +18,10 @@ import footnotePlugin from "markdown-it-footnote";
|
|
|
18
18
|
import deflistPlugin from "markdown-it-deflist";
|
|
19
19
|
import { dictionaryListPlugin } from "./definition-list.mjs";
|
|
20
20
|
import Handlebars from "handlebars";
|
|
21
|
-
import
|
|
21
|
+
import { readMatter } from "./note-matter.mjs";
|
|
22
22
|
import { ALERT_TYPES, alertMarkdownPlugin, scanAlerts } from "./content-alerts.mjs";
|
|
23
23
|
import { spanMarkdownPlugin } from "./content-spans.mjs";
|
|
24
|
-
import {
|
|
24
|
+
import { scanVerse, verseStanzas } from "./content-verse.mjs";
|
|
25
25
|
import { scanFigures } from "./content-figures.mjs";
|
|
26
26
|
import { scanBlocks } from "./content-blocks.mjs";
|
|
27
27
|
import { splitHeadingAttributes } from "./heading-attributes.mjs";
|
|
@@ -358,7 +358,7 @@ function parserFor(fm, findings, file) {
|
|
|
358
358
|
const close = new state.Token("paragraph_close", "p", -1);
|
|
359
359
|
state.tokens.splice(i + 1, 0, open, title, close);
|
|
360
360
|
}
|
|
361
|
-
const
|
|
361
|
+
const verse = scanVerse(state.src).blocks;
|
|
362
362
|
const localFigures = scanFigures(state.src).figures;
|
|
363
363
|
for (const fig of localFigures) if (fig.id) figures.set(fig.id, fig);
|
|
364
364
|
for (const token of state.tokens) {
|
|
@@ -366,10 +366,10 @@ function parserFor(fm, findings, file) {
|
|
|
366
366
|
token.meta = {
|
|
367
367
|
...token.meta,
|
|
368
368
|
source: lines.slice(token.map[0], token.map[1]).join("\n"),
|
|
369
|
-
poem:
|
|
369
|
+
poem: verse.find((poem) => poem.start === token.map[0]),
|
|
370
370
|
};
|
|
371
371
|
}
|
|
372
|
-
//
|
|
372
|
+
// Verse must become inline tokens before footnote collection, so a
|
|
373
373
|
// definition outside the fence remains visible to a verse reference.
|
|
374
374
|
for (let i = state.tokens.length - 1; i >= 0; i--) {
|
|
375
375
|
const token = state.tokens[i];
|
|
@@ -382,22 +382,22 @@ function parserFor(fm, findings, file) {
|
|
|
382
382
|
replacement.push(part);
|
|
383
383
|
return part;
|
|
384
384
|
};
|
|
385
|
-
push("
|
|
386
|
-
for (const stanza of
|
|
385
|
+
push("hl_preview_verse_open", "div", 1, [["class", "verse"]]).map = token.map;
|
|
386
|
+
for (const stanza of verseStanzas(poem.body)) {
|
|
387
387
|
push("paragraph_open", "p", 1);
|
|
388
388
|
stanza.forEach(({ text, level }, row) => {
|
|
389
389
|
if (row) push("hardbreak", "br", 0);
|
|
390
|
-
push("
|
|
391
|
-
["class", `
|
|
390
|
+
push("hl_preview_verse_line_open", "span", 1, [
|
|
391
|
+
["class", `verse-line i${level}`],
|
|
392
392
|
]);
|
|
393
393
|
const line = push("inline", "", 0);
|
|
394
394
|
line.content = text;
|
|
395
395
|
line.children = [];
|
|
396
|
-
push("
|
|
396
|
+
push("hl_preview_verse_line_close", "span", -1);
|
|
397
397
|
});
|
|
398
398
|
push("paragraph_close", "p", -1);
|
|
399
399
|
}
|
|
400
|
-
push("
|
|
400
|
+
push("hl_preview_verse_close", "div", -1);
|
|
401
401
|
state.tokens.splice(i, 1, ...replacement);
|
|
402
402
|
}
|
|
403
403
|
// Work backwards so figure ranges and heading removals retain source maps.
|
|
@@ -583,7 +583,7 @@ export function renderPreview(
|
|
|
583
583
|
let frontmatter = {},
|
|
584
584
|
body = String(text ?? "");
|
|
585
585
|
try {
|
|
586
|
-
const parsed =
|
|
586
|
+
const parsed = readMatter(body);
|
|
587
587
|
frontmatter =
|
|
588
588
|
parsed.data && typeof parsed.data === "object" && !Array.isArray(parsed.data) ?
|
|
589
589
|
structuredClone(parsed.data)
|
|
@@ -665,7 +665,7 @@ export function previewStyles() {
|
|
|
665
665
|
.hl-preview .alert-warning { --hl-accent:var(--hl-warning); --hl-alert-bg:var(--hl-warning-bg); }
|
|
666
666
|
.hl-preview .alert .alert-title { color:var(--hl-accent); font-weight:700; opacity:1; }
|
|
667
667
|
.hl-preview .alert .alert-icon { width:1em; height:1em; margin-right:.5em; vertical-align:-.1em; color:var(--hl-accent); fill:currentColor; }
|
|
668
|
-
.hl-preview .
|
|
669
|
-
${Array.from({ length: 9 }, (_, n) => `.hl-preview .
|
|
668
|
+
.hl-preview .verse-line { white-space:pre-wrap; }
|
|
669
|
+
${Array.from({ length: 9 }, (_, n) => `.hl-preview .verse-line.i${n} { padding-left:${n * 2}em; }`).join("\n")}
|
|
670
670
|
`;
|
|
671
671
|
}
|
package/engine/sql-tables.mjs
CHANGED
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
|
|
29
29
|
import { parseAddress, readCanonicalKey, renderAddress, isAddressTuple } from "./address.mjs";
|
|
30
30
|
import { collectAnchors } from "./anchors.mjs";
|
|
31
|
+
import { keyCaseCollisionDiagnostics } from "./key-case-collisions.mjs";
|
|
31
32
|
import { expandContentTables } from "./content-tables.mjs";
|
|
32
33
|
import { NOTE_VOCABULARY, isGmNote } from "./note-vocabulary.mjs";
|
|
33
34
|
import { encodeAddresses, flattenPublishedAddresses } from "./address-values.mjs";
|
|
@@ -260,12 +261,14 @@ export function findSqlBlocks(markdown) {
|
|
|
260
261
|
* @param {object} [opts.addressContext] - Address resolution context.
|
|
261
262
|
* @param {"all"|"public"} [opts.audience] - Whether to exclude GM notes from
|
|
262
263
|
* local and dependency relations.
|
|
264
|
+
* @param {string} [opts.contentBase] - The directory the records' paths are
|
|
265
|
+
* relative to, so a refused key is reported at its file and line.
|
|
263
266
|
* @returns {Promise<{query: (sql: string) => Promise<object[]>,
|
|
264
267
|
* close: () => Promise<void>}>} The open database.
|
|
265
268
|
*/
|
|
266
269
|
export async function openNotesDatabase(
|
|
267
270
|
records,
|
|
268
|
-
{ dir, dependencies = [], addressContext, audience = "all" } = {},
|
|
271
|
+
{ dir, dependencies = [], addressContext, audience = "all", contentBase } = {},
|
|
269
272
|
) {
|
|
270
273
|
const { DuckDBInstance } = await import("@duckdb/node-api");
|
|
271
274
|
const base = dir ?? fs.mkdtempSync(path.join(os.tmpdir(), "content-sql-"));
|
|
@@ -276,7 +279,20 @@ export async function openNotesDatabase(
|
|
|
276
279
|
const instance = await DuckDBInstance.create(":memory:");
|
|
277
280
|
const connection = await instance.connect();
|
|
278
281
|
await connection.run("SET threads=1");
|
|
279
|
-
|
|
282
|
+
try {
|
|
283
|
+
await createRelations(connection, jsonl, encoded, "", audience);
|
|
284
|
+
} catch (err) {
|
|
285
|
+
// DuckDB names the clashing key but not the notes that spell it.
|
|
286
|
+
if (/Duplicate name ".*" in struct/.test(String(err?.message))) {
|
|
287
|
+
const lines = keyCaseCollisionDiagnostics(records, { contentBase });
|
|
288
|
+
if (lines.length) {
|
|
289
|
+
const located = new Error(lines.join("\n"));
|
|
290
|
+
/** @type {any} */ (located).located = true;
|
|
291
|
+
throw located;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
throw err;
|
|
295
|
+
}
|
|
280
296
|
await createEventRelation(connection, path.join(base, "events.jsonl"), encoded, "", audience);
|
|
281
297
|
await createNameRelation(connection, path.join(base, "names.jsonl"), encoded, "", audience);
|
|
282
298
|
await createRunRelation(connection, path.join(base, "runs.jsonl"), encoded, "", audience);
|
|
@@ -630,7 +646,7 @@ async function createNameRelation(connection, file, records, prefix, audience) {
|
|
|
630
646
|
}
|
|
631
647
|
|
|
632
648
|
/**
|
|
633
|
-
* Create the `runs` relation: one row per span, div and
|
|
649
|
+
* Create the `runs` relation: one row per span, div and verse fence an own
|
|
634
650
|
* note marks with `lang=`, `font=` or `map=`, with the columns
|
|
635
651
|
* {@link module:engine/run-rows.RUN_COLUMNS} names. The columns are declared
|
|
636
652
|
* rather than inferred, so every value stays a string and none becomes a
|
|
@@ -1732,6 +1748,7 @@ export async function prepareTreeSqlTables(
|
|
|
1732
1748
|
(await openNotesDatabase(allRecords, {
|
|
1733
1749
|
dependencies: await cachedDependencies(config),
|
|
1734
1750
|
audience,
|
|
1751
|
+
contentBase,
|
|
1735
1752
|
}));
|
|
1736
1753
|
try {
|
|
1737
1754
|
const generated = await generatedSectionsFor(db, indexRecords, contentBase, {
|
package/engine/web-wikilinks.mjs
CHANGED
|
@@ -234,7 +234,7 @@ const FRONTMATTER_WIKILINK = new RegExp(WIKILINK.source, "g");
|
|
|
234
234
|
* Values are read from the *parsed* frontmatter, so a `[[` inside a YAML comment
|
|
235
235
|
* is not a hit, and every hit can be named by the path a reader would look at.
|
|
236
236
|
*
|
|
237
|
-
* @param {unknown} fm - Parsed frontmatter, as `
|
|
237
|
+
* @param {unknown} fm - Parsed frontmatter, as `readMatter` returns it.
|
|
238
238
|
* @returns {Array<{path: string, link: string}>} In reading order; `path` is the
|
|
239
239
|
* dotted key path of the offending value (`government.summary`, `aliases.1`).
|
|
240
240
|
*/
|
|
@@ -0,0 +1,349 @@
|
|
|
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 regular forms a language's own rules derive from its lexicon.** Each
|
|
16
|
+
* rule a language note states in machine-readable form gives a lexicon word
|
|
17
|
+
* further forms, and nothing else does:
|
|
18
|
+
*
|
|
19
|
+
* - **the noun plural**: `data.grammar.plural`, or the word's own `plural`;
|
|
20
|
+
* - **the verb pattern**: a verb's citation form with one ending of
|
|
21
|
+
* `data.grammar.verbs.persons`, which takes the place of a closing
|
|
22
|
+
* `data.grammar.verbs.replaces` where the pattern states one (see
|
|
23
|
+
* {@link module:engine/verb-pattern.verbForm});
|
|
24
|
+
* - **a replacing suffix**: a suffix of `data.affixes` that states `replaces`,
|
|
25
|
+
* or its `front` form, on a word whose class base its `attachesTo` names,
|
|
26
|
+
* or on any word where it states none: `vowel` takes the place of a closing
|
|
27
|
+
* vowel (a letter whose base is `a`, `e`, `i`, `o` or `u`), letters take the
|
|
28
|
+
* place of those letters, and a stem closing otherwise takes the suffix
|
|
29
|
+
* whole. An affix that states no `replaces` derives nothing here;
|
|
30
|
+
* - **agreement**: a word whose class base `data.grammar.agreement.classes`
|
|
31
|
+
* names, closing on one of `data.grammar.agreement.endings` (the longest
|
|
32
|
+
* that leaves a stem), also takes each other ending in its place, and each
|
|
33
|
+
* of those forms and the word itself takes the plural the
|
|
34
|
+
* `data.grammar.plural` rule gives;
|
|
35
|
+
* - **participles**: a verb closing on one of `data.grammar.participles.root`
|
|
36
|
+
* (the longest that leaves a root) drops it, and each suffix of `data.affixes`
|
|
37
|
+
* that `data.grammar.participles.affixes` names, by its `form`, attaches to
|
|
38
|
+
* the root as its `replaces` says, or whole where it states none. A
|
|
39
|
+
* participle closing on an agreement ending agrees.
|
|
40
|
+
*
|
|
41
|
+
* The mapped-run word check ({@link module:engine/run-words}) and
|
|
42
|
+
* `package-build lexicon --derived` both read {@link derivedForms}, so what
|
|
43
|
+
* the command lists is what the check accepts.
|
|
44
|
+
*
|
|
45
|
+
* @module
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
import { pluralByRule, pluralOf } from "./dictionary-rows.mjs";
|
|
49
|
+
import { replaceClosing, verbForm, verbPattern } from "./verb-pattern.mjs";
|
|
50
|
+
import { parseWordClass } from "./word-classes.mjs";
|
|
51
|
+
import { WORD } from "./word-pattern.mjs";
|
|
52
|
+
|
|
53
|
+
/** Whether a value is a plain map. */
|
|
54
|
+
const isMap = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
55
|
+
|
|
56
|
+
/** A list value, or an empty one. */
|
|
57
|
+
const listOf = (value) => (Array.isArray(value) ? value : []);
|
|
58
|
+
|
|
59
|
+
/** The characters a compound is split at. */
|
|
60
|
+
const JOINS = /[–-]/u;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* A word as it is compared: NFC, lowercase, `’` read as `'`.
|
|
64
|
+
*
|
|
65
|
+
* @param {string} word - The word.
|
|
66
|
+
* @returns {string} Its folded form.
|
|
67
|
+
*/
|
|
68
|
+
export function foldWord(word) {
|
|
69
|
+
return word.normalize("NFC").toLowerCase().replace(/’/gu, "'");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Whether a character is a vowel: its base letter is a, e, i, o or u. */
|
|
73
|
+
const isVowel = (char) => /^[aeiou]/iu.test(char.normalize("NFD"));
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Add a text's words to a set, each whole and each part of a compound.
|
|
77
|
+
*
|
|
78
|
+
* @param {Set<string>} set - The set.
|
|
79
|
+
* @param {string} text - A form or a name.
|
|
80
|
+
*/
|
|
81
|
+
export function addWords(set, text) {
|
|
82
|
+
for (const [word] of text.matchAll(WORD)) {
|
|
83
|
+
const folded = foldWord(word);
|
|
84
|
+
set.add(folded);
|
|
85
|
+
for (const part of folded.split(JOINS)) if (part) set.add(part);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The form a suffix makes of a stem.
|
|
91
|
+
*
|
|
92
|
+
* @param {string} stem - The stem, folded.
|
|
93
|
+
* @param {string} suffix - The suffix, folded, with no hyphen.
|
|
94
|
+
* @param {string|undefined} replaces - The affix's `replaces`; none adds the
|
|
95
|
+
* suffix whole.
|
|
96
|
+
* @returns {string} The derived form.
|
|
97
|
+
*/
|
|
98
|
+
function attach(stem, suffix, replaces) {
|
|
99
|
+
if (replaces === "vowel") {
|
|
100
|
+
const last = Array.from(stem).at(-1);
|
|
101
|
+
return last !== undefined && isVowel(last) ?
|
|
102
|
+
stem.slice(0, -last.length) + suffix
|
|
103
|
+
: stem + suffix;
|
|
104
|
+
}
|
|
105
|
+
return replaceClosing(stem, suffix, replaces === undefined ? undefined : foldWord(replaces));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* An affix's suffixes: its `form` and its `front` form, each written `-x`,
|
|
110
|
+
* folded and without the hyphen.
|
|
111
|
+
*
|
|
112
|
+
* @param {Record<string, any>} affix - One `data.affixes` entry.
|
|
113
|
+
* @returns {string[]} The suffixes.
|
|
114
|
+
*/
|
|
115
|
+
function suffixesOf(affix) {
|
|
116
|
+
const out = [];
|
|
117
|
+
for (const written of [affix.form, affix.front]) {
|
|
118
|
+
if (typeof written !== "string") continue;
|
|
119
|
+
const form = foldWord(written.trim());
|
|
120
|
+
if (!/^[-–]/u.test(form) || /[-–]$/u.test(form)) continue;
|
|
121
|
+
const suffix = form.replace(/^[-–]+/u, "");
|
|
122
|
+
if (suffix) out.push(suffix);
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The suffixes a language's affixes state `replaces` for, each with the class
|
|
129
|
+
* bases its `attachesTo` names.
|
|
130
|
+
*
|
|
131
|
+
* @param {Record<string, any>} data - The language note's `data`.
|
|
132
|
+
* @returns {Array<{suffix: string, replaces: string, classes: ReadonlySet<string>|undefined}>}
|
|
133
|
+
* The suffixes, folded; `classes` is `undefined` where the affix attaches to
|
|
134
|
+
* every class.
|
|
135
|
+
*/
|
|
136
|
+
function replacingSuffixes(data) {
|
|
137
|
+
const out = [];
|
|
138
|
+
for (const affix of listOf(data.affixes)) {
|
|
139
|
+
if (!isMap(affix) || typeof affix.replaces !== "string") continue;
|
|
140
|
+
const classes =
|
|
141
|
+
Array.isArray(affix.attachesTo) ?
|
|
142
|
+
new Set(
|
|
143
|
+
affix.attachesTo
|
|
144
|
+
.filter((item) => typeof item === "string")
|
|
145
|
+
.map((item) => item.trim()),
|
|
146
|
+
)
|
|
147
|
+
: undefined;
|
|
148
|
+
for (const suffix of suffixesOf(affix))
|
|
149
|
+
out.push({ suffix, replaces: affix.replaces, classes });
|
|
150
|
+
}
|
|
151
|
+
return out;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The strings of a list, trimmed and folded, keeping those with letters.
|
|
156
|
+
*
|
|
157
|
+
* @param {unknown} value - The list.
|
|
158
|
+
* @returns {string[]} The strings.
|
|
159
|
+
*/
|
|
160
|
+
const foldedStrings = (value) =>
|
|
161
|
+
listOf(value)
|
|
162
|
+
.filter((item) => typeof item === "string" && item.trim())
|
|
163
|
+
.map((item) => foldWord(item.trim()));
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* A language's agreement rule, as derivation reads it.
|
|
167
|
+
*
|
|
168
|
+
* @typedef {object} Agreement
|
|
169
|
+
* @property {ReadonlySet<string>} classes - The class bases that agree.
|
|
170
|
+
* @property {readonly string[]} endings - The endings, folded, longest first.
|
|
171
|
+
*/
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Read a language's `data.grammar.agreement`.
|
|
175
|
+
*
|
|
176
|
+
* @param {unknown} value - The authored value.
|
|
177
|
+
* @returns {Agreement|undefined} The rule, or `undefined` where none is stated
|
|
178
|
+
* in full.
|
|
179
|
+
*/
|
|
180
|
+
function agreementOf(value) {
|
|
181
|
+
if (!isMap(value)) return undefined;
|
|
182
|
+
const classes = listOf(value.classes).filter((item) => typeof item === "string");
|
|
183
|
+
const endings = [
|
|
184
|
+
...new Set(
|
|
185
|
+
listOf(value.endings)
|
|
186
|
+
.filter((item) => typeof item === "string")
|
|
187
|
+
.map((item) => foldWord(item.trim())),
|
|
188
|
+
),
|
|
189
|
+
];
|
|
190
|
+
if (!classes.length || endings.length < 2) return undefined;
|
|
191
|
+
return {
|
|
192
|
+
classes: new Set(classes.map((item) => item.trim())),
|
|
193
|
+
endings: endings.sort((a, b) => b.length - a.length),
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* The forms an agreeing word takes beside its own.
|
|
199
|
+
*
|
|
200
|
+
* @param {string} form - The word, folded.
|
|
201
|
+
* @param {Agreement} agreement - The rule.
|
|
202
|
+
* @param {Record<string, any>|undefined} grammar - The language's grammar,
|
|
203
|
+
* for its plural rule.
|
|
204
|
+
* @returns {string[]} The forms; none where the word closes on no ending.
|
|
205
|
+
*/
|
|
206
|
+
function agreeingForms(form, agreement, grammar) {
|
|
207
|
+
const ending = agreement.endings.find((one) => form.length > one.length && form.endsWith(one));
|
|
208
|
+
if (ending === undefined) return [];
|
|
209
|
+
const stem = form.slice(0, form.length - ending.length);
|
|
210
|
+
const singulars = agreement.endings.map((one) => stem + one);
|
|
211
|
+
const out = singulars.filter((one) => one !== form);
|
|
212
|
+
for (const one of singulars) {
|
|
213
|
+
const plural = pluralByRule(one, grammar);
|
|
214
|
+
if (plural) out.push(foldWord(plural));
|
|
215
|
+
}
|
|
216
|
+
return out;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* A language's participle rule, as derivation reads it.
|
|
221
|
+
*
|
|
222
|
+
* @typedef {object} Participles
|
|
223
|
+
* @property {readonly string[]} root - The root endings, folded, longest
|
|
224
|
+
* first.
|
|
225
|
+
* @property {Array<{suffix: string, replaces: string|undefined}>} suffixes -
|
|
226
|
+
* The participle suffixes, folded.
|
|
227
|
+
*/
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Read a language's `data.grammar.participles` against its affixes.
|
|
231
|
+
*
|
|
232
|
+
* @param {unknown} value - The authored value.
|
|
233
|
+
* @param {Record<string, any>} data - The language note's `data`.
|
|
234
|
+
* @returns {Participles|undefined} The rule, or `undefined` where it names no
|
|
235
|
+
* root ending or no suffix the affixes state.
|
|
236
|
+
*/
|
|
237
|
+
function participlesOf(value, data) {
|
|
238
|
+
if (!isMap(value)) return undefined;
|
|
239
|
+
const root = [...new Set(foldedStrings(value.root))].sort((a, b) => b.length - a.length);
|
|
240
|
+
const named = new Set(
|
|
241
|
+
listOf(value.affixes)
|
|
242
|
+
.filter((item) => typeof item === "string")
|
|
243
|
+
.map((item) => item.trim()),
|
|
244
|
+
);
|
|
245
|
+
const suffixes = [];
|
|
246
|
+
for (const affix of listOf(data.affixes)) {
|
|
247
|
+
if (!isMap(affix) || typeof affix.form !== "string" || !named.has(affix.form.trim()))
|
|
248
|
+
continue;
|
|
249
|
+
const replaces = typeof affix.replaces === "string" ? affix.replaces : undefined;
|
|
250
|
+
for (const suffix of suffixesOf(affix)) suffixes.push({ suffix, replaces });
|
|
251
|
+
}
|
|
252
|
+
if (!root.length || !suffixes.length) return undefined;
|
|
253
|
+
return { root, suffixes };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* The participles of a verb.
|
|
258
|
+
*
|
|
259
|
+
* @param {string} citation - The citation form, folded.
|
|
260
|
+
* @param {Participles} participles - The rule.
|
|
261
|
+
* @returns {string[]} The participles; none where the verb closes on no root
|
|
262
|
+
* ending.
|
|
263
|
+
*/
|
|
264
|
+
function participleForms(citation, participles) {
|
|
265
|
+
const ending = participles.root.find(
|
|
266
|
+
(one) => citation.length > one.length && citation.endsWith(one),
|
|
267
|
+
);
|
|
268
|
+
if (ending === undefined) return [];
|
|
269
|
+
const root = citation.slice(0, citation.length - ending.length);
|
|
270
|
+
return participles.suffixes.map(({ suffix, replaces }) => attach(root, suffix, replaces));
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* One form a rule derives from a lexicon word.
|
|
275
|
+
*
|
|
276
|
+
* @typedef {object} DerivedForm
|
|
277
|
+
* @property {string} form - The form, folded.
|
|
278
|
+
* @property {Record<string, any>} word - The `data.words` entry it comes from.
|
|
279
|
+
*/
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Every form the language's rules derive from its lexicon words, in
|
|
283
|
+
* `data.words` order, each once, leaving out a form that is a lexicon word.
|
|
284
|
+
*
|
|
285
|
+
* @param {Record<string, any>|undefined} data - The language note's `data`.
|
|
286
|
+
* @returns {DerivedForm[]} The forms.
|
|
287
|
+
*/
|
|
288
|
+
export function derivedForms(data) {
|
|
289
|
+
const words = listOf(data?.words).filter(
|
|
290
|
+
(word) => isMap(word) && typeof word.form === "string",
|
|
291
|
+
);
|
|
292
|
+
const grammar = isMap(data?.grammar) ? data.grammar : undefined;
|
|
293
|
+
const verbs = verbPattern(grammar?.verbs);
|
|
294
|
+
const replaces = verbs?.replaces === undefined ? undefined : foldWord(verbs.replaces);
|
|
295
|
+
const suffixes = replacingSuffixes(data ?? {});
|
|
296
|
+
const agreement = agreementOf(grammar?.agreement);
|
|
297
|
+
const participles = participlesOf(grammar?.participles, data ?? {});
|
|
298
|
+
const seen = new Set(words.map((word) => foldWord(word.form)));
|
|
299
|
+
const out = [];
|
|
300
|
+
for (const word of words) {
|
|
301
|
+
const add = (form) => {
|
|
302
|
+
const folded = foldWord(form);
|
|
303
|
+
if (!folded || seen.has(folded)) return;
|
|
304
|
+
seen.add(folded);
|
|
305
|
+
out.push({ form: folded, word });
|
|
306
|
+
};
|
|
307
|
+
const folded = foldWord(word.form);
|
|
308
|
+
const base = parseWordClass(word.class).base;
|
|
309
|
+
const plural = pluralOf(word, grammar);
|
|
310
|
+
if (plural) add(plural);
|
|
311
|
+
if (verbs && base === "v")
|
|
312
|
+
for (const { ending } of verbs.endings)
|
|
313
|
+
add(verbForm(folded, foldWord(ending), replaces));
|
|
314
|
+
const attaching = suffixes.filter(
|
|
315
|
+
({ classes }) => classes === undefined || (base !== undefined && classes.has(base)),
|
|
316
|
+
);
|
|
317
|
+
if (attaching.length)
|
|
318
|
+
for (const [stem] of word.form.matchAll(WORD)) {
|
|
319
|
+
const one = foldWord(stem);
|
|
320
|
+
for (const { suffix, replaces: closing } of attaching)
|
|
321
|
+
add(attach(one, suffix, closing));
|
|
322
|
+
}
|
|
323
|
+
if (agreement && base !== undefined && agreement.classes.has(base))
|
|
324
|
+
for (const form of agreeingForms(folded, agreement, grammar)) add(form);
|
|
325
|
+
if (participles && base === "v")
|
|
326
|
+
for (const participle of participleForms(folded, participles)) {
|
|
327
|
+
add(participle);
|
|
328
|
+
if (agreement)
|
|
329
|
+
for (const form of agreeingForms(participle, agreement, grammar)) add(form);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
return out;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* The words a language note accepts of its own: its forms, and what its rules
|
|
337
|
+
* derive from them (see {@link derivedForms}), each whole and each part of a
|
|
338
|
+
* compound.
|
|
339
|
+
*
|
|
340
|
+
* @param {Record<string, any>} data - The language note's `data`.
|
|
341
|
+
* @returns {Set<string>} The words, folded.
|
|
342
|
+
*/
|
|
343
|
+
export function lexiconWords(data) {
|
|
344
|
+
const accepted = new Set();
|
|
345
|
+
for (const word of listOf(data?.words))
|
|
346
|
+
if (isMap(word) && typeof word.form === "string") addWords(accepted, word.form);
|
|
347
|
+
for (const { form } of derivedForms(data)) addWords(accepted, form);
|
|
348
|
+
return accepted;
|
|
349
|
+
}
|
package/engine/word-terms.mjs
CHANGED
|
@@ -368,6 +368,15 @@ export const AFFIX_FIELDS = Object.freeze([
|
|
|
368
368
|
"What it takes the place of at the close of the stem: `vowel` for any closing " +
|
|
369
369
|
"vowel, or the letters it replaces; added to the whole stem where absent.",
|
|
370
370
|
}),
|
|
371
|
+
Object.freeze({
|
|
372
|
+
name: "attachesTo",
|
|
373
|
+
kind: "list",
|
|
374
|
+
shape: "a list of word classes, such as `[n, adj]`",
|
|
375
|
+
entries: Object.freeze({ kind: "string", shape: "a word class, such as `n`" }),
|
|
376
|
+
describe:
|
|
377
|
+
"The word classes it attaches to, by their base; every class where absent. A " +
|
|
378
|
+
"suffix stating `replaces` derives forms of these classes' words alone.",
|
|
379
|
+
}),
|
|
371
380
|
Object.freeze({
|
|
372
381
|
name: "lengthens",
|
|
373
382
|
kind: "boolean",
|
|
@@ -1378,33 +1378,109 @@ a.tag:hover {
|
|
|
1378
1378
|
}
|
|
1379
1379
|
|
|
1380
1380
|
/* Verse lines are hard breaks in the markup; paragraphs mark stanzas. */
|
|
1381
|
-
.single-content .
|
|
1381
|
+
.single-content .verse {
|
|
1382
1382
|
margin: 1.5rem 0;
|
|
1383
1383
|
line-height: 1.7;
|
|
1384
1384
|
}
|
|
1385
1385
|
|
|
1386
|
-
.single-content .
|
|
1386
|
+
.single-content .verse p {
|
|
1387
1387
|
margin: 0;
|
|
1388
1388
|
}
|
|
1389
1389
|
|
|
1390
|
-
.single-content .
|
|
1390
|
+
.single-content .verse p + p {
|
|
1391
1391
|
margin-top: 1em;
|
|
1392
1392
|
}
|
|
1393
1393
|
|
|
1394
|
-
.single-content .
|
|
1394
|
+
.single-content .verse .verse-line {
|
|
1395
1395
|
display: inline-block;
|
|
1396
|
-
margin-inline-start: var(--
|
|
1396
|
+
margin-inline-start: var(--verse-indent, 0em);
|
|
1397
1397
|
white-space: pre-wrap;
|
|
1398
1398
|
}
|
|
1399
1399
|
|
|
1400
|
-
.single-content .
|
|
1401
|
-
.single-content .
|
|
1402
|
-
.single-content .
|
|
1403
|
-
.single-content .
|
|
1404
|
-
.single-content .
|
|
1405
|
-
.single-content .
|
|
1406
|
-
.single-content .
|
|
1407
|
-
.single-content .
|
|
1400
|
+
.single-content .verse .verse-line.i1 { --verse-indent: 1.25em; }
|
|
1401
|
+
.single-content .verse .verse-line.i2 { --verse-indent: 2.5em; }
|
|
1402
|
+
.single-content .verse .verse-line.i3 { --verse-indent: 3.75em; }
|
|
1403
|
+
.single-content .verse .verse-line.i4 { --verse-indent: 5em; }
|
|
1404
|
+
.single-content .verse .verse-line.i5 { --verse-indent: 6.25em; }
|
|
1405
|
+
.single-content .verse .verse-line.i6 { --verse-indent: 7.5em; }
|
|
1406
|
+
.single-content .verse .verse-line.i7 { --verse-indent: 8.75em; }
|
|
1407
|
+
.single-content .verse .verse-line.i8 { --verse-indent: 10em; }
|
|
1408
|
+
|
|
1409
|
+
/* Numbered verse carries its numbers in a margin. A verse in columns is one
|
|
1410
|
+
grid per verse line: its number, then each column's parts stacked, the
|
|
1411
|
+
first in the normal tone and the rest muted, every part in the body serif
|
|
1412
|
+
unless its face sets it. A narrow verse stacks the columns of each line. */
|
|
1413
|
+
.single-content .verse.verse-numbered {
|
|
1414
|
+
position: relative;
|
|
1415
|
+
padding-inline-start: 3em;
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
.single-content .verse .verse-number {
|
|
1419
|
+
color: var(--text-muted);
|
|
1420
|
+
font-size: 0.8em;
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
.single-content .verse-numbered .verse-line .verse-number {
|
|
1424
|
+
position: absolute;
|
|
1425
|
+
inset-inline-start: 0;
|
|
1426
|
+
width: 2.2em;
|
|
1427
|
+
text-align: end;
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
.single-content .verse.verse-columns {
|
|
1431
|
+
container-type: inline-size;
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
.single-content .verse-numbered.verse-columns {
|
|
1435
|
+
padding-inline-start: 0;
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
.single-content .verse-columns .verse-row {
|
|
1439
|
+
display: grid;
|
|
1440
|
+
grid-auto-flow: column;
|
|
1441
|
+
grid-auto-columns: 1fr;
|
|
1442
|
+
column-gap: 1.5rem;
|
|
1443
|
+
margin-bottom: 0.5em;
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
.single-content .verse-numbered.verse-columns .verse-row {
|
|
1447
|
+
grid-template-columns: 2.5em;
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
.single-content .verse-columns .verse-number {
|
|
1451
|
+
text-align: end;
|
|
1452
|
+
padding-top: 0.2em;
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
.single-content .verse-columns :is(.verse-column, .verse-part) {
|
|
1456
|
+
display: block;
|
|
1457
|
+
white-space: pre-wrap;
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
.single-content .verse-columns .verse-part {
|
|
1461
|
+
font-family: var(--font-body);
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
.single-content .verse-columns .verse-secondary {
|
|
1465
|
+
color: var(--text-muted);
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
.single-content .verse-columns .verse-part[dir="rtl"] {
|
|
1469
|
+
text-align: start;
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
@container (max-width: 32rem) {
|
|
1473
|
+
.single-content .verse-columns .verse-row {
|
|
1474
|
+
grid-auto-flow: row;
|
|
1475
|
+
grid-template-columns: 1fr;
|
|
1476
|
+
}
|
|
1477
|
+
.single-content .verse-numbered.verse-columns .verse-row {
|
|
1478
|
+
grid-template-columns: 2.5em 1fr;
|
|
1479
|
+
}
|
|
1480
|
+
.single-content .verse-numbered.verse-columns .verse-column {
|
|
1481
|
+
grid-column: 2;
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1408
1484
|
|
|
1409
1485
|
/* Ornamental horizontal rule: thin line flanking a centered gold
|
|
1410
1486
|
diamond. Uses gradients on ::before/::after to keep the markup
|
|
@@ -3021,6 +3097,8 @@ summary.info-sidebar-title::after {
|
|
|
3021
3097
|
.single-body .dictionary > div > dd { display:inline; margin:0; font-weight:normal; }
|
|
3022
3098
|
.single-body .dictionary > div > dd > :is(p, ul, ol, dl, blockquote, pre, table, div) { text-indent:0; }
|
|
3023
3099
|
.single-body .dictionary > div > dd > p:first-child { display:inline; }
|
|
3100
|
+
.single-body .dictionary > div > dd + dd { display:block; text-indent:0; }
|
|
3101
|
+
.single-body .dictionary > div > dd + dd > p:first-child { display:block; }
|
|
3024
3102
|
@media (max-width:600px) { .single-body .dictionary { --indent:100px; } }
|
|
3025
3103
|
|
|
3026
3104
|
/* ---- Text in one of the faces ----
|
|
@@ -3028,7 +3106,7 @@ summary.info-sidebar-title::after {
|
|
|
3028
3106
|
right-to-left block or verse reads from the right. `font=serif`, `sans`
|
|
3029
3107
|
and `mono` set the site's own serif and mono, and the reader's sans. */
|
|
3030
3108
|
.script-block { margin:1rem 0; line-height:1.7; }
|
|
3031
|
-
.script-block[dir="rtl"], .
|
|
3109
|
+
.script-block[dir="rtl"], .verse[dir="rtl"] { text-align:start; }
|
|
3032
3110
|
.font-serif { font-family:var(--font-body); }
|
|
3033
3111
|
.font-sans { font-family:sans-serif; }
|
|
3034
3112
|
.font-mono { font-family:var(--font-mono); }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@heroiclands/package-build",
|
|
3
|
-
"version": "23.
|
|
3
|
+
"version": "23.13.0",
|
|
4
4
|
"description": "Shared toolchain for building and shipping a HeroicLands Foundry VTT package — content compilation, manifest, localization, staging, bundle, release and deployment.",
|
|
5
5
|
"license": "GPL-3.0-or-later",
|
|
6
6
|
"type": "module",
|
|
@@ -151,9 +151,9 @@
|
|
|
151
151
|
"fflate": "^0.8.3",
|
|
152
152
|
"flesch": "^2.0.1",
|
|
153
153
|
"glob": "^13.0.6",
|
|
154
|
-
"gray-matter": "^4.0.3",
|
|
155
154
|
"handlebars": "^4.7.10",
|
|
156
155
|
"ignore": "^7.0.12",
|
|
156
|
+
"js-yaml": "^5.5.0",
|
|
157
157
|
"jsonc-parser": "^3.3.1",
|
|
158
158
|
"loglevel": "^1.9.2",
|
|
159
159
|
"loglevel-plugin-prefix": "^0.8.4",
|