@heroiclands/package-build 24.0.3 → 24.0.4

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 CHANGED
@@ -1,5 +1,22 @@
1
1
  # @heroiclands/package-build
2
2
 
3
+ ## 24.0.4
4
+
5
+ ### Patch Changes
6
+
7
+ **Languages**
8
+
9
+ - Core vocabulary coverage uses only the nearest definition when a language
10
+ redefines an inherited expression.
11
+
12
+ - Inherited dictionary words appear in a daughter language’s script with the
13
+ applicable native spelling.
14
+
15
+ **Verse**
16
+
17
+ - Parallel verse keeps its Roman reading beside the native script, with
18
+ gaps between stanzas.
19
+
3
20
  ## 24.0.3
4
21
 
5
22
  ### Patch Changes
@@ -490,7 +490,11 @@ column stacks one or more **entries**, joined by `:`. An entry is
490
490
  - **`mapping`** names a [mapping note](#mapping-notes), which rewrites the
491
491
  line before it is set. Left out, the line is set as written.
492
492
  - **`font`** names a face or a generic family, as `font=` does. Left out, the
493
- line is set in the roman serif.
493
+ line keeps the page's normal typeface.
494
+
495
+ A language-only entry stays in Roman letters even when the language's
496
+ `data.writtenIn` names its usual script. Ordinary language-marked prose still
497
+ selects that script automatically; columns use the entry's explicit mapping.
494
498
 
495
499
  A poem has at most two columns, and either may be left out. In every column
496
500
  the first entry is set in the normal tone and each later one in grey.
@@ -805,7 +809,12 @@ is one of them.
805
809
 
806
810
  **With `lang=`, the language's dictionary comes first.** Where
807
811
  [`languages.<name>.note`](../configuration.md#languages) names the language's
808
- note, each word of the run is looked up in its `data.words`:
812
+ note, each word of the run is looked up in its `data.words` and the words of
813
+ its `data.family` chain. The nearest exact NFC form supplies the entire word
814
+ entry, including variants and native spellings; an override replaces the whole
815
+ ancestor entry. Case and word separators remain part of that identity.
816
+ Grammar, registered names and the default writing script remain the daughter's
817
+ own. An inherited native spelling applies only to the map and face it names:
809
818
 
810
819
  1. **A word whose entry states a `native` form for the map** is written by it:
811
820
  the form stated for the run's face where there is one, else the form stated
@@ -6637,9 +6637,18 @@ The lint holds each to what it means, each finding located at the key at fault:
6637
6637
  Where no note states `data.concepts`, every id is unknown, and the message
6638
6638
  says so.
6639
6639
 
6640
- A language reads the words of its `data.family` chain as its own, so a parent's
6641
- tagged word covers the daughter's concept. `data.core` is not inherited: each
6642
- language that must cover the list states it.
6640
+ A language reads the words of its `data.family` chain as its own. The nearest
6641
+ definition of a form wins, using the dictionary's matching: Unicode NFC, with
6642
+ case and word separators kept. If the daughter defines the same form, only
6643
+ that definition's `concept` ids count; an untagged definition covers none of
6644
+ the ancestor's concepts. A different inherited expression can still cover
6645
+ them. `data.core` is not inherited: each language that must cover the list
6646
+ states it.
6647
+
6648
+ A `form` may contain several words. Its `gloss` records the expression's
6649
+ meaning, `built` may name its existing pieces, and `concept` may name every
6650
+ concept those senses support. The report counts declared ids; it does not
6651
+ prove an expression's meaning or infer coverage from grammar or untagged prose.
6643
6652
 
6644
6653
  `package-build core` is the report. A concept no word serves is never a lint
6645
6654
  finding; the report lists it so the language can be filled in. See
@@ -91,6 +91,9 @@ import {
91
91
  expandVerseOrigins,
92
92
  scanVerse,
93
93
  sourcePosition,
94
+ hasColumns,
95
+ verseColumns,
96
+ STANZA_BREAK,
94
97
  } from "./content-verse.mjs";
95
98
  import { scanSpans } from "./content-spans.mjs";
96
99
  import { scanScrolls, scrollParagraphs } from "./content-scroll.mjs";
@@ -908,8 +911,23 @@ function analyseRuns(source, ctx, syntax) {
908
911
  collect(0, text);
909
912
 
910
913
  const poems = new Map();
914
+ const romanColumnLines = new Set();
911
915
  for (const poem of scanVerse(text).blocks) {
912
916
  poems.set(poem.start, poem);
917
+ // Expansion writes one span per display entry. An entry with no map
918
+ // keeps the source spelling and page font, even when its language
919
+ // normally selects a script. The fence itself holds only structure.
920
+ if (poem.classes.includes(COLUMNS_CLASS)) {
921
+ const entries = verseColumns(poem, lines[poem.start]).flatMap(
922
+ (column) => column.entries,
923
+ );
924
+ let entry = 0;
925
+ for (let line = poem.start + 1; line < poem.end; line++) {
926
+ if (!lines[line].trim() || STANZA_BREAK.test(lines[line].trim())) continue;
927
+ if (entries[entry++ % entries.length]?.map === undefined)
928
+ romanColumnLines.add(line);
929
+ }
930
+ }
913
931
  const keys = runKeys(poem.attributes);
914
932
  if (!Object.keys(keys).length) continue;
915
933
  const opening = lines[poem.start];
@@ -1272,7 +1290,10 @@ function analyseRuns(source, ctx, syntax) {
1272
1290
  const keys = Object.assign({}, ...around(construct).map((c) => c.keys), construct.keys);
1273
1291
  // The language's script fills what the run leaves unstated, once its
1274
1292
  // own keys are known to name something.
1275
- if (named) Object.assign(keys, fromWrittenIn(keys, ctx.writtenIn, maps));
1293
+ const columnSource = construct.kind === "span" && romanColumnLines.has(construct.line0);
1294
+ const columnFence = construct.kind === "verse" && hasColumns(construct.node);
1295
+ if (named && !columnSource && !columnFence)
1296
+ Object.assign(keys, fromWrittenIn(keys, ctx.writtenIn, maps));
1276
1297
  if (construct.kind === "scroll" && named) {
1277
1298
  scrollFace(construct, keys);
1278
1299
  named = errors.length === unchecked;
@@ -18,7 +18,8 @@
18
18
  * The list is the `data.concepts` of the package's `doc` notes. A language
19
19
  * must cover it where it states `data.core: true`. A word covers every
20
20
  * concept its `concept` names, and a language reads the words of its
21
- * `data.family` chain as its own. A missing concept is a fact to work through,
21
+ * `data.family` chain as its own, with the nearest definition of each form
22
+ * winning. A missing concept is a fact to work through,
22
23
  * never a finding.
23
24
  *
24
25
  * @module
@@ -114,9 +115,16 @@ export function coreReport(records, { language } = {}) {
114
115
  concepts: concepts.length,
115
116
  languages: chosen.map((record) => {
116
117
  const have = new Set();
118
+ const forms = new Set();
117
119
  for (const key of chain(noteAddressOf(record) ?? ""))
118
- for (const word of byKey.get(key)?.data?.words ?? [])
120
+ for (const word of byKey.get(key)?.data?.words ?? []) {
121
+ if (!isMap(word) || typeof word.form !== "string") continue;
122
+ // Match dictionary headwords: NFC, retaining case and word separators.
123
+ const form = word.form.normalize("NFC");
124
+ if (forms.has(form)) continue;
125
+ forms.add(form);
119
126
  for (const id of idsOf(word)) have.add(id);
127
+ }
120
128
  const missing = [];
121
129
  for (const group of groups) {
122
130
  const members = concepts.filter((concept) => concept.group === group);
@@ -59,7 +59,7 @@ import { fromWrittenIn } from "./written-in.mjs";
59
59
  import { writingsOf } from "./logographic.mjs";
60
60
  import { FONTS_PACKAGE } from "./script-fonts.mjs";
61
61
  import { GENERIC_FAMILIES, applyMap } from "./text-maps.mjs";
62
- import { isLanguageRecord, noteAddressOf } from "./word-rows.mjs";
62
+ import { isLanguageRecord, lineageChain, noteAddressOf } from "./word-rows.mjs";
63
63
  import { derivedForms } from "./word-forms.mjs";
64
64
  import { LANGUAGE_SUBTYPE, glossText } from "./word-terms.mjs";
65
65
 
@@ -183,7 +183,7 @@ function lexiconOf(language, data) {
183
183
  * @returns {string|undefined} The Address.
184
184
  */
185
185
  function noteKey(note, contentPackage, types) {
186
- if (typeof note !== "string" || !contentPackage) return undefined;
186
+ if (!contentPackage) return undefined;
187
187
  const tuple = parseAddress(
188
188
  note,
189
189
  { package: contentPackage, system: "note", type: "skill", types: new Set(types) },
@@ -248,7 +248,9 @@ function languageNotes(languages, contentPackage, records) {
248
248
 
249
249
  /**
250
250
  * Each configured language's lexicon, read from the language note its `note`
251
- * names. A language with no `note`, or one naming no language note, has none.
251
+ * names and its family chain. The nearest exact NFC form supplies the whole
252
+ * word; grammar, registers and other settings remain the daughter's own.
253
+ * A language with no `note`, or one naming no language note, has none.
252
254
  *
253
255
  * @param {Iterable<Record<string, any>>|undefined} records - Index records.
254
256
  * @param {{languages?: Readonly<Record<string, {tag: string, note?: string}>>, contentPackage?: string}} config -
@@ -256,9 +258,39 @@ function languageNotes(languages, contentPackage, records) {
256
258
  * @returns {Map<string, Lexicon>} The lexicons by language name.
257
259
  */
258
260
  export function lexiconsFromRecords(records, config = {}) {
261
+ const list = [...(records ?? [])];
262
+ const byKey = recordsByKey(list, config.contentPackage);
263
+ const types = new Set(["skill", ...list.map((record) => record?.type).filter(Boolean)]);
264
+ const lineage = {
265
+ has: (key) => isLanguage(byKey.get(key)),
266
+ parentOf: (key) => {
267
+ const record = byKey.get(key);
268
+ return noteKey(record.data?.family, record.package ?? config.contentPackage, types);
269
+ },
270
+ };
259
271
  const lexicons = new Map();
260
- for (const [name, record] of languageRecordsByName(records, config))
261
- lexicons.set(name, lexiconOf(name, record.data));
272
+ for (const [name, { key, record }] of languageNotes(
273
+ config.languages,
274
+ config.contentPackage,
275
+ list,
276
+ )) {
277
+ if (!record) continue;
278
+ const chain = lineageChain(key, lineage);
279
+ let data = record.data;
280
+ if (chain.length > 1) {
281
+ const words = new Map();
282
+ for (const ancestor of chain)
283
+ for (const word of listOf(byKey.get(ancestor).data?.words)) {
284
+ if (!isMap(word) || typeof word.form !== "string") continue;
285
+ // The nearest complete definition wins before word matching
286
+ // folds case or splits compounds. Only words are inherited.
287
+ const form = word.form.normalize("NFC");
288
+ if (!words.has(form)) words.set(form, word);
289
+ }
290
+ data = { ...data, words: [...words.values()] };
291
+ }
292
+ lexicons.set(name, lexiconOf(name, data));
293
+ }
262
294
  return lexicons;
263
295
  }
264
296
 
@@ -36,7 +36,7 @@
36
36
  * @module
37
37
  */
38
38
 
39
- import { languageRecordsByName } from "./map-lexicon.mjs";
39
+ import { lexiconsFromRecords } from "./map-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";
@@ -82,7 +82,8 @@ export function registeredNameWords(data) {
82
82
 
83
83
  /**
84
84
  * Each configured language's word book: one per language whose `note` names a
85
- * language note.
85
+ * language note. Its effective lexicon shares the renderer's nearest inherited
86
+ * words, and derives forms and registered names from its own settings.
86
87
  *
87
88
  * @param {Iterable<Record<string, any>>|undefined} records - Index records.
88
89
  * @param {{languages?: Readonly<Record<string, {tag: string, note?: string}>>, contentPackage?: string}} config -
@@ -92,11 +93,10 @@ export function registeredNameWords(data) {
92
93
  export function wordBooksFromRecords(records, config = {}) {
93
94
  const list = [...(records ?? [])];
94
95
  const books = new Map();
95
- const languages = languageRecordsByName(list, config);
96
- if (!languages.size) return books;
97
- for (const [name, record] of languages) {
98
- const own = lexiconWords(record.data);
99
- const names = registeredNameWords(record.data);
96
+ const lexicons = lexiconsFromRecords(list, config);
97
+ for (const [name, lexicon] of lexicons) {
98
+ const own = lexiconWords(lexicon.data);
99
+ const names = registeredNameWords(lexicon.data);
100
100
  books.set(
101
101
  name,
102
102
  Object.freeze({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heroiclands/package-build",
3
- "version": "24.0.3",
3
+ "version": "24.0.4",
4
4
  "description": "Shared toolchain for building and shipping a HeroicLands Foundry VTT package — content compilation, manifest, localization, staging, bundle, release and deployment.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "type": "module",
@@ -1,6 +1,8 @@
1
1
  /**
2
2
  * Each configured language's lexicon, read from the language note its `note`
3
- * names. A language with no `note`, or one naming no language note, has none.
3
+ * names and its family chain. The nearest exact NFC form supplies the whole
4
+ * word; grammar, registers and other settings remain the daughter's own.
5
+ * A language with no `note`, or one naming no language note, has none.
4
6
  *
5
7
  * @param {Iterable<Record<string, any>>|undefined} records - Index records.
6
8
  * @param {{languages?: Readonly<Record<string, {tag: string, note?: string}>>, contentPackage?: string}} config -
@@ -17,7 +17,8 @@ export function registeredNameWords(data: Record<string, any> | undefined): Set<
17
17
  */
18
18
  /**
19
19
  * Each configured language's word book: one per language whose `note` names a
20
- * language note.
20
+ * language note. Its effective lexicon shares the renderer's nearest inherited
21
+ * words, and derives forms and registered names from its own settings.
21
22
  *
22
23
  * @param {Iterable<Record<string, any>>|undefined} records - Index records.
23
24
  * @param {{languages?: Readonly<Record<string, {tag: string, note?: string}>>, contentPackage?: string}} config -