@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/map-lexicon.mjs
CHANGED
|
@@ -54,6 +54,7 @@ import { nearest } from "./near-miss.mjs";
|
|
|
54
54
|
import { FONTS_PACKAGE } from "./script-fonts.mjs";
|
|
55
55
|
import { GENERIC_FAMILIES, applyMap } from "./text-maps.mjs";
|
|
56
56
|
import { isLanguageRecord, noteAddressOf } from "./word-rows.mjs";
|
|
57
|
+
import { derivedForms } from "./word-forms.mjs";
|
|
57
58
|
import { LANGUAGE_SUBTYPE, glossText } from "./word-terms.mjs";
|
|
58
59
|
|
|
59
60
|
/** The configuration file a language is declared in. */
|
|
@@ -103,6 +104,9 @@ function said(names) {
|
|
|
103
104
|
* @property {readonly LexiconWord[]} words - Its words, in `data.words` order.
|
|
104
105
|
* @property {ReadonlyMap<string, readonly NativeForm[]>} byForm - The native
|
|
105
106
|
* forms by the word's form, NFC; the first word of a form keeps it.
|
|
107
|
+
* @property {ReadonlyArray<{form: string, from: LexiconWord}>} derived - The
|
|
108
|
+
* forms the language's rules derive from its words, folded, as the mapped-run
|
|
109
|
+
* word check accepts them (see {@link module:engine/word-forms.derivedForms}).
|
|
106
110
|
*/
|
|
107
111
|
|
|
108
112
|
/**
|
|
@@ -134,21 +138,30 @@ function readNatives(value) {
|
|
|
134
138
|
function lexiconOf(language, data) {
|
|
135
139
|
const words = [];
|
|
136
140
|
const byForm = new Map();
|
|
141
|
+
const bySource = new Map();
|
|
137
142
|
for (const word of listOf(data?.words)) {
|
|
138
143
|
if (!isMap(word) || typeof word.form !== "string") continue;
|
|
139
144
|
const natives = readNatives(word.native);
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
);
|
|
145
|
+
const read = Object.freeze({
|
|
146
|
+
form: word.form,
|
|
147
|
+
class: typeof word.class === "string" ? word.class : null,
|
|
148
|
+
gloss: glossText(word.gloss),
|
|
149
|
+
natives,
|
|
150
|
+
});
|
|
151
|
+
words.push(read);
|
|
152
|
+
bySource.set(word, read);
|
|
148
153
|
const key = word.form.normalize("NFC");
|
|
149
154
|
if (!byForm.has(key)) byForm.set(key, natives);
|
|
150
155
|
}
|
|
151
|
-
|
|
156
|
+
const derived = derivedForms(data).map(({ form, word }) =>
|
|
157
|
+
Object.freeze({ form, from: bySource.get(word) }),
|
|
158
|
+
);
|
|
159
|
+
return Object.freeze({
|
|
160
|
+
language,
|
|
161
|
+
words: Object.freeze(words),
|
|
162
|
+
byForm,
|
|
163
|
+
derived: Object.freeze(derived),
|
|
164
|
+
});
|
|
152
165
|
}
|
|
153
166
|
|
|
154
167
|
/**
|
|
@@ -442,15 +455,17 @@ export function transliterate(ctx, keys, text) {
|
|
|
442
455
|
/**
|
|
443
456
|
* Every word of a language's lexicon with its native form under a map: the
|
|
444
457
|
* stated exception, else the rows' spelling, each exactly as the build gives
|
|
445
|
-
* it.
|
|
458
|
+
* it. With `derived`, the forms the language's rules derive follow the words,
|
|
459
|
+
* each case folded and naming the word it comes from in `from`, so the list
|
|
460
|
+
* is what the mapped-run word check accepts of the lexicon.
|
|
446
461
|
*
|
|
447
462
|
* @param {object} ctx - The run context.
|
|
448
|
-
* @param {{lang: string, map: string, font?: string}} keys -
|
|
449
|
-
* map and
|
|
450
|
-
* @returns {{entries: Array<{form: string, native: string, exception: boolean, class: string|null, gloss: string|null}>}|{problem: string, about: string}}
|
|
451
|
-
* The words in `data.words` order, or the problem.
|
|
463
|
+
* @param {{lang: string, map: string, font?: string, derived?: boolean}} keys -
|
|
464
|
+
* The language, the map, the face, and whether to list derived forms.
|
|
465
|
+
* @returns {{entries: Array<{form: string, native: string, exception: boolean, class: string|null, gloss: string|null, from?: string}>}|{problem: string, about: string}}
|
|
466
|
+
* The words in `data.words` order, then any derived forms, or the problem.
|
|
452
467
|
*/
|
|
453
|
-
export function lexiconEntries(ctx, { lang, map, font }) {
|
|
468
|
+
export function lexiconEntries(ctx, { lang, map, font, derived = false }) {
|
|
454
469
|
const found = commandMapping(ctx, { map, font, lang });
|
|
455
470
|
if ("problem" in found) return { problem: found.problem, about: found.about };
|
|
456
471
|
const lexicon = ctx.lexicons?.get(lang);
|
|
@@ -462,9 +477,13 @@ export function lexiconEntries(ctx, { lang, map, font }) {
|
|
|
462
477
|
"in its `note`",
|
|
463
478
|
};
|
|
464
479
|
const { map: textMap, font: face, native } = found.mapping;
|
|
465
|
-
const
|
|
466
|
-
|
|
467
|
-
|
|
480
|
+
const listed = [
|
|
481
|
+
...lexicon.words.map((word) => ({ word, form: word.form, from: undefined })),
|
|
482
|
+
...(derived ?
|
|
483
|
+
lexicon.derived.map((one) => ({ word: one.from, form: one.form, from: one.from.form }))
|
|
484
|
+
: []),
|
|
485
|
+
];
|
|
486
|
+
const mapped = listed.map((one) => applyMap(textMap, one.form, { font: face, native }));
|
|
468
487
|
const unsound = mapped.flatMap((one) => one.pieces).find((one) => one.uncapitalised);
|
|
469
488
|
if (unsound)
|
|
470
489
|
return {
|
|
@@ -477,15 +496,15 @@ export function lexiconEntries(ctx, { lang, map, font }) {
|
|
|
477
496
|
),
|
|
478
497
|
};
|
|
479
498
|
return {
|
|
480
|
-
entries:
|
|
481
|
-
form
|
|
499
|
+
entries: listed.map(({ word, form, from }, i) => ({
|
|
500
|
+
form,
|
|
482
501
|
native: mapped[i].text,
|
|
483
502
|
exception:
|
|
484
|
-
native?.(
|
|
485
|
-
(
|
|
486
|
-
native?.(word.form.toLowerCase()) !== undefined),
|
|
503
|
+
native?.(form) !== undefined ||
|
|
504
|
+
(form.toLowerCase() !== form && native?.(form.toLowerCase()) !== undefined),
|
|
487
505
|
class: word.class,
|
|
488
506
|
gloss: word.gloss,
|
|
507
|
+
...(from === undefined ? {} : { from }),
|
|
489
508
|
})),
|
|
490
509
|
};
|
|
491
510
|
}
|
package/engine/names.mjs
CHANGED
|
@@ -33,6 +33,8 @@ const compare = (a, b) =>
|
|
|
33
33
|
* @property {string} full - Declared full name.
|
|
34
34
|
* @property {true} indexed - The identity belongs to the indexed catalog.
|
|
35
35
|
* @property {boolean} [fullUse] - The label is a full-name form rather than an alias.
|
|
36
|
+
* @property {string} [type] - The note's type, absent on an ambiguous label identity.
|
|
37
|
+
* @property {string|null} [subType] - The note's subtype, or null.
|
|
36
38
|
* @property {NameMatch[]} [candidates] - Indexed identities sharing the label.
|
|
37
39
|
* @property {string} [destination] - Canonical Address used by a resolved link.
|
|
38
40
|
*/
|
|
@@ -40,10 +42,18 @@ const compare = (a, b) =>
|
|
|
40
42
|
/**
|
|
41
43
|
* The prepared name and declared-alias lookup.
|
|
42
44
|
* @typedef {object} NameCatalog
|
|
43
|
-
* @property {(label: string, preferred?: {key: string}) => NameMatch|undefined} recognize - Match a whole visible label.
|
|
45
|
+
* @property {(label: string, preferred?: {key: string}, kind?: NameKind) => NameMatch|undefined} recognize - Match a whole visible label, among notes of `kind` when it supplies a type.
|
|
44
46
|
* @property {(target: string) => NameMatch|undefined} resolve - Resolve an Address target without its anchor to its indexed identity.
|
|
45
47
|
*/
|
|
46
48
|
|
|
49
|
+
/**
|
|
50
|
+
* What a typed `.name` span says the named thing is. A null type matches any note;
|
|
51
|
+
* a type matches only notes of that type, and a subtype only notes of that subtype.
|
|
52
|
+
* @typedef {object} NameKind
|
|
53
|
+
* @property {string|null} type - Supplied type, or null.
|
|
54
|
+
* @property {string|null} subType - Supplied subtype, or null.
|
|
55
|
+
*/
|
|
56
|
+
|
|
47
57
|
/**
|
|
48
58
|
* One captured marked-name occurrence.
|
|
49
59
|
* @typedef {object} MarkedName
|
|
@@ -150,10 +160,16 @@ export function createNameCatalog({ records, index }) {
|
|
|
150
160
|
const names = new Map(),
|
|
151
161
|
byFile = new Map(),
|
|
152
162
|
entries = new Map();
|
|
153
|
-
const addName = (key, full, aliases
|
|
154
|
-
const entry = entries.get(key) ?? {
|
|
163
|
+
const addName = (key, full, aliases, type, subType) => {
|
|
164
|
+
const entry = entries.get(key) ?? {
|
|
165
|
+
key,
|
|
166
|
+
full,
|
|
167
|
+
indexed: true,
|
|
168
|
+
type,
|
|
169
|
+
subType: typeof subType === "string" && subType ? subType : null,
|
|
170
|
+
};
|
|
155
171
|
entries.set(key, entry);
|
|
156
|
-
for (const text of [full, ...aliases]) {
|
|
172
|
+
for (const text of [full, ...(aliases ?? [])]) {
|
|
157
173
|
if (typeof text !== "string" || !text.trim()) continue;
|
|
158
174
|
for (const spelling of nameForms(text)) {
|
|
159
175
|
if (!names.has(spelling)) names.set(spelling, new Map());
|
|
@@ -173,7 +189,13 @@ export function createNameCatalog({ records, index }) {
|
|
|
173
189
|
const full = record.name?.full;
|
|
174
190
|
if (!parts || parts.type === FOLDER_TYPE || typeof full !== "string") continue;
|
|
175
191
|
const page = canonicalKey(parts.package, "note", parts.type, parts.shortcode);
|
|
176
|
-
const entry = addName(
|
|
192
|
+
const entry = addName(
|
|
193
|
+
index.resolve(page) ? page : canonical,
|
|
194
|
+
full,
|
|
195
|
+
record.name.aliases,
|
|
196
|
+
parts.type,
|
|
197
|
+
record.subType,
|
|
198
|
+
);
|
|
177
199
|
entries.set(canonical, entry);
|
|
178
200
|
if (index.resolve(page)) entries.set(page, entry);
|
|
179
201
|
if (record.file?.path) byFile.set(record.file.path, entry);
|
|
@@ -189,11 +211,16 @@ export function createNameCatalog({ records, index }) {
|
|
|
189
211
|
continue;
|
|
190
212
|
const page = canonicalKey(parts.package, "note", parts.type, parts.shortcode);
|
|
191
213
|
const key = index.foreign.index.has(page) ? page : canonical;
|
|
192
|
-
entries.set(canonical, addName(key, hit.name, hit.aliases));
|
|
214
|
+
entries.set(canonical, addName(key, hit.name, hit.aliases, parts.type, hit.subType));
|
|
193
215
|
}
|
|
194
216
|
return {
|
|
195
|
-
recognize(label, preferred) {
|
|
196
|
-
const candidates = [...(names.get(normalizeName(label))?.values() ?? [])]
|
|
217
|
+
recognize(label, preferred, kind) {
|
|
218
|
+
const candidates = [...(names.get(normalizeName(label))?.values() ?? [])].filter(
|
|
219
|
+
(entry) =>
|
|
220
|
+
!kind?.type ||
|
|
221
|
+
(entry.type === kind.type &&
|
|
222
|
+
(kind.subType == null || entry.subType === kind.subType)),
|
|
223
|
+
);
|
|
197
224
|
const target =
|
|
198
225
|
candidates.find((entry) => entry.key === preferred?.key) ??
|
|
199
226
|
(candidates.length === 1 ? candidates[0] : undefined);
|
|
@@ -268,7 +295,7 @@ export function collectUnwrittenNames({ records, catalog }) {
|
|
|
268
295
|
const kinds = new Map();
|
|
269
296
|
for (const record of records.filter(isNoteRecord)) {
|
|
270
297
|
for (const occurrence of record.names ?? []) {
|
|
271
|
-
if (catalog.recognize(occurrence.name)) continue;
|
|
298
|
+
if (catalog.recognize(occurrence.name, undefined, occurrence)) continue;
|
|
272
299
|
const type = occurrence.type,
|
|
273
300
|
subType = occurrence.subType,
|
|
274
301
|
kind = JSON.stringify([type, subType]);
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
|
|
3
|
+
* Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
|
|
4
|
+
*
|
|
5
|
+
* This work is licensed under the GNU General Public License v3.0 (GPLv3).
|
|
6
|
+
* You may copy, modify, and distribute it under the terms of that license.
|
|
7
|
+
*
|
|
8
|
+
* For full terms, see the LICENSE.md file in the project root or visit:
|
|
9
|
+
* https://www.gnu.org/licenses/gpl-3.0.html
|
|
10
|
+
*
|
|
11
|
+
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* **A page's frontmatter, read and written.** Reading splits and parses a note
|
|
16
|
+
* exactly as the content index does ({@link splitFrontmatter} and `yaml`), so a
|
|
17
|
+
* value reads the same on the site and in the preview as in a compendium pack.
|
|
18
|
+
* Writing serializes with `js-yaml`, which quotes every string a YAML 1.1
|
|
19
|
+
* reader would take for a boolean, a number or a timestamp, so Hugo reads each
|
|
20
|
+
* value back as the string or number it was.
|
|
21
|
+
*
|
|
22
|
+
* @module
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import YAML from "yaml";
|
|
26
|
+
import { dump } from "js-yaml";
|
|
27
|
+
import { splitFrontmatter } from "./diagnostics.mjs";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A page split at its frontmatter.
|
|
31
|
+
*
|
|
32
|
+
* @typedef {object} NoteMatter
|
|
33
|
+
* @property {unknown} data - The parsed frontmatter; an empty map where the
|
|
34
|
+
* page has none or the block is empty.
|
|
35
|
+
* @property {string} content - The body after the closing fence and its line
|
|
36
|
+
* break, or the whole text where there is no frontmatter.
|
|
37
|
+
* @property {string} matter - The YAML source between the fences; empty where
|
|
38
|
+
* there is no frontmatter.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Split a page's frontmatter from its body and parse it.
|
|
43
|
+
*
|
|
44
|
+
* @param {string} source - The complete Markdown source.
|
|
45
|
+
* @returns {NoteMatter} The parsed frontmatter, the body and the YAML source.
|
|
46
|
+
* @throws {Error} The `yaml` parse error, where the frontmatter is not YAML.
|
|
47
|
+
*/
|
|
48
|
+
export function readMatter(source) {
|
|
49
|
+
const fence = splitFrontmatter(source);
|
|
50
|
+
if (!fence) return { data: {}, content: source, matter: "" };
|
|
51
|
+
return { data: YAML.parse(fence.yaml) ?? {}, content: fence.body, matter: fence.yaml };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* A page with `data` as its frontmatter above `content`.
|
|
56
|
+
*
|
|
57
|
+
* @param {string} content - The body.
|
|
58
|
+
* @param {Record<string, unknown>} data - The frontmatter.
|
|
59
|
+
* @returns {string} The fenced YAML, then the body closed on a line break; the
|
|
60
|
+
* body alone where `data` serializes to an empty map.
|
|
61
|
+
*/
|
|
62
|
+
export function writeMatter(content, data) {
|
|
63
|
+
const yaml = dump(data).trim();
|
|
64
|
+
const body = content.endsWith("\n") ? content : `${content}\n`;
|
|
65
|
+
return yaml === "{}" ? body : `---\n${yaml}\n---\n${body}`;
|
|
66
|
+
}
|
package/engine/pdf-build.mjs
CHANGED
|
@@ -980,6 +980,7 @@ async function prepareWorld(resolved, { contentBase, findings, resolveReplacemen
|
|
|
980
980
|
dir,
|
|
981
981
|
dependencies: await cachedDependencies(resolved),
|
|
982
982
|
audience: "public",
|
|
983
|
+
contentBase,
|
|
983
984
|
});
|
|
984
985
|
|
|
985
986
|
// Pages, indexed and gated exactly as the site does. A book of a tree whose
|
package/engine/pdf-render.mjs
CHANGED
|
@@ -94,7 +94,16 @@ import { spanMarkdownPlugin, scanSpans } from "./content-spans.mjs";
|
|
|
94
94
|
import { GENERIC_FONTS, renderRuns, scanRuns } from "./content-scripts.mjs";
|
|
95
95
|
import { scanFigures } from "./content-figures.mjs";
|
|
96
96
|
import { BLOCK_CONTAINERS, scanBlocks, BLOCK_NAMES } from "./content-blocks.mjs";
|
|
97
|
-
import {
|
|
97
|
+
import {
|
|
98
|
+
COLUMNS_CLASS,
|
|
99
|
+
STANZA_BREAK,
|
|
100
|
+
inlineVerse,
|
|
101
|
+
lineNumber,
|
|
102
|
+
rightToLeft,
|
|
103
|
+
scanVerse,
|
|
104
|
+
verseColumns,
|
|
105
|
+
verseStanzas,
|
|
106
|
+
} from "./content-verse.mjs";
|
|
98
107
|
import { matchAllOutsideCode, replaceOutsideCode } from "./code-fences.mjs";
|
|
99
108
|
import { HTML_TAG, htmlMessage } from "./content-html.mjs";
|
|
100
109
|
import { EXPRESSION } from "./markdown-expressions.mjs";
|
|
@@ -205,12 +214,12 @@ export function createParser(registry) {
|
|
|
205
214
|
md.use(spanMarkdownPlugin);
|
|
206
215
|
// Verse is held inside a literal fence. Parse its inline content before
|
|
207
216
|
// footnote_tail collects definitions, using the surrounding note's env.
|
|
208
|
-
md.core.ruler.after("inline", "
|
|
217
|
+
md.core.ruler.after("inline", "verse_inline", (state) => {
|
|
209
218
|
for (const token of state.tokens) {
|
|
210
|
-
if (token.type !== "fence" || !/^
|
|
219
|
+
if (token.type !== "fence" || !/^verse(?:\s|$)/.test(token.info?.trim() || ""))
|
|
211
220
|
continue;
|
|
212
221
|
token.meta ??= {};
|
|
213
|
-
token.meta.
|
|
222
|
+
token.meta.verseStanzas = verseStanzas(token.content).map((stanza) =>
|
|
214
223
|
stanza.map(({ text, level }) => {
|
|
215
224
|
const children = [];
|
|
216
225
|
state.md.inline.parse(inlineVerse(text), state.md, state.env, children);
|
|
@@ -772,20 +781,24 @@ function renderBlock(tokens, i, out, ctx) {
|
|
|
772
781
|
}
|
|
773
782
|
case "fence":
|
|
774
783
|
case "code_block": {
|
|
775
|
-
if (token.type === "fence" && /^
|
|
776
|
-
const
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
784
|
+
if (token.type === "fence" && /^verse(?:\s|$)/.test(token.info?.trim() || "")) {
|
|
785
|
+
const opening = `${token.markup}${token.info}`;
|
|
786
|
+
const poem = scanVerse(`${opening}\n${token.content}${token.markup}`).blocks[0];
|
|
787
|
+
const step = Number(poem?.attributes.numstep ?? 0) || 0;
|
|
788
|
+
const content =
|
|
789
|
+
poem?.classes.includes(COLUMNS_CLASS) ?
|
|
790
|
+
verseGrid(token, poem, opening, step, ctx)
|
|
791
|
+
: step ? numberedVerse(token, step, ctx)
|
|
792
|
+
: token.meta.verseStanzas
|
|
793
|
+
.map((stanza) =>
|
|
794
|
+
stanza
|
|
795
|
+
.map(({ children, level }) => {
|
|
796
|
+
const verse = renderInline({ children }, ctx);
|
|
797
|
+
return `${level ? `#h(${level * 1.25}em)` : ""}${verse}`;
|
|
798
|
+
})
|
|
799
|
+
.join(" \\\n"),
|
|
800
|
+
)
|
|
801
|
+
.join("\n\n");
|
|
789
802
|
const anchor = poem?.id ? ` <${sectionLabel(ctx.anchorPrefix, poem.id)}>` : "";
|
|
790
803
|
out.push(
|
|
791
804
|
`\n#block(width: 100%)[${scriptWrapped(content, poem?.classes, poem?.attributes)}]${anchor}\n`,
|
|
@@ -1251,6 +1264,82 @@ function typstLanguage(tag) {
|
|
|
1251
1264
|
/** The classes that set a run in a font: a face's run carries `script`. */
|
|
1252
1265
|
const FONT_CLASSES = Object.freeze(["script", ...Object.values(GENERIC_FONTS)]);
|
|
1253
1266
|
|
|
1267
|
+
/** A line number in the book: muted, as on the page. */
|
|
1268
|
+
const typstNumber = (number) => (number ? `[#text(fill: luma(45%))[${number}]]` : "[]");
|
|
1269
|
+
|
|
1270
|
+
/**
|
|
1271
|
+
* A numbered verse without columns: a grid of each line's number and the
|
|
1272
|
+
* line, with a row of space between stanzas.
|
|
1273
|
+
*
|
|
1274
|
+
* @param {object} token - The verse's fence token.
|
|
1275
|
+
* @param {number} step - Its `numstep`.
|
|
1276
|
+
* @param {object} ctx - Render context.
|
|
1277
|
+
* @returns {string} Typst markup.
|
|
1278
|
+
*/
|
|
1279
|
+
function numberedVerse(token, step, ctx) {
|
|
1280
|
+
let count = 0;
|
|
1281
|
+
const stanzas = token.meta.verseStanzas.map((stanza) =>
|
|
1282
|
+
stanza
|
|
1283
|
+
.map(({ children, level }) => {
|
|
1284
|
+
const verse = renderInline({ children }, ctx);
|
|
1285
|
+
return ` ${typstNumber(lineNumber(++count, step))}, [${level ? `#h(${level * 1.25}em)` : ""}${verse}],`;
|
|
1286
|
+
})
|
|
1287
|
+
.join("\n"),
|
|
1288
|
+
);
|
|
1289
|
+
return [
|
|
1290
|
+
"#grid(columns: (2.5em, 1fr), column-gutter: 1em, row-gutter: 0.35em,",
|
|
1291
|
+
stanzas.join("\n grid.cell(colspan: 2)[#v(0.5em)],\n"),
|
|
1292
|
+
")",
|
|
1293
|
+
].join("\n");
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
/**
|
|
1297
|
+
* A verse in columns: a grid with a row per verse line, its number first when
|
|
1298
|
+
* the verse is numbered, then each column's entries stacked, the first in the
|
|
1299
|
+
* normal tone and each later one grey. A part whose text runs right to left
|
|
1300
|
+
* is set right to left inside its cell, and the grid keeps the page's
|
|
1301
|
+
* direction, so the columns stay in order. A `***` line is a row of space
|
|
1302
|
+
* across the grid.
|
|
1303
|
+
*
|
|
1304
|
+
* @param {object} token - The expanded verse's fence token.
|
|
1305
|
+
* @param {object} poem - The verse, from `scanVerse`.
|
|
1306
|
+
* @param {string} opening - Its opening line.
|
|
1307
|
+
* @param {number} step - Its `numstep`.
|
|
1308
|
+
* @param {object} ctx - Render context.
|
|
1309
|
+
* @returns {string} Typst markup.
|
|
1310
|
+
*/
|
|
1311
|
+
function verseGrid(token, poem, opening, step, ctx) {
|
|
1312
|
+
const counts = verseColumns(poem, opening).map((column) => column.entries.length);
|
|
1313
|
+
const width = counts.length + (step ? 1 : 0);
|
|
1314
|
+
const columns = [...(step ? ["2.5em"] : []), ...counts.map(() => "1fr")];
|
|
1315
|
+
const raw = verseStanzas(token.content);
|
|
1316
|
+
const rows = [];
|
|
1317
|
+
let count = 0;
|
|
1318
|
+
token.meta.verseStanzas.forEach((group, at) => {
|
|
1319
|
+
if (raw[at].length === 1 && STANZA_BREAK.test(raw[at][0].text)) {
|
|
1320
|
+
rows.push(` grid.cell(colspan: ${width})[#v(0.5em)],`);
|
|
1321
|
+
return;
|
|
1322
|
+
}
|
|
1323
|
+
const parts = group.map(({ children }, k) => {
|
|
1324
|
+
const part = renderInline({ children }, ctx);
|
|
1325
|
+
return rightToLeft(raw[at][k].text) ? `#set text(dir: rtl)\n${part}` : part;
|
|
1326
|
+
});
|
|
1327
|
+
let next = 0;
|
|
1328
|
+
const cells = counts.map((n) => {
|
|
1329
|
+
const stacked = parts.slice(next, (next += n));
|
|
1330
|
+
if (stacked.length === 1) return `[${stacked[0]}]`;
|
|
1331
|
+
return `[#stack(dir: ttb, spacing: 0.3em, ${stacked.map((part, k) => (k ? `[#text(fill: luma(45%))[${part}]]` : `[${part}]`)).join(", ")})]`;
|
|
1332
|
+
});
|
|
1333
|
+
const number = step ? [typstNumber(lineNumber(++count, step))] : [];
|
|
1334
|
+
rows.push(` ${[...number, ...cells].join(", ")},`);
|
|
1335
|
+
});
|
|
1336
|
+
return [
|
|
1337
|
+
`#grid(columns: (${columns.join(", ")}${columns.length === 1 ? "," : ""}), column-gutter: 1.5em, row-gutter: 0.65em,`,
|
|
1338
|
+
...rows,
|
|
1339
|
+
")",
|
|
1340
|
+
].join("\n");
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1254
1343
|
/**
|
|
1255
1344
|
* Wrap a block's Typst in its font, its language and its direction.
|
|
1256
1345
|
*
|
|
@@ -0,0 +1,29 @@
|
|
|
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
|
+
* Reads standard input to its end as UTF-8 text.
|
|
16
|
+
*
|
|
17
|
+
* The stream is read asynchronously, so a pipe whose writer is slower than the
|
|
18
|
+
* reader, or a non-blocking descriptor, delivers the whole input instead of
|
|
19
|
+
* failing with `EAGAIN`.
|
|
20
|
+
*
|
|
21
|
+
* @param {NodeJS.ReadableStream} [stream] The stream to drain; standard input by default.
|
|
22
|
+
* @returns {Promise<string>} The complete input.
|
|
23
|
+
*/
|
|
24
|
+
export async function readStdin(stream = process.stdin) {
|
|
25
|
+
stream.setEncoding("utf8");
|
|
26
|
+
let text = "";
|
|
27
|
+
for await (const chunk of stream) text += chunk;
|
|
28
|
+
return text;
|
|
29
|
+
}
|
package/engine/run-rows.mjs
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
|
-
* Spans, divs and
|
|
15
|
+
* Spans, divs and verse fences marked with `lang=`, `font=` or `map=`, projected
|
|
16
16
|
* from own-note index records into the SQL `runs` relation. Every value is a
|
|
17
17
|
* column value and none is a key, so two notes writing one tag in different
|
|
18
18
|
* case are two rows. Documentation records and assets contribute no rows.
|
|
@@ -41,7 +41,7 @@ export const RUN_COLUMNS = Object.freeze([
|
|
|
41
41
|
* One run as an index record carries it.
|
|
42
42
|
*
|
|
43
43
|
* @typedef {object} RecordRun
|
|
44
|
-
* @property {"span"|"block"|"
|
|
44
|
+
* @property {"span"|"block"|"verse"} kind - The construct.
|
|
45
45
|
* @property {string|null} lang - The language it is set in, as written: its
|
|
46
46
|
* own `lang=`, else the one the div or fence around it states.
|
|
47
47
|
* @property {string|null} tag - The BCP 47 tag it resolves to, or null.
|
package/engine/run-words.mjs
CHANGED
|
@@ -21,16 +21,9 @@
|
|
|
21
21
|
* - a word of a name in the language note's own `data.register` whose `tongue`
|
|
22
22
|
* is not `translation`, so a foreign name the run uses is listed there first,
|
|
23
23
|
* as `other:<tongue>` or under another tag the note declares;
|
|
24
|
-
* - a form one rule the language states derives from a lexicon form:
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* which takes the place of a closing `data.grammar.verbs.replaces` where
|
|
28
|
-
* the pattern states one (see {@link module:engine/verb-pattern.verbForm});
|
|
29
|
-
* - a suffix of `data.affixes` that states `replaces`, or its `front` form:
|
|
30
|
-
* `vowel` takes the place of a closing vowel (a letter whose base is `a`,
|
|
31
|
-
* `e`, `i`, `o` or `u`), letters take the place of those letters, and a
|
|
32
|
-
* stem closing otherwise takes the suffix whole. An affix that states no
|
|
33
|
-
* `replaces` derives nothing.
|
|
24
|
+
* - a form one rule the language states derives from a lexicon form: a noun
|
|
25
|
+
* plural, a verb's present form, a replacing suffix, an agreeing form or a
|
|
26
|
+
* participle (see {@link module:engine/word-forms.derivedForms}).
|
|
34
27
|
*
|
|
35
28
|
* A word not accepted whole is split at its hyphens and en dashes, and each
|
|
36
29
|
* part is checked. An apostrophe joins a word, so `k'aralzon` is one word, and
|
|
@@ -42,11 +35,9 @@
|
|
|
42
35
|
* @module
|
|
43
36
|
*/
|
|
44
37
|
|
|
45
|
-
import { pluralOf } from "./dictionary-rows.mjs";
|
|
46
38
|
import { languageRecordsByName } from "./map-lexicon.mjs";
|
|
47
39
|
import { RESERVED_TONGUE } from "./language-terms.mjs";
|
|
48
|
-
import {
|
|
49
|
-
import { parseWordClass } from "./word-classes.mjs";
|
|
40
|
+
import { addWords, foldWord, lexiconWords } from "./word-forms.mjs";
|
|
50
41
|
import { WORD } from "./word-pattern.mjs";
|
|
51
42
|
|
|
52
43
|
/** Whether a value is a plain map. */
|
|
@@ -61,104 +52,6 @@ const JOINS = /[–-]/u;
|
|
|
61
52
|
/** A letter, which a checked word holds at least one of. */
|
|
62
53
|
const LETTER = /\p{L}/u;
|
|
63
54
|
|
|
64
|
-
/**
|
|
65
|
-
* A word as it is compared: NFC, lowercase, `’` read as `'`.
|
|
66
|
-
*
|
|
67
|
-
* @param {string} word - The word.
|
|
68
|
-
* @returns {string} Its folded form.
|
|
69
|
-
*/
|
|
70
|
-
export function foldWord(word) {
|
|
71
|
-
return word.normalize("NFC").toLowerCase().replace(/’/gu, "'");
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/** Whether a character is a vowel: its base letter is a, e, i, o or u. */
|
|
75
|
-
const isVowel = (char) => /^[aeiou]/iu.test(char.normalize("NFD"));
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* Add a text's words to a set, each whole and each part of a compound.
|
|
79
|
-
*
|
|
80
|
-
* @param {Set<string>} set - The set.
|
|
81
|
-
* @param {string} text - A form or a name.
|
|
82
|
-
*/
|
|
83
|
-
function addWords(set, text) {
|
|
84
|
-
for (const [word] of text.matchAll(WORD)) {
|
|
85
|
-
const folded = foldWord(word);
|
|
86
|
-
set.add(folded);
|
|
87
|
-
for (const part of folded.split(JOINS)) if (part) set.add(part);
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
* The form a suffix stating `replaces` makes of a stem.
|
|
93
|
-
*
|
|
94
|
-
* @param {string} stem - The stem, folded.
|
|
95
|
-
* @param {string} suffix - The suffix, folded, with no hyphen.
|
|
96
|
-
* @param {string} replaces - The affix's `replaces`.
|
|
97
|
-
* @returns {string} The derived form.
|
|
98
|
-
*/
|
|
99
|
-
function attach(stem, suffix, replaces) {
|
|
100
|
-
if (replaces === "vowel") {
|
|
101
|
-
const last = Array.from(stem).at(-1);
|
|
102
|
-
return last !== undefined && isVowel(last) ?
|
|
103
|
-
stem.slice(0, -last.length) + suffix
|
|
104
|
-
: stem + suffix;
|
|
105
|
-
}
|
|
106
|
-
return replaceClosing(stem, suffix, foldWord(replaces));
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
/**
|
|
110
|
-
* The suffixes a language's affixes state `replaces` for: each `form`, and
|
|
111
|
-
* its `front` form, written `-x`.
|
|
112
|
-
*
|
|
113
|
-
* @param {Record<string, any>} data - The language note's `data`.
|
|
114
|
-
* @returns {Array<{suffix: string, replaces: string}>} The suffixes, folded.
|
|
115
|
-
*/
|
|
116
|
-
function replacingSuffixes(data) {
|
|
117
|
-
const out = [];
|
|
118
|
-
for (const affix of listOf(data.affixes)) {
|
|
119
|
-
if (!isMap(affix) || typeof affix.replaces !== "string") continue;
|
|
120
|
-
for (const written of [affix.form, affix.front]) {
|
|
121
|
-
if (typeof written !== "string") continue;
|
|
122
|
-
const form = foldWord(written.trim());
|
|
123
|
-
if (!/^[-–]/u.test(form) || /[-–]$/u.test(form)) continue;
|
|
124
|
-
const suffix = form.replace(/^[-–]+/u, "");
|
|
125
|
-
if (suffix) out.push({ suffix, replaces: affix.replaces });
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
return out;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/**
|
|
132
|
-
* The words a language note accepts of its own: its forms, and what its
|
|
133
|
-
* plural rows, verb pattern and replacing suffixes derive from them.
|
|
134
|
-
*
|
|
135
|
-
* @param {Record<string, any>} data - The language note's `data`.
|
|
136
|
-
* @returns {Set<string>} The words, folded.
|
|
137
|
-
*/
|
|
138
|
-
export function lexiconWords(data) {
|
|
139
|
-
const accepted = new Set();
|
|
140
|
-
const grammar = isMap(data?.grammar) ? data.grammar : undefined;
|
|
141
|
-
const verbs = verbPattern(grammar?.verbs);
|
|
142
|
-
const replaces = verbs?.replaces === undefined ? undefined : foldWord(verbs.replaces);
|
|
143
|
-
const suffixes = replacingSuffixes(data ?? {});
|
|
144
|
-
for (const word of listOf(data?.words)) {
|
|
145
|
-
if (!isMap(word) || typeof word.form !== "string") continue;
|
|
146
|
-
addWords(accepted, word.form);
|
|
147
|
-
const plural = pluralOf(word, grammar);
|
|
148
|
-
if (plural) addWords(accepted, plural);
|
|
149
|
-
if (verbs && parseWordClass(word.class).base === "v")
|
|
150
|
-
for (const { ending } of verbs.endings)
|
|
151
|
-
addWords(accepted, verbForm(foldWord(word.form), foldWord(ending), replaces));
|
|
152
|
-
if (suffixes.length)
|
|
153
|
-
for (const [stem] of word.form.matchAll(WORD)) {
|
|
154
|
-
const folded = foldWord(stem);
|
|
155
|
-
for (const { suffix, replaces } of suffixes)
|
|
156
|
-
accepted.add(attach(folded, suffix, replaces));
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
return accepted;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
55
|
/**
|
|
163
56
|
* The words of every name a language note registers under a tongue other than
|
|
164
57
|
* `translation`.
|
package/engine/site-build.mjs
CHANGED
|
@@ -57,7 +57,7 @@ import { decodeNoteAddresses, noteAddressContext, encodeAddresses } from "./note
|
|
|
57
57
|
import fs from "node:fs";
|
|
58
58
|
import path from "node:path";
|
|
59
59
|
import { createRequire } from "node:module";
|
|
60
|
-
import
|
|
60
|
+
import { readMatter, writeMatter } from "./note-matter.mjs";
|
|
61
61
|
|
|
62
62
|
import { addressSlug } from "./content-address.mjs";
|
|
63
63
|
import { protectCode } from "./code-fences.mjs";
|
|
@@ -166,7 +166,7 @@ function readNote(file, ctx) {
|
|
|
166
166
|
let source;
|
|
167
167
|
try {
|
|
168
168
|
source = fs.readFileSync(file, "utf8");
|
|
169
|
-
parsed =
|
|
169
|
+
parsed = readMatter(source);
|
|
170
170
|
} catch {
|
|
171
171
|
return null;
|
|
172
172
|
}
|
|
@@ -401,7 +401,7 @@ export function writeHomepages(outRoot, pages, config, { related, records, forei
|
|
|
401
401
|
resolveArtFields(data, webSrc, artSrc);
|
|
402
402
|
const dest = path.join(outRoot, HOMEPAGE_DESTINATION);
|
|
403
403
|
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
404
|
-
fs.writeFileSync(dest,
|
|
404
|
+
fs.writeFileSync(dest, writeMatter(page.body, encodeAddresses(data)));
|
|
405
405
|
}
|
|
406
406
|
return pages.length;
|
|
407
407
|
}
|
|
@@ -1133,7 +1133,7 @@ export function renderPages(pages, options) {
|
|
|
1133
1133
|
if (write) {
|
|
1134
1134
|
const dest = path.join(outRoot, pageDestination(page));
|
|
1135
1135
|
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
1136
|
-
fs.writeFileSync(dest,
|
|
1136
|
+
fs.writeFileSync(dest, writeMatter(body, encodeAddresses(data)));
|
|
1137
1137
|
}
|
|
1138
1138
|
if (outputs) outputs.set(page.file, { markdown: body, frontmatter: data });
|
|
1139
1139
|
byKind[page.kind] = (byKind[page.kind] ?? 0) + 1;
|