@heroiclands/package-build 23.12.5 → 23.12.7

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 (95) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/assets/styles/content-base.css +25 -0
  3. package/bin/content-commands.mjs +264 -3
  4. package/bin/package-build.mjs +25 -0
  5. package/content-config.mjs +98 -0
  6. package/docs/api.md +18 -14
  7. package/docs/authoring/dates-and-calendars.md +29 -1
  8. package/docs/authoring/links-and-markup.md +442 -50
  9. package/docs/commands.md +127 -0
  10. package/docs/configuration.md +90 -3
  11. package/docs/content-format.md +3 -1
  12. package/docs/guides/book.md +11 -0
  13. package/docs/guides/module-project.md +17 -1
  14. package/docs/guides/site.md +19 -0
  15. package/docs/reference/format-details.md +434 -23
  16. package/docs/reference/note-types.md +45 -21
  17. package/engine/base-compiler.mjs +2 -0
  18. package/engine/bundles.mjs +7 -2
  19. package/engine/compile-corpus.mjs +3 -1
  20. package/engine/content-charset.mjs +21 -2
  21. package/engine/content-format.yaml +1 -2
  22. package/engine/content-index.mjs +33 -2
  23. package/engine/content-links.mjs +3 -0
  24. package/engine/content-poetry.mjs +22 -4
  25. package/engine/content-scripts.mjs +1219 -0
  26. package/engine/content-session.mjs +2 -0
  27. package/engine/data-keys.mjs +5 -1
  28. package/engine/dictionary-layout.mjs +18 -12
  29. package/engine/dictionary-rows.mjs +11 -2
  30. package/engine/event-rows.mjs +63 -18
  31. package/engine/event-views.mjs +28 -15
  32. package/engine/helpers.mjs +8 -2
  33. package/engine/index-records.mjs +2 -0
  34. package/engine/infobox.mjs +4 -8
  35. package/engine/journals.mjs +14 -3
  36. package/engine/language-data.mjs +137 -1
  37. package/engine/language-tag.mjs +103 -0
  38. package/engine/language-terms.mjs +16 -4
  39. package/engine/map-lexicon.mjs +762 -0
  40. package/engine/markdown-expressions.mjs +72 -17
  41. package/engine/markup-validation.mjs +17 -3
  42. package/engine/names.mjs +1 -1
  43. package/engine/note-calendars.mjs +314 -0
  44. package/engine/note-vocabulary.mjs +124 -16
  45. package/engine/pdf-build.mjs +28 -10
  46. package/engine/pdf-render.mjs +116 -11
  47. package/engine/reckoning-markers.mjs +63 -3
  48. package/engine/run-rows.mjs +119 -0
  49. package/engine/run-words.mjs +266 -0
  50. package/engine/scenes.mjs +1 -0
  51. package/engine/script-font-delivery.mjs +321 -0
  52. package/engine/script-fonts.mjs +172 -0
  53. package/engine/site-build.mjs +10 -3
  54. package/engine/site-config.mjs +5 -0
  55. package/engine/site-root.mjs +19 -5
  56. package/engine/sql-tables.mjs +120 -4
  57. package/engine/text-maps.mjs +607 -0
  58. package/engine/verb-pattern.mjs +99 -0
  59. package/engine/word-pattern.mjs +27 -0
  60. package/engine/word-rows.mjs +8 -2
  61. package/engine/word-terms.mjs +44 -0
  62. package/hugo-theme/layouts/_default/baseof.html +3 -0
  63. package/hugo-theme/static/css/style.css +10 -0
  64. package/manifest.mjs +9 -1
  65. package/package.json +1 -1
  66. package/types/content-config.d.mts +38 -0
  67. package/types/engine/compile-corpus.d.mts +2 -1
  68. package/types/engine/content-index.d.mts +17 -3
  69. package/types/engine/content-poetry.d.mts +7 -0
  70. package/types/engine/content-scripts.d.mts +226 -0
  71. package/types/engine/data-keys.d.mts +2 -2
  72. package/types/engine/dictionary-rows.d.mts +15 -0
  73. package/types/engine/event-rows.d.mts +16 -1
  74. package/types/engine/event-views.d.mts +2 -1
  75. package/types/engine/helpers.d.mts +5 -1
  76. package/types/engine/journals.d.mts +7 -2
  77. package/types/engine/language-data.d.mts +2 -0
  78. package/types/engine/language-tag.d.mts +8 -0
  79. package/types/engine/language-terms.d.mts +2 -1
  80. package/types/engine/map-lexicon.d.mts +274 -0
  81. package/types/engine/markup-validation.d.mts +7 -2
  82. package/types/engine/note-calendars.d.mts +60 -0
  83. package/types/engine/pdf-build.d.mts +26 -3
  84. package/types/engine/pdf-render.d.mts +17 -2
  85. package/types/engine/reckoning-markers.d.mts +32 -1
  86. package/types/engine/run-rows.d.mts +89 -0
  87. package/types/engine/run-words.d.mts +86 -0
  88. package/types/engine/script-font-delivery.d.mts +134 -0
  89. package/types/engine/script-fonts.d.mts +168 -0
  90. package/types/engine/site-build.d.mts +2 -1
  91. package/types/engine/site-root.d.mts +11 -3
  92. package/types/engine/text-maps.d.mts +175 -0
  93. package/types/engine/verb-pattern.d.mts +65 -0
  94. package/types/engine/word-pattern.d.mts +13 -0
  95. package/types/engine/word-terms.d.mts +6 -0
