@heroiclands/package-build 25.0.0 → 25.1.1

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 (105) hide show
  1. package/CHANGELOG.md +26 -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 +144 -6
  9. package/docs/authoring/links-and-markup.md +260 -481
  10. package/docs/commands.md +110 -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 +193 -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 +458 -575
  19. package/docs/reference/format-details.md +292 -404
  20. package/docs/reference/note-types.md +83 -63
  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 +13 -1
  25. package/engine/content-format.yaml +6 -2
  26. package/engine/content-index.mjs +6 -2
  27. package/engine/content-links.mjs +1 -0
  28. package/engine/content-scripts.mjs +614 -459
  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 +701 -0
  37. package/engine/encoding-notes.mjs +186 -0
  38. package/engine/encoding-standard.mjs +42 -0
  39. package/engine/encoding-template.mjs +370 -0
  40. package/engine/encoding-terms.mjs +178 -0
  41. package/engine/encoding.mjs +952 -0
  42. package/engine/extension-attributes.mjs +2 -0
  43. package/engine/helpers.mjs +1 -1
  44. package/engine/index.mjs +7 -0
  45. package/engine/language-terms.mjs +17 -16
  46. package/engine/{logographic.mjs → language-writings.mjs} +29 -38
  47. package/engine/markup-validation.mjs +1 -1
  48. package/engine/note-vocabulary.mjs +4 -158
  49. package/engine/pdf-render.mjs +31 -3
  50. package/engine/roman-characters.mjs +135 -16
  51. package/engine/roman-run.mjs +149 -0
  52. package/engine/roman-verse.mjs +95 -0
  53. package/engine/run-rows.mjs +29 -31
  54. package/engine/run-words.mjs +2 -2
  55. package/engine/script-emphasis-terms.mjs +3 -0
  56. package/engine/script-emphasis.mjs +3 -10
  57. package/engine/script-font-delivery.mjs +9 -12
  58. package/engine/script-fonts.mjs +2 -1
  59. package/engine/site-build.mjs +14 -5
  60. package/engine/site-preview.mjs +12 -2
  61. package/engine/sql-tables.mjs +36 -31
  62. package/engine/verse-metre.mjs +1 -1
  63. package/engine/word-forms.mjs +1 -1
  64. package/engine/word-inflections.mjs +48 -0
  65. package/engine/word-rows.mjs +1 -1
  66. package/engine/word-terms.mjs +16 -17
  67. package/package.json +11 -5
  68. package/types/config.d.mts +5 -0
  69. package/types/convert.d.mts +44 -0
  70. package/types/e2e.d.mts +1 -1
  71. package/types/engine/base-compiler.d.mts +4 -2
  72. package/types/engine/content-index.d.mts +4 -1
  73. package/types/engine/content-scripts.d.mts +103 -42
  74. package/types/engine/converter-data.d.mts +11 -0
  75. package/types/engine/dictionary-rows.d.mts +1 -25
  76. package/types/engine/encoding-integrity.d.mts +14 -0
  77. package/types/engine/encoding-language.d.mts +36 -0
  78. package/types/engine/encoding-lexicon.d.mts +259 -0
  79. package/types/engine/encoding-notes.d.mts +39 -0
  80. package/types/engine/encoding-standard.d.mts +11 -0
  81. package/types/engine/encoding-template.d.mts +44 -0
  82. package/types/engine/encoding-terms.d.mts +143 -0
  83. package/types/engine/encoding.d.mts +155 -0
  84. package/types/engine/helpers.d.mts +1 -1
  85. package/types/engine/index.d.mts +4 -0
  86. package/types/engine/language-terms.d.mts +1 -1
  87. package/types/engine/{logographic.d.mts → language-writings.d.mts} +6 -6
  88. package/types/engine/markup-validation.d.mts +1 -1
  89. package/types/engine/pdf-render.d.mts +1 -1
  90. package/types/engine/roman-characters.d.mts +2 -2
  91. package/types/engine/roman-run.d.mts +101 -0
  92. package/types/engine/roman-verse.d.mts +23 -0
  93. package/types/engine/run-rows.d.mts +13 -20
  94. package/types/engine/script-emphasis-terms.d.mts +2 -0
  95. package/types/engine/script-emphasis.d.mts +1 -1
  96. package/types/engine/script-font-delivery.d.mts +2 -3
  97. package/types/engine/script-fonts.d.mts +5 -0
  98. package/types/engine/site-preview.d.mts +1 -1
  99. package/types/engine/word-inflections.d.mts +25 -0
  100. package/engine/map-lexicon.mjs +0 -1045
  101. package/engine/text-maps.mjs +0 -654
  102. package/engine/written-in.mjs +0 -204
  103. package/types/engine/map-lexicon.d.mts +0 -330
  104. package/types/engine/text-maps.d.mts +0 -189
  105. package/types/engine/written-in.d.mts +0 -76
