@heroiclands/package-build 24.1.1 → 25.1.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 (107) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +1 -0
  3. package/bin/content-commands.mjs +30 -96
  4. package/bin/encoding-commands.mjs +146 -0
  5. package/bin/package-build.mjs +35 -24
  6. package/config.mjs +25 -0
  7. package/convert.mjs +182 -0
  8. package/docs/api.md +154 -11
  9. package/docs/authoring/links-and-markup.md +260 -473
  10. package/docs/commands.md +118 -119
  11. package/docs/configuration.md +31 -13
  12. package/docs/content-format.md +11 -4
  13. package/docs/diagnostics.md +43 -16
  14. package/docs/encoding-core.md +135 -0
  15. package/docs/guides/book.md +2 -2
  16. package/docs/guides/module-project.md +23 -1
  17. package/docs/guides/site.md +2 -2
  18. package/docs/guides/writing-in-invented-scripts.md +453 -569
  19. package/docs/reference/format-details.md +290 -380
  20. package/docs/reference/note-types.md +80 -62
  21. package/docs/source-preview.md +8 -0
  22. package/e2e.mjs +1 -1
  23. package/engine/base-compiler.mjs +27 -9
  24. package/engine/content-charset.mjs +21 -1
  25. package/engine/content-format.yaml +7 -2
  26. package/engine/content-index.mjs +6 -2
  27. package/engine/content-links.mjs +1 -0
  28. package/engine/content-scripts.mjs +658 -423
  29. package/engine/content-scroll.mjs +4 -4
  30. package/engine/content-spans.mjs +40 -9
  31. package/engine/content-verse.mjs +1 -1
  32. package/engine/converter-data.mjs +70 -0
  33. package/engine/dictionary-rows.mjs +2 -34
  34. package/engine/encoding-integrity.mjs +94 -0
  35. package/engine/encoding-language.mjs +506 -0
  36. package/engine/encoding-lexicon.mjs +699 -0
  37. package/engine/encoding-notes.mjs +186 -0
  38. package/engine/encoding-standard.mjs +42 -0
  39. package/engine/encoding-template.mjs +313 -0
  40. package/engine/encoding-terms.mjs +141 -0
  41. package/engine/encoding.mjs +946 -0
  42. package/engine/extension-attributes.mjs +2 -0
  43. package/engine/helpers.mjs +1 -1
  44. package/engine/index.mjs +10 -0
  45. package/engine/language-data.mjs +4 -1
  46. package/engine/language-terms.mjs +23 -12
  47. package/engine/{logographic.mjs → language-writings.mjs} +29 -38
  48. package/engine/markup-validation.mjs +1 -1
  49. package/engine/note-vocabulary.mjs +4 -158
  50. package/engine/pdf-render.mjs +31 -3
  51. package/engine/roman-characters.mjs +232 -0
  52. package/engine/roman-run.mjs +149 -0
  53. package/engine/roman-verse.mjs +95 -0
  54. package/engine/run-rows.mjs +29 -31
  55. package/engine/run-words.mjs +2 -2
  56. package/engine/script-emphasis-terms.mjs +3 -0
  57. package/engine/script-emphasis.mjs +3 -10
  58. package/engine/script-font-delivery.mjs +9 -12
  59. package/engine/script-fonts.mjs +2 -1
  60. package/engine/site-build.mjs +14 -5
  61. package/engine/site-preview.mjs +12 -2
  62. package/engine/sql-tables.mjs +36 -31
  63. package/engine/verse-metre.mjs +1 -1
  64. package/engine/word-forms.mjs +1 -1
  65. package/engine/word-inflections.mjs +48 -0
  66. package/engine/word-rows.mjs +1 -1
  67. package/engine/word-terms.mjs +16 -17
  68. package/package.json +11 -5
  69. package/types/config.d.mts +5 -0
  70. package/types/convert.d.mts +44 -0
  71. package/types/e2e.d.mts +1 -1
  72. package/types/engine/base-compiler.d.mts +4 -2
  73. package/types/engine/content-charset.d.mts +8 -0
  74. package/types/engine/content-index.d.mts +4 -1
  75. package/types/engine/content-scripts.d.mts +108 -42
  76. package/types/engine/converter-data.d.mts +11 -0
  77. package/types/engine/dictionary-rows.d.mts +1 -25
  78. package/types/engine/encoding-integrity.d.mts +14 -0
  79. package/types/engine/encoding-language.d.mts +36 -0
  80. package/types/engine/encoding-lexicon.d.mts +259 -0
  81. package/types/engine/encoding-notes.d.mts +39 -0
  82. package/types/engine/encoding-standard.d.mts +11 -0
  83. package/types/engine/encoding-template.d.mts +43 -0
  84. package/types/engine/encoding-terms.d.mts +105 -0
  85. package/types/engine/encoding.d.mts +155 -0
  86. package/types/engine/helpers.d.mts +1 -1
  87. package/types/engine/index.d.mts +5 -0
  88. package/types/engine/language-terms.d.mts +1 -1
  89. package/types/engine/{logographic.d.mts → language-writings.d.mts} +6 -6
  90. package/types/engine/markup-validation.d.mts +1 -1
  91. package/types/engine/pdf-render.d.mts +1 -1
  92. package/types/engine/roman-characters.d.mts +45 -0
  93. package/types/engine/roman-run.d.mts +101 -0
  94. package/types/engine/roman-verse.d.mts +23 -0
  95. package/types/engine/run-rows.d.mts +13 -20
  96. package/types/engine/script-emphasis-terms.d.mts +2 -0
  97. package/types/engine/script-emphasis.d.mts +1 -1
  98. package/types/engine/script-font-delivery.d.mts +2 -3
  99. package/types/engine/script-fonts.d.mts +5 -0
  100. package/types/engine/site-preview.d.mts +1 -1
  101. package/types/engine/word-inflections.d.mts +25 -0
  102. package/engine/map-lexicon.mjs +0 -1034
  103. package/engine/text-maps.mjs +0 -654
  104. package/engine/written-in.mjs +0 -204
  105. package/types/engine/map-lexicon.d.mts +0 -330
  106. package/types/engine/text-maps.d.mts +0 -189
  107. package/types/engine/written-in.d.mts +0 -76
