@heroiclands/package-build 23.6.0 → 23.7.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.
@@ -24,22 +24,33 @@
24
24
  * binds.
25
25
  *
26
26
  * A word row holds the words its own note states, and nothing is copied for a
27
- * daughter language. `language_lineage` is one row per language and each of
28
- * its ancestors through `data.family`, itself included at depth `0`, so a
29
- * language's words with its inherited ones are a join of the two.
27
+ * daughter language. Its `gloss` is the word's senses joined by `; `, its
28
+ * `senses` each sense in order, and its `attested` the notes whose prose cites
29
+ * it ({@link module:engine/word-citations}), or `NULL` where none does.
30
+ * `language_lineage` is one row per language and each of its ancestors through
31
+ * `data.family`, itself included at depth `0`, so a language's words with its
32
+ * inherited ones are a join of the two.
30
33
  *
31
34
  * @module
32
35
  */
33
36
 
34
37
  import { renderAddress } from "./address.mjs";
35
38
  import { glossaryLanguage } from "./glossary-language.mjs";
36
- import { GLOSSARY_ENTRY_KEYS, LANGUAGE_SUBTYPE, WORD_KEYS } from "./word-terms.mjs";
39
+ import {
40
+ AFFIX_KEYS,
41
+ FIELD_KEYS,
42
+ GLOSSARY_ENTRY_KEYS,
43
+ LANGUAGE_SUBTYPE,
44
+ WORD_KEYS,
45
+ glossSenses,
46
+ glossText,
47
+ } from "./word-terms.mjs";
37
48
 
38
49
  /** The columns a word row carries before the word's own keys. */
39
50
  const WORD_IDENTITY_COLUMNS = Object.freeze(["language", "position"]);
40
51
 
41
52
  /** The columns a word row derives after the word's own keys. */
42
- const WORD_DERIVED_COLUMNS = Object.freeze(["built_text", "built_parts"]);
53
+ const WORD_DERIVED_COLUMNS = Object.freeze(["built_text", "built_parts", "senses", "attested"]);
43
54
 
44
55
  /** Every column of the `words` relation, in order. */
