@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.
@@ -158,6 +158,7 @@ language note says which, as one map and one face:
158
158
 
159
159
  ```yaml
160
160
  data:
161
+ roman: "ABDEFGHIKLMNOPRSTUVWYZabdefghiklmnoprstuvwyzÁÉÍÓÚÝáéíóúýËëÀà0123456789."
161
162
  writtenIn: { map: semaron, font: semaron-kuriale }
162
163
  ```
163
164
 
@@ -300,7 +301,6 @@ data:
300
301
  - { from: "y", to: "󰉅", before: vowel } # U+F0245
301
302
  - ["y", "󰈷"] # U+F0237
302
303
  - [".", "󰉨"] # U+F0268
303
- - [",", "󰉨"] # U+F0268
304
304
  fonts: [{ font: semaron-kuriale }]
305
305
  ---
306
306
 
@@ -339,8 +339,8 @@ them, none included. A mapping that drops a language's vowels writes `[a, ""]`.
339
339
  `y` is the consonant letter before a vowel and the vowel `i` elsewhere.
340
340
  - **Capitals and marks need rows of their own.** A row matches the character as
341
341
  written, so `L` and `l` are two rows, and a script with no capitals gives both
342
- the same code point. The full stop and the comma are characters too: a run
343
- that holds one needs a row for it, or the face-coverage check reports it.
342
+ the same code point. A period is a character too and needs its own row.
343
+ A language's `data.roman` decides which source characters may appear.
344
344
  - **A character no row matches passes through unchanged,** and the face-coverage
345
345
  check then reports it. A mapping for a face that draws nothing on roman code
346
346
  points lists every letter the spelling uses.
@@ -381,14 +381,19 @@ A row with a fault is left out of the map and the rest apply.
381
381
 
382
382
  ## How a mapping treats punctuation
383
383
 
384
+ The language note's `data.roman` lists its exact permitted Roman source
385
+ characters. Include capitals, accents, digits and punctuation explicitly;
386
+ `package-build lint` checks them before mapping. A map cannot admit a character
387
+ that the language's inventory excludes. There are no automatic punctuation
388
+ substitutions.
389
+
384
390
  The toolchain is used with one convention for marks: **mood, questions and
385
391
  quotation are carried by words, not marks,** unless a language's note rules
386
392
  otherwise. A mapping therefore lists the marks its script has and nothing more.
387
- In the sample, a comma and a full stop are the script's one point:
393
+ In the sample, only a period writes the script's pause point:
388
394
 
389
395
  ```yaml
390
396
  - [".", "\U000F0268"] # U+F0268
391
- - [",", "\U000F0268"] # U+F0268
392
397
  ```
393
398
 
394
399
  (Formatting rewrites the escapes, as above.) A character with no row passes
@@ -398,10 +403,11 @@ mapped run is an error that names the mark and the mapping:
398
403
  > `` `?` (U+003F) is not drawn by the font `semaron-kuriale`, and the map `semaron` has no row for it ``
399
404
 
400
405
  The remedy is to write the question or the quotation in words, as the language
401
- does, and leave the mark out of the run. A language whose note rules
402
- otherwise gets rows for its marks in the mapping, and the check stops
403
- reporting them. The same holds for the digits, the hyphen and the catch: a row
404
- for each makes the mark part of the script; none leaves it out.
406
+ does, and leave the mark out of the run. A language whose note permits a mark
407
+ includes it in `data.roman`; its script needs a row for that mark as well.
408
+ Vylari permits period alone as punctuation. Its lexical catch is written with
409
+ a grave-accented vowel, as in `kàralzon`, and that vowel needs its own mapping
410
+ row; an apostrophe is not another spelling of it.
405
411
 
406
412
  ## The dictionary first
407
413
 
@@ -793,7 +799,7 @@ paragraphs: indentation, line breaks and spans survive formatting untouched.
793
799
 
794
800
  ````markdown
795
801
  ```verse {lang=vyl font=semaron-kuriale map=semaron .nativefont}
796
- Lokazor lonak,
802
+ Lokazor lonak.
797
803
  lokamen zaron.
798
804
  ```
799
805
  ````
@@ -1114,6 +1120,7 @@ name: { full: Vylari Language }
1114
1120
  type: skill
1115
1121
  subType: language
1116
1122
  data:
1123
+ roman: "ABDEFGHIKLMNOPRSTUVWYZabdefghiklmnoprstuvwyzÁÉÍÓÚÝáéíóúýËëÀà0123456789."
1117
1124
  writtenIn: { map: semaron, font: semaron-kuriale }
