@heroiclands/package-build 23.12.6 → 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 (83) hide show
  1. package/CHANGELOG.md +12 -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 +72 -0
  6. package/docs/api.md +10 -9
  7. package/docs/authoring/links-and-markup.md +439 -47
  8. package/docs/commands.md +127 -0
  9. package/docs/configuration.md +66 -3
  10. package/docs/content-format.md +3 -1
  11. package/docs/guides/book.md +11 -0
  12. package/docs/guides/module-project.md +16 -0
  13. package/docs/guides/site.md +19 -0
  14. package/docs/reference/format-details.md +372 -6
  15. package/docs/reference/note-types.md +36 -11
  16. package/engine/base-compiler.mjs +2 -0
  17. package/engine/bundles.mjs +7 -2
  18. package/engine/compile-corpus.mjs +3 -1
  19. package/engine/content-charset.mjs +21 -2
  20. package/engine/content-format.yaml +1 -0
  21. package/engine/content-index.mjs +12 -0
  22. package/engine/content-poetry.mjs +22 -4
  23. package/engine/content-scripts.mjs +1219 -0
  24. package/engine/content-session.mjs +2 -0
  25. package/engine/data-keys.mjs +5 -1
  26. package/engine/dictionary-layout.mjs +18 -12
  27. package/engine/dictionary-rows.mjs +11 -2
  28. package/engine/helpers.mjs +8 -2
  29. package/engine/index-records.mjs +1 -0
  30. package/engine/journals.mjs +14 -3
  31. package/engine/language-data.mjs +137 -1
  32. package/engine/language-tag.mjs +103 -0
  33. package/engine/language-terms.mjs +16 -4
  34. package/engine/map-lexicon.mjs +762 -0
  35. package/engine/markup-validation.mjs +17 -3
  36. package/engine/names.mjs +1 -1
  37. package/engine/note-vocabulary.mjs +113 -0
  38. package/engine/pdf-build.mjs +28 -10
  39. package/engine/pdf-render.mjs +116 -11
  40. package/engine/run-rows.mjs +119 -0
  41. package/engine/run-words.mjs +266 -0
  42. package/engine/scenes.mjs +1 -0
  43. package/engine/script-font-delivery.mjs +321 -0
  44. package/engine/script-fonts.mjs +172 -0
  45. package/engine/site-build.mjs +10 -3
  46. package/engine/site-config.mjs +5 -0
  47. package/engine/site-root.mjs +19 -5
  48. package/engine/sql-tables.mjs +111 -1
  49. package/engine/text-maps.mjs +607 -0
  50. package/engine/verb-pattern.mjs +99 -0
  51. package/engine/word-pattern.mjs +27 -0
  52. package/engine/word-rows.mjs +8 -2
  53. package/engine/word-terms.mjs +44 -0
  54. package/hugo-theme/layouts/_default/baseof.html +3 -0
  55. package/hugo-theme/static/css/style.css +10 -0
  56. package/manifest.mjs +9 -1
  57. package/package.json +1 -1
  58. package/types/content-config.d.mts +26 -0
  59. package/types/engine/compile-corpus.d.mts +2 -1
  60. package/types/engine/content-index.d.mts +11 -3
  61. package/types/engine/content-poetry.d.mts +7 -0
  62. package/types/engine/content-scripts.d.mts +226 -0
  63. package/types/engine/data-keys.d.mts +2 -2
  64. package/types/engine/dictionary-rows.d.mts +15 -0
  65. package/types/engine/helpers.d.mts +5 -1
  66. package/types/engine/journals.d.mts +7 -2
  67. package/types/engine/language-data.d.mts +2 -0
  68. package/types/engine/language-tag.d.mts +8 -0
  69. package/types/engine/language-terms.d.mts +2 -1
  70. package/types/engine/map-lexicon.d.mts +274 -0
  71. package/types/engine/markup-validation.d.mts +7 -2
  72. package/types/engine/pdf-build.d.mts +26 -3
  73. package/types/engine/pdf-render.d.mts +17 -2
  74. package/types/engine/run-rows.d.mts +89 -0
  75. package/types/engine/run-words.d.mts +86 -0
  76. package/types/engine/script-font-delivery.d.mts +134 -0
  77. package/types/engine/script-fonts.d.mts +168 -0
  78. package/types/engine/site-build.d.mts +2 -1
  79. package/types/engine/site-root.d.mts +11 -3
  80. package/types/engine/text-maps.d.mts +175 -0
  81. package/types/engine/verb-pattern.d.mts +65 -0
  82. package/types/engine/word-pattern.d.mts +13 -0
  83. package/types/engine/word-terms.d.mts +6 -0