45
56
  export const WORD_COLUMNS = Object.freeze([
@@ -51,6 +62,12 @@ export const WORD_COLUMNS = Object.freeze([
51
62
  /** Every column of the `glossary` relation, in order. */
52
63
  export const GLOSSARY_COLUMNS = Object.freeze(["note", "position", ...GLOSSARY_ENTRY_KEYS]);
53
64
 
65
+ /** Every column of the `affixes` relation, in order. */
66
+ export const AFFIX_COLUMNS = Object.freeze([...WORD_IDENTITY_COLUMNS, ...AFFIX_KEYS]);
67
+
68
+ /** Every column of the `fields` relation, in order. */
69
+ export const FIELD_COLUMNS = Object.freeze([...WORD_IDENTITY_COLUMNS, ...FIELD_KEYS]);
70
+
54
71
  /** Every column of the `language_lineage` relation, in order. */
55
72
  export const LINEAGE_COLUMNS = Object.freeze(["language", "ancestor", "depth"]);
56
73
 
@@ -64,32 +81,89 @@ const isMap = (value) => Boolean(value) && typeof value === "object" && !Array.i
64
81
  const bare = (form) => String(form).replace(/^-+|-+$/g, "");
65
82
 
66
83
  /**
67
- * A lookup from a built piece to its gloss, over one language's words (stems
68
- * included) and affixes.
84
+ * A lookup from a built piece to its gloss, over the words (stems included)
85
+ * and affixes of one or more languages.
69
86
  *
70
- * A piece matches a word or an affix written exactly as it is, and failing
87
+ * Languages are given nearest first: a language's own words, then its own
88
+ * affixes, then the next language's, so a daughter's entry wins over its
89
+ * parent's. A piece matches an entry written exactly as it is, and failing
71
90
  * that one written with the same letters and different joining hyphens, so
72
- * `wulf-` finds the word `wulf`. A word's own gloss wins over an affix's.
91
+ * `wulf-` finds the word `wulf`. Within a language a word's own gloss wins over
92
+ * an affix's.
73
93
  *
74
- * @param {object} data - The language note's `data`.
94
+ * @param {...object} datas - Each language note's `data`, nearest first.
75
95
  * @returns {(piece: string) => string|null} The gloss, or `null` where none resolves.
76
96
  */
77
- export function pieceGlosses(data) {
97
+ export function pieceGlosses(...datas) {
78
98
  const exact = new Map();
79
99
  const loose = new Map();
80
100
  const take = (entries) => {
81
101
  for (const entry of Array.isArray(entries) ? entries : []) {
82
- if (!isMap(entry) || typeof entry.form !== "string" || typeof entry.gloss !== "string")
83
- continue;
84
- if (!exact.has(entry.form)) exact.set(entry.form, entry.gloss);
85
- if (!loose.has(bare(entry.form))) loose.set(bare(entry.form), entry.gloss);
102
+ if (!isMap(entry) || typeof entry.form !== "string") continue;
103
+ const gloss = glossText(entry.gloss);
104
+ if (gloss === null) continue;
105
+ if (!exact.has(entry.form)) exact.set(entry.form, gloss);
106
+ if (!loose.has(bare(entry.form))) loose.set(bare(entry.form), gloss);
86
107
  }
87
108
  };
88
- take(data?.words);
89
- take(data?.affixes);
109
+ for (const data of datas) {
110
+ take(data?.words);
111
+ take(data?.affixes);
112
+ }
90
113
  return (piece) => exact.get(piece) ?? loose.get(bare(piece)) ?? null;
91
114
  }
92
115
 
116
+ /**
117
+ * The chain of languages from one up through `data.family`: the language
118
+ * itself, then its parent, and so on. The chain stops at a parent that is no
119
+ * language, and before a language it has already reached, so a cycle the lint
120
+ * refuses still yields a finite chain. `language_lineage`, the dictionary and
121
+ * the resolution of built pieces all walk it.
122
+ *
123
+ * @param {string|undefined} start - The language's key.
124
+ * @param {object} reader
125
+ * @param {(key: string) => boolean} reader.has - Whether a key is a language.
126
+ * @param {(key: string) => string|undefined} reader.parentOf - A language's
127
+ * parent key, where it states one.
128
+ * @returns {string[]} The keys, nearest first.
129
+ */
130
+ export function lineageChain(start, { has, parentOf }) {
131
+ const chain = [];
132
+ const seen = new Set();
133
+ for (let current = start; current && has(current) && !seen.has(current);) {
134
+ seen.add(current);
135
+ chain.push(current);
136
+ current = parentOf(current) || undefined;
137
+ }
138
+ return chain;
139
+ }
140
+
141
+ /** A language record's `data.family` where it is a plain Address string. */
142
+ const familyKey = (record) => {
143
+ const family = record?.data?.family;
144
+ return typeof family === "string" && family ? family : undefined;
145
+ };
146
+
147
+ /**
148
+ * A lookup of language records by Address, and the chain walk over them.
149
+ *
150
+ * @param {Iterable<Record<string, any>>} records - Index records.
151
+ * @returns {{languages: Map<string, object>, chain: (key: string) => string[]}}
152
+ */
153
+ export function languageLineages(records) {
154
+ const languages = new Map();
155
+ for (const record of records)
156
+ if (isLanguageRecord(record)) {
157
+ const key = noteAddressOf(record);
158
+ if (key && !languages.has(key)) languages.set(key, record);
159
+ }
160
+ const reader = {
161
+ has: (key) => languages.has(key),
162
+ parentOf: (key) => familyKey(languages.get(key)),
163
+ };
164
+ return { languages, chain: (key) => lineageChain(key, reader) };
165
+ }
166
+
93
167
  /**
94
168
  * Whether a record is a language note's.
95
169
  *
@@ -107,7 +181,7 @@ export function isLanguageRecord(record) {
107
181
  * @param {Record<string, any>} record - An index record.
108
182
  * @returns {string|null} The Address, or `null` for a record naming no note.
109
183
  */
110
- function noteAddressOf(record) {
184
+ export function noteAddressOf(record) {
111
185
  if (!record?.package || !record.type || !record.shortcode) return null;
112
186
  return renderAddress({
113
187
  package: String(record.package),
@@ -117,6 +191,31 @@ function noteAddressOf(record) {
117
191
  });
118
192
  }
119
193
 
194
+ /**
195
+ * A lookup of the notes citing a word, from records that carry `citations`.
196
+ *
197
+ * @param {Iterable<Record<string, any>>} records - Index records.
198
+ * @returns {(language: string, form: string) => string[]} The citing notes;
199
+ * empty where none cites the word.
200
+ */
201
+ export function citationLookup(records) {
202
+ const byLanguage = new Map();
203
+ for (const record of records) {
204
+ if (!isLanguageRecord(record) || !Array.isArray(record.citations)) continue;
205
+ const language = noteAddressOf(record);
206
+ if (!language) continue;
207
+ const words = new Map();
208
+ for (const entry of record.citations)
209
+ if (isMap(entry) && typeof entry.form === "string" && Array.isArray(entry.notes))
210
+ words.set(
211
+ entry.form,
212
+ entry.notes.filter((note) => typeof note === "string"),
213
+ );
214
+ byLanguage.set(language, words);
215
+ }
216
+ return (language, form) => byLanguage.get(language)?.get(form) ?? [];
217
+ }
218
+
120
219
  /**
121
220
  * The `words` rows of a set of index records.
122
221
  *
@@ -124,9 +223,23 @@ function noteAddressOf(record) {
124
223
  *
125
224
  * @param {Iterable<Record<string, any>>} records - Index records, every
126
225
  * Address a string, already narrowed to the audience being published to.
226
+ * @param {Iterable<Record<string, any>>} [all] - Every record of the package,
227
+ * for resolving a built piece through an ancestor the audience does not see.
127
228
  * @returns {object[]} One row per word, in record then entry order.
128
229
  */
129
- export function wordRows(records) {
230
+ export function wordRows(records, all = records) {
231
+ const { languages, chain } = languageLineages(all);
232
+ const citedBy = citationLookup(all);
233
+ const visible = new Set();
234
+ for (const record of records) {
235
+ const key = noteAddressOf(record);
236
+ if (key) visible.add(key);
237
+ }
238
+ const known = new Set();
239
+ for (const record of all) {
240
+ const key = noteAddressOf(record);
241
+ if (key) known.add(key);
242
+ }
130
243
  const rows = [];
131
244
  for (const record of records) {
132
245
  if (!isLanguageRecord(record)) continue;
@@ -134,7 +247,7 @@ export function wordRows(records) {
134
247
  if (!Array.isArray(words) || !words.length) continue;
135
248
  const language = noteAddressOf(record);
136
249
  if (!language) continue;
137
- const glossOf = pieceGlosses(record.data);
250
+ const glossOf = pieceGlosses(...chain(language).map((key) => languages.get(key).data));
138
251
  words.forEach((entry, position) => {
139
252
  if (!isMap(entry)) return;
140
253
  const row = { language, position };
@@ -144,12 +257,70 @@ export function wordRows(records) {
144
257
  row.built_text = built.length ? built.join(" + ") : null;
145
258
  row.built_parts =
146
259
  built.length ? built.map((form) => ({ form, gloss: glossOf(form) })) : null;
260
+ row.gloss = glossText(entry.gloss);
261
+ row.senses = glossSenses(entry.gloss);
262
+ // A citing note the audience does not see is left out, as the note is.
263
+ const attested = citedBy(language, entry.form).filter(
264
+ (note) => visible.has(note) || !known.has(note),
265
+ );
266
+ row.attested = attested.length ? attested : null;
147
267
  rows.push(row);
148
268
  });
149
269
  }
150
270
  return rows;
151
271
  }
152
272
 
273
+ /**
274
+ * The rows of one list a language note states beside its words: one per entry,
275
+ * keyed by the note, with every key of the entry a column.
276
+ *
277
+ * @param {Iterable<Record<string, any>>} records - Index records.
278
+ * @param {string} list - The `data` key holding the entries.
279
+ * @param {readonly string[]} keys - The keys an entry carries.
280
+ * @returns {object[]} One row per entry, in record then entry order.
281
+ */
282
+ function languageListRows(records, list, keys) {
283
+ const rows = [];
284
+ for (const record of records) {
285
+ if (!isLanguageRecord(record)) continue;
286
+ const entries = record.data?.[list];
287
+ if (!Array.isArray(entries) || !entries.length) continue;
288
+ const language = noteAddressOf(record);
289
+ if (!language) continue;
290
+ entries.forEach((entry, position) => {
291
+ if (!isMap(entry)) return;
292
+ const row = { language, position };
293
+ for (const key of keys) row[key] = entry[key] ?? null;
294
+ rows.push(row);
295
+ });
296
+ }
297
+ return rows;
298
+ }
299
+
300
+ /**
301
+ * The `affixes` rows of a set of index records: one per `data.affixes` entry
302
+ * of a language note, which is the row's `language`.
303
+ *
304
+ * @param {Iterable<Record<string, any>>} records - Index records, every
305
+ * Address a string, already narrowed to the audience being published to.
306
+ * @returns {object[]} One row per affix, in record then entry order.
307
+ */
308
+ export function affixRows(records) {
309
+ return languageListRows(records, "affixes", AFFIX_KEYS);
310
+ }
311
+
312
+ /**
313
+ * The `fields` rows of a set of index records: one per `data.fields` entry of
314
+ * a language note, which is the row's `language`.
315
+ *
316
+ * @param {Iterable<Record<string, any>>} records - Index records, every
317
+ * Address a string, already narrowed to the audience being published to.
318
+ * @returns {object[]} One row per field, in record then entry order.
319
+ */
320
+ export function fieldRows(records) {
321
+ return languageListRows(records, "fields", FIELD_KEYS);
322
+ }
323
+
153
324
  /**
154
325
  * The `glossary` rows of a set of index records: one per entry of a culture's
155
326
  * or an affiliation's `data.glossary`, with `name_sort` the name and `common`
@@ -209,25 +380,13 @@ export function glossaryRows(records, all = records) {
209
380
  * @returns {object[]} One row per language and ancestor, in record then depth order.
210
381
  */
211
382
  export function lineageRows(records, all = records) {
212
- const languages = new Map();
213
- for (const record of all)
214
- if (isLanguageRecord(record)) {
215
- const key = noteAddressOf(record);
216
- if (key && !languages.has(key)) languages.set(key, record);
217
- }
383
+ const { chain } = languageLineages(all);
218
384
  const rows = [];
219
385
  for (const record of records) {
220
386
  if (!isLanguageRecord(record)) continue;
221
387
  const language = noteAddressOf(record);
222
388
  if (!language) continue;
223
- const seen = new Set();
224
- let current = language;
225
- for (let depth = 0; current && languages.has(current) && !seen.has(current); depth++) {
226
- seen.add(current);
227
- rows.push({ language, ancestor: current, depth });
228
- const family = languages.get(current).data?.family;
229
- current = typeof family === "string" && family ? family : undefined;
230
- }
389
+ chain(language).forEach((ancestor, depth) => rows.push({ language, ancestor, depth }));
231
390
  }
232
391
  return rows;
233
392
  }
@@ -88,6 +88,15 @@ export const WORD_FIELDS = Object.freeze([
88
88
  shape: "the word as cited, bare",
89
89
  describe: "The word as cited, with no backticks.",
90
90
  }),
91
+ Object.freeze({
92
+ name: "variants",
93
+ kind: "list",
94
+ shape: "a list of forms",
95
+ entries: Object.freeze({ kind: "string", shape: "a form, bare" }),
96
+ describe:
97
+ "The word's other forms, such as a long form beside a short one; a marked use of " +
98
+ "one cites the word.",
99
+ }),
91
100
  Object.freeze({
92
101
  name: "name_sort",
93
102
  kind: "string",
@@ -112,10 +121,12 @@ export const WORD_FIELDS = Object.freeze([
112
121
  }),
113
122
  Object.freeze({
114
123
  name: "gloss",
115
- kind: "string",
124
+ kind: "string-or-list",
116
125
  required: true,
117
- shape: "a string",
118
- describe: "The meaning, in brief.",
126
+ shape: "a string, or a list of senses",
127
+ entries: Object.freeze({ kind: "string", shape: "a sense" }),
128
+ describe:
129
+ "The meaning, in brief; a list states one sense per entry, which a dictionary numbers.",
119
130
  }),
120
131
  Object.freeze({
121
132
  name: "source",
@@ -134,11 +145,11 @@ export const WORD_FIELDS = Object.freeze([
134
145
  "write them; absent for a root word.",
135
146
  }),
136
147
  Object.freeze({
137
- name: "attested",
138
- kind: "list",
139
- shape: "a list of Addresses",
140
- entries: Object.freeze({ kind: "address", shape: "an Address" }),
141
- describe: "The notes the word is cited from.",
148
+ name: "note",
149
+ kind: "string",
150
+ shape: "a string",
151
+ describe:
152
+ "What a source says of the word beyond its pieces, such as a cognate it is read with.",
142
153
  }),
143
154
  Object.freeze({
144
155
  name: "pronunciation",
@@ -163,14 +174,41 @@ export const WORD_FIELDS = Object.freeze([
163
174
  Object.freeze({
164
175
  name: "frame",
165
176
  kind: "string",
166
- shape: "a string",
167
- describe: "The frame a root is set in.",
177
+ shape: "a frame of the language's `sounds.frames`",
178
+ describe: "The frame a root is set in, named in the language's `sounds.frames`.",
168
179
  }),
169
180
  ]);
170
181
 
171
182
  /** The keys of {@link WORD_FIELDS}, in declared order. */
172
183
  export const WORD_KEYS = Object.freeze(WORD_FIELDS.map((field) => field.name));
173
184
 
185
+ /**
186
+ * A word's senses: one for a string gloss, one per entry for a list, each
187
+ * trimmed, blanks dropped.
188
+ *
189
+ * @param {unknown} gloss - The word's `gloss`.
190
+ * @returns {string[]} The senses, in order.
191
+ */
192
+ export function glossSenses(gloss) {
193
+ const list = Array.isArray(gloss) ? gloss : [gloss];
194
+ return list
195
+ .filter((sense) => typeof sense === "string")
196
+ .map((sense) => sense.trim())
197
+ .filter(Boolean);
198
+ }
199
+
200
+ /**
201
+ * A word's gloss as one line of text: its senses joined by `; `, or `null`
202
+ * where it states none.
203
+ *
204
+ * @param {unknown} gloss - The word's `gloss`.
205
+ * @returns {string|null}
206
+ */
207
+ export function glossText(gloss) {
208
+ const senses = glossSenses(gloss);
209
+ return senses.length ? senses.join("; ") : null;
210
+ }
211
+
174
212
  /**
175
213
  * The keys one entry of a language note's `data.fields` carries.
176
214
  *
@@ -217,7 +255,29 @@ export const AFFIX_FIELDS = Object.freeze([
217
255
  name: "attaches",
218
256
  kind: "string",
219
257
  shape: "a string",
220
- describe: "How it attaches, such as `replaces a closing vowel`.",
258
+ describe: "How it attaches, in words, such as `replaces a closing vowel`.",
259
+ }),
260
+ Object.freeze({
261
+ name: "replaces",
262
+ kind: "string",
263
+ shape: "`vowel`, or the letters, such as `a`",
264
+ describe:
265
+ "What it takes the place of at the close of the stem: `vowel` for any closing " +
266
+ "vowel, or the letters it replaces; added to the whole stem where absent.",
267
+ }),
268
+ Object.freeze({
269
+ name: "lengthens",
270
+ kind: "boolean",
271
+ shape: "`true` or `false`",
272
+ describe:
273
+ "Whether the stem's last vowel lengthens before the affix; `false` where unstated. An " +
274
+ "affix that `replaces` the stem's close does not also lengthen it.",
275
+ }),
276
+ Object.freeze({
277
+ name: "front",
278
+ kind: "string",
279
+ shape: "the affix as written, such as `-ür`",
280
+ describe: "Its form after front vowels, where the language keeps vowel harmony.",
221
281
  }),
222
282
  Object.freeze({
223
283
  name: "examples",
@@ -226,8 +286,20 @@ export const AFFIX_FIELDS = Object.freeze([
226
286
  entries: Object.freeze({ kind: "string", shape: "a string" }),
227
287
  describe: "The names the affix is read from.",
228
288
  }),
289
+ Object.freeze({
290
+ name: "tones",
291
+ kind: "string",
292
+ shape: "a tone pattern, such as `L`",
293
+ describe: "The tone pattern the affix carries, written as a word's `tones` is.",
294
+ }),
229
295
  ]);
230
296
 
297
+ /** The keys of {@link WORD_FIELD_FIELDS}, in declared order. */
298
+ export const FIELD_KEYS = Object.freeze(WORD_FIELD_FIELDS.map((field) => field.name));
299
+
300
+ /** The keys of {@link AFFIX_FIELDS}, in declared order. */
301
+ export const AFFIX_KEYS = Object.freeze(AFFIX_FIELDS.map((field) => field.name));
302
+
231
303
  /**
232
304
  * The keys one entry of a culture's or an affiliation's `data.glossary`
233
305
  * carries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heroiclands/package-build",
3
- "version": "23.6.0",
3
+ "version": "23.7.0",
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",
@@ -6,6 +6,8 @@
6
6
  * @property {object} [addressContext] - Address resolution context.
7
7
  * @property {(address: string) => string|undefined} [labelOf] - A note's
8
8
  * `name.full`, by its Address.
9
+ * @property {boolean|"only"} [showKey=true] - Whether the key is printed;
10
+ * `"only"` prints the key and nothing else.
9
11
  * @property {string} [pageLanguage] - The language of the page the fence is
10
12
  * on, which no entry is tagged with.
11
13
  */
@@ -61,6 +63,11 @@ export type LayoutOptions = {
61
63
  * `name.full`, by its Address.
62
64
  */
63
65
  labelOf?: ((address: string) => string | undefined) | undefined;
66
+ /**
67
+ * - Whether the key is printed;
68
+ * `"only"` prints the key and nothing else.
69
+ */
70
+ showKey?: boolean | "only" | undefined;
64
71
  /**
65
72
  * - The language of the page the fence is
66
73
  * on, which no entry is tagged with.
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Whether a tag a row names is one `data.tongues` declares.
3
+ *
4
+ * @param {string} tag - The tag as the row writes it.
5
+ * @param {Set<string>} declared - The declared tags.
6
+ * @returns {boolean}
7
+ */
8
+ export function tongueDeclared(tag: string, declared: Set<string>): boolean;
9
+ /**
10
+ * Check a `skill` note's language data against what it means.
11
+ *
12
+ * @param {object} note - A `skill` note: `file`, `raw` and parsed `fm`.
13
+ * @param {object} [context]
14
+ * @param {object} [context.index] - The index, for whether an Address
15
+ * resolves. Without one, only what the note says of itself is checked.
16
+ * @param {readonly string[]} [context.types] - The note types a scope may name.
17
+ * @returns {object[]} The findings.
18
+ */
19
+ export function checkLanguageData(note: object, { index, types }?: {
20
+ index?: object | undefined;
21
+ types?: readonly string[] | undefined;
22
+ }): object[];