1118
1125
  core: true
1119
1126
  fields:
@@ -1177,7 +1184,7 @@ The lintel over the door of the oath road is cut with a couplet, and the
1177
1184
  stonecutter set the couplet in the script of the court, so that every traveller
1178
1185
  could read it as the court reads it.
1179
1186
 
1180
- [Lokazor lonak thiruthrimar, lokamen zaron.]{lang=vyl font=semaron-kuriale map=semaron .nativefont}
1187
+ [Lokazor lonak thiruthrimar. lokamen zaron.]{lang=vyl font=semaron-kuriale map=semaron .nativefont}
1181
1188
  ```
1182
1189
 
1183
1190
  Linting reports the unknown word, at its first character:
@@ -1198,6 +1205,7 @@ name: { full: Vylari Language }
1198
1205
  type: skill
1199
1206
  subType: language
1200
1207
  data:
1208
+ roman: "ABDEFGHIKLMNOPRSTUVWYZabdefghiklmnoprstuvwyzÁÉÍÓÚÝáéíóúýËëÀà0123456789."
1201
1209
  writtenIn: { map: semaron, font: semaron-kuriale }
1202
1210
  core: true
1203
1211
  fields:
@@ -1251,7 +1259,7 @@ The lint is now clean, and the conversion can be read. `package-build transliter
1251
1259
  prints the code points the build writes for the couplet:
1252
1260
 
1253
1261
  ```console
1254
- $ package-build transliterate semaron --lang vyl --hex "Lokazor lonak, lokamen zaron."
1262
+ $ package-build transliterate semaron --lang vyl --hex "Lokazor lonak. lokamen zaron."
1255
1263
  U+F020A U+F023D U+F0239 U+F0230 U+F0246 U+F023D U+F023F U+0020 U+F023A U+F023D U+F023C U+F0230 U+F0239 U+F0268 U+0020 U+F023A U+F023D U+F0239 U+F0230 U+F023B U+F0233 U+F023C U+0020 U+F0246 U+F0248 U+F023F U+F023D U+F023C U+F0268
1256
1264
  ```
1257
1265
 
@@ -1264,8 +1272,8 @@ The site sets the run as
1264
1272
  ```
1265
1273
 
1266
1274
  where the characters are the code points above: `Lokazor` begins with the
1267
- capital `L` of the rows, `zaron` is the exception's long vowel, and the comma
1268
- and full stop are the script's one point.
1275
+ capital `L` of the rows, `zaron` is the exception's long vowel, and each period
1276
+ writes the script's pause point.
1269
1277
 
1270
1278
  A poem in columns and a poem with its metre checked sit on one note. Both parse
1271
1279
  with no finding: the first sets native Vylari in column one and English over Roman
@@ -1341,7 +1349,7 @@ The door is marked with the same words as the lintel. Although Semaron's
1341
1349
  convention is rubric, this Vylari span uses no Markdown emphasis: spans with a
1342
1350
  non-English language cannot contain bold or italic markup.
1343
1351
 
1344
- [Lokazor lonak, zaron.]{lang=vyl font=semaron-kuriale map=semaron .nativefont}
1352
+ [Lokazor lonak. zaron.]{lang=vyl font=semaron-kuriale map=semaron .nativefont}
1345
1353
  ```
1346
1354
 
1347
1355
  Finally, the core report lists what the language still lacks:
@@ -2002,6 +2002,30 @@ in that column are grey; each row resets that choice. `numstep` counts logical
2002
2002
  rows, and `***` separates stanzas. Language and native script choices remain
2003
2003
  on the authored inline runs, independently for every reading.
2004
2004
 