@@ -14,7 +14,7 @@
14
14
  /**
15
15
  * **Delivering the installed faces to each target.** A consumer that installs
16
16
  * `@heroiclands/fonts` and sets text in one of its faces, with `font=` or by a
17
- * language note's `data.writtenIn`, gets the fonts on the site, in the Foundry
17
+ * encoding document's `data.font`, gets the fonts on the site, in the Foundry
18
18
  * module and in its books; without both, nothing ships and nothing links to it.
19
19
  *
20
20
  * - **Site and Foundry** receive `fonts.css`, listing only the WOFF2 sources,
@@ -32,7 +32,6 @@ import YAML from "yaml";
32
32
  import { baseStyleLayer } from "./base-styles.mjs";
33
33
  import { scanRuns } from "./content-scripts.mjs";
34
34
  import { FONTS_MANIFEST, resolveScriptFonts } from "./script-fonts.mjs";
35
- import { LANGUAGE_SUBTYPE } from "./word-terms.mjs";
36
35
 
37
36
  /**
38
37
  * Where the files land in the staged Foundry module, relative to its root.
@@ -72,14 +71,14 @@ const usedFaces = new WeakMap();
72
71
  const FRONTMATTER = /^\uFEFF?---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/;
73
72
 
74
73
  /**
75
- * The face a language note names in `data.writtenIn`, where it is installed.
74
+ * The face an encoding document names in `data.font`, where it is installed.
76
75
  *
77
76
  * @param {string} source - The note's file.
78
77
  * @param {import("./script-fonts.mjs").ScriptFonts} installed - The fonts package.
79
78
  * @param {import("./content-session.mjs").ContentNoteSource} [parsed] - Already parsed authored note.
80
79
  * @returns {string|undefined} The face's slug.
81
80
  */