@@ -34,6 +34,9 @@
34
34
  * type;
35
35
  * - every language a cognate's `forms` names is a language note;
36
36
  * - a register row states a `tongue` or a `kind`;
37
+ * - a register row tagged `other:<tongue>` or `exonym:<tongue>` names a name
38
+ * the tongue's own language note registers under that tongue, case folded
39
+ * and NFC (see {@link foreignRowFindings});
37
40
  * - a sound-change set's name stands once in the note, a step's label once
38
41
  * in its set, and its `from` and `to` name language notes;
39
42
  * - a frame's classes are bases of the master word classes;
@@ -52,7 +55,7 @@
52
55
 
53
56
  import { pathLabel as label } from "./data-keys.mjs";
54
57
  import { positionOfFrontmatterPath } from "./diagnostics.mjs";
55
- import { addressKeyOf } from "./culture-reach.mjs";
58
+ import { addressKeyOf, isLanguageNote } from "./culture-reach.mjs";
56
59
  import { AddressEntries } from "./address-values.mjs";
57
60
  import { isAddressTuple, renderAddress } from "./address.mjs";
58
61
  import {
@@ -111,6 +114,119 @@ export function tongueDeclared(tag, declared) {
111
114
  : declared.has(`${match[1]}:${TONGUE_PLACEHOLDER}`);
112
115
  }
113
116
 
117
+ /** The tag families whose rows name a name of another tongue's register. */
118
+ const FOREIGN_FAMILIES = new Set(["other", "exonym"]);
119
+
120
+ /** How a foreign row that its tongue's register does not bear out is reported. */
121
+ export const FOREIGN_ROW_SEVERITY = "warning";
122
+
123
+ /** A name as a foreign row is compared: NFC and lowercase. */
124
+ const foldName = (name) => name.normalize("NFC").toLowerCase();
125
+
126
+ /**
127
+ * Each tag a language note of the index declares with a `language`, to the
128
+ * canonical Addresses of the language notes it names, keyed once per index.
129
+ *
130
+ * @type {WeakMap<object, Map<string, Set<string>>>}
131
+ */
132
+ const tongueLanguagesByIndex = new WeakMap();
133
+
134
+ /**
135
+ * The language notes each tag names: every `data.tongues` entry of the index's
136
+ * own language notes whose `tag` is a plain tag and that states a `language`.
137
+ *
138
+ * @param {object} index - The link index.
139
+ * @returns {Map<string, Set<string>>} Canonical Addresses by tag.
140
+ */
141
+ function tongueLanguages(index) {
142
+ const known = tongueLanguagesByIndex.get(index);
143
+ if (known) return known;
144
+ const out = new Map();
145
+ for (const note of index.notes ?? []) {
146
+ if (!isLanguageNote(note.fm)) continue;
147
+ for (const tongue of listOf(note.fm?.data?.tongues)) {
148
+ if (!isMap(tongue) || typeof tongue.tag !== "string" || tongue.tag.includes(":"))
149
+ continue;
150
+ const key = addressKeyOf(tongue.language, index, "skill");
151
+ if (!key) continue;
152
+ if (!out.has(tongue.tag)) out.set(tongue.tag, new Set());
153
+ out.get(tongue.tag).add(key);
154
+ }
155
+ }
156
+ tongueLanguagesByIndex.set(index, out);
157
+ return out;
158
+ }
159
+
160
+ /**
161
+ * The findings on a register's rows tagged `other:<tongue>` or
162
+ * `exonym:<tongue>`, each checked against the register of the tongue's own
163
+ * language note.
164
+ *
165
+ * A tongue names the language note a `data.tongues` entry of one of the
166
+ * index's language notes tags it with, through its `language`: `other:helonic`
167
+ * reads the note that `{tag: helonic, language: skill-helonclng}` names. The
168
+ * row passes when that note's `data.register` holds a row of the same name
169
+ * whose `tongue` is `helonic`. A tongue no entry links to a language note, or
170
+ * one linked to two, is a finding at the row's `tongue`; a language note of
171
+ * another package is not read, since its register does not reach this index.
172
+ * Bare `other`, and every other tag, are not checked.
173
+ *
174
+ * @param {Array<{row: object, i: number}>} rows - The register rows whose tag
175
+ * `data.tongues` declares.
176
+ * @param {object|undefined} index - The link index.
177
+ * @param {(path: Array<string|number>, severity: string, message: string) => object} finding -
178
+ * Builds a finding at a frontmatter path.
179
+ * @returns {object[]} The findings.
180
+ */
181
+ function foreignRowFindings(rows, index, finding) {
182
+ if (!index?.addressHit) return [];
183
+ const findings = [];
184
+ for (const { row, i } of rows) {
185
+ const match = USED_TAG_PATTERN.exec(row.tongue);
186
+ if (!match || match[2] === undefined || !FOREIGN_FAMILIES.has(match[1])) continue;
187
+ const tongue = match[2];
188
+ const languages = [...(tongueLanguages(index).get(tongue) ?? [])];
189
+ if (languages.length !== 1) {
190
+ findings.push(
191
+ finding(
192
+ ["data", "register", i, "tongue"],
193
+ FOREIGN_ROW_SEVERITY,
194
+ languages.length ?
195
+ `the tongue \`${tongue}\` in \`${row.tongue}\` names ${languages.length} ` +
196
+ `language notes, ${languages.join(" and ")}; the \`data.tongues\` entries ` +
197
+ `tagged \`${tongue}\` name one`
198
+ : `the tongue \`${tongue}\` in \`${row.tongue}\` names no language note: no ` +
199
+ `language note's \`data.tongues\` tags \`${tongue}\` with a \`language\``,
200
+ ),
201
+ );
202
+ continue;
203
+ }
204
+ const target = index.addressHit(languages[0]);
205
+ const fm = target?.fm;
206
+ if (!fm || !isLanguageNote(fm)) continue;
207
+ const name = foldName(row.name);
208
+ const listed = listOf(fm.data?.register).some(
209
+ (other) =>
210
+ isMap(other) &&
211
+ other.tongue === tongue &&
212
+ typeof other.name === "string" &&
213
+ foldName(other.name) === name,
214
+ );
215
+ if (listed) continue;
216
+ const title = typeof fm.name?.full === "string" ? fm.name.full : fm.name;
217
+ findings.push(
218
+ finding(
219
+ ["data", "register", i, "name"],
220
+ FOREIGN_ROW_SEVERITY,
221
+ `\`${row.name}\` is registered as \`${row.tongue}\`, but the register of ` +
222
+ `${title} (${fm.type}-${fm.shortcode}) lists no \`${tongue}\` name ` +
223
+ `\`${row.name}\`; correct the spelling, or register the name there first`,
224
+ ),
225
+ );
226
+ }
227
+ return findings;
228
+ }
229
+
114
230
  /**
115
231
  * The entries of a cognate's `forms`, whatever form it reached the check in:
116
232
  * `[written key, value, Address]`.
@@ -192,6 +308,22 @@ export function checkLanguageData(note, { index, types } = {}) {
192
308
  "`data.grammar.verbs` must state exactly one of `invariant: true` or `persons`",
193
309
  ),
194
310
  );
311
+ else if (invariant && Object.hasOwn(verbs, "replaces"))
312
+ findings.push(
313
+ finding(
314
+ ["data", "grammar", "verbs", "replaces"],
315
+ "error",
316
+ "`data.grammar.verbs.replaces` states what a `persons` ending replaces, and an invariant pattern has none",
317
+ ),
318
+ );
319
+ else if (typeof verbs.replaces === "string" && !verbs.replaces.trim())
320
+ findings.push(
321
+ finding(
322
+ ["data", "grammar", "verbs", "replaces"],
323
+ "error",
324
+ "`data.grammar.verbs.replaces` must name the letters an ending replaces; leave it out where endings are added",
325
+ ),
326
+ );
195
327
  else if (invariant && (verbs.invariant == null || verbs.invariant === false))
196
328
  findings.push(
197
329
  finding(
@@ -319,6 +451,7 @@ export function checkLanguageData(note, { index, types } = {}) {
319
451
  );
320
452
  });
321
453
  };
454
+ const foreignRows = [];
322
455
  listOf(data.register).forEach((row, i) => {
323
456
  if (!isMap(row)) return;
324
457
  if (row.tongue == null && row.kind == null)
@@ -331,7 +464,10 @@ export function checkLanguageData(note, { index, types } = {}) {
331
464
  );
332
465
  if (typeof row.tongue !== "string") return;
333
466
  undeclared([row.tongue], () => ["data", "register", i, "tongue"]);
467
+ if (typeof row.name === "string" && tongueDeclared(row.tongue, declared))
468
+ foreignRows.push({ row, i });
334
469
  });
470
+ findings.push(...foreignRowFindings(foreignRows, index, finding));
335
471
 
336
472
  // ── Sounds ───────────────────────────────────────────────────────────────
337
473
  const sounds = isMap(data.sounds) ? data.sounds : {};
@@ -0,0 +1,103 @@
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
+ * **BCP 47 language tags** (RFC 5646), checked without the IANA registry.
16
+ *
17
+ * A tag is accepted when it is well-formed by the RFC's grammar and meets the
18
+ * two validity rules that need no registry: no variant subtag appears twice,
19
+ * and no extension singleton appears twice. Matching is case-insensitive, as
20
+ * the RFC requires. Whether each subtag is registered is not checked.
21
+ *
22
+ * A constructed language takes `art-x-<name>`: `art` is the registered
23
+ * collective code for artificial languages, and the private-use part names
24
+ * which one.
25
+ *
26
+ * @module
27
+ */
28
+
29
+ const ALPHANUM = "[a-z0-9]";
30
+
31
+ /** What separates a tag's subtags: RFC 5646's own separator, not an Address's. */
32
+ const SUBTAG_SEPARATOR = "-";
33
+
34
+ /** `langtag`, with each part captured so the validity rules can read them. */
35
+ const LANGTAG = new RegExp(
36
+ "^" +
37
+ // language: 2–3 letters with up to three extlangs, 4 letters, or 5–8 letters
38
+ "(?:[a-z]{2,3}(?:-[a-z]{3}){0,3}|[a-z]{4}|[a-z]{5,8})" +
39
+ // script
40
+ "(?:-[a-z]{4})?" +
41
+ // region
42
+ "(?:-(?:[a-z]{2}|[0-9]{3}))?" +
43
+ // variants
44
+ `((?:-(?:${ALPHANUM}{5,8}|[0-9]${ALPHANUM}{3}))*)` +
45
+ // extensions: a singleton other than x, then subtags of 2–8
46
+ `((?:-[0-9a-wy-z](?:-${ALPHANUM}{2,8})+)*)` +
47
+ // private use
48
+ `(?:-x(?:-${ALPHANUM}{1,8})+)?` +
49
+ "$",
50
+ "i",
51
+ );
52
+
53
+ /** A tag that is private use alone. */
54
+ const PRIVATE_USE = new RegExp(`^x(?:-${ALPHANUM}{1,8})+$`, "i");
55
+
56
+ /** RFC 5646's grandfathered tags, which the grammar does not otherwise admit. */
57
+ const GRANDFATHERED = new Set([
58
+ "en-gb-oed",
59
+ "i-ami",
60
+ "i-bnn",
61
+ "i-default",
62
+ "i-enochian",
63
+ "i-hak",
64
+ "i-klingon",
65
+ "i-lux",
66
+ "i-mingo",
67
+ "i-navajo",
68
+ "i-pwn",
69
+ "i-tao",
70
+ "i-tay",
71
+ "i-tsu",
72
+ "sgn-be-fr",
73
+ "sgn-be-nl",
74
+ "sgn-ch-de",
75
+ "art-lojban",
76
+ "cel-gaulish",
77
+ "no-bok",
78
+ "no-nyn",
79
+ "zh-guoyu",
80
+ "zh-hakka",
81
+ "zh-min",
82
+ "zh-min-nan",
83
+ "zh-xiang",
84
+ ]);
85
+
86
+ /**
87
+ * Whether a value is a BCP 47 language tag.
88
+ *
89
+ * @param {unknown} value - The value to test.
90
+ * @returns {boolean} Whether it is a well-formed tag with no repeated variant
91
+ * or extension singleton.
92
+ */
93
+ export function isLanguageTag(value) {
94
+ if (typeof value !== "string" || value === "") return false;
95
+ const tag = value.toLowerCase();
96
+ if (GRANDFATHERED.has(tag) || PRIVATE_USE.test(tag)) return true;
97
+ const match = LANGTAG.exec(tag);
98
+ if (!match) return false;
99
+ const variants = match[1].split(SUBTAG_SEPARATOR).filter(Boolean);
100
+ if (new Set(variants).size !== variants.length) return false;
101
+ const singletons = match[2].split(SUBTAG_SEPARATOR).filter((part) => part.length === 1);
102
+ return new Set(singletons).size === singletons.length;
103
+ }
@@ -1597,7 +1597,8 @@ function listOfMaps(name, fields, describe) {
1597
1597
 
1598
1598
  /**
1599
1599
  * The six present persons of the one optional regular verb pattern, in
1600
- * presentation order. Empty endings leave the citation form unchanged.
1600
+ * presentation order. Each ending is added to the citation form, or takes the
1601
+ * place of its closing `replaces`; an empty ending adds nothing.
1601
1602
  *
1602
1603
  * @type {readonly import("./data-keys.mjs").InnerKeySpec[]}
1603
1604
  */
@@ -1617,7 +1618,7 @@ export const VERB_PERSON_FIELDS = Object.freeze(
1617
1618
  required: true,
1618
1619
  allowEmpty: true,
1619
1620
  shape: "an ending string, empty for unchanged",
1620
- describe: `The ending added to the citation form for ${label}.`,
1621
+ describe: `The ending for ${label}, added to the citation form or in place of its closing \`replaces\`.`,
1621
1622
  }),
1622
1623
  ),