2005
+ **A language note declares its Roman source inventory in `data.roman`.** It is
2006
+ one literal NFC string containing each permitted character once, including every
2007
+ capital, accent, digit and punctuation mark the language uses. For example,
2008
+ `roman: "kaKàÀ.1"` permits exactly those seven characters. There is no range
2009
+ syntax, automatic case expansion, or inventory inherited from a parent language.
2010
+ An empty string explicitly declares that the language has no written characters.
2011
+ Spaces, tabs and line breaks are structural and are not listed in the inventory.
2012
+ A missing declaration on a configured language note is an error even when no
2013
+ passage uses the language. A directly typed native sign is separate from
2014
+ Roman input only when the run explicitly selects `.nativefont`, the character
2015
+ belongs to a recognized setting-script encoding, and the selected face draws it.
2016
+ A font's ability to draw Roman punctuation does not admit that punctuation.
2017
+
2018
+ `package-build lint` checks the visible source of language-tagged spans, divs,
2019
+ verse and scroll fences against that inventory before any map or native-word
2020
+ replacement can hide a character. Both the configured name and its BCP 47 tag
2021
+ select the same inventory. A nested language selects its own; link destinations,
2022
+ attributes, column numbers, indentation periods and `***` stanza separators are
2023
+ structure. Escaping a character does not admit it. Source spelling must be NFC;
2024
+ errors retain the original file position. Unassociated external tags such as
2025
+ `en` and `zxx` keep their ordinary behavior. Standalone `package-build markdown`
2026
+ checks markup without loading language inventories. Structured frontmatter words
2027
+ are separate from prose runs.
2028
+
2005
2029
  **A face draws the code points its `fonts.json` entry lists in `codepoints`,
2006
2030
  and whitespace.** After the map, any other character in a run set in a face is
2007
2031
  an error, located at the source character it came from; through a map, at the
