@heroiclands/package-build 23.12.7 → 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.
Files changed (76) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/README.md +1 -1
  3. package/assets/preview/gitea.ini +2 -2
  4. package/assets/styles/content-base.css +143 -23
  5. package/bin/content-commands.mjs +15 -4
  6. package/bin/package-build-preview.mjs +2 -2
  7. package/docs/api.md +18 -18
  8. package/docs/authoring/assets.md +1 -1
  9. package/docs/authoring/links-and-markup.md +156 -22
  10. package/docs/commands.md +32 -10
  11. package/docs/configuration.md +8 -1
  12. package/docs/content-format.md +3 -3
  13. package/docs/diagnostics.md +42 -0
  14. package/docs/reference/format-details.md +105 -22
  15. package/docs/reference/note-types.md +8 -1
  16. package/docs/reference/theme-pages.md +51 -0
  17. package/docs/source-preview.md +1 -1
  18. package/e2e.mjs +1 -1
  19. package/engine/anchors.mjs +2 -2
  20. package/engine/asset-shipping.mjs +22 -8
  21. package/engine/code-fences.mjs +3 -3
  22. package/engine/content-blocks.mjs +3 -3
  23. package/engine/content-figures.mjs +5 -5
  24. package/engine/content-index.mjs +1 -1
  25. package/engine/content-scripts.mjs +55 -21
  26. package/engine/content-spans.mjs +2 -2
  27. package/engine/content-verse.mjs +750 -0
  28. package/engine/dictionary-rows.mjs +15 -4
  29. package/engine/field-reference.mjs +4 -4
  30. package/engine/first-use.mjs +17 -27
  31. package/engine/key-case-collisions.mjs +136 -0
  32. package/engine/language-data.mjs +203 -6
  33. package/engine/language-terms.mjs +74 -5
  34. package/engine/map-lexicon.mjs +42 -23
  35. package/engine/names.mjs +36 -9
  36. package/engine/note-matter.mjs +66 -0
  37. package/engine/pdf-build.mjs +1 -0
  38. package/engine/pdf-render.mjs +107 -18
  39. package/engine/read-stdin.mjs +29 -0
  40. package/engine/run-rows.mjs +2 -2
  41. package/engine/run-words.mjs +4 -111
  42. package/engine/site-build.mjs +4 -4
  43. package/engine/site-preview.mjs +14 -14
  44. package/engine/sql-tables.mjs +20 -3
  45. package/engine/web-wikilinks.mjs +1 -1
  46. package/engine/word-forms.mjs +349 -0
  47. package/engine/word-terms.mjs +9 -0
  48. package/hugo-theme/static/css/style.css +92 -14
  49. package/package.json +3 -3
  50. package/types/e2e.d.mts +2 -2
  51. package/types/engine/address-fields.d.mts +1 -1
  52. package/types/engine/address-values.d.mts +1 -1
  53. package/types/engine/asset-shipping.d.mts +5 -0
  54. package/types/engine/code-fences.d.mts +3 -3
  55. package/types/engine/content-format-check.d.mts +1 -1
  56. package/types/engine/content-scripts.d.mts +8 -4
  57. package/types/engine/content-verse.d.mts +240 -0
  58. package/types/engine/dictionary-rows.d.mts +10 -0
  59. package/types/engine/field-reference.d.mts +1 -1
  60. package/types/engine/key-case-collisions.d.mts +28 -0
  61. package/types/engine/language-data.d.mts +0 -2
  62. package/types/engine/map-lexicon.d.mts +19 -6
  63. package/types/engine/names.d.mts +34 -3
  64. package/types/engine/note-matter.d.mts +48 -0
  65. package/types/engine/note-vocabulary.d.mts +1 -1
  66. package/types/engine/pdf-coverage.d.mts +1 -1
  67. package/types/engine/read-stdin.d.mts +11 -0
  68. package/types/engine/run-rows.d.mts +2 -2
  69. package/types/engine/run-words.d.mts +0 -15
  70. package/types/engine/sql-tables.d.mts +4 -1
  71. package/types/engine/text-maps.d.mts +1 -1
  72. package/types/engine/web-wikilinks.d.mts +1 -1
  73. package/types/engine/word-forms.d.mts +82 -0
  74. package/types/release.d.mts +1 -1
  75. package/engine/content-poetry.mjs +0 -240
  76. package/types/engine/content-poetry.d.mts +0 -58
@@ -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
- words.push(
141
- Object.freeze({
142
- form: word.form,
143
- class: typeof word.class === "string" ? word.class : null,
144
- gloss: glossText(word.gloss),
145
- natives,
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
- return Object.freeze({ language, words: Object.freeze(words), byForm });
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 - The language, the
449
- * map and the face.
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 mapped = lexicon.words.map((word) =>
466
- applyMap(textMap, word.form, { font: face, native }),
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: lexicon.words.map((word, i) => ({
481
- form: word.form,
499
+ entries: listed.map(({ word, form, from }, i) => ({
500
+ form,
482
501
  native: mapped[i].text,
483
502
  exception:
484
- native?.(word.form) !== undefined ||
485
- (word.form.toLowerCase() !== word.form &&
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) ?? { key, full, indexed: true };
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(index.resolve(page) ? page : canonical, full, record.name.aliases);
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
+ }
@@ -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
@@ -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 { inlineVerse, poetryStanzas, scanPoetry } from "./content-poetry.mjs";
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", "poetry_inline", (state) => {
217
+ md.core.ruler.after("inline", "verse_inline", (state) => {
209
218
  for (const token of state.tokens) {
210
- if (token.type !== "fence" || !/^poetry(?:\s|$)/.test(token.info?.trim() || ""))
219
+ if (token.type !== "fence" || !/^verse(?:\s|$)/.test(token.info?.trim() || ""))
211
220
  continue;
212
221
  token.meta ??= {};
213
- token.meta.poetryStanzas = poetryStanzas(token.content).map((stanza) =>
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" && /^poetry(?:\s|$)/.test(token.info?.trim() || "")) {
776
- const content = token.meta.poetryStanzas
777
- .map((stanza) =>
778
- stanza
779
- .map(({ children, level }) => {
780
- const verse = renderInline({ children }, ctx);
781
- return `${level ? `#h(${level * 1.25}em)` : ""}${verse}`;
782
- })
783
- .join(" \\\n"),
784
- )
785
- .join("\n\n");
786
- const poem = scanPoetry(
787
- `${token.markup}${token.info}\n${token.content}${token.markup}`,
788
- ).blocks[0];
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
+ }
@@ -12,7 +12,7 @@
12
12
  */
13
13
 
14
14
  /**
15
- * Spans, divs and poetry fences marked with `lang=`, `font=` or `map=`, projected
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"|"poetry"} kind - The construct.
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.
@@ -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
- * - the noun plural `data.grammar.plural` gives, or the word's own `plural`;
26
- * - a verb's citation form with one ending of `data.grammar.verbs.persons`,
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 { replaceClosing, verbForm, verbPattern } from "./verb-pattern.mjs";
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`.
@@ -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 matter from "gray-matter";
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 = matter(source);
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, matter.stringify(page.body, encodeAddresses(data)));
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, matter.stringify(body, encodeAddresses(data)));
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;