1623
1624
  );
@@ -1635,7 +1636,18 @@ const VERB_PATTERN_FIELDS = Object.freeze([
1635
1636
  kind: "map",
1636
1637
  shape: "a map — `{ I, you, heShe, we, youAll, they }` of ending strings",
1637
1638
  fields: VERB_PERSON_FIELDS,
1638
- describe: "All six present endings, each added to the citation form; excludes `invariant`.",
1639
+ describe:
1640
+ "All six present endings, each added to the citation form or in place of its " +
1641
+ "closing `replaces`; excludes `invariant`.",
1642
+ }),
1643
+ Object.freeze({
1644
+ name: "replaces",
1645
+ kind: "string",
1646
+ shape: "the closing letters every ending takes the place of",
1647
+ describe:
1648
+ "Letters a citation form closes on, swapped for each `persons` ending: with `then`, " +
1649
+ "`lokathen` and `zor` give `lokazor`. A citation form closing otherwise takes the " +
1650
+ "ending whole. With `persons` only.",
1639
1651
  }),
1640
1652
  ]);
1641
1653
 
@@ -1664,7 +1676,7 @@ const GRAMMAR_FIELDS = Object.freeze([
1664
1676
  Object.freeze({
1665
1677
  name: "verbs",
1666
1678
  kind: "map",
1667
- shape: "a map — `{ invariant: true }` or `{ persons: { I, you, heShe, we, youAll, they } }`",
1679
+ shape: "a map — `{ invariant: true }` or `{ persons: { I, you, heShe, we, youAll, they }, replaces? }`",
1668
1680
  fields: VERB_PATTERN_FIELDS,
1669
1681
  describe: "One optional regular present pattern, printed once at the dictionary head.",
1670
1682
  }),