@@ -6095,6 +6119,7 @@ SoHL sets `system.quantity` to `1` for each compendium item.
6095
6119
  | `family` | `Address` | `language` only: the parent language, naming a skill note with `subType: language`; this language reads the parent's words as its own — see [a language's words](#a-languages-words) |
6096
6120
  | `abbreviation` | `string` | `language` only: the tag a dictionary prints for it, as `Prov.`; its full name where unstated — see [dictionaries and glossaries](#dictionaries-and-glossaries) |
6097
6121
  | `core` | `boolean` | `language` only: `true` where the language must cover the [core vocabulary](#the-core-vocabulary) |
6122
+ | `roman` | literal NFC string, may be empty | Exact permitted Roman source characters, including both cases, accents, digits and punctuation; required on each configured language note. No family inheritance or range syntax. |
6098
6123
  | `grammar` | `{ plural?, verbs?, agreement?, participles? }` | Optional noun plurals, one verb pattern, agreement and participles; supplied shapes only are checked |
6099
6124
  | `grammar.plural` | `{ ending, becomes }[]` | Literal suffix replacements in authored order; the first match wins |
6100
6125
  | `grammar.plural[].ending` | `string`, required, may be empty | Bare suffix to replace; empty matches every noun |
@@ -468,6 +468,7 @@ These fields are accepted by every note type. A field's value can still be irrel
468
468
  | `data.fields` | list of `{ name, holds }` | The fields the language's words are grouped under. A language skill note only. |
469
469
  | `data.words` | list of `{ form, class, field, gloss, … }` | The words this language states, one map each, in authored order; its automatic dictionary sorts them alphabetically. A language skill note only. See [a language's words](format-details.md#a-languages-words). |
470
470
  | `data.affixes` | list of `{ form, gloss, attaches?, examples? }` | The affixes a word's `built` pieces may name, each with its gloss. A language skill note only. |
471
+ | `data.roman` | a literal NFC string of permitted characters, including case, accents, digits and punctuation | The exact Roman source characters this language permits, each once. No ranges, case expansion or inherited inventory; spaces and line breaks are structural. An empty string declares no written characters. Required on each configured language note. |
471
472
  | `data.grammar` | a map — `{ plural?, verbs?, agreement?, participles?, native? }` | Optional noun plurals, one verb pattern, agreement, participles and the endings' logographic writings, for dictionary presentation, the mapped-run word check and logographic maps. Only the shape of supplied keys is checked; no word must follow or state a rule. A language skill note only. |
472
473
  | `data.tongues` | list of `{ tag, meaning, language? }` | The tags a register row's `tongue` may name, each with what it means. A language skill note only. |
473
474
  | `data.register` | list of `{ name, tongue?, kind?, built?, gloss?, why? }` | Every name the setting gives in the language's reach, with its tongue and pieces; the notes carrying each name are derived. A language skill note only. |
@@ -0,0 +1,179 @@
1
+ /*
2
+ * This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
3
+ * Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
4
+ *
5
+ * This work is licensed under the GNU General Public License v3.0 (GPLv3).
6
+ * You may copy, modify, and distribute it under the terms of that license.
7
+ *
8
+ * For full terms, see the LICENSE.md file in the project root or visit:
9
+ * https://www.gnu.org/licenses/gpl-3.0.html
10
+ *
11
+ * SPDX-License-Identifier: GPL-3.0-or-later
12
+ */
13
+
14
+ import fs from "node:fs";
15
+ import path from "node:path";
16
+ import { pathToFileURL } from "node:url";
17
+ import { loadPackageBuildConfig } from "../config.mjs";
18
+ import { loadPackConfig, packConfigPath } from "./pack-config.mjs";
19
+ import { checkForeignAssetBindings } from "./asset-bindings.mjs";
20
+ import { formatDiagnostic, positionOfYamlPath } from "./diagnostics.mjs";
21
+ import { stageAssets } from "../stage.mjs";
22
+ import { rowsStagingPictureRoots, stagePackageAssets } from "./asset-shipping.mjs";
23
+ import { baseStyleStageEntry } from "./base-styles.mjs";
24
+ import { checkScriptFonts, copyScriptFonts, STAGED_FONTS_DIR } from "./script-font-delivery.mjs";
25
+
26
+ /**
27
+ * A finding for each `packageBuild.assets` row that would stage a picture root
28
+ * itself, located at the row's `from` when the configuration is YAML.
29
+ *
30
+ * @param {readonly {from: string, to: string}[]} rows - The configured rows.
31
+ * @param {{rootDir: string, assetsBase: string}} opts - Where they resolve.
32
+ * @returns {object[]} The findings.
33
+ */
34
+ function pictureRootRowFindings(rows, { rootDir, assetsBase }) {
35
+ const offending = rowsStagingPictureRoots(rows, { rootDir, assetsBase });
36
+ if (!offending.length) return [];
37
+ const file = packConfigPath();
38
+ const text =
39
+ file.endsWith(".yaml") || file.endsWith(".yml") ? fs.readFileSync(file, "utf8") : "";
40
+ return offending.map(({ index, from, root }) => ({
41
+ file,
42
+ ...(text ? positionOfYamlPath(text, ["packageBuild", "assets", index, "from"]) : {}),
43
+ severity: "error",
44
+ message:
45
+ `\`packageBuild.assets[${index}]\` copies \`${from}\`, which reaches ${root}/ — ` +
46
+ "the stage copies the icon and image roots itself, flat and filtered by " +
47
+ "each file's provenance, so a row copying them would ship every file " +
48
+ "unfiltered. Remove the row",
49
+ }));
50
+ }
51
+
52
+ /**
53
+ * The repository's `packageBuild.assetTransform`, loaded.
54
+ *
55
+ * One loader for every command that writes a picture, so the stage and the CDN
56
+ * hold the same bytes for the same file.
57
+ *
58
+ * @param {object} config - The resolved package-build configuration.
59
+ * @returns {Promise<((sourcePath: string) => string|null|undefined)|undefined>}
60
+ * The transform, or `undefined` when the repository names none.
61
+ */
62
+ export async function loadAssetTransform(config) {
63
+ if (!config.assetTransform) return undefined;
64
+ const module = await import(pathToFileURL(config.assetTransform).href).catch((err) => {
65
+ throw new Error(
66
+ `cannot load \`packageBuild.assetTransform\` (${config.assetTransform}): ${err.message}`,
67
+ );
68
+ });
69
+ const transform = module.transform;
70
+ if (typeof transform !== "function") {
71
+ throw new Error(
72
+ `\`packageBuild.assetTransform\` ` +
73
+ `(${config.assetTransform}) exports no \`transform\` ` +
74
+ `function. It must export ` +
75
+ `\`transform(sourcePath) -> string | null\`.`,
76
+ );
77
+ }
78
+ return transform;
79
+ }
80
+
81
+ /**
82
+ * Stage static files, provenance-filtered pictures, base styles and fonts.
83
+ * @param {object} [options]
84
+ * @param {ReturnType<typeof loadPackageBuildConfig>} [options.config]
85
+ * @param {import("../content-config.mjs").ContentBuildConfig} [options.packConfig]
86
+ * @param {import("./content-session.mjs").ContentSession} [options.session]
87
+ */
88
+ export async function stagePackageBuildAssets({
89
+ config = loadPackageBuildConfig(),
90
+ packConfig = loadPackConfig(),
91
+ session,
92
+ } = {}) {
93
+ const assetsBase = packConfig.paths.assets;
94
+ const findings = [
95
+ ...pictureRootRowFindings(config.assets, {
96
+ rootDir: config.rootDir,
97
+ assetsBase,
98
+ }),
99
+ ...checkForeignAssetBindings(packConfig, { assets: config.assets }),
100
+ ];
101
+ if (findings.length) {
102
+ const error = new Error(findings.map(formatDiagnostic).join("\n"));
103
+ error.located = true;
104
+ throw error;
105
+ }
106
+
107
+ const transform = await loadAssetTransform(config);
108
+
109
+ // `to:` is relative to the staged package root, so a
110
+ // repository's table reads `lang`, not `build/stage/lang`.
111
+ const entries = config.assets.map(({ from, to }) => [from, path.join(config.stageDir, to)]);
112
+ let { entries: count, files } = stageAssets(entries, {
113
+ cwd: config.rootDir,
114
+ transform,
115
+ });
116
+
117
+ // The picture roots, staged by the toolchain rather than by a row:
118
+ // flat, filtered by each file's provenance, and listed in the
119
+ // package's asset set.
120
+ const pictures = stagePackageAssets({
121
+ assetsBase,
122
+ contentPackage: packConfig.contentPackage,
123
+ stageDir: path.resolve(config.rootDir, config.stageDir),
124
+ // The manifest's title names the set; a package with none is
125
+ // named by its content package.
126
+ title:
127
+ typeof config.manifest?.title === "string" && config.manifest.title.trim() ?
128
+ config.manifest.title
129
+ : packConfig.contentPackage,
130
+ transform,
131
+ shippingLicenses: config.shippingLicenses,
132
+ });
133
+ if (pictures.findings.length) {
134
+ const error = new Error(pictures.findings.map(formatDiagnostic).join("\n"));
135
+ error.located = true;
136
+ throw error;
137
+ }
138
+ files += pictures.staged.length;
139
+ if (pictures.staged.length || pictures.excluded.length) {
140
+ console.log(
141
+ `Pictures: ${pictures.staged.length} staged, ` +
142
+ `${pictures.excluded.length} left out by provenance.`,
143
+ );
144
+ }
145
+
146
+ // Staged in its own pass, without the repository's transform: the
147
+ // sheet is this toolchain's file rather than one of the
148
+ // repository's assets, and a `transform` written to retheme a
149
+ // package's own SVGs has no business being handed it.
150
+ if (config.baseStyles) {
151
+ const staged = stageAssets([baseStyleStageEntry(config.stageDir)], {
152
+ cwd: config.rootDir,
153
+ });
154
+ count += staged.entries;
155
+ files += staged.files;
156
+ }
157
+
158
+ // The installed fonts, when the package has installed the fonts
159
+ // package and a note sets text in one of its faces: the
160
+ // stylesheet, the WOFF2 files and the licence, which the
161
+ // manifest's `styles` names.
162
+ const fontFindings = checkScriptFonts(packConfig, session);
163
+ if (fontFindings.length) {
164
+ const error = new Error(fontFindings.map(formatDiagnostic).join("\n"));
165
+ error.located = true;
166
+ throw error;
167
+ }
168
+ const fonts = copyScriptFonts(
169
+ packConfig,
170
+ path.resolve(config.rootDir, config.stageDir, STAGED_FONTS_DIR),
171
+ ["woff2"],
172
+ session,
173
+ );
174
+ if (fonts) {
175
+ count += 1;
176
+ files += fonts;
177
+ }
178
+ console.log(`✅ Static assets staged (${count} entries, ${files} files).`);
179
+ }
@@ -77,16 +77,20 @@ function readIndex(file) {
77
77
  *
78
78
  * @param {object} opts
79
79
  * @param {object} opts.config - Resolved content configuration.
80
+ * @param {Map<string, {raw: string}>} [opts.sources] - Captured authored text for diagnostics.
80
81
  * @param {string} opts.calendariaVersion - Version whose import format is targeted.
81
82
  * @returns {{directory: string, calendars: number, files: number}}
82
83
  */
83
- export function emitCalendarArtifacts({ config, calendariaVersion } = {}) {
84
+ export function emitCalendarArtifacts({ config, calendariaVersion, sources } = {}) {
84
85
  if (!config) throw new Error("calendar emission requires a content configuration");
85
86
  if (!/^\d+\.\d+\.\d+$/.test(calendariaVersion ?? "")) {
86
87
  throw new Error("calendar emission requires a Calendaria version such as 1.4.2");
87
88
  }
88
89
 
89
90
  const root = config.rootDir;
91
+ const rawSource = (file) =>
92
+ sources?.get(path.resolve(root, file))?.raw ??
93
+ fs.readFileSync(path.resolve(root, file), "utf8");
90
94
  const contentBase = path.resolve(root, config.paths.content);
91
95
  const indexFile = path.resolve(
92
96
  root,
@@ -108,7 +112,7 @@ export function emitCalendarArtifacts({ config, calendariaVersion } = {}) {
108
112
  );
109
113
  const names = new Map();
110
114
  for (const note of calendars) {
111
- const raw = fs.readFileSync(path.resolve(root, note.file), "utf8");
115
+ const raw = rawSource(note.file);
112
116
  const shortcode = note.fm.shortcode;
113
117
  if (typeof shortcode !== "string" || !/^[a-z0-9]+$/.test(shortcode)) {
114
118
  failAt(
@@ -135,7 +139,7 @@ export function emitCalendarArtifacts({ config, calendariaVersion } = {}) {
135
139
  failAt(
136
140
  note.file,
137
141
  "calendar notes require a world note declaring data.year",
138
- fs.readFileSync(path.resolve(root, note.file), "utf8"),
142
+ rawSource(note.file),
139
143
  ["subType"],
140
144
  );
141
145
  }
@@ -96,6 +96,7 @@ export async function compilePacks({
96
96
  contentBase: config.paths.content,
97
97
  skipDirectories: config.skipDirectories,
98
98
  });
99
+ const measured = (name, work) => (session ? session.timings.measure(name, work) : work());
99
100
  const packNames = sourcePacks.filter((name) => !packName || name === packName);
100
101
 
101
102
  // A prebuilt pack's JSON is checked in rather than generated, so it is
@@ -114,12 +115,14 @@ export async function compilePacks({
114
115
  // content tree, and a package that ships only prebuilt packs has none at
115
116
  // all — so running it would fail the build over a tree nothing reads.
116
117
  if (needsGeneration) {
117
- const errors = await generatePacksJson({
118
- only: packName,
119
- config,
120
- session,
121
- reportPreparation,
122
- });
118
+ const errors = await measured("rendering/documents", () =>
119
+ generatePacksJson({
120
+ only: packName,
121
+ config,
122
+ session,
123
+ reportPreparation,
124
+ }),
125
+ );
123
126
  if (errors > 0) {
124
127
  throw new Error(
125
128
  `Pack JSON generation reported ${errors} error(s); refusing to ` +
@@ -141,11 +144,13 @@ export async function compilePacks({
141
144
 
142
145
  const stage = path.join(stageDest, name);
143
146
  log.info(`Pack ${name}: compiling to LevelDB at ${stage}`);
144
- await compilePack(source, stage, {
145
- recursive: true,
146
- log: false,
147
- transformEntry: cleanPackEntry,
148
- });
147
+ await measured("pack compilation", () =>
148
+ compilePack(source, stage, {
149
+ recursive: true,
150
+ log: false,
151
+ transformEntry: cleanPackEntry,
152
+ }),
153
+ );
149
154
 
150
155
  // A Scene's map image lives on an embedded Level, stored under its own
151
156
  // LevelDB key. Nothing in Foundry ties the two together on read: a
@@ -176,6 +176,17 @@ const SCRIPT_BLOCKS = Object.freeze([
176
176
  [0x13430, 0x1345f], // the Egyptian format controls Zethu uses
177
177
  ]);
178
178
 
179
+ /**
180
+ * Whether a code point belongs to a declared setting-script encoding block.
181
+ * This identifies native input independently of a language's Roman inventory;
182
+ * a selected native face must still draw the individual character.
183
+ * @param {number} cp - A Unicode code point.
184
+ * @returns {boolean}
185
+ */
186
+ export function isSettingScriptCodePoint(cp) {
187
+ return SCRIPT_BLOCKS.some(([from, to]) => cp >= from && cp <= to);
188
+ }
189
+
179
190
  /**
180
191
  * Whether the charset admits a code point anywhere in a note.
181
192
  *
@@ -184,10 +195,7 @@ const SCRIPT_BLOCKS = Object.freeze([
184
195
  */
185
196
  export function isAllowedCodePoint(cp) {
186
197
  return (
187
- isLetterTier(cp) ||
188
- TYPOGRAPHY.has(cp) ||
189
- NOTATION.has(cp) ||
190
- SCRIPT_BLOCKS.some(([from, to]) => cp >= from && cp <= to)
198
+ isLetterTier(cp) || TYPOGRAPHY.has(cp) || NOTATION.has(cp) || isSettingScriptCodePoint(cp)
191
199
  );
192
200
  }
193
201
 
@@ -200,6 +200,7 @@ types:
200
200
  - other
201
201
  skill:
202
202
  data:
203
+ - roman
203
204
  - grammar
204
205
  - templatePriority
205
206
  - masteryLevel