@@ -16,7 +16,7 @@
16
16
  * from the right, as a handscroll unrolls.
17
17
  *
18
18
  * ````markdown
19
- * ```scroll {rows=25 cols=12 lang=tsu map=tengchou}
19
+ * ```scroll {rows=25 cols=12 lang=tsu enc=tengchou}
20
20
  * chen teng chou hing
21
21
  * tan tsou dou rou
22
22
  *
@@ -29,7 +29,7 @@
29
29
  * blank line starts the next paragraph, flush at the top of a fresh column.
30
30
  * - **`rows=`** is the characters in a column and **`cols=`** the columns in a
31
31
  * panel, each a whole number above zero. Either may be left out.
32
- * - **`lang=`, `font=` and `map=`** are read by
32
+ * - **`lang=`, `font=` and `enc=`** are read by
33
33
  * {@link module:engine/content-scripts.scanRuns}, which maps the text and
34
34
  * refuses a fence whose script its note does not state is written in
35
35
  * columns.
@@ -56,7 +56,7 @@ export const SCROLL_KEYS = Object.freeze([
56
56
  "cols",
57
57
  "lang",
58
58
  "font",
59
- "map",
59
+ "enc",
60
60
  "data-pagefind-ignore",
61
61
  ]);
62
62
 
@@ -195,7 +195,7 @@ export function scanScrolls(source) {
195
195
  errors.push({
196
196
  line: i + 1,
197
197
  column: columns.key,
198
- message: `\`${key}=\` is not a scroll attribute; a scroll takes \`rows=\`, \`cols=\`, \`lang=\`, \`font=\` and \`map=\``,
198
+ message: `\`${key}=\` is not a scroll attribute; a scroll takes \`rows=\`, \`cols=\`, \`lang=\`, \`font=\` and \`enc=\``,
199
199
  });
200
200
  scroll.invalid = true;
201
201
  } else if (Object.hasOwn(SIZE_KEYS, key)) {
@@ -27,11 +27,11 @@ import { cartoucheProblems, glyphProblems } from "./content-glyphs.mjs";
27
27
  * @param {boolean} [options.spoilers] - Add focusable CSS spoiler wrappers (Foundry).
28
28
  */
29
29
  export function spanMarkdownPlugin(md, { spoilers = false, source = false } = {}) {
30
- if (spoilers) {
30
+ {
31
31
  md.renderer.rules.span_open = (tokens, index, options, env, renderer) => {
32
32
  const token = tokens[index];
33
33
  const secret = (token.attrGet("class") ?? "").split(/\s+/).includes("secret");
34
- if (secret) {
34
+ if (spoilers && secret) {
35
35
  token.attrSet("data-inline-spoiler", "");
36
36
  token.attrSet("tabindex", "0");
37
37
  if (!token.attrGet("title"))
@@ -39,7 +39,18 @@ export function spanMarkdownPlugin(md, { spoilers = false, source = false } = {}
39
39
  if (!token.attrGet("aria-label"))
40
40
  token.attrSet("aria-label", "Spoiler; focus to reveal");
41
41
  }
42
- return renderer.renderToken(tokens, index, options);
42
+ const original = token.attrs;
43
+ if (["em", "strong"].includes(token.tag))
44
+ token.attrs = (token.attrs ?? []).flatMap(([key, value]) => {
45
+ if (key !== "class") return [[key, value]];
46
+ const classes = value
47
+ .split(/\s+/)
48
+ .filter((name) => !["emph", "strong"].includes(name));
49
+ return classes.length ? [[key, classes.join(" ")]] : [];
50
+ });
51
+ const rendered = renderer.renderToken(tokens, index, options);
52
+ token.attrs = original;
53
+ return rendered;
43
54
  };
44
55
  }
45
56
  md.inline.ruler.after("link", "bracket_span", (state, silent) => {
@@ -97,7 +108,7 @@ export function spanMarkdownPlugin(md, { spoilers = false, source = false } = {}
97
108
  // there for ordinary spans, including names used by the first-use audit.
98
109
  if (
99
110
  state.src[start - 1] === "[" &&
100
- !["lang", "font", "map"].some((key) => Object.hasOwn(parsed.values, key)) &&
111
+ !["lang", "font", "enc"].some((key) => Object.hasOwn(parsed.values, key)) &&
101
112
  !parsed.classes.some(
102
113
  (name) => name === "script" || /^font-(?:mono|serif|sans)$/.test(name),
103
114
  )
@@ -110,7 +121,11 @@ export function spanMarkdownPlugin(md, { spoilers = false, source = false } = {}
110
121
  state.pos = finish;
111
122
  return true;
112
123
  }
113
- if (source) {
124
+ const emphasis =
125
+ parsed.classes.includes("emph") ? "em"
126
+ : parsed.classes.includes("strong") ? "strong"
127
+ : undefined;
128
+ if (source && !emphasis) {
114
129
  // An incomplete attribute stays opaque through the end of its line.
115
130
  if (state.src[finish] === "{") {
116
131
  const newline = state.src.indexOf("\n", finish);
@@ -126,7 +141,7 @@ export function spanMarkdownPlugin(md, { spoilers = false, source = false } = {}
126
141
  state.pos = finish;
127
142
  return true;
128
143
  }
129
- const token = state.push("span_open", "span", 1);
144
+ const token = state.push("span_open", emphasis ?? "span", 1);
130
145
  token.meta = {
131
146
  span: true,
132
147
  start,
@@ -162,7 +177,7 @@ export function spanMarkdownPlugin(md, { spoilers = false, source = false } = {}
162
177
  }
163
178
  }
164
179
  state.tokens.push(...nested);
165
- state.push("span_close", "span", -1);
180
+ state.push("span_close", emphasis ?? "span", -1);
166
181
  state.pos = finish;
167
182
  return true;
168
183
  });
@@ -315,7 +330,23 @@ export function renderSpans(source) {
315
330
  (span) => !spans.some((parent) => parent.start < span.start && parent.end >= span.end),
316
331
  )
317
332
  .sort((a, b) => b.start - a.start)) {
318
- const attrs = span.attrs.map(([key, value]) => ` ${key}="${escape(value)}"`).join("");
333
+ const classes = (span.attrs.find(([key]) => key === "class")?.[1] ?? "").split(/\s+/);
334
+ const tag =
335
+ classes.includes("emph") ? "em"
336
+ : classes.includes("strong") ? "strong"
337
+ : "span";
338
+ const attrs = span.attrs
339
+ .flatMap(([key, value]) => {
340
+ if (key === "class" && tag !== "span") {
341
+ value = value
342
+ .split(/\s+/)
343
+ .filter((name) => !["emph", "strong"].includes(name))
344
+ .join(" ");
345
+ if (!value) return [];
346
+ }
347
+ return [` ${key}="${escape(value)}"`];
348
+ })
349
+ .join("");
319
350
  const inner = renderSpans(markdown.slice(span.start + 1, span.contentEnd)).markdown;
320
351
  const secret = span.attrs.some(
321
352
  ([key, value]) => key === "class" && value.split(/\s+/).includes("secret"),
@@ -323,7 +354,7 @@ export function renderSpans(source) {
323
354
  const content =
324
355
  secret ?
325
356
  `<span${attrs} data-inline-secret data-pagefind-ignore><button type="button" class="inline-secret-toggle" aria-label="Show spoiler" aria-expanded="false">Show spoiler</button><span class="inline-secret-content" hidden>${inner}</span></span>`
326
- : `<span${attrs}>${inner}</span>`;
357
+ : `<${tag}${attrs}>${inner}</${tag}>`;
327
358
  markdown = markdown.slice(0, span.start) + content + markdown.slice(span.end);
328
359
  }
329
360
  return { markdown, errors };
@@ -208,7 +208,7 @@ export const VERSE_KEYS = Object.freeze([
208
208
  "scan",
209
209
  "lang",
210
210
  "font",
211
- "map",
211
+ "enc",
212
212
  "numstep",
213
213
  "cols",
214
214
  "dir",
@@ -0,0 +1,70 @@
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
+ /** Build the browser's JSON inputs from the same context that renders native runs. */
14
+ import fs from "node:fs";
15
+ import path from "node:path";
16
+ import { runContext } from "./content-scripts.mjs";
17
+ import { languageRecordsByName, serializeLexicon } from "./encoding-lexicon.mjs";
18
+ import { indexRecordsFor } from "./content-index.mjs";
19
+ import { validateData } from "../convert.mjs";
20
+
21
+ /** Return only the published definitions and lexical inputs the converter reads. */
22
+ export function buildConverterData(config, records) {
23
+ const context = runContext(config, records);
24
+ if (context.encodingFindings?.length)
25
+ throw new Error(context.encodingFindings.map((finding) => finding.message).join("\n"));
26
+ const languageNotes = languageRecordsByName(records, config);
27
+ const data = { format: 2, contentPackage: config.contentPackage, encodings: {}, languages: {} };
28
+ for (const [name, definition] of context.encodingDefinitions ?? [])
29
+ data.encodings[name] = definition;
30
+ for (const language of Object.keys(config.languages ?? {})) {
31
+ const note = languageNotes.get(language);
32
+ const name = note?.name;
33
+ data.languages[language] = {
34
+ name: typeof name === "string" ? name : (name?.full ?? language),
35
+ ...(context.lexicons?.has(language) ?
36
+ { lexicon: serializeLexicon(context.lexicons.get(language)) }
37
+ : {}),
38
+ };
39
+ }
40
+ return validateData(data);
41
+ }
42
+
43
+ /** Write opted-in converter inputs during the package's manifest build. */
44
+ export function writeConverterData({ config, packageBuild, records }) {
45
+ if (!packageBuild.converterData) return undefined;
46
+ const stage = path.resolve(config.rootDir, packageBuild.stageDir);
47
+ const out = path.resolve(stage, packageBuild.converterData);
48
+ const relative = path.relative(stage, out);
49
+ if (
50
+ !relative ||
51
+ relative.startsWith(`..${path.sep}`) ||
52
+ relative === ".." ||
53
+ path.isAbsolute(relative)
54
+ )
55
+ throw new Error("converterData must be a file within the build stage");
56
+ const problems = [];
57
+ const inputs =
58
+ records ??
59
+ indexRecordsFor({
60
+ config,
61
+ contentBase: config.paths.content,
62
+ skipDirectories: config.skipDirectories,
63
+ problems,
64
+ });
65
+ if (problems.length) throw new Error(problems.map((finding) => finding.message).join("\n"));
66
+ const data = buildConverterData(config, inputs);
67
+ fs.mkdirSync(path.dirname(out), { recursive: true });
68
+ fs.writeFileSync(out, JSON.stringify(data, null, 4) + "\n", "utf8");
69
+ return { path: out, data };
70
+ }
@@ -48,6 +48,8 @@
48
48
  * @module
49
49
  */
50
50
 
51
+ import { pluralOf, pluralByRule } from "./word-inflections.mjs";
52
+ export { pluralOf, pluralByRule } from "./word-inflections.mjs";
51
53
  import { createHash } from "node:crypto";
52
54
  import { parseAddress, renderAddress } from "./address.mjs";
53
55
  import { glossaryLanguage } from "./glossary-language.mjs";
@@ -130,40 +132,6 @@ const isSingleWord = (title) => !/[\s\-‐‑]/u.test(title);
130
132
  /** Whether a glossary entry states a definition. */
131
133
  const defines = (entry) => typeof entry.definition === "string" && entry.definition.trim() !== "";
132
134
 
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) {
144
- if (parseWordClass(word.class).base !== "n") return null;
145
- if (typeof word.plural === "string" && word.plural) return word.plural;
146
- return pluralByRule(word.form, grammar);
147
- }
148
-
149
- /**
150
- * The plural a language's `data.grammar.plural` rule gives a form: the first
151
- * literal ending replacement that fits, whatever the form's class.
152
- *
153
- * @param {string} form - The form.
154
- * @param {Record<string, any>|undefined} grammar - Its language's
155
- * `data.grammar`.
156
- * @returns {string|null} The plural, or `null` where no rule fits.
157
- */
158
- export function pluralByRule(form, grammar) {
159
- for (const rule of Array.isArray(grammar?.plural) ? grammar.plural : []) {
160
- if (typeof rule?.ending !== "string" || typeof rule.becomes !== "string") continue;
161
- if (form.endsWith(rule.ending))
162
- return (rule.ending ? form.slice(0, -rule.ending.length) : form) + rule.becomes;
163
- }
164
- return null;
165
- }
166
-
167
135
  /**
168
136
  * The stable prose anchor of a word in one language's dictionary. The exact
169
137
  * language and NFC form identify it, including case and accents.
@@ -0,0 +1,94 @@
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
+ /** Prove ordinary output tokens uniquely decodable without adding invisible metadata. */
15
+
16
+ /**
17
+ * Check codeword integrity with a trie and finite Sardinas–Patterson residuals.
18
+ * Source-equivalent compositions are redundant rules, not distinct spellings.
19
+ * @param {Array<{from:string,to:string,key:string,before?:string,at?:string}>} rows - Validated ordinary rows.
20
+ * @param {(key:string,message:string)=>never} fault - Positioned schema-error constructor.
21
+ */
22
+ export function assertUniqueOutputs(rows, fault) {
23
+ const bySource = new Map();
24
+ for (const row of rows) {
25
+ if (row.before || row.at) continue;
26
+ const first = [...row.from][0];
27
+ if (!bySource.has(first)) bySource.set(first, []);
28
+ bySource.get(first).push(row);
29
+ }
30
+ for (const group of bySource.values()) group.sort((a, b) => b.from.length - a.from.length);
31
+ const redundant = (row) => {
32
+ if (row.before || row.at || [...row.from].length < 2) return false;
33
+ let at = 0,
34
+ output = "";
35
+ while (at < row.from.length) {
36
+ const first = String.fromCodePoint(row.from.codePointAt(at));
37
+ const part = (bySource.get(first) ?? []).find(
38
+ (one) => one.from.length < row.from.length && row.from.startsWith(one.from, at),
39
+ );
40
+ if (!part) return false;
41
+ output += part.to;
42
+ at += part.from.length;
43
+ }
44
+ return output === row.to;
45
+ };
46
+ const words = [
47
+ ...new Map(rows.filter((row) => !redundant(row)).map((row) => [row.to, row])).values(),
48
+ ];
49
+ const node = () => ({ children: new Map(), word: undefined, words: [] });
50
+ const root = node();
51
+ for (const word of words) {
52
+ let cursor = root;
53
+ cursor.words.push(word);
54
+ for (const char of word.to) {
55
+ if (!cursor.children.has(char)) cursor.children.set(char, node());
56
+ cursor = cursor.children.get(char);
57
+ cursor.words.push(word);
58
+ }
59
+ cursor.word = word;
60
+ }
61
+ const pending = [],
62
+ seen = new Set();
63
+ const add = (residual, key) => {
64
+ if (!residual)
65
+ fault(key, "Output sequences are ambiguous; the encoding must be uniquely decodable");
66
+ if (!seen.has(residual)) {
67
+ seen.add(residual);
68
+ pending.push({ residual, key });
69
+ }
70
+ };
71
+ // A residual is the unmatched suffix after cancelling one output prefix.
72
+ for (const word of words) {
73
+ let cursor = root,
74
+ offset = 0;
75
+ for (const char of word.to) {
76
+ cursor = cursor.children.get(char);
77
+ offset += char.length;
78
+ if (cursor.word && offset < word.to.length) add(word.to.slice(offset), word.key);
79
+ }
80
+ }
81
+ for (let i = 0; i < pending.length; i++) {
82
+ const { residual, key } = pending[i];
83
+ let cursor = root,
84
+ offset = 0;
85
+ for (const char of residual) {
86
+ cursor = cursor.children.get(char);
87
+ if (!cursor) break;
88
+ offset += char.length;
89
+ if (cursor.word) add(residual.slice(offset), key);
90
+ }
91
+ if (cursor && offset === residual.length)
92
+ for (const word of cursor.words) add(word.to.slice(offset), key);
93
+ }
94
+ }