@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.
- package/CHANGELOG.md +26 -0
- package/README.md +1 -0
- package/bin/content-commands.mjs +30 -96
- package/bin/encoding-commands.mjs +146 -0
- package/bin/package-build.mjs +35 -24
- package/config.mjs +25 -0
- package/convert.mjs +182 -0
- package/docs/api.md +144 -6
- package/docs/authoring/links-and-markup.md +260 -481
- package/docs/commands.md +110 -119
- package/docs/configuration.md +31 -13
- package/docs/content-format.md +11 -4
- package/docs/diagnostics.md +43 -16
- package/docs/encoding-core.md +193 -0
- package/docs/guides/book.md +2 -2
- package/docs/guides/module-project.md +23 -1
- package/docs/guides/site.md +2 -2
- package/docs/guides/writing-in-invented-scripts.md +458 -575
- package/docs/reference/format-details.md +292 -404
- package/docs/reference/note-types.md +83 -63
- package/docs/source-preview.md +8 -0
- package/e2e.mjs +1 -1
- package/engine/base-compiler.mjs +27 -9
- package/engine/content-charset.mjs +13 -1
- package/engine/content-format.yaml +6 -2
- package/engine/content-index.mjs +6 -2
- package/engine/content-links.mjs +1 -0
- package/engine/content-scripts.mjs +614 -459
- package/engine/content-scroll.mjs +4 -4
- package/engine/content-spans.mjs +40 -9
- package/engine/content-verse.mjs +1 -1
- package/engine/converter-data.mjs +70 -0
- package/engine/dictionary-rows.mjs +2 -34
- package/engine/encoding-integrity.mjs +94 -0
- package/engine/encoding-language.mjs +506 -0
- package/engine/encoding-lexicon.mjs +701 -0
- package/engine/encoding-notes.mjs +186 -0
- package/engine/encoding-standard.mjs +42 -0
- package/engine/encoding-template.mjs +370 -0
- package/engine/encoding-terms.mjs +178 -0
- package/engine/encoding.mjs +952 -0
- package/engine/extension-attributes.mjs +2 -0
- package/engine/helpers.mjs +1 -1
- package/engine/index.mjs +7 -0
- package/engine/language-terms.mjs +17 -16
- package/engine/{logographic.mjs → language-writings.mjs} +29 -38
- package/engine/markup-validation.mjs +1 -1
- package/engine/note-vocabulary.mjs +4 -158
- package/engine/pdf-render.mjs +31 -3
- package/engine/roman-characters.mjs +135 -16
- package/engine/roman-run.mjs +149 -0
- package/engine/roman-verse.mjs +95 -0
- package/engine/run-rows.mjs +29 -31
- package/engine/run-words.mjs +2 -2
- package/engine/script-emphasis-terms.mjs +3 -0
- package/engine/script-emphasis.mjs +3 -10
- package/engine/script-font-delivery.mjs +9 -12
- package/engine/script-fonts.mjs +2 -1
- package/engine/site-build.mjs +14 -5
- package/engine/site-preview.mjs +12 -2
- package/engine/sql-tables.mjs +36 -31
- package/engine/verse-metre.mjs +1 -1
- package/engine/word-forms.mjs +1 -1
- package/engine/word-inflections.mjs +48 -0
- package/engine/word-rows.mjs +1 -1
- package/engine/word-terms.mjs +16 -17
- package/package.json +11 -5
- package/types/config.d.mts +5 -0
- package/types/convert.d.mts +44 -0
- package/types/e2e.d.mts +1 -1
- package/types/engine/base-compiler.d.mts +4 -2
- package/types/engine/content-index.d.mts +4 -1
- package/types/engine/content-scripts.d.mts +103 -42
- package/types/engine/converter-data.d.mts +11 -0
- package/types/engine/dictionary-rows.d.mts +1 -25
- package/types/engine/encoding-integrity.d.mts +14 -0
- package/types/engine/encoding-language.d.mts +36 -0
- package/types/engine/encoding-lexicon.d.mts +259 -0
- package/types/engine/encoding-notes.d.mts +39 -0
- package/types/engine/encoding-standard.d.mts +11 -0
- package/types/engine/encoding-template.d.mts +44 -0
- package/types/engine/encoding-terms.d.mts +143 -0
- package/types/engine/encoding.d.mts +155 -0
- package/types/engine/helpers.d.mts +1 -1
- package/types/engine/index.d.mts +4 -0
- package/types/engine/language-terms.d.mts +1 -1
- package/types/engine/{logographic.d.mts → language-writings.d.mts} +6 -6
- package/types/engine/markup-validation.d.mts +1 -1
- package/types/engine/pdf-render.d.mts +1 -1
- package/types/engine/roman-characters.d.mts +2 -2
- package/types/engine/roman-run.d.mts +101 -0
- package/types/engine/roman-verse.d.mts +23 -0
- package/types/engine/run-rows.d.mts +13 -20
- package/types/engine/script-emphasis-terms.d.mts +2 -0
- package/types/engine/script-emphasis.d.mts +1 -1
- package/types/engine/script-font-delivery.d.mts +2 -3
- package/types/engine/script-fonts.d.mts +5 -0
- package/types/engine/site-preview.d.mts +1 -1
- package/types/engine/word-inflections.d.mts +25 -0
- package/engine/map-lexicon.mjs +0 -1045
- package/engine/text-maps.mjs +0 -654
- package/engine/written-in.mjs +0 -204
- package/types/engine/map-lexicon.d.mts +0 -330
- package/types/engine/text-maps.d.mts +0 -189
- 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
|
|
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 `
|
|
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
|
-
"
|
|
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 \`
|
|
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)) {
|
package/engine/content-spans.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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", "
|
|
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
|
-
|
|
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
|
|
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
|
-
:
|
|
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 };
|
package/engine/content-verse.mjs
CHANGED
|
@@ -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
|
+
}
|