@@ -21,6 +21,7 @@ import * as sql from "./sql-tables.mjs";
21
21
  import { captureContentSources } from "./content-sources.mjs";
22
22
  import { buildLinkIndex } from "./content-links.mjs";
23
23
  import { buildContentLinkIndex } from "./helpers.mjs";
24
+ import { runContext } from "./content-scripts.mjs";
24
25
  import { loadForeignIndexes } from "./metadata-index.mjs";
25
26
  import { loadPackConfig } from "./pack-config.mjs";
26
27
  import { routerFor } from "./pack-router.mjs";
@@ -199,6 +200,7 @@ export async function createContentSession({
199
200
  return memo("compile", async () => {
200
201
  const sqlTables = await session.sqlTables("all");
201
202
  return {
203
+ runs: runContext(config, records),
202
204
  records,
203
205
  parsed,
204
206
  problems,
@@ -62,7 +62,7 @@ import { nearest } from "./near-miss.mjs";
62
62
  * @property {string} name - The key as an author writes it.
63
63
  * @property {string} [kind] - Its value's shape: `string`, `date`, `number`,
64
64
  * `integer`, `boolean`, `address`, `list`, `map`, `scalar-or-map`,
65
- * `string-or-list`, or `string-list-or-map` — see {@link fitsKind}. Absent, the check
65
+ * `string-or-list`, `string-list-or-map` or `list-or-map` — see {@link fitsKind}. Absent, the check
66
66
  * makes no claim about the value.
67
67
  * @property {readonly string[]} [allowed] - The closed set a present value is
68
68
  * one of, where the key takes a fixed word.
@@ -156,6 +156,10 @@ export function fitsKind(value, kind) {
156
156
  // described by `values`.
157
157
  case "string-list-or-map":
158
158
  return fitsKind(value, "string-or-list") || fitsKind(value, "map");
159
+ // A list described by `entries`, or a map with the keys `fields` names:
160
+ // a mapping row is `[from, to]` or `{from, to, before?, at?}`.
161
+ case "list-or-map":
162
+ return Array.isArray(value) || fitsKind(value, "map");
159
163
  default:
160
164
  return true;
161
165
  }
@@ -48,7 +48,7 @@
48
48
 
49
49
  import { parseAddress, readCanonicalKey, renderAddress, splitAnchor } from "./address.mjs";
50
50
  import { dictionaryEntryAnchor, dictionaryLetter } from "./dictionary-rows.mjs";
51
- import { VERB_PERSON_FIELDS } from "./language-terms.mjs";
51
+ import { verbPattern } from "./verb-pattern.mjs";
52
52
  import {
53
53
  WORD_CLASS_BASES,
54
54
  WORD_CLASS_MODIFIERS,
@@ -191,16 +191,22 @@ function verbTables(rows, opts) {
191
191
  patterns.set(opts.pageLanguage ?? "", { verbs: opts.verbs });
192
192
  const tables = [];
193
193
  for (const [language, { verbs, label }] of [...patterns].sort(([a], [b]) => compare(a, b))) {
194
- let endings;
195
- if (verbs.invariant === true) endings = [["All persons", "unchanged"]];
196
- else if (
197
- VERB_PERSON_FIELDS.every((person) => typeof verbs.persons?.[person.name] === "string")
198
- )
199
- endings = VERB_PERSON_FIELDS.map((person) => [
200
- person.label,
201
- verbs.persons[person.name] ? `-${verbs.persons[person.name]}` : "unchanged",
202
- ]);
203
- else continue;
194
+ const pattern = verbPattern(verbs);
195
+ if (!pattern) continue;
196
+ const replaced = pattern.replaces === undefined ? undefined : `-${pattern.replaces}`;
197
+ const endings =
198
+ pattern.invariant ?
199
+ [["All persons", "unchanged"]]
200
+ : pattern.endings.map(({ label, ending }) => [
201
+ label,
202
+ ending ? `-${ending}`
203
+ : replaced ? `${replaced} dropped`
204
+ : "unchanged",
205
+ ]);
206
+ const heading =
207
+ replaced && !pattern.invariant ?
208
+ `Ending in place of a closing ${replaced}`
209
+ : "Ending added to citation form";
204
210
  const title =
205
211
  patterns.size > 1 ?
206
212
  `**Verbs (${String(opts.labelOf?.(language) ?? label ?? language).replace(/[\r\n]+/g, " ")})**`
@@ -209,7 +215,7 @@ function verbTables(rows, opts) {
209
215
  [
210
216
  title,
211
217
  "",
212
- "| Person | Ending added to citation form |",
218
+ `| Person | ${verbCell(heading)} |`,
213
219
  "| --- | --- |",
214
220
  ...endings.map((ending) => `| ${ending.map(verbCell).join(" | ")} |`),
215
221
  ].join("\n"),
@@ -130,8 +130,17 @@ const isSingleWord = (title) => !/[\s\-‐‑]/u.test(title);
130
130
  /** Whether a glossary entry states a definition. */
131
131
  const defines = (entry) => typeof entry.definition === "string" && entry.definition.trim() !== "";
132
132
 
133
- /** A noun's stated plural, or the first literal ending replacement that fits. */
134
- function pluralOf(word, grammar) {
133
+ /**
134
+ * A noun's stated plural, or the first literal ending replacement that fits.
135
+ *
136
+ * @param {{form: string, class?: unknown, plural?: unknown}} word - A word of
137
+ * `data.words`.
138
+ * @param {Record<string, any>|undefined} grammar - Its language's
139
+ * `data.grammar`.
140
+ * @returns {string|null} The plural, or `null` for a word that is no noun or
141
+ * that no rule fits.
142
+ */
143
+ export function pluralOf(word, grammar) {
135
144
  if (parseWordClass(word.class).base !== "n") return null;
136
145
  if (typeof word.plural === "string" && word.plural) return word.plural;
137
146
  for (const rule of Array.isArray(grammar?.plural) ? grammar.plural : []) {
@@ -26,6 +26,7 @@
26
26
  */
27
27
 
28
28
  import { renderAlerts } from "./content-alerts.mjs";
29
+ import { renderRuns } from "./content-scripts.mjs";
29
30
  import { buildReferenceTargets } from "./reference-targets.mjs";
30
31
  import { standingsDigest } from "./standings.mjs";
31
32
 
@@ -224,10 +225,15 @@ md.renderer.rules.footnote_open = (tokens, idx, options, env, renderer) => {
224
225
  * @param {(address: string) => string|undefined} [resolveRole] - From a
225
226
  * picture's address to the role its asset declares — see
226
227
  * {@link module:engine/content-figures.scanFigures}.
228
+ * @param {import("./content-scripts.mjs").RunContext} [runs] - The package's
229
+ * languages, faces and maps. Given, text marked with `lang=`, `font=` and
230
+ * `map=` is rewritten first, so it is tagged for its language, mapped and
231
+ * set in its font.
227
232
  * @returns {string} The rendered HTML.
228
233
  */
229
- export function renderFoundryMarkdown(body, figures, footnoteNumbers, docId, resolveRole) {
230
- const figured = renderFigureBlocks(body, (block) => md.render(block), figures, {
234
+ export function renderFoundryMarkdown(body, figures, footnoteNumbers, docId, resolveRole, runs) {
235
+ const marked = runs ? renderRuns(body, runs, undefined, { target: "foundry" }).markdown : body;
236
+ const figured = renderFigureBlocks(marked, (block) => md.render(block), figures, {
231
237
  resolveRole,
232
238
  });
233
239
  const blocks = renderBlocks(figured.markdown, "foundry");
@@ -65,6 +65,7 @@ export const DERIVED_KEYS = Object.freeze([
65
65
  "resolvedCalendar",
66
66
  "citations",
67
67
  "names",
68
+ "runs",
68
69
  ]);
69
70
 
70
71
  /**
@@ -485,10 +485,12 @@ function soleFigurePicture(figure, page) {
485
485
  * @param {Map<string, number>} footnoteNumbers - Shared across the note.
486
486
  * @param {(address: string) => string|undefined} [resolveRole] - See
487
487
  * {@link splitPages}.
488
+ * @param {import("./content-scripts.mjs").RunContext} [runs] - The package's
489
+ * languages, faces and maps — see {@link module:engine/helpers.renderFoundryMarkdown}.
488
490
  * @returns {{name: string, type: "image", src: string, image: {caption?: string}}
489
491
  * |{name: string, type: "text", text: {format: number, content: string}}}
490
492
  */
491
- function buildFigurePageFields(figure, page, pageId, footnoteNumbers, resolveRole) {
493
+ function buildFigurePageFields(figure, page, pageId, footnoteNumbers, resolveRole, runs) {
492
494
  const picture = soleFigurePicture(figure, page);
493
495
  if (picture && !captionCarriesMarkup(figure.caption)) {
494
496
  let src = picture;
@@ -515,6 +517,7 @@ function buildFigurePageFields(figure, page, pageId, footnoteNumbers, resolveRol
515
517
  footnoteNumbers,
516
518
  pageId,
517
519
  resolveRole,
520
+ runs,
518
521
  );
519
522
  return {
520
523
  name: figure.label || captionPageName(figure.caption),
@@ -547,13 +550,15 @@ function buildFigurePageFields(figure, page, pageId, footnoteNumbers, resolveRol
547
550
  * carries its own figure already, from {@link splitPages}.
548
551
  * @param {(address: string) => string|undefined} [resolveRole] - See
549
552
  * {@link splitPages}.
553
+ * @param {import("./content-scripts.mjs").RunContext} [runs] - The package's
554
+ * languages, faces and maps — see {@link module:engine/helpers.renderFoundryMarkdown}.
550
555
  * @returns {Array<{_id: string, name: string, type: string,
551
556
  * title: {show: boolean, level: number},
552
557
  * text: {format: number, content: string}, _key: string,
553
558
  * ownership?: {default: number}}>} The page documents, in order.
554
559
  * @throws {Error} When the note has no content at all, or repeats an anchor.
555
560
  */
556
- export function buildPages(rawPages, entryId, noteName, figures, resolveRole) {
561
+ export function buildPages(rawPages, entryId, noteName, figures, resolveRole, runs) {
557
562
  if (rawPages.length === 0) {
558
563
  throw new Error(
559
564
  `note "${noteName}" has no Introduction content and no H1 headings — nothing to compile`,
@@ -573,7 +578,7 @@ export function buildPages(rawPages, entryId, noteName, figures, resolveRole) {
573
578
  const withheld = (page.classes ?? []).includes(WITHHELD_CLASS);
574
579
  const fields =
575
580
  page.figure ?
576
- buildFigurePageFields(page.figure, page, pageId, footnoteNumbers, resolveRole)
581
+ buildFigurePageFields(page.figure, page, pageId, footnoteNumbers, resolveRole, runs)
577
582
  : {
578
583
  name: page.name,
579
584
  type: "text",
@@ -587,6 +592,7 @@ export function buildPages(rawPages, entryId, noteName, figures, resolveRole) {
587
592
  footnoteNumbers,
588
593
  pageId,
589
594
  resolveRole,
595
+ runs,
590
596
  )
591
597
  : "",
592
598
  },
@@ -638,6 +644,8 @@ export function buildPages(rawPages, entryId, noteName, figures, resolveRole) {
638
644
  * name reads `Map 1` rather than `Figure 1` where it is due — see
639
645
  * {@link module:engine/content-figures.scanFigures}.
640
646
  * @param {import("./content-session.mjs").ContentSyntax} [params.syntax] - Session scanner projections.
647
+ * @param {import("./content-scripts.mjs").RunContext} [params.runs] - The package's
648
+ * languages, faces and maps — see {@link module:engine/helpers.renderFoundryMarkdown}.
641
649
  * @returns {object} The JournalEntry document, keyed for the pack.
642
650
  */
643
651
  export function buildJournalEntry({
@@ -651,6 +659,7 @@ export function buildJournalEntry({
651
659
  notice = "",
652
660
  resolveRole,
653
661
  syntax,
662
+ runs,
654
663
  }) {
655
664
  const rawPages = splitPages(markdown, leadName, resolveRole, syntax);
656
665
  const pages = buildPages(
@@ -659,6 +668,7 @@ export function buildJournalEntry({
659
668
  name,
660
669
  scanFigures(markdown, { resolveRole }).figures,
661
670
  resolveRole,
671
+ runs,
662
672
  );
663
673
  for (const box of infoboxes) {
664
674
  const pageId = makeId("journal-infobox-page", `${id}:${box.id}`);
@@ -844,6 +854,7 @@ export class Journals extends BasePackCompiler {
844
854
  .filter(Boolean)
845
855
  .join("\n\n"),
846
856
  resolveRole: (pathname) => roles.get(pathname),
857
+ runs: this.corpus?.runs,
847
858
  infoboxes: boxes.map((box) => ({
848
859
  id: box.id,
849
860
  name: `${
@@ -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
  }),