@heroiclands/package-build 24.1.0 → 25.0.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.
- package/CHANGELOG.md +32 -0
- package/README.md +2 -0
- package/bin/content-commands.mjs +183 -15
- package/bin/package-build.mjs +5 -170
- package/docs/api.md +23 -11
- package/docs/authoring/links-and-markup.md +8 -0
- package/docs/commands.md +91 -0
- package/docs/guides/writing-in-invented-scripts.md +23 -15
- package/docs/reference/format-details.md +25 -0
- package/docs/reference/note-types.md +1 -0
- package/engine/build-assets.mjs +179 -0
- package/engine/calendar-artifacts.mjs +7 -3
- package/engine/compendiums.mjs +16 -11
- package/engine/content-charset.mjs +12 -4
- package/engine/content-format.yaml +1 -0
- package/engine/content-scripts.mjs +94 -14
- package/engine/content-session.mjs +62 -14
- package/engine/content-timings.mjs +67 -0
- package/engine/index.mjs +3 -0
- package/engine/language-data.mjs +4 -1
- package/engine/language-terms.mjs +10 -0
- package/engine/map-lexicon.mjs +12 -1
- package/engine/roman-characters.mjs +113 -0
- package/engine/script-font-delivery.mjs +22 -15
- package/package.json +1 -1
- package/types/engine/build-assets.d.mts +23 -0
- package/types/engine/calendar-artifacts.d.mts +5 -1
- package/types/engine/content-charset.d.mts +8 -0
- package/types/engine/content-scripts.d.mts +5 -0
- package/types/engine/content-session.d.mts +19 -1
- package/types/engine/content-timings.d.mts +14 -0
- package/types/engine/index.d.mts +1 -0
- package/types/engine/roman-characters.d.mts +45 -0
- package/types/engine/script-font-delivery.d.mts +8 -4
|
@@ -92,7 +92,7 @@
|
|
|
92
92
|
import MarkdownIt from "markdown-it";
|
|
93
93
|
|
|
94
94
|
import { scanBlocks, BLOCK_CONTAINERS } from "./content-blocks.mjs";
|
|
95
|
-
import { scanVerse, hasColumns, verseLine, verseGroups } from "./content-verse.mjs";
|
|
95
|
+
import { scanVerse, hasColumns, verseLine, verseGroups, STANZA_BREAK } from "./content-verse.mjs";
|
|
96
96
|
import { scanSpans } from "./content-spans.mjs";
|
|
97
97
|
import { scanScrolls, scrollParagraphs } from "./content-scroll.mjs";
|
|
98
98
|
import { directionsFromRecords } from "./script-direction.mjs";
|
|
@@ -113,7 +113,9 @@ import { unknownWordMessage, unknownWords, wordBooksFromRecords } from "./run-wo
|
|
|
113
113
|
import { metreBooksFromRecords, metreFindings, metresFromRecords } from "./verse-metre.mjs";
|
|
114
114
|
import { applyMap, mapsFromRecords } from "./text-maps.mjs";
|
|
115
115
|
import { writtenInOf } from "./written-in.mjs";
|
|
116
|
-
import {
|
|
116
|
+
import { romanInventoriesOf, romanTextProblems } from "./roman-characters.mjs";
|
|
117
|
+
import { isSettingScriptCodePoint } from "./content-charset.mjs";
|
|
118
|
+
import { WIKILINK } from "./wikilink-syntax.mjs";
|
|
117
119
|
import { matchAllOutsideCode } from "./code-fences.mjs";
|
|
118
120
|
import { parseExtensionAttributes } from "./extension-attributes.mjs";
|
|
119
121
|
|
|
@@ -159,6 +161,8 @@ const CONFIG = "package-build.config.yaml";
|
|
|
159
161
|
* @property {ReadonlyMap<string, import("./verse-metre.mjs").MetreBook>} [metreBooks] -
|
|
160
162
|
* What each configured language's verse is counted by, from
|
|
161
163
|
* {@link module:engine/verse-metre.metreBooksFromRecords}.
|
|
164
|
+
* @property {ReadonlyMap<string, import("./roman-characters.mjs").RomanInventory>} [romanInventories] -
|
|
165
|
+
* Exact Roman source inventories by configured alias and BCP 47 tag.
|
|
162
166
|
* @property {ReadonlyMap<string, import("./script-emphasis.mjs").Emphasis>} [emphases] -
|
|
163
167
|
* Each face's emphasis conventions, from the script notes, by
|
|
164
168
|
* {@link module:engine/script-emphasis.emphasesFromRecords}.
|
|
@@ -241,6 +245,7 @@ export function runContext(config, records) {
|
|
|
241
245
|
...base,
|
|
242
246
|
maps,
|
|
243
247
|
writtenIn: writtenInOf(languageRecords, maps),
|
|
248
|
+
romanInventories: romanInventoriesOf(languageRecords, config.languages),
|
|
244
249
|
lexicons: lexiconsFromRecords(records, config),
|
|
245
250
|
wordBooks: wordBooksFromRecords(records, config),
|
|
246
251
|
metres: metresFromRecords(records),
|
|
@@ -1176,12 +1181,11 @@ function analyseRuns(source, ctx, syntax) {
|
|
|
1176
1181
|
);
|
|
1177
1182
|
};
|
|
1178
1183
|
|
|
1184
|
+
const rawReferences = [];
|
|
1179
1185
|
// Raw wikilinks belong to the resolver, not the span rewriter. Validate
|
|
1180
1186
|
// their own control attributes with the same grammar before resolution,
|
|
1181
1187
|
// without mapping a destination or consuming the trailing attributes.
|
|
1182
1188
|
for (const match of matchAllOutsideCode(text, new RegExp(WIKILINK.source, "g"))) {
|
|
1183
|
-
const parsed = parseWikilink(match[1], match[0]);
|
|
1184
|
-
if (!parsed.attributes) continue;
|
|
1185
1189
|
const close = match[0].indexOf("]]") + 1;
|
|
1186
1190
|
const from = match.index + close + 2;
|
|
1187
1191
|
const attrs = parseExtensionAttributes(match[0].slice(close + 2, -1), { positions: true });
|
|
@@ -1200,6 +1204,19 @@ function analyseRuns(source, ctx, syntax) {
|
|
|
1200
1204
|
};
|
|
1201
1205
|
const unchecked = errors.length;
|
|
1202
1206
|
checkOwn(construct);
|
|
1207
|
+
const bar = match[1].indexOf("|");
|
|
1208
|
+
if (bar >= 0 && errors.length === unchecked) {
|
|
1209
|
+
const ancestors = around(construct);
|
|
1210
|
+
const inherited = ancestors.filter((parent) => parent.keys.lang !== undefined).at(-1)
|
|
1211
|
+
?.keys.lang;
|
|
1212
|
+
rawReferences.push({
|
|
1213
|
+
lang: construct.keys.lang ?? inherited,
|
|
1214
|
+
from: match.index + 2 + bar + 1,
|
|
1215
|
+
to: match.index + close - 1,
|
|
1216
|
+
where: construct.where,
|
|
1217
|
+
construct,
|
|
1218
|
+
});
|
|
1219
|
+
}
|
|
1203
1220
|
if (errors.length !== unchecked || !native(construct)) continue;
|
|
1204
1221
|
const keys = { ...construct.keys };
|
|
1205
1222
|
nativeDefaults(construct, keys);
|
|
@@ -1215,6 +1232,9 @@ function analyseRuns(source, ctx, syntax) {
|
|
|
1215
1232
|
|
|
1216
1233
|
const verseMarkers = new Map();
|
|
1217
1234
|
for (const poem of scanVerse(text).blocks) {
|
|
1235
|
+
for (let line = poem.start + 1; line < poem.end; line++)
|
|
1236
|
+
if (STANZA_BREAK.test(lines[line].trim()))
|
|
1237
|
+
verseMarkers.set(starts[line], starts[line] + lines[line].length);
|
|
1218
1238
|
if (hasColumns(poem)) {
|
|
1219
1239
|
for (const group of verseGroups(poem, lines)) {
|
|
1220
1240
|
if (group.stanzaBreak) continue;
|
|
@@ -1401,6 +1421,47 @@ function analyseRuns(source, ctx, syntax) {
|
|
|
1401
1421
|
}
|
|
1402
1422
|
};
|
|
1403
1423
|
|
|
1424
|
+
/** The configured source inventory for either an alias or its BCP 47 tag. */
|
|
1425
|
+
const inventoryOf = (lang) =>
|
|
1426
|
+
lang === undefined ? undefined : (
|
|
1427
|
+
(ctx.romanInventories?.get(lang) ?? ctx.romanInventories?.get(lang.toLowerCase()))
|
|
1428
|
+
);
|
|
1429
|
+
|
|
1430
|
+
/** Check visible source before mapping or lexicon-native replacement. */
|
|
1431
|
+
const roman = (parts, detail) => {
|
|
1432
|
+
const inventory = inventoryOf(detail.run.lang);
|
|
1433
|
+
if (!inventory) return;
|
|
1434
|
+
if (!inventory.characters) {
|
|
1435
|
+
report(
|
|
1436
|
+
{ line: detail.run.line, column: detail.run.column },
|
|
1437
|
+
`the language note for \`${inventory.language}\` must declare \`data.roman\``,
|
|
1438
|
+
);
|
|
1439
|
+
return;
|
|
1440
|
+
}
|
|
1441
|
+
for (const [from, to] of parts) {
|
|
1442
|
+
let base = from;
|
|
1443
|
+
for (const line of text.slice(from, to).split("\n")) {
|
|
1444
|
+
// A terminal unescaped backslash is a Markdown line break.
|
|
1445
|
+
const breakMark =
|
|
1446
|
+
/(?<!\\)(?:\\\\)*\\$/.test(line) && text[base + line.length] === "\n";
|
|
1447
|
+
const plain = unescapeRoman(breakMark ? line.slice(0, -1) : line);
|
|
1448
|
+
for (const problem of romanTextProblems(plain.text, inventory, (char) => {
|
|
1449
|
+
const point = char.codePointAt(0);
|
|
1450
|
+
return (
|
|
1451
|
+
detail.run.nativefont === true &&
|
|
1452
|
+
isSettingScriptCodePoint(point) &&
|
|
1453
|
+
detail.face?.draws.has(point)
|
|
1454
|
+
);
|
|
1455
|
+
}))
|
|
1456
|
+
report(
|
|
1457
|
+
positionOf(starts, base + (plain.offsets[problem.offset] ?? 0)),
|
|
1458
|
+
problem.message,
|
|
1459
|
+
);
|
|
1460
|
+
base += line.length + 1;
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
};
|
|
1464
|
+
|
|
1404
1465
|
/**
|
|
1405
1466
|
* Report each word of a mapped run that is not a word of its language, once
|
|
1406
1467
|
* per run, at its first character.
|
|
@@ -1515,6 +1576,7 @@ function analyseRuns(source, ctx, syntax) {
|
|
|
1515
1576
|
};
|
|
1516
1577
|
runs.push(run);
|
|
1517
1578
|
const plain =
|
|
1579
|
+
inventoryOf(lang) !== undefined ||
|
|
1518
1580
|
font !== undefined ||
|
|
1519
1581
|
map !== undefined ||
|
|
1520
1582
|
(lang !== undefined &&
|
|
@@ -1577,15 +1639,18 @@ function analyseRuns(source, ctx, syntax) {
|
|
|
1577
1639
|
}
|
|
1578
1640
|
if (plain && errors.length === before)
|
|
1579
1641
|
plainInline(inline, from, marking, detail, detail.holes);
|
|
1580
|
-
if (
|
|
1642
|
+
if (errors.length === before) {
|
|
1581
1643
|
const parts = outsideDelimiters(
|
|
1582
1644
|
outsideHoles(from, to, detail.holes),
|
|
1583
1645
|
detail.emphasis,
|
|
1584
1646
|
);
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1647
|
+
roman(parts, detail);
|
|
1648
|
+
if (named) {
|
|
1649
|
+
lexical(parts, detail);
|
|
1650
|
+
capitals(parts, detail);
|
|
1651
|
+
writings(parts, detail);
|
|
1652
|
+
covered(parts, detail);
|
|
1653
|
+
}
|
|
1589
1654
|
}
|
|
1590
1655
|
continue;
|
|
1591
1656
|
}
|
|
@@ -1647,15 +1712,30 @@ function analyseRuns(source, ctx, syntax) {
|
|
|
1647
1712
|
for (let line = first; line < last; line++)
|
|
1648
1713
|
plainInline(lines[line], starts[line], marking, detail, detail.holes);
|
|
1649
1714
|
}
|
|
1650
|
-
if (
|
|
1715
|
+
if (errors.length === before) {
|
|
1651
1716
|
const parts = outsideDelimiters(outsideHoles(from, to, detail.holes), detail.emphasis);
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1717
|
+
roman(parts, detail);
|
|
1718
|
+
if (named) {
|
|
1719
|
+
lexical(parts, detail);
|
|
1720
|
+
capitals(parts, detail);
|
|
1721
|
+
writings(parts, detail);
|
|
1722
|
+
covered(parts, detail);
|
|
1723
|
+
}
|
|
1656
1724
|
}
|
|
1657
1725
|
}
|
|
1658
1726
|
|
|
1727
|
+
for (const reference of rawReferences) {
|
|
1728
|
+
const font = reference.construct.keys.font ?? ctx.writtenIn?.get(reference.lang)?.font;
|
|
1729
|
+
roman([[reference.from, reference.to]], {
|
|
1730
|
+
run: {
|
|
1731
|
+
lang: reference.lang,
|
|
1732
|
+
nativefont: native(reference.construct),
|
|
1733
|
+
...reference.where,
|
|
1734
|
+
},
|
|
1735
|
+
face: font === undefined ? undefined : faces.get(font),
|
|
1736
|
+
});
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1659
1739
|
const order = (a, b) => a.line - b.line || a.column - b.column;
|
|
1660
1740
|
runs.sort(order);
|
|
1661
1741
|
details.sort((a, b) => order(a.run, b.run));
|
|
@@ -13,6 +13,9 @@
|
|
|
13
13
|
|
|
14
14
|
/** One invocation's authored sources and context-specific prepared views. */
|
|
15
15
|
import path from "node:path";
|
|
16
|
+
import fs from "node:fs";
|
|
17
|
+
import { DEPLOYED_ASSET_TYPES } from "./asset-types.mjs";
|
|
18
|
+
import { createContentTimings } from "./content-timings.mjs";
|
|
16
19
|
import * as contentIndex from "./content-index.mjs";
|
|
17
20
|
import * as blocks from "./content-blocks.mjs";
|
|
18
21
|
import * as alerts from "./content-alerts.mjs";
|
|
@@ -52,6 +55,8 @@ import { lintNoteStates } from "./stub-lint.mjs";
|
|
|
52
55
|
* @property {() => Promise<import("./content-index.mjs").PreparedContentIndex>} publication
|
|
53
56
|
* @property {() => ReturnType<import("./compile-corpus.mjs").buildCompileCorpus>} compileCorpus
|
|
54
57
|
* @property {(targets: readonly ContentTarget[]) => Promise<ContentSession>} prepare
|
|
58
|
+
* @property {ReturnType<typeof createContentTimings>} timings
|
|
59
|
+
* @property {() => void} assertUnchanged
|
|
55
60
|
* @property {() => void} dispose
|
|
56
61
|
*/
|
|
57
62
|
|
|
@@ -71,6 +76,8 @@ export const CONTENT_TARGETS = Object.freeze(["index", "lint", "links", "compile
|
|
|
71
76
|
* @param {string} [options.assetsBase] - Assets root, defaulting to configuration.
|
|
72
77
|
* @param {readonly string[]} [options.skipDirectories] - Resolved content scope.
|
|
73
78
|
* @param {ReturnType<typeof routerFor>} [options.router] - One router shared by this session's compile.
|
|
79
|
+
* @param {boolean} [options.trackChanges] - Snapshot physical input metadata for build phase guards.
|
|
80
|
+
* @param {ReturnType<typeof createContentTimings>} [options.timings] - Invocation stage accounting.
|
|
74
81
|
* @returns {Promise<ContentSession>} Captured sources and lazy prepared views; dispose after use.
|
|
75
82
|
*/
|
|
76
83
|
export async function createContentSession({
|
|
@@ -79,14 +86,36 @@ export async function createContentSession({
|
|
|
79
86
|
assetsBase = config.paths.assets,
|
|
80
87
|
skipDirectories = config.skipDirectories,
|
|
81
88
|
router = routerFor(config),
|
|
89
|
+
timings = createContentTimings(),
|
|
90
|
+
trackChanges = false,
|
|
82
91
|
} = {}) {
|
|
83
92
|
const tree = path.resolve(contentBase);
|
|
84
93
|
const assets = path.resolve(assetsBase);
|
|
85
94
|
const scope = [...skipDirectories];
|
|
86
|
-
const inventory =
|
|
95
|
+
const inventory = timings.sync("source capture", () =>
|
|
96
|
+
captureContentSources(tree, assets, scope),
|
|
97
|
+
);
|
|
98
|
+
const fingerprint = (file) => {
|
|
99
|
+
const stat = fs.statSync(file, { throwIfNoEntry: false });
|
|
100
|
+
return stat ? `${stat.ino}:${stat.size}:${stat.mtimeMs}:${stat.ctimeMs}` : "absent";
|
|
101
|
+
};
|
|
102
|
+
const fingerprints =
|
|
103
|
+
trackChanges ?
|
|
104
|
+
new Map(
|
|
105
|
+
[
|
|
106
|
+
...new Set([
|
|
107
|
+
tree,
|
|
108
|
+
...DEPLOYED_ASSET_TYPES.map((entry) => path.resolve(assets, entry.root)),
|
|
109
|
+
...inventory.paths.map((entry) => entry.file),
|
|
110
|
+
]),
|
|
111
|
+
].map((file) => [file, fingerprint(file)]),
|
|
112
|
+
)
|
|
113
|
+
: undefined;
|
|
87
114
|
const parsed = new Map();
|
|
88
115
|
const problems = [];
|
|
89
|
-
const foreign =
|
|
116
|
+
const foreign = timings.sync("dependency indexes", () =>
|
|
117
|
+
loadForeignIndexes(config, [config.contentPackage]),
|
|
118
|
+
);
|
|
90
119
|
const syntaxCache = new Map();
|
|
91
120
|
const prepared = new Map();
|
|
92
121
|
let disposed = false;
|
|
@@ -104,23 +133,31 @@ export async function createContentSession({
|
|
|
104
133
|
});
|
|
105
134
|
return syntaxCache.get(text);
|
|
106
135
|
};
|
|
107
|
-
const records =
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
136
|
+
const records = timings.sync("index derivation", () =>
|
|
137
|
+
contentIndex.indexRecordsFor({
|
|
138
|
+
config,
|
|
139
|
+
contentBase: tree,
|
|
140
|
+
assetsBase: assets,
|
|
141
|
+
skipDirectories: scope,
|
|
142
|
+
sources: inventory.indexSources,
|
|
143
|
+
parsed,
|
|
144
|
+
problems,
|
|
145
|
+
foreignIndex: foreign.index,
|
|
146
|
+
syntax,
|
|
147
|
+
}),
|
|
148
|
+
);
|
|
118
149
|
const memo = (key, make) => {
|
|
119
150
|
active();
|
|
120
|
-
if (!prepared.has(key))
|
|
151
|
+
if (!prepared.has(key))
|
|
152
|
+
prepared.set(
|
|
153
|
+
key,
|
|
154
|
+
Promise.resolve().then(() => timings.measure(key, make)),
|
|
155
|
+
);
|
|
156
|
+
else timings.hit(key);
|
|
121
157
|
return prepared.get(key);
|
|
122
158
|
};
|
|
123
159
|
const session = {
|
|
160
|
+
timings,
|
|
124
161
|
config,
|
|
125
162
|
contentBase: tree,
|
|
126
163
|
assetsBase: assets,
|
|
@@ -133,6 +170,17 @@ export async function createContentSession({
|
|
|
133
170
|
problems,
|
|
134
171
|
foreign,
|
|
135
172
|
syntax,
|
|
173
|
+
/** Refuse a stale capture after any build phase changes an input. */
|
|
174
|
+
assertUnchanged() {
|
|
175
|
+
active();
|
|
176
|
+
if (!fingerprints)
|
|
177
|
+
throw new Error("Content change tracking was not enabled for this session");
|
|
178
|
+
for (const [file, before] of fingerprints)
|
|
179
|
+
if (fingerprint(file) !== before)
|
|
180
|
+
throw new Error(
|
|
181
|
+
`Content sources changed during the build: ${file}. Finish source generation before content build.`,
|
|
182
|
+
);
|
|
183
|
+
},
|
|
136
184
|
/** Refuse use with a different configuration, root, scope or router. */
|
|
137
185
|
assertContext(options = {}) {
|
|
138
186
|
active();
|
|
@@ -0,0 +1,67 @@
|
|
|
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
|
+
/** Sequential, nested stage accounting for one content invocation. */
|
|
15
|
+
export function createContentTimings() {
|
|
16
|
+
const started = performance.now();
|
|
17
|
+
let changed = started;
|
|
18
|
+
const stack = ["orchestration"];
|
|
19
|
+
const stages = new Map();
|
|
20
|
+
const hits = new Map();
|
|
21
|
+
const account = () => {
|
|
22
|
+
const now = performance.now();
|
|
23
|
+
const name = stack.at(-1);
|
|
24
|
+
stages.set(name, (stages.get(name) ?? 0) + now - changed);
|
|
25
|
+
changed = now;
|
|
26
|
+
};
|
|
27
|
+
const begin = (name) => {
|
|
28
|
+
account();
|
|
29
|
+
stack.push(name);
|
|
30
|
+
return () => {
|
|
31
|
+
account();
|
|
32
|
+
stack.pop();
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
return {
|
|
36
|
+
/** @template T @param {string} name @param {() => T} work @returns {T} */
|
|
37
|
+
sync(name, work) {
|
|
38
|
+
const stop = begin(name);
|
|
39
|
+
try {
|
|
40
|
+
return work();
|
|
41
|
+
} finally {
|
|
42
|
+
stop();
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
/** @template T @param {string} name @param {() => Promise<T>|T} work @returns {Promise<T>} */
|
|
46
|
+
async measure(name, work) {
|
|
47
|
+
const stop = begin(name);
|
|
48
|
+
try {
|
|
49
|
+
return await work();
|
|
50
|
+
} finally {
|
|
51
|
+
stop();
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
/** @param {string} name */
|
|
55
|
+
hit(name) {
|
|
56
|
+
hits.set(name, (hits.get(name) ?? 0) + 1);
|
|
57
|
+
},
|
|
58
|
+
snapshot() {
|
|
59
|
+
account();
|
|
60
|
+
return {
|
|
61
|
+
totalMs: changed - started,
|
|
62
|
+
stages: Object.fromEntries(stages),
|
|
63
|
+
hits: Object.fromEntries(hits),
|
|
64
|
+
};
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
package/engine/index.mjs
CHANGED
|
@@ -227,6 +227,9 @@ export * as contentLint from "./content-lint.mjs";
|
|
|
227
227
|
|
|
228
228
|
/** The charset authored content is held to, so a book can choose its face. */
|
|
229
229
|
export * as contentCharset from "./content-charset.mjs";
|
|
230
|
+
|
|
231
|
+
/** Exact permitted Roman source characters for configured languages. */
|
|
232
|
+
export * as romanCharacters from "./roman-characters.mjs";
|
|
230
233
|
export * as pathCharset from "./path-charset.mjs";
|
|
231
234
|
|
|
232
235
|
/** Naming an interface icon in a note, without drawing one there. */
|
package/engine/language-data.mjs
CHANGED
|
@@ -73,6 +73,7 @@ import {
|
|
|
73
73
|
import { LANGUAGE_SUBTYPE } from "./word-terms.mjs";
|
|
74
74
|
import { WORD_CLASS_BASES } from "./word-classes.mjs";
|
|
75
75
|
import { nearest } from "./near-miss.mjs";
|
|
76
|
+
import { romanInventoryProblems } from "./roman-characters.mjs";
|
|
76
77
|
|
|
77
78
|
/** The bases a frame's `class` names. */
|
|
78
79
|
const CLASS_BASES = new Set(WORD_CLASS_BASES.map((item) => item.token));
|
|
@@ -614,7 +615,9 @@ export function checkLanguageData(note, { index, types } = {}) {
|
|
|
614
615
|
),
|
|
615
616
|
);
|
|
616
617
|
|
|
617
|
-
const findings =
|
|
618
|
+
const findings = romanInventoryProblems(data.roman).map((message) =>
|
|
619
|
+
finding(["data", "roman"], "error", message),
|
|
620
|
+
);
|
|
618
621
|
|
|
619
622
|
// Only the shape of a supplied declaration: no word is compared with it.
|
|
620
623
|
const grammar = isMap(data.grammar) ? data.grammar : {};
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
|
|
31
31
|
/** The `data` keys a language note states its sounds, names and history in. */
|
|
32
32
|
export const LANGUAGE_DATA_KEYS = Object.freeze([
|
|
33
|
+
"roman",
|
|
33
34
|
"tongues",
|
|
34
35
|
"register",
|
|
35
36
|
"retired",
|
|
@@ -1889,6 +1890,15 @@ const GRAMMAR_FIELDS = Object.freeze([
|
|
|
1889
1890
|
* @type {readonly object[]}
|
|
1890
1891
|
*/
|
|
1891
1892
|
export const LANGUAGE_DATA_FIELDS = Object.freeze([
|
|
1893
|
+
Object.freeze({
|
|
1894
|
+
name: "roman",
|
|
1895
|
+
kind: "string",
|
|
1896
|
+
shape: "a literal NFC string of permitted characters, including case, accents, digits and punctuation",
|
|
1897
|
+
describe:
|
|
1898
|
+
"The exact Roman source characters this language permits, each once. No ranges, case " +
|
|
1899
|
+
"expansion or inherited inventory; spaces and line breaks are structural. An empty " +
|
|
1900
|
+
"string declares no written characters. Required on each configured language note.",
|
|
1901
|
+
}),
|
|
1892
1902
|
Object.freeze({
|
|
1893
1903
|
name: "grammar",
|
|
1894
1904
|
kind: "map",
|
package/engine/map-lexicon.mjs
CHANGED
|
@@ -330,7 +330,18 @@ export function languageNoteFindings(config, records) {
|
|
|
330
330
|
}
|
|
331
331
|
const findings = [];
|
|
332
332
|
for (const [name, { record }] of languageNotes(languages, contentPackage, records)) {
|
|
333
|
-
if (record)
|
|
333
|
+
if (record) {
|
|
334
|
+
if (typeof record.data?.roman !== "string")
|
|
335
|
+
findings.push({
|
|
336
|
+
file: configFile ?? CONFIG,
|
|
337
|
+
...(text ?
|
|
338
|
+
positionOfYamlPath(text, ["languages", name, "note"], { key: true })
|
|
339
|
+
: {}),
|
|
340
|
+
severity: "error",
|
|
341
|
+
message: `the language note named by \`languages.${name}.note\` must declare \`data.roman\``,
|
|
342
|
+
});
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
334
345
|
findings.push({
|
|
335
346
|
file: configFile ?? CONFIG,
|
|
336
347
|
...(text ? positionOfYamlPath(text, ["languages", name, "note"], { key: true }) : {}),
|
|
@@ -0,0 +1,113 @@
|
|
|
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
|
+
/** A language's exact Roman source characters, before any script conversion. */
|
|
15
|
+
|
|
16
|
+
/** Unicode graphemes keep every canonically composing sequence together. */
|
|
17
|
+
const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
18
|
+
|
|
19
|
+
/** A code point as the author sees it in a finding. */
|
|
20
|
+
const codeName = (char) => `U+${char.codePointAt(0).toString(16).toUpperCase().padStart(4, "0")}`;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Semantic problems in a supplied inventory. Shape belongs to the vocabulary.
|
|
24
|
+
* An empty string explicitly declares no written characters.
|
|
25
|
+
* @param {unknown} value - The authored `data.roman`.
|
|
26
|
+
* @returns {string[]} Findings at that field.
|
|
27
|
+
*/
|
|
28
|
+
export function romanInventoryProblems(value) {
|
|
29
|
+
if (typeof value !== "string") return [];
|
|
30
|
+
const problems = [];
|
|
31
|
+
if (value !== value.normalize("NFC"))
|
|
32
|
+
problems.push(
|
|
33
|
+
"`data.roman` must use NFC spelling; write precomposed accented letters where available",
|
|
34
|
+
);
|
|
35
|
+
if (/\s/u.test(value))
|
|
36
|
+
problems.push(
|
|
37
|
+
"`data.roman` lists characters without whitespace; spaces and line breaks are structural",
|
|
38
|
+
);
|
|
39
|
+
if (/[\p{Cc}\p{Cf}\p{Cs}]/u.test(value))
|
|
40
|
+
problems.push("`data.roman` contains a control, format or unpaired surrogate character");
|
|
41
|
+
const seen = new Set();
|
|
42
|
+
const repeated = new Set();
|
|
43
|
+
for (const char of value) {
|
|
44
|
+
if (seen.has(char)) repeated.add(char);
|
|
45
|
+
seen.add(char);
|
|
46
|
+
}
|
|
47
|
+
if (repeated.size)
|
|
48
|
+
problems.push(
|
|
49
|
+
`\`data.roman\` repeats ${[...repeated].map((char) => `\`${char}\` (${codeName(char)})`).join(", ")}; list each character once`,
|
|
50
|
+
);
|
|
51
|
+
return problems;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @typedef {object} RomanInventory
|
|
56
|
+
* @property {string} language - The configured language name.
|
|
57
|
+
* @property {ReadonlySet<string>|undefined} characters - Exact code points, absent for a missing declaration.
|
|
58
|
+
*/
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Each configured language note's own inventory, by alias and BCP 47 tag.
|
|
62
|
+
* Language-family ancestry does not supply an inventory. Unassociated external
|
|
63
|
+
* tags have no entry and keep their ordinary behavior.
|
|
64
|
+
* @param {ReadonlyMap<string, Record<string, any>>} records - Configured language notes.
|
|
65
|
+
* @param {Readonly<Record<string, {tag: string}>>} languages - Language configuration.
|
|
66
|
+
* @returns {Map<string, RomanInventory>}
|
|
67
|
+
*/
|
|
68
|
+
export function romanInventoriesOf(records, languages = {}) {
|
|
69
|
+
const out = new Map();
|
|
70
|
+
for (const [language, record] of records) {
|
|
71
|
+
const value = record.data?.roman;
|
|
72
|
+
const inventory = Object.freeze({
|
|
73
|
+
language,
|
|
74
|
+
characters: typeof value === "string" ? new Set(value) : undefined,
|
|
75
|
+
});
|
|
76
|
+
out.set(language, inventory);
|
|
77
|
+
const tag = languages[language]?.tag;
|
|
78
|
+
if (typeof tag === "string") out.set(tag.toLowerCase(), inventory);
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Invalid source characters, retaining offsets in the supplied unescaped text.
|
|
85
|
+
* No punctuation, case or normalization repair is performed.
|
|
86
|
+
* @param {string} text - Visible Roman source.
|
|
87
|
+
* @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
|
+
* @returns {Array<{offset: number, message: string}>}
|
|
90
|
+
*/
|
|
91
|
+
export function romanTextProblems(text, inventory, native = () => false) {
|
|
92
|
+
const out = [];
|
|
93
|
+
if (!inventory.characters) return out;
|
|
94
|
+
for (const { segment: cluster, index } of graphemes.segment(text)) {
|
|
95
|
+
let offset = index;
|
|
96
|
+
if (cluster !== cluster.normalize("NFC")) {
|
|
97
|
+
out.push({
|
|
98
|
+
offset,
|
|
99
|
+
message: `\`${cluster}\` is not NFC spelling in \`${inventory.language}\`; write \`${cluster.normalize("NFC")}\``,
|
|
100
|
+
});
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
for (const char of cluster) {
|
|
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
|
+
});
|
|
109
|
+
offset += char.length;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return out;
|
|
113
|
+
}
|