@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
|
@@ -11,7 +11,16 @@
|
|
|
11
11
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
/** A language's
|
|
14
|
+
/** A language's Roman additions and the shared punctuation standard. */
|
|
15
|
+
|
|
16
|
+
import { isSettingScriptCodePoint } from "./content-charset.mjs";
|
|
17
|
+
|
|
18
|
+
/** Sentence terminals in the shared Roman standard. */
|
|
19
|
+
const TERMINALS = new Set(".?‼!");
|
|
20
|
+
/** Unconditional punctuation; ^ is conditional indentation or a language addition. */
|
|
21
|
+
const STANDARD = new Set(".?‼!,«»‹›—…/{}⟦⟧");
|
|
22
|
+
/** Exactly the encoding definition's word-separating whitespace class. */
|
|
23
|
+
export const isRomanWhitespace = (char) => /^[\p{Zs}\u0009-\u000D]$/u.test(char);
|
|
15
24
|
|
|
16
25
|
/** Unicode graphemes keep every canonically composing sequence together. */
|
|
17
26
|
const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
@@ -38,6 +47,21 @@ export function romanInventoryProblems(value) {
|
|
|
38
47
|
);
|
|
39
48
|
if (/[\p{Cc}\p{Cf}\p{Cs}]/u.test(value))
|
|
40
49
|
problems.push("`data.roman` contains a control, format or unpaired surrogate character");
|
|
50
|
+
const supplied = [...new Set(value)].filter(
|
|
51
|
+
(char) => STANDARD.has(char) || /[;:"“”]/u.test(char),
|
|
52
|
+
);
|
|
53
|
+
if (supplied.length)
|
|
54
|
+
problems.push(
|
|
55
|
+
"`data.roman` lists only language additions, without standard punctuation, literal markers, semicolons, colons or English quotation marks: " +
|
|
56
|
+
supplied.map((char) => `\`${char}\``).join(", "),
|
|
57
|
+
);
|
|
58
|
+
const native = [...new Set(value)].filter((char) =>
|
|
59
|
+
isSettingScriptCodePoint(char.codePointAt(0)),
|
|
60
|
+
);
|
|
61
|
+
if (native.length)
|
|
62
|
+
problems.push(
|
|
63
|
+
"`data.roman` lists Roman additions, without setting-script characters; write exact native characters through codage",
|
|
64
|
+
);
|
|
41
65
|
const seen = new Set();
|
|
42
66
|
const repeated = new Set();
|
|
43
67
|
for (const char of value) {
|
|
@@ -85,29 +109,124 @@ export function romanInventoriesOf(records, languages = {}) {
|
|
|
85
109
|
* No punctuation, case or normalization repair is performed.
|
|
86
110
|
* @param {string} text - Visible Roman source.
|
|
87
111
|
* @param {RomanInventory} inventory - Its language's declared characters.
|
|
88
|
-
* @param {(char: string) => boolean} [native] - Whether a directly typed native sign is drawn in the selected native face.
|
|
89
112
|
* @returns {Array<{offset: number, message: string}>}
|
|
90
113
|
*/
|
|
91
|
-
export function romanTextProblems(text, inventory
|
|
114
|
+
export function romanTextProblems(text, inventory) {
|
|
92
115
|
const out = [];
|
|
93
116
|
if (!inventory.characters) return out;
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
117
|
+
const decomposed = new Map(
|
|
118
|
+
[...graphemes.segment(text)]
|
|
119
|
+
.filter(({ segment }) => segment !== segment.normalize("NFC"))
|
|
120
|
+
.map(({ segment, index }) => [index, segment]),
|
|
121
|
+
);
|
|
122
|
+
const quotes = [];
|
|
123
|
+
let sentenceStart = true;
|
|
124
|
+
const report = (offset, message) => out.push({ offset, message });
|
|
125
|
+
for (let offset = 0; offset < text.length;) {
|
|
126
|
+
const char = String.fromCodePoint(text.codePointAt(offset));
|
|
127
|
+
if (char === "⟦") {
|
|
128
|
+
// The encoding validates codage and its boundary grammar. Roman
|
|
129
|
+
// punctuation and language letters never reinterpret its contents.
|
|
130
|
+
const close = text.indexOf("⟧", offset + 1);
|
|
131
|
+
offset = close < 0 ? text.length : close + 1;
|
|
132
|
+
sentenceStart = false;
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
const cluster = decomposed.get(offset);
|
|
136
|
+
if (cluster) {
|
|
137
|
+
report(
|
|
98
138
|
offset,
|
|
99
|
-
|
|
100
|
-
|
|
139
|
+
`\`${cluster}\` is not NFC spelling in \`${inventory.language}\`; write \`${cluster.normalize("NFC")}\``,
|
|
140
|
+
);
|
|
141
|
+
offset += cluster.length;
|
|
142
|
+
sentenceStart = false;
|
|
101
143
|
continue;
|
|
102
144
|
}
|
|
103
|
-
|
|
104
|
-
if (!/[ \t\r\n]/u.test(char) && !inventory.characters.has(char) && !native(char))
|
|
105
|
-
out.push({
|
|
106
|
-
offset,
|
|
107
|
-
message: `\`${char}\` (${codeName(char)}) is not in \`${inventory.language}\`'s \`data.roman\` inventory`,
|
|
108
|
-
});
|
|
145
|
+
if (isRomanWhitespace(char)) {
|
|
109
146
|
offset += char.length;
|
|
147
|
+
continue;
|
|
110
148
|
}
|
|
149
|
+
if (char === "^") {
|
|
150
|
+
const start =
|
|
151
|
+
Math.max(text.lastIndexOf("\n", offset - 1), text.lastIndexOf("/", offset - 1)) + 1;
|
|
152
|
+
if (/^\^*$/.test(text.slice(start, offset))) {
|
|
153
|
+
// Indentation is layout and leaves sentence-start ellipsis
|
|
154
|
+
// semantics intact. The reference encoder uses these bounds.
|
|
155
|
+
offset += char.length;
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (text.startsWith("<<", offset) || text.startsWith(">>", offset)) {
|
|
160
|
+
report(
|
|
161
|
+
offset,
|
|
162
|
+
`write \`${char === "<" ? "«" : "»"}\` for a quotation, not \`${char}${char}\``,
|
|
163
|
+
);
|
|
164
|
+
offset += 2;
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
if (text.startsWith("...", offset)) {
|
|
168
|
+
report(offset, "write the ellipsis `…`, not three periods `...`");
|
|
169
|
+
offset += 3;
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
if (char === ";" || char === ":") {
|
|
173
|
+
report(
|
|
174
|
+
offset,
|
|
175
|
+
`\`${char}\` (${codeName(char)}) is not standard Roman punctuation; write \`,\` for a pause within a sentence or \`.\` between complete sentences`,
|
|
176
|
+
);
|
|
177
|
+
} else if (/^["“”]$/u.test(char)) {
|
|
178
|
+
report(
|
|
179
|
+
offset,
|
|
180
|
+
"write `«…»` for an outer quotation and `‹…›` for a quotation inside it",
|
|
181
|
+
);
|
|
182
|
+
} else if (isSettingScriptCodePoint(char.codePointAt(0))) {
|
|
183
|
+
report(
|
|
184
|
+
offset,
|
|
185
|
+
`write the setting-script character ${codeName(char)} as \`⟦${codeName(char)}⟧\` or by its encoding's codage name`,
|
|
186
|
+
);
|
|
187
|
+
} else if (char === "^" && !inventory.characters.has(char)) {
|
|
188
|
+
report(
|
|
189
|
+
offset,
|
|
190
|
+
"`^` is line-start indentation; elsewhere it must be a declared language addition in `data.roman`",
|
|
191
|
+
);
|
|
192
|
+
} else if (!STANDARD.has(char) && !inventory.characters.has(char)) {
|
|
193
|
+
report(
|
|
194
|
+
offset,
|
|
195
|
+
`\`${char}\` (${codeName(char)}) is not in \`${inventory.language}\`'s \`data.roman\` inventory`,
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
if (char === "«" || char === "‹") {
|
|
199
|
+
const expected = quotes.length % 2 === 0 ? "«" : "‹";
|
|
200
|
+
if (char !== expected)
|
|
201
|
+
report(offset, `a quotation at this depth opens with \`${expected}\``);
|
|
202
|
+
quotes.push({ char, offset });
|
|
203
|
+
sentenceStart = true;
|
|
204
|
+
} else if (char === "»" || char === "›") {
|
|
205
|
+
const opening = quotes.at(-1);
|
|
206
|
+
const expected = opening?.char === "«" ? "»" : "›";
|
|
207
|
+
if (!opening) report(offset, `quotation closer \`${char}\` has no opening quotation`);
|
|
208
|
+
else {
|
|
209
|
+
if (char !== expected)
|
|
210
|
+
report(offset, `this quotation closes with \`${expected}\`, not \`${char}\``);
|
|
211
|
+
quotes.pop();
|
|
212
|
+
sentenceStart = false;
|
|
213
|
+
}
|
|
214
|
+
} else if (char === "—" || (char === "…" && !sentenceStart)) {
|
|
215
|
+
const rest = text.slice(offset + char.length);
|
|
216
|
+
if ([...rest].some((next) => !isRomanWhitespace(next)) && !TERMINALS.has(rest[0]))
|
|
217
|
+
report(
|
|
218
|
+
offset,
|
|
219
|
+
`\`${char}\` is immediately followed by a terminal (\`.\`, \`?\`, \`‼\` or \`!\`) except at the end of a run`,
|
|
220
|
+
);
|
|
221
|
+
sentenceStart = false;
|
|
222
|
+
} else if (TERMINALS.has(char)) sentenceStart = true;
|
|
223
|
+
else sentenceStart = false;
|
|
224
|
+
offset += char.length;
|
|
111
225
|
}
|
|
112
|
-
|
|
226
|
+
for (const opening of quotes)
|
|
227
|
+
report(
|
|
228
|
+
opening.offset,
|
|
229
|
+
`quotation \`${opening.char}\` needs its closing \`${opening.char === "«" ? "»" : "›"}\``,
|
|
230
|
+
);
|
|
231
|
+
return out.sort((a, b) => a.offset - b.offset);
|
|
113
232
|
}
|
|
@@ -0,0 +1,149 @@
|
|
|
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
|
+
* Project an authored run to logical Roman text while retaining source offsets.
|
|
16
|
+
* Presentation-only children contribute their text; names contribute standard
|
|
17
|
+
* braces. The caller decides which children are independent runs and which
|
|
18
|
+
* ranges are layout or emphasis delimiters.
|
|
19
|
+
* @module
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @typedef {object} RomanSpan
|
|
24
|
+
* @property {number} start - Opening bracket offset.
|
|
25
|
+
* @property {number} end - Offset past the attribute block.
|
|
26
|
+
* @property {number} contentEnd - Closing bracket offset.
|
|
27
|
+
* @property {Array<[string, string]>} [attrs] - Authored attributes.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @typedef {object} ProjectedSpan
|
|
32
|
+
* @property {RomanSpan} source - The authored child.
|
|
33
|
+
* @property {number} from - Its first logical UTF-16 offset, including name braces.
|
|
34
|
+
* @property {number} to - Offset past its logical text and braces.
|
|
35
|
+
* @property {number} contentFrom - Logical text after its opening name brace.
|
|
36
|
+
* @property {number} contentTo - Offset past the logical text, before its closing name brace.
|
|
37
|
+
* @property {boolean} name - Whether this child marks a name.
|
|
38
|
+
* @property {boolean} included - Whether this child contributes its own text.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Remove Markdown escapes, inline presentation syntax and excluded layout.
|
|
43
|
+
* The returned offsets refer to the original source, in UTF-16 units, matching
|
|
44
|
+
* the source scanner. A supplementary character has two entries, both pointing
|
|
45
|
+
* at its original first unit. Synthetic braces point at the name's brackets.
|
|
46
|
+
* @param {string} source - Complete authored source.
|
|
47
|
+
* @param {object} [options] - Bounds and projections chosen by the run scanner.
|
|
48
|
+
* @param {number} [options.from] - First included source offset.
|
|
49
|
+
* @param {number} [options.to] - Offset past the included source.
|
|
50
|
+
* @param {readonly RomanSpan[]} [options.spans] - Scanned spans, with absolute offsets.
|
|
51
|
+
* @param {(span: RomanSpan) => boolean} [options.includeSpan] - Whether a child inherits this run.
|
|
52
|
+
* @param {(span: RomanSpan) => string} [options.opaqueSpan] - Placeholder for an independent child. The default empty name keeps sentence state without introducing that child's letters.
|
|
53
|
+
* @param {ReadonlyArray<readonly [number, number]>} [options.exclude] - Absolute source ranges that carry layout or presentation syntax.
|
|
54
|
+
* @param {boolean} [options.markNames] - Whether name children contribute braces.
|
|
55
|
+
* @returns {{text: string, offsets: number[], spans: ProjectedSpan[]}} Logical text and source locations, with child ranges for presentation.
|
|
56
|
+
*/
|
|
57
|
+
export function plainRomanRun(
|
|
58
|
+
source,
|
|
59
|
+
{
|
|
60
|
+
from = 0,
|
|
61
|
+
to = source.length,
|
|
62
|
+
spans = [],
|
|
63
|
+
includeSpan = () => true,
|
|
64
|
+
opaqueSpan = () => "{}",
|
|
65
|
+
exclude = [],
|
|
66
|
+
markNames = true,
|
|
67
|
+
} = {},
|
|
68
|
+
) {
|
|
69
|
+
let text = "";
|
|
70
|
+
const offsets = [];
|
|
71
|
+
const projected = [];
|
|
72
|
+
const children = [...spans]
|
|
73
|
+
.filter((span) => span.start >= from && span.end <= to)
|
|
74
|
+
.sort((a, b) => a.start - b.start || b.end - a.end);
|
|
75
|
+
const byStart = new Map(children.map((span) => [span.start, span]));
|
|
76
|
+
const excluded = [...exclude].sort((a, b) => a[0] - b[0]);
|
|
77
|
+
let excludedIndex = 0;
|
|
78
|
+
const append = (value, offset) => {
|
|
79
|
+
text += value;
|
|
80
|
+
for (let unit = 0; unit < value.length; unit++) offsets.push(offset);
|
|
81
|
+
};
|
|
82
|
+
const walk = (first, past) => {
|
|
83
|
+
let at = first;
|
|
84
|
+
while (at < past) {
|
|
85
|
+
while (excluded[excludedIndex]?.[1] <= at) excludedIndex++;
|
|
86
|
+
const omitted = excluded[excludedIndex];
|
|
87
|
+
if (omitted && omitted[0] <= at) {
|
|
88
|
+
at = Math.min(past, omitted[1]);
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
const candidate = byStart.get(at);
|
|
92
|
+
const span = candidate && candidate.end <= past ? candidate : undefined;
|
|
93
|
+
if (span) {
|
|
94
|
+
const name = (span.attrs?.find(([key]) => key === "class")?.[1] ?? "")
|
|
95
|
+
.split(/\s+/)
|
|
96
|
+
.includes("name");
|
|
97
|
+
const included = includeSpan(span);
|
|
98
|
+
const range = {
|
|
99
|
+
source: span,
|
|
100
|
+
from: text.length,
|
|
101
|
+
to: text.length,
|
|
102
|
+
contentFrom: text.length,
|
|
103
|
+
contentTo: text.length,
|
|
104
|
+
name,
|
|
105
|
+
included,
|
|
106
|
+
};
|
|
107
|
+
projected.push(range);
|
|
108
|
+
if (included) {
|
|
109
|
+
if (name && markNames) append("{", span.start);
|
|
110
|
+
range.contentFrom = text.length;
|
|
111
|
+
walk(span.start + 1, span.contentEnd);
|
|
112
|
+
range.contentTo = text.length;
|
|
113
|
+
if (name && markNames) append("}", span.contentEnd);
|
|
114
|
+
} else {
|
|
115
|
+
append(opaqueSpan(span), span.start);
|
|
116
|
+
range.contentTo = text.length;
|
|
117
|
+
}
|
|
118
|
+
range.to = text.length;
|
|
119
|
+
at = span.end;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
// A codage's notation belongs to its encoding. Do not unescape or
|
|
123
|
+
// interpret anything between its immutable boundaries.
|
|
124
|
+
if (source[at] === "⟦") {
|
|
125
|
+
const close = source.indexOf("⟧", at + 1);
|
|
126
|
+
const end = close >= 0 && close < past ? close + 1 : past;
|
|
127
|
+
while (at < end) {
|
|
128
|
+
const char = String.fromCodePoint(source.codePointAt(at));
|
|
129
|
+
append(char, at);
|
|
130
|
+
at += char.length;
|
|
131
|
+
}
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
const escaped =
|
|
135
|
+
source[at] === "\\" && at + 1 < past && /[!-/:-@[-`{-~]/.test(source[at + 1]);
|
|
136
|
+
const offset = at + (escaped ? 1 : 0);
|
|
137
|
+
// A terminal Markdown line-break slash is layout, not Roman.
|
|
138
|
+
if (source[at] === "\\" && !escaped && /[\r\n]/.test(source[at + 1] ?? "")) {
|
|
139
|
+
at++;
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
const char = String.fromCodePoint(source.codePointAt(offset));
|
|
143
|
+
append(char, offset);
|
|
144
|
+
at = offset + char.length;
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
walk(from, to);
|
|
148
|
+
return { text, offsets, spans: projected };
|
|
149
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
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
|
+
/** Logical verse text and separately located structural boundaries. @module */
|
|
15
|
+
import { verseLine } from "./content-verse.mjs";
|
|
16
|
+
import { plainRomanRun } from "./roman-run.mjs";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Remove visual indentation before checking or encoding Roman verse. Each
|
|
20
|
+
* content line keeps its own boundary, including the last line before a stanza
|
|
21
|
+
* or canto; those extra structural controls do not consume its line boundary.
|
|
22
|
+
* The caller writes the boundaries through its selected encoding.
|
|
23
|
+
* @param {string} source - Complete authored source.
|
|
24
|
+
* @param {Parameters<typeof plainRomanRun>[1] & {isCantoLine?: (line: string) => boolean}} [options] - Run bounds, spans and layout projection.
|
|
25
|
+
* @returns {ReturnType<typeof plainRomanRun> & {boundaries: Array<{kind: "line"|"stanza"|"canto", at: number, sourceOffset: number}>, errors: Array<{offset: number, message: string}>}} Visible text, positions and structural events.
|
|
26
|
+
*/
|
|
27
|
+
export function plainRomanVerse(source, options = {}) {
|
|
28
|
+
const {
|
|
29
|
+
from = 0,
|
|
30
|
+
to = source.length,
|
|
31
|
+
exclude = [],
|
|
32
|
+
isCantoLine = (line) => /^\s*---\s+\{\.canto\}\s*$/.test(line),
|
|
33
|
+
} = options;
|
|
34
|
+
const lines = [];
|
|
35
|
+
const layout = [...exclude];
|
|
36
|
+
let at = from;
|
|
37
|
+
for (const match of source.slice(from, to).matchAll(/([^\r\n]*)(\r\n|\r|\n|$)/g)) {
|
|
38
|
+
if (!match[0]) continue;
|
|
39
|
+
const line = match[1];
|
|
40
|
+
const end = at + line.length;
|
|
41
|
+
const kind =
|
|
42
|
+
isCantoLine(line) ? "canto"
|
|
43
|
+
: line.trim() ? "content"
|
|
44
|
+
: "blank";
|
|
45
|
+
lines.push({ from: at, to: end, kind });
|
|
46
|
+
if (kind === "canto") layout.push([at, end]);
|
|
47
|
+
else if (kind === "content") {
|
|
48
|
+
const { markerStart, markerLength } = verseLine(line);
|
|
49
|
+
if (markerLength) layout.push([at + markerStart, at + markerStart + markerLength]);
|
|
50
|
+
}
|
|
51
|
+
at += match[0].length;
|
|
52
|
+
}
|
|
53
|
+
const projected = plainRomanRun(source, { ...options, from, to, exclude: layout });
|
|
54
|
+
const logicalAt = (offset) => {
|
|
55
|
+
let first = 0,
|
|
56
|
+
past = projected.offsets.length;
|
|
57
|
+
while (first < past) {
|
|
58
|
+
const middle = (first + past) >>> 1;
|
|
59
|
+
if (projected.offsets[middle] < offset) first = middle + 1;
|
|
60
|
+
else past = middle;
|
|
61
|
+
}
|
|
62
|
+
return first;
|
|
63
|
+
};
|
|
64
|
+
/** @type {Array<{kind: "line"|"stanza"|"canto", at: number, sourceOffset: number}>} */
|
|
65
|
+
const boundaries = [];
|
|
66
|
+
for (let index = 0; index < lines.length; index++) {
|
|
67
|
+
const line = lines[index];
|
|
68
|
+
if (line.kind === "content")
|
|
69
|
+
boundaries.push({ kind: "line", at: logicalAt(line.to), sourceOffset: line.to });
|
|
70
|
+
else if (line.kind === "canto")
|
|
71
|
+
boundaries.push({ kind: "canto", at: logicalAt(line.from), sourceOffset: line.from });
|
|
72
|
+
else if (lines[index - 1]?.kind === "content") {
|
|
73
|
+
const following = lines.slice(index + 1).find((next) => next.kind !== "blank");
|
|
74
|
+
if (following?.kind === "content")
|
|
75
|
+
boundaries.push({
|
|
76
|
+
kind: "stanza",
|
|
77
|
+
at: logicalAt(line.from),
|
|
78
|
+
sourceOffset: line.from,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const errors = [];
|
|
83
|
+
for (let index = 0; index < projected.text.length; index++) {
|
|
84
|
+
if (projected.text[index] === "⟦") {
|
|
85
|
+
const close = projected.text.indexOf("⟧", index + 1);
|
|
86
|
+
index = close < 0 ? projected.text.length : close;
|
|
87
|
+
} else if (projected.text[index] === "/")
|
|
88
|
+
errors.push({
|
|
89
|
+
offset: projected.offsets[index],
|
|
90
|
+
message:
|
|
91
|
+
"a verse fence uses authored line endings for verse boundaries; write no literal `/` inside it",
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
return { ...projected, boundaries, errors };
|
|
95
|
+
}
|
package/engine/run-rows.mjs
CHANGED
|
@@ -12,15 +12,14 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
|
-
* Spans, divs and verse fences marked with `lang=`, `font=` or `
|
|
15
|
+
* Spans, divs and verse fences marked with `lang=`, `font=` or `enc=`, projected
|
|
16
16
|
* from own-note index records into the SQL `runs` relation. Every value is a
|
|
17
17
|
* column value and none is a key, so two notes writing one tag in different
|
|
18
18
|
* case are two rows. Documentation records and assets contribute no rows.
|
|
19
19
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* projected by the shared scanner.
|
|
20
|
+
* Encoding-document language and font defaults are filled by
|
|
21
|
+
* {@link fillEncodingRuns} once every document exists. Roman runs use the
|
|
22
|
+
* default encoding; ordinary font inheritance is projected by the scanner.
|
|
24
23
|
*
|
|
25
24
|
* @module
|
|
26
25
|
*/
|
|
@@ -29,9 +28,8 @@ import { canonicalKey, readCanonicalKey } from "./address.mjs";
|
|
|
29
28
|
import { encodeAddresses } from "./address-values.mjs";
|
|
30
29
|
import { isNoteRecord } from "./index-records.mjs";
|
|
31
30
|
import { scanRuns } from "./content-scripts.mjs";
|
|
32
|
-
import {
|
|
33
|
-
import {
|
|
34
|
-
import { writtenInOf } from "./written-in.mjs";
|
|
31
|
+
import { resolveScriptFonts } from "./script-fonts.mjs";
|
|
32
|
+
import { encodingsFromRecords, encodingAddress } from "./encoding-notes.mjs";
|
|
35
33
|
|
|
36
34
|
/** Every column of the `runs` relation, in order. */
|
|
37
35
|
export const RUN_COLUMNS = Object.freeze([
|
|
@@ -40,7 +38,7 @@ export const RUN_COLUMNS = Object.freeze([
|
|
|
40
38
|
"lang",
|
|
41
39
|
"tag",
|
|
42
40
|
"font",
|
|
43
|
-
"
|
|
41
|
+
"enc",
|
|
44
42
|
"line",
|
|
45
43
|
"column",
|
|
46
44
|
]);
|
|
@@ -54,11 +52,8 @@ export const RUN_COLUMNS = Object.freeze([
|
|
|
54
52
|
* own `lang=`, else the one the div or fence around it states.
|
|
55
53
|
* @property {string|null} tag - The BCP 47 tag it resolves to, or null.
|
|
56
54
|
* @property {string|null} font - Its explicit font, an inherited ordinary
|
|
57
|
-
* prose family, or the
|
|
58
|
-
* @property {string|null}
|
|
59
|
-
* opted-in native run; null for Roman language-only text.
|
|
60
|
-
* @property {boolean} [nativefont] - Explicit native opt-in on this construct;
|
|
61
|
-
* absent on Roman runs. This internal flag is not a SQL column.
|
|
55
|
+
* prose family, or the font of the selected encoding document.
|
|
56
|
+
* @property {string|null} enc - Its resolved encoding document, or default for Roman text.
|
|
62
57
|
* @property {number} line - 1-based file line of its opening.
|
|
63
58
|
* @property {number} column - 1-based UTF-16 file column of its opening.
|
|
64
59
|
*/
|
|
@@ -86,36 +81,39 @@ export function recordRuns(body, { context, bodyLine = 1, bodyColumn = 1, syntax
|
|
|
86
81
|
lang: run.lang ?? null,
|
|
87
82
|
tag: run.tag ?? null,
|
|
88
83
|
font: run.font ?? null,
|
|
89
|
-
|
|
90
|
-
...(run.nativefont ? { nativefont: true } : {}),
|
|
84
|
+
enc: run.enc ?? null,
|
|
91
85
|
line: bodyLine + run.line - 1,
|
|
92
86
|
column: run.column + (run.line === 1 ? bodyColumn - 1 : 0),
|
|
93
87
|
}));
|
|
94
88
|
}
|
|
95
89
|
|
|
96
90
|
/**
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
* once every record exists, since a run's language note may be recorded after
|
|
100
|
-
* the note that marks it.
|
|
91
|
+
* Resolve recorded encoding Addresses and fill the language and font supplied
|
|
92
|
+
* by each selected document, once every index record exists.
|
|
101
93
|
*
|
|
102
94
|
* @param {Record<string, any>[]} records - The package's index records; each
|
|
103
95
|
* note record's `runs` is updated in place.
|
|
104
|
-
* @param {{languages?: Readonly<Record<string, {tag: string, note?: string}>>, contentPackage?: string}} config -
|
|
96
|
+
* @param {{languages?: Readonly<Record<string, {tag: string, note?: string}>>, contentPackage?: string, rootDir?:string, paths?:{content?:string}}} config -
|
|
105
97
|
* The configured languages, whose `note` names each language note.
|
|
106
98
|
*/
|
|
107
|
-
export function
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
99
|
+
export function fillEncodingRuns(records, config) {
|
|
100
|
+
const { encodings } = encodingsFromRecords(
|
|
101
|
+
records,
|
|
102
|
+
config,
|
|
103
|
+
config?.rootDir ? resolveScriptFonts(config.rootDir) : undefined,
|
|
104
|
+
);
|
|
111
105
|
for (const record of records) {
|
|
112
106
|
if (!record || !isNoteRecord(record) || !Array.isArray(record.runs)) continue;
|
|
113
107
|
for (const run of record.runs) {
|
|
114
|
-
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
if (
|
|
108
|
+
run.enc ??= "default";
|
|
109
|
+
const encoding =
|
|
110
|
+
encodings.get(run.enc) ??
|
|
111
|
+
encodings.get(encodingAddress(run.enc, config.contentPackage));
|
|
112
|
+
if (!encoding) continue;
|
|
113
|
+
run.enc = encoding.name;
|
|
114
|
+
run.lang ??= encoding.language;
|
|
115
|
+
run.font ??= encoding.font;
|
|
116
|
+
run.tag ??= config.languages?.[run.lang]?.tag ?? null;
|
|
119
117
|
}
|
|
120
118
|
}
|
|
121
119
|
}
|
|
@@ -150,7 +148,7 @@ export function runsRows(records) {
|
|
|
150
148
|
lang: run.lang ?? null,
|
|
151
149
|
tag: run.tag ?? null,
|
|
152
150
|
font: run.font ?? null,
|
|
153
|
-
|
|
151
|
+
enc: run.enc ?? null,
|
|
154
152
|
line: run.line,
|
|
155
153
|
column: run.column,
|
|
156
154
|
});
|
package/engine/run-words.mjs
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
15
|
* **A mapped run's words are words of its language.** In a run marked with
|
|
16
|
-
* `lang=`, whose language's `note` names its language note, and with `
|
|
16
|
+
* `lang=`, whose language's `note` names its language note, and with `enc=`,
|
|
17
17
|
* each word (see {@link module:engine/word-pattern.WORD}) is accepted when,
|
|
18
18
|
* case folded, it is:
|
|
19
19
|
*
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
* @module
|
|
37
37
|
*/
|
|
38
38
|
|
|
39
|
-
import { lexiconsFromRecords } from "./
|
|
39
|
+
import { lexiconsFromRecords } from "./encoding-lexicon.mjs";
|
|
40
40
|
import { RESERVED_TONGUE } from "./language-terms.mjs";
|
|
41
41
|
import { addWords, foldWord, lexiconWords } from "./word-forms.mjs";
|
|
42
42
|
import { WORD } from "./word-pattern.mjs";
|
|
@@ -44,3 +44,6 @@ export const SCRIPT_DIRECTIONS = Object.freeze(["vertical-rl"]);
|
|
|
44
44
|
|
|
45
45
|
/** The `subType` of a script note. */
|
|
46
46
|
export const SCRIPT_SUBTYPE = "script";
|
|
47
|
+
|
|
48
|
+
/** The ordinary prose families accepted by font=. */
|
|
49
|
+
export const GENERIC_FAMILIES = Object.freeze(["mono", "serif", "sans"]);
|
|
@@ -21,14 +21,12 @@
|
|
|
21
21
|
* script:
|
|
22
22
|
* fonts: [semaron-kuriale]
|
|
23
23
|
* emphasis: rubric
|
|
24
|
-
* strongEmphasis: overline
|
|
25
24
|
* ```
|
|
26
25
|
*
|
|
27
26
|
* - **`fonts`** lists installed faces by the slug `font=` names them by. A
|
|
28
27
|
* face belongs to one script.
|
|
29
28
|
* - **`emphasis`** is `rubric` (red text) or `overline` (a line above the
|
|
30
29
|
* word), and sets both `*…*` and `**…**`.
|
|
31
|
-
* - **`strongEmphasis`**, where stated, sets `**…**` alone.
|
|
32
30
|
*
|
|
33
31
|
* Inside a run in one of those faces, `*…*` and `**…**` stay an `<em>` and a
|
|
34
32
|
* `<strong>` on the site and in Foundry, carrying {@link module:engine/script-emphasis-terms.EMPHASIS_CLASSES}'s
|
|
@@ -48,7 +46,7 @@ import {
|
|
|
48
46
|
LANGUAGE_EMPHASES,
|
|
49
47
|
SCRIPT_SUBTYPE,
|
|
50
48
|
} from "./script-emphasis-terms.mjs";
|
|
51
|
-
import { GENERIC_FAMILIES } from "./
|
|
49
|
+
import { GENERIC_FAMILIES } from "./script-emphasis-terms.mjs";
|
|
52
50
|
import { LANGUAGE_SUBTYPE } from "./word-terms.mjs";
|
|
53
51
|
|
|
54
52
|
export {
|
|
@@ -97,12 +95,7 @@ export function emphasesFromRecords(records) {
|
|
|
97
95
|
const script = record.data?.script;
|
|
98
96
|
if (!isMap(script) || !Array.isArray(script.fonts) || !isConvention(script.emphasis))
|
|
99
97
|
continue;
|
|
100
|
-
const strong
|
|
101
|
-
script.strongEmphasis === undefined || script.strongEmphasis === null ?
|
|
102
|
-
script.emphasis
|
|
103
|
-
: script.strongEmphasis;
|
|
104
|
-
if (!isConvention(strong)) continue;
|
|
105
|
-
const emphasis = Object.freeze({ em: script.emphasis, strong });
|
|
98
|
+
const emphasis = Object.freeze({ em: script.emphasis, strong: script.emphasis });
|
|
106
99
|
for (const slug of script.fonts)
|
|
107
100
|
if (typeof slug === "string" && !out.has(slug)) out.set(slug, emphasis);
|
|
108
101
|
}
|
|
@@ -114,7 +107,7 @@ export function emphasesFromRecords(records) {
|
|
|
114
107
|
*
|
|
115
108
|
* @param {ReadonlyMap<string, Record<string, any>>} byName - Each configured
|
|
116
109
|
* language's note record, by language name, from
|
|
117
|
-
* {@link module:engine/
|
|
110
|
+
* {@link module:engine/encoding-lexicon.languageRecordsByName}.
|
|
118
111
|
* @returns {Map<string, LanguageEmphasis>} The conventions, by language name;
|
|
119
112
|
* a language whose note states none, or one the build does not know, is
|
|
120
113
|
* absent.
|