82
- function writtenInFace(source, installed, parsed) {
81
+ function encodingNoteFace(source, installed, parsed) {
83
82
  const yaml = FRONTMATTER.exec(source)?.[1];
84
83
  if (yaml === undefined) return undefined;
85
84
  let fm;
@@ -88,18 +87,16 @@ function writtenInFace(source, installed, parsed) {
88
87
  } catch {
89
88
  return undefined;
90
89
  }
91
- if (fm?.type !== "skill" || String(fm.subType ?? "").toLowerCase() !== LANGUAGE_SUBTYPE)
92
- return undefined;
93
- const font = fm.data?.writtenIn?.font;
90
+ if (fm?.type !== "doc" || !fm.data?.mappings || !fm.data?.rules) return undefined;
91
+ const font = fm.data?.font;
94
92
  return typeof font === "string" && installed.faces.has(font) ? font : undefined;
95
93
  }
96
94
 
97
95
  /**
98
96
  * The faces the package's notes set text in, read from the notes
99
97
  * themselves: every Markdown file under the content tree that writes `font=`
100
- * is scanned for its runs, and the face every language note names in
101
- * `data.writtenIn` counts, since a run marked with the language alone is set
102
- * in it.
98
+ * is scanned for its runs, and encoding documents supply the fonts their
99
+ * encoded runs infer.
103
100
  *
104
101
  * @param {DeliveryConfig} config - The resolved configuration.
105
102
  * @param {import("./script-fonts.mjs").ScriptFonts} installed - The fonts package.
@@ -123,8 +120,8 @@ export function facesUsed(config, installed, session) {
123
120
  if (entry.isDirectory()) walk(full);
124
121
  else if (entry.isFile() && entry.name.endsWith(".md")) {
125
122
  const source = session?.sources.get(full)?.raw ?? fs.readFileSync(full, "utf8");
126
- if (/^\s*writtenIn:/m.test(source)) {
127
- const face = writtenInFace(source, installed, session?.parsed.get(full));
123
+ if (/^\s*font:/m.test(source)) {
124
+ const face = encodingNoteFace(source, installed, session?.parsed.get(full));
128
125
  if (face) used.add(face);
129
126
  }
130
127
  if (!/\bfont=/.test(source)) continue;
@@ -17,7 +17,7 @@
17
17
  * each one draws — in its `fonts.json`, and this module reads them from the
18
18
  * **consuming package's** install.
19
19
  *
20
- * package-build declares no dependency on `@heroiclands/fonts` in any field:
20
+ * package-build declares no runtime dependency on `@heroiclands/fonts`:
21
21
  * the fonts package depends on package-build to build, so the consumer adds it
22
22
  * as a devDependency of its own and package-build resolves it from the
23
23
  * consumer's root. Resolving from this module's own location would find
@@ -42,6 +42,7 @@ export const FONTS_MANIFEST = "fonts.json";
42
42
  * @typedef {object} FaceCodepoint
43
43
  * @property {string} codepoint - `U+` and the hexadecimal code point.
44
44
  * @property {string} glyph - The glyph's name in the face.
45
+ * @property {string} [code] - The font's case-sensitive glyph code.
45
46
  * @property {string} kind - What it is: `letter`, `numeral`, `mark` and so on.
46
47
  */
47
48
 
@@ -807,8 +807,8 @@ export function renderSitePage(
807
807
  ...markupAnchorFindings(page.body),
808
808
  ])
809
809
  secretErrors.push({
810
- file: page.file,
811
- line: (page.bodyLine ?? 1) + error.line - 1,
810
+ file: error.file ?? page.file,
811
+ line: error.file ? error.line : (page.bodyLine ?? 1) + error.line - 1,
812
812
  column: error.column,
813
813
  message: error.message,
814
814
  });
@@ -818,8 +818,8 @@ export function renderSitePage(
818
818
  headingErrors.push({ file: page.file, ...error });
819
819
  for (const error of footnoteFindings(page.body))
820
820
  footnoteErrors.push({
821
- file: page.file,
822
- line: (page.bodyLine ?? 1) + error.line - 1,
821
+ file: error.file ?? page.file,
822
+ line: error.file ? error.line : (page.bodyLine ?? 1) + error.line - 1,
823
823
  column: error.column,
824
824
  message: error.message,
825
825
  });
@@ -1008,11 +1008,20 @@ export function renderPages(pages, options) {
1008
1008
  drawings: maps,
1009
1009
  runs,
1010
1010
  });
1011
+ const externalRunFindings = new Set();
1011
1012
  for (const page of pages) {
1012
1013
  const result = renderOne(page);
1013
1014
  tableErrors.push(...result.tableErrors);
1014
1015
  expressionErrors.push(...result.expressionErrors);
1015
- secretErrors.push(...result.secretErrors);
1016
+ secretErrors.push(
1017
+ ...result.secretErrors.filter((error) => {
1018
+ if (!error.file || error.file === page.file) return true;
1019
+ const key = JSON.stringify([error.file, error.line, error.column, error.message]);
1020
+ if (externalRunFindings.has(key)) return false;
1021
+ externalRunFindings.add(key);
1022
+ return true;
1023
+ }),
1024
+ );
1016
1025
  captionErrors.push(...result.captionErrors);
1017
1026
  headingErrors.push(...result.headingErrors);
1018
1027
  footnoteErrors.push(...result.footnoteErrors);
@@ -21,6 +21,7 @@ import { PREVIEW_HELPERS, colored, wikiSource } from "./preview-source.mjs";
21
21
  import { dataFields } from "./note-vocabulary.mjs";
22
22
  import { applyComputedBeingAge } from "./being-age.mjs";
23
23
  import { DEFAULT_CURRENT_DATE, normalizeCurrentDate } from "./current-date.mjs";
24
+ import { scanRuns } from "./content-scripts.mjs";
24
25
 
25
26
  export { PREVIEW_HELPERS };
26
27
 
@@ -76,7 +77,7 @@ function localInfobox(fm, md) {
76
77
  * @param {string} text - Markdown, optionally beginning with YAML frontmatter.
77
78
  * @param {{dark?: boolean, infobox?: boolean, file?: string, currentDate?: string,
78
79
  * index?: import("./preview-index.mjs").PreviewIndex}} [options] - Explicit palette, profile visibility, diagnostic filename, neutral current date (default "720.1") and the content index, from {@link module:engine/preview-index.previewIndex}.
79
- * @returns {{ok: boolean, html: string, frontmatter: object, findings: object[]}} HTML and metadata with locally calculable ages; unavailable ages remain absent or null.
80
+ * @returns {{ok: boolean, html: string, frontmatter: object, findings: object[]}} HTML and metadata with locally calculable ages; unavailable ages remain absent or null. Error findings set `ok` to false while retaining the HTML.
80
81
  */
81
82
  export function renderPreview(
82
83
  text,
@@ -84,6 +85,7 @@ export function renderPreview(
84
85
  ) {
85
86
  const date = normalizeCurrentDate(currentDate);
86
87
  const findings = [];
88
+ let bodyLine = 1;
87
89
  let frontmatter = {},
88
90
  body = String(text ?? "");
89
91
  try {
@@ -92,6 +94,7 @@ export function renderPreview(
92
94
  parsed.data && typeof parsed.data === "object" && !Array.isArray(parsed.data) ?
93
95
  structuredClone(parsed.data)
94
96
  : {};
97
+ bodyLine = body.slice(0, body.length - parsed.content.length).split("\n").length;
95
98
  body = parsed.content;
96
99
  } catch (error) {
97
100
  findings.push({ file, severity: "warning", message: String(error.message) });
@@ -123,6 +126,13 @@ export function renderPreview(
123
126
  let markdown = authored;
124
127
  let figures = scan.figures;
125
128
  if (indexed) {
129
+ for (const error of scanRuns(body, index.runs).errors)
130
+ findings.push({
131
+ file,
132
+ severity: "error",
133
+ ...error,
134
+ line: error.file && error.file !== file ? error.line : bodyLine + error.line - 1,
135
+ });
126
136
  const expanded = index.expand(authored, { fm: frontmatter, file });
127
137
  markdown = expanded.markdown;
128
138
  figures = expanded.figures;
@@ -134,7 +144,7 @@ export function renderPreview(
134
144
  });
135
145
  const profile = infobox ? localInfobox(frontmatter, renderer.md) : "";
136
146
  return {
137
- ok: true,
147
+ ok: !findings.some((finding) => finding.severity === "error"),
138
148
  html: `<div class="hl-preview hl-preview-${dark ? "dark" : "light"}">${profile}<main class="hl-preview-main">${content}</main></div>`,
139
149
  frontmatter,
140
150
  findings,
@@ -36,8 +36,7 @@ import { EVENT_COLUMNS, EVENT_SORT_COLUMNS, eventCalendars, eventRows } from "./
36
36
  import { reckoningContext } from "./reckoning-markers.mjs";
37
37
  import { NAME_COLUMNS, namesRows } from "./name-rows.mjs";
38
38
  import { RUN_COLUMNS, ownerOf, runsRows } from "./run-rows.mjs";
39
- import { MAP_COLUMNS, mapRows } from "./text-maps.mjs";
40
- import { NATIVE_COLUMNS, nativeRows } from "./map-lexicon.mjs";
39
+ import { NATIVE_COLUMNS, nativeRows } from "./encoding-lexicon.mjs";
41
40
  import {
42
41
  GLOSSARY_COLUMNS,
43
42
  AFFIX_COLUMNS,
@@ -217,6 +216,31 @@ export function findSqlBlocks(markdown) {
217
216
  return blocks;
218
217
  }
219
218
 
219
+ /** Keep encoding source keys as case-sensitive values in the SQL projection.
220
+ * The canonical index retains its authored mapping object; DuckDB structs
221
+ * cannot distinguish a and A as field names.
222
+ */
223
+ function sqlRecord(record) {
224
+ const mappings = record?.data?.mappings;
225
+ if (
226
+ record?.type !== "doc" ||
227
+ !mappings ||
228
+ typeof mappings !== "object" ||
229
+ Array.isArray(mappings)
230
+ )
231
+ return record;
232
+ return {
233
+ ...record,
234
+ data: {
235
+ ...record.data,
236
+ mappings: Object.entries(mappings).map(([from, value]) => ({
237
+ from,
238
+ codes: Array.isArray(value) ? value : [value],
239
+ })),
240
+ },
241
+ };
242
+ }
243
+
220
244
  /**
221
245
  * Open a DuckDB view over the content index.
222
246
  *
@@ -274,7 +298,7 @@ export async function openNotesDatabase(
274
298
  const base = dir ?? fs.mkdtempSync(path.join(os.tmpdir(), "content-sql-"));
275
299
  fs.mkdirSync(base, { recursive: true });
276
300
  const jsonl = path.join(base, "notes.jsonl");
277
- const encoded = records.map((record) => encodeAddresses(record));
301
+ const encoded = records.map((record) => sqlRecord(encodeAddresses(record)));
278
302
 
279
303
  const instance = await DuckDBInstance.create(":memory:");
280
304
  const connection = await instance.connect();
@@ -284,7 +308,7 @@ export async function openNotesDatabase(
284
308
  } catch (err) {
285
309
  // DuckDB names the clashing key but not the notes that spell it.
286
310
  if (/Duplicate name ".*" in struct/.test(String(err?.message))) {
287
- const lines = keyCaseCollisionDiagnostics(records, { contentBase });
311
+ const lines = keyCaseCollisionDiagnostics(encoded, { contentBase });
288
312
  if (lines.length) {
289
313
  const located = new Error(lines.join("\n"));
290
314
  /** @type {any} */ (located).located = true;
@@ -296,7 +320,7 @@ export async function openNotesDatabase(
296
320
  await createEventRelation(connection, path.join(base, "events.jsonl"), encoded, "", audience);
297
321
  await createNameRelation(connection, path.join(base, "names.jsonl"), encoded, "", audience);
298
322
  await createRunRelation(connection, path.join(base, "runs.jsonl"), encoded, "", audience);
299
- await createMapRelation(connection, path.join(base, "maps.jsonl"), encoded, "", audience);
323
+ await createNativeRelation(connection, path.join(base, "natives.jsonl"), encoded, "", audience);
300
324
  await createWordRelations(connection, path.join(base, "notes"), encoded, "", audience);
301
325
 
302
326
  // One schema per declared dependency, so `FROM sohl.notes` reads the notes
@@ -317,7 +341,7 @@ export async function openNotesDatabase(
317
341
  .readFileSync(dep.file, "utf8")
318
342
  .split("\n")
319
343
  .filter((line) => line.trim())
320
- .map((line) => flattenPublishedAddresses(JSON.parse(line)));
344
+ .map((line) => sqlRecord(flattenPublishedAddresses(JSON.parse(line))));
321
345
  await createRelations(connection, flat, depRecords, `${schema}.`, audience);
322
346
  await createEventRelation(
323
347
  connection,
@@ -340,9 +364,9 @@ export async function openNotesDatabase(
340
364
  `${schema}.`,
341
365
  audience,
342
366
  );
343
- await createMapRelation(
367
+ await createNativeRelation(
344
368
  connection,
345
- flat.replace(/\.jsonl$/, "-maps.jsonl"),
369
+ flat.replace(/\.jsonl$/, "-natives.jsonl"),
346
370
  depRecords,
347
371
  `${schema}.`,
348
372
  audience,
@@ -382,7 +406,6 @@ export async function openNotesDatabase(
382
406
  "events",
383
407
  "names",
384
408
  "runs",
385
- "maps",
386
409
  "words",
387
410
  "affixes",
388
411
  "fields",
@@ -643,7 +666,7 @@ async function createNameRelation(connection, file, records, prefix, audience) {
643
666
 
644
667
  /**
645
668
  * Create the `runs` relation: one row per span, div and verse fence an own
646
- * note marks with `lang=`, `font=` or `map=`, with the columns
669
+ * note marks with `lang=`, `font=` or `enc=`, with the columns
647
670
  * {@link module:engine/run-rows.RUN_COLUMNS} names. The columns are declared
648
671
  * rather than inferred, so every value stays a string and none becomes a
649
672
  * struct key whose case DuckDB folds.
@@ -665,30 +688,12 @@ async function createRunRelation(connection, file, records, prefix, audience) {
665
688
  ]);
666
689
  }
667
690
 
668
- /**
669
- * Create the `maps` relation: one row per row of a mapping note's
670
- * `data.map`, with the columns {@link module:engine/text-maps.MAP_COLUMNS}
671
- * names; and the `natives` relation, one row per native form of a language's
672
- * word, with the columns {@link module:engine/map-lexicon.NATIVE_COLUMNS}
673
- * names. A row is a row here, never a struct key, so `A` and `a` are two rows.
674
- *
675
- * @param {object} connection - An open DuckDB connection.
676
- * @param {string} file - Where to write the rows.
677
- * @param {object[]} records - Index records, every Address a string.
678
- * @param {string} prefix - A schema to qualify the relation name with.
679
- * @param {"all"|"public"} audience - Whether to leave out GM notes.
680
- * @returns {Promise<void>}
681
- */
682
- async function createMapRelation(connection, file, records, prefix, audience) {
683
- const rows = mapRows(
684
- audience === "public" ? records.filter((record) => !isGmNote(record)) : records,
685
- ownerOf,
686
- );
687
- await createTypedRelation(connection, file, `${prefix}maps`, rows, MAP_COLUMNS, ["row"]);
691
+ /** Project authored native entries into the natives SQL relation. */
692
+ async function createNativeRelation(connection, file, records, prefix, audience) {
688
693
  const visible = audience === "public" ? records.filter((record) => !isGmNote(record)) : records;
689
694
  await createTypedRelation(
690
695
  connection,
691
- file.replace(/maps\.jsonl$/, "natives.jsonl"),
696
+ file,
692
697
  `${prefix}natives`,
693
698
  nativeRows(visible),
694
699
  NATIVE_COLUMNS,
@@ -52,7 +52,7 @@
52
52
  import { nearest } from "./near-miss.mjs";
53
53
  import { isNoteRecord } from "./index-records.mjs";
54
54
  import { METRE_KINDS, METRE_NAME } from "./metre-declaration.mjs";
55
- import { languageRecordsByName } from "./map-lexicon.mjs";
55
+ import { languageRecordsByName } from "./encoding-lexicon.mjs";
56
56
  import { STRESS_LONGER, STRESS_MARKS, STRESS_TWO } from "./language-terms.mjs";
57
57
  import { parseWordClass } from "./word-classes.mjs";
58
58
  import { derivedForms, foldWord } from "./word-forms.mjs";
@@ -50,7 +50,7 @@
50
50
  * @module
51
51
  */
52
52
 
53
- import { pluralByRule, pluralOf } from "./dictionary-rows.mjs";
53
+ import { pluralByRule, pluralOf } from "./word-inflections.mjs";
54
54
  import { replaceClosing, verbForm, verbPattern } from "./verb-pattern.mjs";
55
55
  import { parseWordClass } from "./word-classes.mjs";
56
56
  import { WORD } from "./word-pattern.mjs";
@@ -0,0 +1,48 @@
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
+ import { parseWordClass } from "./word-classes.mjs";
15
+
16
+ /**
17
+ * A noun's stated plural, or the first literal ending replacement that fits.
18
+ *
19
+ * @param {{form: string, class?: unknown, plural?: unknown}} word - A word of
20
+ * `data.words`.
21
+ * @param {Record<string, any>|undefined} grammar - Its language's
22
+ * `data.grammar`.
23
+ * @returns {string|null} The plural, or `null` for a word that is no noun or
24
+ * that no rule fits.
25
+ */
26
+ export function pluralOf(word, grammar) {
27
+ if (parseWordClass(word.class).base !== "n") return null;
28
+ if (typeof word.plural === "string" && word.plural) return word.plural;
29
+ return pluralByRule(word.form, grammar);
30
+ }
31
+
32
+ /**
33
+ * The plural a language's `data.grammar.plural` rule gives a form: the first
34
+ * literal ending replacement that fits, whatever the form's class.
35
+ *
36
+ * @param {string} form - The form.
37
+ * @param {Record<string, any>|undefined} grammar - Its language's
38
+ * `data.grammar`.
39
+ * @returns {string|null} The plural, or `null` where no rule fits.
40
+ */
41
+ export function pluralByRule(form, grammar) {
42
+ for (const rule of Array.isArray(grammar?.plural) ? grammar.plural : []) {
43
+ if (typeof rule?.ending !== "string" || typeof rule.becomes !== "string") continue;
44
+ if (form.endsWith(rule.ending))
45
+ return (rule.ending ? form.slice(0, -rule.ending.length) : form) + rule.becomes;
46
+ }
47
+ return null;
48
+ }
@@ -71,7 +71,7 @@ export const GLOSSARY_COLUMNS = Object.freeze(["note", "position", ...GLOSSARY_E
71
71
 
72
72
  /**
73
73
  * The affix's own keys an affix row carries: all but `native`, its writings
74
- * under a logographic map, which `package-build lexicon` resolves.
74
+ * under an encoding, which `package-build lexicon` resolves.
75
75
  */
76
76
  const AFFIX_ROW_KEYS = Object.freeze(AFFIX_KEYS.filter((key) => key !== "native"));
77
77
 
@@ -106,28 +106,28 @@ export const WORD_SOURCE_FIELDS = Object.freeze([
106
106
  */
107
107
  export const WORD_NATIVE_FIELDS = Object.freeze([
108
108
  Object.freeze({
109
- name: "map",
109
+ name: "enc",
110
110
  kind: "string",
111
111
  required: true,
112
- shape: "a mapping note's `data.map.name`",
113
- describe: "The map the native form is written for.",
112
+ shape: "an encoding document Address",
113
+ describe: "The encoding the native form is written for.",
114
114
  }),
115
115
  Object.freeze({
116
116
  name: "font",
117
117
  kind: "string",
118
- shape: "a face the map lists",
118
+ shape: "a face the encoding lists",
119
119
  describe:
120
- "The one face the native form is for; unstated, it is for every face the map lists, " +
120
+ "The one face the native form is for; unstated, it is for every face the encoding lists, " +
121
121
  "and a form stated for a face wins over it there.",
122
122
  }),
123
123
  Object.freeze({
124
124
  name: "text",
125
125
  kind: "string",
126
126
  required: true,
127
- shape: "the word in the script, as the map's characters",
127
+ shape: "the word in the script, as the encoding's characters",
128
128
  describe:
129
- "The word as the script writes it, where the map's rows would write it otherwise, " +
130
- "or as a logographic map writes it.",
129
+ "The word as the script writes it, where the encoding's rows would write it otherwise, " +
130
+ "or by an explicit native spelling.",
131
131
  }),
132
132
  ]);
133
133
 
@@ -253,17 +253,16 @@ export const WORD_FIELDS = Object.freeze([
253
253
  Object.freeze({
254
254
  name: "native",
255
255
  kind: "list",
256
- shape: "a list of `{ map, font?, text }`",
256
+ shape: "a list of `{ enc, font?, text }`",
257
257
  entries: Object.freeze({
258
258
  kind: "map",
259
- shape: "a map — `{ map, font?, text }`",
259
+ shape: "a map — `{ enc, font?, text }`",
260
260
  fields: WORD_NATIVE_FIELDS,
261
261
  }),
262
262
  describe:
263
- "The word's native form under a map. Under a map that spells by its rows it is " +
264
- "stated only where the rows spell the word differently, and the word's derived " +
265
- "forms are spelled by the rows; under a logographic map it is the word's writing, " +
266
- "which its derived forms and the words built on it are written from.",
263
+ "The word's own writing for an encoding and optional face. It precedes complete " +
264
+ "parts and the encoding's rows; an entry for a face precedes an unqualified entry. " +
265
+ "Derived forms can use the stem and explicitly written grammatical endings.",
267
266
  }),
268
267
  ]);
269
268
 
@@ -426,14 +425,14 @@ export const AFFIX_FIELDS = Object.freeze([
426
425
  Object.freeze({
427
426
  name: "native",
428
427
  kind: "list",
429
- shape: "a list of `{ map, font?, text }`",
428
+ shape: "a list of `{ enc, font?, text }`",
430
429
  entries: Object.freeze({
431
430
  kind: "map",
432
- shape: "a map — `{ map, font?, text }`",
431
+ shape: "a map — `{ enc, font?, text }`",
433
432
  fields: WORD_NATIVE_FIELDS,
434
433
  }),
435
434
  describe:
436
- "The affix's writing under a logographic map, which a word whose `built` names the " +
435
+ "The affix's writing under an encoding, which a word whose `built` names the " +
437
436
  "affix is written with.",
438
437
  }),
439
438
  ]);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@heroiclands/package-build",
3
- "version": "24.1.1",
4
- "description": "Shared toolchain for building and shipping a HeroicLands Foundry VTT package — content compilation, manifest, localization, staging, bundle, release and deployment.",
3
+ "version": "25.1.0",
4
+ "description": "Shared toolchain for building and shipping a HeroicLands Foundry VTT package \u2014 content compilation, manifest, localization, staging, bundle, release and deployment.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "type": "module",
7
7
  "main": "./index.mjs",
@@ -97,7 +97,12 @@
97
97
  "import": "./templates.mjs"
98
98
  },
99
99
  "./changelog": "./changelog.cjs",
100
- "./package.json": "./package.json"
100
+ "./package.json": "./package.json",
101
+ "./convert": {
102
+ "types": "./types/convert.d.mts",
103
+ "import": "./convert.mjs",
104
+ "default": "./convert.mjs"
105
+ }
101
106
  },
102
107
  "bin": {
103
108
  "package-build": "./bin/package-build.mjs",
@@ -134,7 +139,8 @@
134
139
  "stage.mjs",
135
140
  "templates.mjs",
136
141
  "types",
137
- "vendor"
142
+ "vendor",
143
+ "convert.mjs"
138
144
  ],
139
145
  "dependencies": {
140
146
  "@duckdb/node-api": "^1.5.5-r.4",
@@ -184,7 +190,7 @@
184
190
  },
185
191
  "devDependencies": {
186
192
  "@changesets/cli": "^3.0.0",
187
- "@heroiclands/fonts": "^0.1.5",
193
+ "@heroiclands/fonts": "^1.0.0",
188
194
  "@types/node": "^26.2.0",
189
195
  "esbuild": "^0.28.2",
190
196
  "markdownlint": "^0.42.0",
@@ -113,6 +113,7 @@ export const DERIVED_MANIFEST_KEYS: Readonly<Record<string, string>>;
113
113
  * named first in the manifest's `styles`.
114
114
  * @property {Readonly<Record<string, unknown>>} manifest The manifest
115
115
  * specification, emitted as declared.
116
+ * @property {string|null} converterData Optional JSON path relative to the build stage.
116
117
  * @property {string|null} manifestFlags Module to load a `flags` function
117
118
  * from, for namespaced flags a repository has
118
119
  * to compute. `null` when it declares none.
@@ -260,6 +261,10 @@ export type PackageBuildConfig = {
260
261
  * specification, emitted as declared.
261
262
  */
262
263
  manifest: Readonly<Record<string, unknown>>;
264
+ /**
265
+ * Optional JSON path relative to the build stage.
266
+ */
267
+ converterData: string | null;
263
268
  /**
264
269
  * Module to load a `flags` function
265
270
  * from, for namespaced flags a repository has
@@ -0,0 +1,44 @@
1
+ /** Validate the format-2 converter data without opening files or using Node.
2
+ * @param {ConverterData} data Parsed JSON, treated as read-only.
3
+ * @returns {ConverterData} The validated data.
4
+ */
5
+ export function validateData(data: ConverterData): ConverterData;
6
+ /** Encode with the selected encoding document's language, optionally stated explicitly.
7
+ * @param {ConverterData} data The module's parsed converter data.
8
+ * @param {string|null|undefined} language A language key, or null to infer it from the encoding.
9
+ * @param {string} text Roman input.
10
+ * @param {ConversionOptions} [options] Encoding, face or verse overrides.
11
+ */
12
+ export function encode(data: ConverterData, language: string | null | undefined, text: string, options?: ConversionOptions): {
13
+ text: string;
14
+ findings: object[];
15
+ };
16
+ /** Decode with the selected encoding document's optional language lexicon.
17
+ * @param {ConverterData} data The module's parsed converter data.
18
+ * @param {string|null|undefined} language A language key, or null to infer it from the encoding.
19
+ * @param {string} text Encoded Unicode input.
20
+ * @param {ConversionOptions} [options] Encoding or face overrides.
21
+ */
22
+ export function decode(data: ConverterData, language: string | null | undefined, text: string, options?: ConversionOptions): {
23
+ text: string;
24
+ findings: object[];
25
+ };
26
+ export type ConverterLexicon = {
27
+ language: string;
28
+ data: Record<string, any>;
29
+ };
30
+ export type ConverterLanguage = {
31
+ name: string;
32
+ lexicon?: ConverterLexicon;
33
+ };
34
+ export type ConverterData = {
35
+ format: 2;
36
+ contentPackage: string;
37
+ encodings: Record<string, any>;
38
+ languages: Record<string, ConverterLanguage>;
39
+ };
40
+ export type ConversionOptions = {
41
+ enc?: string;
42
+ font?: string;
43
+ verse?: boolean;
44
+ };
package/types/e2e.d.mts CHANGED
@@ -494,7 +494,7 @@ export const E2E_SCENE_ID: "heroiclandsScene";
494
494
  /** How the suite may be run. */
495
495
  export const E2E_MODES: readonly string[];
496
496
  /** The Chrome for Testing build shared by local and CI Cypress runs. */
497
- export const CHROME_BUILD_ID: "158.0.8110.0";
497
+ export const CHROME_BUILD_ID: "158.0.8122.0";
498
498
  /**
499
499
  * The disposable world's identity and credentials.
500
500
  */
@@ -476,10 +476,11 @@ export class BasePackCompiler {
476
476
  * naming the file alone beats naming a line that is not the problem.
477
477
  *
478
478
  * @param {string} message - What is wrong, in one sentence.
479
- * @param {{line?: number, column?: number}} [position] - Where, if known.
479
+ * @param {{file?: string, line?: number, column?: number}} [position] - Where, if known.
480
480
  * @returns {void}
481
481
  */
482
482
  noteWarn(message: string, position?: {
483
+ file?: string;
483
484
  line?: number;
484
485
  column?: number;
485
486
  }): void;
@@ -487,10 +488,11 @@ export class BasePackCompiler {
487
488
  * Reports an error about the note being compiled.
488
489
  *
489
490
  * @param {string} message - What is wrong, in one sentence.
490
- * @param {{line?: number, column?: number}} [position] - Where, if known.
491
+ * @param {{file?: string, line?: number, column?: number}} [position] - Where, if known.
491
492
  * @returns {void}
492
493
  */
493
494
  noteError(message: string, position?: {
495
+ file?: string;
494
496
  line?: number;
495
497
  column?: number;
496
498
  }): void;
@@ -9,6 +9,14 @@
9
9
  * @returns {boolean} Whether Tier 1 admits it.
10
10
  */
11
11
  export function isLetterTier(cp: number): boolean;
12
+ /**
13
+ * Whether a code point belongs to a declared setting-script encoding block.
14
+ * This identifies native input independently of a language's Roman inventory;
15
+ * a selected native face must still draw the individual character.
16
+ * @param {number} cp - A Unicode code point.
17
+ * @returns {boolean}
18
+ */
19
+ export function isSettingScriptCodePoint(cp: number): boolean;
12
20
  /**
13
21
  * Whether the charset admits a code point anywhere in a note.
14
22
  *