@heroiclands/package-build 23.6.0 → 23.6.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 CHANGED
@@ -1,5 +1,21 @@
1
1
  # @heroiclands/package-build
2
2
 
3
+ ## 23.6.1
4
+
5
+ ### Patch Changes
6
+
7
+ **Dictionaries and glossaries**
8
+
9
+ - A language's Suffixes and Fields tables can be set from its frontmatter, by querying `affixes` and `fields` in an `sql` fence.
10
+ - An affix can state its own tone pattern in `tones`.
11
+ - `key=false` leaves the key off a dictionary or glossary fence, so a page with one fence per heading states the key once.
12
+ - A daughter language's words built from its parent's words and affixes no longer warn that a piece matches nothing, and their glosses print.
13
+ - Dictionaries abbreviate particle as _ptcl._, since _part._ reads as participle.
14
+
15
+ **Diagnostics**
16
+
17
+ - File paths stay relative to the working directory when `PACKAGE_BUILD_CONFIG` names the configuration through a symlinked path, as macOS temporary directories are.
18
+
3
19
  ## 23.6.0
4
20
 
5
21
  ### Minor Changes
@@ -309,18 +309,20 @@ A column's heading is its SQL alias, so `AS "Name"` is what a reader sees. A cel
309
309
 
310
310
  ### What a query can read
311
311
 
312
- | Relation | What it holds |
313
- | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
314
- | `notes` | Every note except a stub. This is the one to use unless a stub is wanted. |
315
- | `entries` | Every note, stubs included. |
316
- | `events` | One row per event a `lore`, `place` or `affiliation` note writes in `data.events`, with `whenSort` to order them by date. |
317
- | `words` | One row per word a language note writes in `data.words`, with that note as its `language`, its `position` and what it is built from. |
318
- | `glossary` | One row per entry a culture or an affiliation writes in `data.glossary`, with the `note` it belongs to and the `language` it resolves to. |
319
- | `language_lineage` | One row per language note and each language it inherits words from through `data.family`, itself included at `depth` 0. Join it to `words` for a language's words with inherited ones. |
320
- | `dictionary` | One row per word per language it is listed under, inherited words included, with its `depth`. See [dictionaries and glossaries](#dictionaries-and-glossaries). |
321
- | `terms` | One row per glossary entry, rank or office per note that owns it, with the `cultures` it reaches. See [dictionaries and glossaries](#dictionaries-and-glossaries). |
322
- | `market` | The market scale as three columns — `value`, `name`, `trade` — so a table can print `village` beside the number a note wrote. |
323
- | `<package>.notes` | The same relations — `notes`, `entries`, `events`, `words`, `glossary`, `language_lineage`, `dictionary` and `terms` — for each package this one declares a dependency on, in a schema named for it. |
312
+ | Relation | What it holds |
313
+ | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
314
+ | `notes` | Every note except a stub. This is the one to use unless a stub is wanted. |
315
+ | `entries` | Every note, stubs included. |
316
+ | `events` | One row per event a `lore`, `place` or `affiliation` note writes in `data.events`, with `whenSort` to order them by date. |
317
+ | `words` | One row per word a language note writes in `data.words`, with that note as its `language`, its `position` and what it is built from. |
318
+ | `affixes` | One row per affix a language note writes in `data.affixes`, with that note as its `language`, its `position`, and each key the affix states: `form`, `gloss`, `attaches`, `examples` and `tones`. |
319
+ | `fields` | One row per field a language note writes in `data.fields`, with that note as its `language`, its `position`, and the field's `name` and what it `holds`. |
320
+ | `glossary` | One row per entry a culture or an affiliation writes in `data.glossary`, with the `note` it belongs to and the `language` it resolves to. |
321
+ | `language_lineage` | One row per language note and each language it inherits words from through `data.family`, itself included at `depth` 0. Join it to `words` for a language's words with inherited ones. |
322
+ | `dictionary` | One row per word per language it is listed under, inherited words included, with its `depth`. See [dictionaries and glossaries](#dictionaries-and-glossaries). |
323
+ | `terms` | One row per glossary entry, rank or office per note that owns it, with the `cultures` it reaches. See [dictionaries and glossaries](#dictionaries-and-glossaries). |
324
+ | `market` | The market scale as three columns — `value`, `name`, `trade` — so a table can print `village` beside the number a note wrote. |
325
+ | `<package>.notes` | The same relations — `notes`, `entries`, `events`, `words`, `affixes`, `fields`, `glossary`, `language_lineage`, `dictionary` and `terms` — for each package this one declares a dependency on, in a schema named for it. |
324
326
 
325
327
  Nested frontmatter is addressed exactly as a note writes it: `name.full`, `sohl.weight`, `data.born`. Every Address is a string — the full canonical Address, as in `list_contains(data.parents, 'thalorna-note-place-xerathia')` — whichever package published it. Every note carries a computed `state` column of `full`, `draft` or `stub`. Notes tagged `gm` are absent from the relations on public surfaces.
326
328
 
@@ -417,14 +419,35 @@ ORDER BY e.whenSort, e.address
417
419
 
418
420
  An event with no `where.locus` keeps its row with an empty **Where** cell.
419
421
 
422
+ ### A language's affixes and fields
423
+
424
+ `affixes` and `fields` hold the entries of a language note's `data.affixes` and `data.fields`, so a page can set them as tables. A Suffixes table is a `layout=table` fence over `affixes`; the `tones` key is the tone pattern an affix carries:
425
+
426
+ ````markdown
427
+ ```sql
428
+ SELECT form AS "Suffix", attaches AS "Stem", gloss AS "Makes", array_to_string(examples, ', ') AS "Example", tones AS "Tones"
429
+ FROM affixes
430
+ WHERE language = 'thalorna-note-skill-ekpelulng' AND form LIKE '-%'
431
+ ORDER BY position
432
+ ```
433
+ ````
434
+
435
+ | Suffix | Stem | Makes | Example | Tones |
436
+ | ------ | ----- | -------------------------------------------- | ------- | ----- |
437
+ | -ndo | added | the keeper of it: one who tends and holds it | luwando | L |
438
+ | -ma | added | the child of it: one who is fed by it | yesoma | H |
439
+
440
+ `ORDER BY position` keeps the order the note states them in. The same shape serves `fields`, with `name` and `holds`.
441
+
420
442
  ### Fence attributes
421
443
 
422
- A braced list may follow the language on the opening fence. Three attributes are accepted, and anything else is reported by name.
444
+ A braced list may follow the language on the opening fence. Four attributes are accepted, and anything else is reported by name.
423
445
 
424
446
  | Attribute | Value | Default | What it does |
425
447
  | --------------- | ----------------------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
426
448
  | `allow-empty` | `true` or `false` | `false` | Whether a query selecting no rows is allowed. A zero-row result is a finding otherwise, because a table that silently prints nothing is the commonest way a query goes wrong unnoticed. |
427
449
  | `section-level` | an integer from 1 through 6 | `2` | The heading level given to each group a `_section` column creates, and to each letter of a dictionary or a glossary. |
450
+ | `key` | `true` or `false` | `true` | Whether a dictionary or a glossary prints its key. `false` leaves it out, for a page that states the key once above several fences. |
428
451
  | `layout` | `table`, `dictionary` or `glossary` | `table` | How the result is set: as a table, as a dictionary of words, or as a glossary of terms — see [dictionaries and glossaries](#dictionaries-and-glossaries). |
429
452
 
430
453
  ````markdown
@@ -488,9 +511,17 @@ SELECT * FROM terms WHERE list_contains(cultures, 'thalorna-note-lore-provenzian
488
511
  ```
489
512
  ````
490
513
 
514
+ A page that sets several fences, one per heading, states its key once and sets `key=false` on each fence, so the abbreviations are not repeated under every heading:
515
+
516
+ ````markdown
517
+ ```sql {layout=dictionary key=false section-level=4}
518
+ SELECT * FROM dictionary WHERE language = 'thalorna-note-skill-vylarilng' AND depth = 0
519
+ ```
520
+ ````
521
+
491
522
  Either layout prints, in order:
492
523
 
493
- - **a key**, once, listing only the abbreviations the result uses — a language's tag, and for words a class or a modifier — each with what it stands for, and the common mark where an entry carries it;
524
+ - **a key**, once, unless the fence sets `key=false`, listing only the abbreviations the result uses — a language's tag, and for words a class or a modifier — each with what it stands for, and the common mark where an entry carries it;
494
525
  - **a heading per letter**, at `section-level`, accents taken off and apostrophes skipped, so `'ostal` files under O and `Écuyer` under E;
495
526
  - **one paragraph per entry**: `•` for a common entry, the headword in bold, its pronunciation between slashes, its language tag in italics, the senses, and after a dash the notes it is cited from, as links.
496
527
 
@@ -143,6 +143,6 @@ Places, affiliations, lore and beings may name their culture in `data.culture`,
143
143
 
144
144
  An affiliation may name the trade, temple or order tongue it keeps in its own `data.language`. An affiliation that reaches no culture is a warning, in a tree holding a culture note. A language needs no culture and no affiliation. See [type: affiliation](reference/format-details.md#type-affiliation).
145
145
 
146
- A language note (`type: skill`, `subType: language`) states its words in frontmatter: `data.words` states each word as a map of its form, class, field and gloss, with its source, the pieces it is built from and the notes it is attested in, beside `data.classModifiers`, `data.fields` and `data.affixes`. The language of a word is the note it sits on, and none of it reaches the compiled Item. A daughter language names its parent in `data.family` and reads the parent's words as its own; the `language_lineage` relation joins a language to its ancestors. Classes come from the [master word classes](reference/format-details.md#word-classes). A culture or an affiliation states its own glossary terms in `data.glossary`, and every entry resolves to a language — its own `language`, else the affiliation's, its parents' or its culture's, else the culture's — or is an error at the entry. The `words`, `glossary` and `language_lineage` relations hold them for `sql` fences, the `dictionary` relation lists each word under every language that has it, inherited words included, and the `terms` relation each glossary entry, rank and office with the cultures it reaches; a fence sets them with `layout=dictionary` or `layout=glossary`. A language note names the tag a dictionary prints for it in `data.abbreviation`. See [a language's words](reference/format-details.md#a-languages-words), [glossary terms](reference/format-details.md#glossary-terms) and [dictionaries and glossaries](reference/format-details.md#dictionaries-and-glossaries).
146
+ A language note (`type: skill`, `subType: language`) states its words in frontmatter: `data.words` states each word as a map of its form, class, field and gloss, with its source, the pieces it is built from and the notes it is attested in, beside `data.classModifiers`, `data.fields` and `data.affixes`. The language of a word is the note it sits on, and none of it reaches the compiled Item. A daughter language names its parent in `data.family` and reads the parent's words as its own; the `language_lineage` relation joins a language to its ancestors. Classes come from the [master word classes](reference/format-details.md#word-classes). A culture or an affiliation states its own glossary terms in `data.glossary`, and every entry resolves to a language — its own `language`, else the affiliation's, its parents' or its culture's, else the culture's — or is an error at the entry. The `words`, `affixes`, `fields`, `glossary` and `language_lineage` relations hold them for `sql` fences, the `dictionary` relation lists each word under every language that has it, inherited words included, and the `terms` relation each glossary entry, rank and office with the cultures it reaches; a fence sets them with `layout=dictionary` or `layout=glossary`. A language note names the tag a dictionary prints for it in `data.abbreviation`. See [a language's words](reference/format-details.md#a-languages-words), [glossary terms](reference/format-details.md#glossary-terms) and [dictionaries and glossaries](reference/format-details.md#dictionaries-and-glossaries).
147
147
 
148
148
  Blockquote alerts use `[!NOTE]`, `[!TIP]`, `[!IMPORTANT]`, `[!WARNING]`, or `[!]`, with optional attributes after the marker. They replace the former info and warn div fences; secret blocks remain supported. See [alerts](authoring/links-and-markup.md#alerts).
@@ -2827,6 +2827,16 @@ language note tagged `gm` contributes no row on a public surface. A row holds
2827
2827
  the words its own note states, and nothing more: a daughter language's
2828
2828
  inherited words are read through `language_lineage`.
2829
2829
 
2830
+ **`affixes`** is one row per entry of `data.affixes` on every language note,
2831
+ and **`fields`** one per entry of `data.fields`, in the order the note states
2832
+ them. Each has `language` and `position` as `words` does, then every key of the
2833
+ entry as its own column: `form`, `gloss`, `attaches`, `examples` and `tones` for
2834
+ an affix, `name` and `holds` for a field. A key an entry does not state is
2835
+ `NULL`, `examples` is a list, and a language note tagged `gm` contributes no
2836
+ row on a public surface. An affix's `tones` is the tone pattern it carries,
2837
+ written as a word's `tones` is; it is a key of its own and is never read out of
2838
+ `attaches`.
2839
+
2830
2840
  **`language_lineage`** is one row per language note and each language it
2831
2841
  inherits words from through [`data.family`](#a-languages-words): `language`,
2832
2842
  `ancestor` and `depth`, with the language itself at depth `0`, its parent at
@@ -3143,10 +3153,11 @@ value containing spaces with double quotes and escape an internal quote as
3143
3153
  `\"`. Boolean values are exactly `true` or `false`; other spellings are
3144
3154
  errors. Duplicate and unknown attributes are errors.
3145
3155
 
3146
- | Attribute | What it does |
3147
- | ------------------------------------------- | --------------------------------------------------- |
3148
- | `allow-empty=true` | Permits a query selecting no rows. Default `false`. |
3149
- | `section-level=1` through `section-level=6` | Sets the heading level for `_section`. Default `2`. |
3156
+ | Attribute | What it does |
3157
+ | ------------------------------------------- | ----------------------------------------------------------------- |
3158
+ | `allow-empty=true` | Permits a query selecting no rows. Default `false`. |
3159
+ | `section-level=1` through `section-level=6` | Sets the heading level for `_section`. Default `2`. |
3160
+ | `key=false` | Leaves the key out of a dictionary or a glossary. Default `true`. |
3150
3161
 
3151
3162
  ##### Page lists
3152
3163
 
@@ -5234,11 +5245,12 @@ SoHL sets `system.quantity` to `1` for each compendium item.
5234
5245
  | `words[].tones` | `string` | A tone pattern, as `LHL` |
5235
5246
  | `words[].root` | `string` | A consonantal skeleton, as `d-l-k` |
5236
5247
  | `words[].frame` | `string` | The frame a root is set in |
5237
- | `affixes` | `{ form, gloss, attaches?, examples? }[]` | `language` only: the affixes a word's `built` pieces may name |
5248
+ | `affixes` | `{ form, gloss, attaches?, examples?, tones? }[]` | `language` only: the affixes a word's `built` pieces may name |
5238
5249
  | `affixes[].form` | `string`, required | The affix as a `built` piece names it, as `-rith` |
5239
5250
  | `affixes[].gloss` | `string`, required | What it means |
5240
5251
  | `affixes[].attaches` | `string` | How it attaches, as `replaces a closing vowel` |
5241
5252
  | `affixes[].examples` | `string[]` | The names it is read from |
5253
+ | `affixes[].tones` | `string` | The tone pattern it carries, as `L` |
5242
5254
 
5243
5255
  #### A language's words
5244
5256
 
@@ -6743,7 +6755,7 @@ A base is one of these. A base marked `no` never prints in the glossary.
6743
6755
  | `adv` | adv. | adverb | yes |
6744
6756
  | `pron` | pron. | pronoun | yes |
6745
6757
  | `num` | num. | numeral | yes |
6746
- | `part` | part. | particle | yes |
6758
+ | `part` | ptcl. | particle | yes |
6747
6759
  | `art` | art. | article | yes |
6748
6760
  | `conj` | conj. | conjunction | yes |
6749
6761
  | `dem` | dem. | demonstrative | yes |
@@ -492,6 +492,7 @@ These fields are accepted by every note type. A field's value can still be irrel
492
492
  | `data.affixes[].gloss` | a string | yes | What the affix means. |
493
493
  | `data.affixes[].attaches` | a string | no | How it attaches, such as `replaces a closing vowel`. |
494
494
  | `data.affixes[].examples` | a list of strings | no | The names the affix is read from. |
495
+ | `data.affixes[].tones` | a tone pattern, such as `L` | no | The tone pattern the affix carries, written as a word's `tones` is. |
495
496
 
496
497
  **System mappings**
497
498
 
@@ -21,7 +21,7 @@
21
21
  * column is read where the query selects it. The output is Markdown, so every
22
22
  * surface sets it as it sets a note:
23
23
  *
24
- * - **the key**, once, at the head: the abbreviations this rendering uses,
24
+ * - **the key**, once, at the head unless the fence sets `key=false`: the abbreviations this rendering uses,
25
25
  * each with its expansion, then the common mark where an entry carries it;
26
26
  * - **a heading per letter**, from `letter`, at the fence's `section-level`;
27
27
  * - **one paragraph per entry**: the common mark, the bold headword, the
@@ -92,6 +92,7 @@ function sentence(text) {
92
92
  * @property {object} [addressContext] - Address resolution context.
93
93
  * @property {(address: string) => string|undefined} [labelOf] - A note's
94
94
  * `name.full`, by its Address.
95
+ * @property {boolean} [showKey=true] - Whether the key is printed.
95
96
  * @property {string} [pageLanguage] - The language of the page the fence is
96
97
  * on, which no entry is tagged with.
97
98
  */
@@ -189,6 +190,7 @@ function render(
189
190
  sectionLevel = 2,
190
191
  addressContext,
191
192
  labelOf = () => undefined,
193
+ showKey = true,
192
194
  pageLanguage,
193
195
  },
194
196
  words,
@@ -284,7 +286,7 @@ function render(
284
286
  if (usedModifiers.has(modifier.token))
285
287
  key.push(`_${modifier.abbreviation}_ ${modifier.expansion}`);
286
288
  if (anyCommon) key.push(`${COMMON_MARK} ${COMMON_KEY}`);
287
- if (key.length) paragraphs.unshift(`**Key:** ${key.join("; ")}.`);
289
+ if (showKey && key.length) paragraphs.unshift(`**Key:** ${key.join("; ")}.`);
288
290
  return paragraphs.join("\n\n");
289
291
  }
290
292
 
@@ -53,7 +53,7 @@ import { parseAddress, renderAddress } from "./address.mjs";
53
53
  import { glossaryLanguage } from "./glossary-language.mjs";
54
54
  import { isNoteRecord } from "./index-records.mjs";
55
55
  import { parseWordClass, WORD_CLASS_BASES } from "./word-classes.mjs";
56
- import { isLanguageRecord, pieceGlosses } from "./word-rows.mjs";
56
+ import { isLanguageRecord, lineageChain, pieceGlosses } from "./word-rows.mjs";
57
57
 
58
58
  /** Every column of the `dictionary` relation, in order. */
59
59
  export const DICTIONARY_COLUMNS = Object.freeze([
@@ -224,17 +224,14 @@ export function dictionaryAndTermRows(records, all = records) {
224
224
  const full = record.name?.full;
225
225
  return typeof full === "string" && full ? full : null;
226
226
  };
227
- const lineage = (language) => {
228
- const chain = [];
229
- const seen = new Set();
230
- for (let current = language; current && languages.has(current) && !seen.has(current);) {
231
- seen.add(current);
232
- chain.push(current);
233
- const family = languages.get(current).data?.family;
234
- current = typeof family === "string" && family ? family : undefined;
235
- }
236
- return chain;
237
- };
227
+ const lineage = (language) =>
228
+ lineageChain(language, {
229
+ has: (key) => languages.has(key),
230
+ parentOf: (key) => {
231
+ const family = languages.get(key).data?.family;
232
+ return typeof family === "string" && family ? family : undefined;
233
+ },
234
+ });
238
235
  const ownWords = new Map();
239
236
  for (const [key, record] of languages) {
240
237
  const list = [];
@@ -470,7 +467,7 @@ export function dictionaryAndTermRows(records, all = records) {
470
467
  };
471
468
  const source = isMap(word.source) ? word.source : {};
472
469
  const built = Array.isArray(word.built) ? word.built.map(String) : [];
473
- const glossOf = pieceGlosses(languages.get(statedIn)?.data);
470
+ const glossOf = pieceGlosses(...lineage(statedIn).map((key) => languages.get(key).data));
474
471
  const out = {
475
472
  ...blank,
476
473
  kind: typeof source.kind === "string" ? source.kind : null,
@@ -52,7 +52,7 @@ import {
52
52
  pronunciationProblem,
53
53
  } from "./word-terms.mjs";
54
54
  import { WORD_CLASS_MODIFIERS, parseWordClass } from "./word-classes.mjs";
55
- import { pieceGlosses } from "./word-rows.mjs";
55
+ import { lineageChain, pieceGlosses } from "./word-rows.mjs";
56
56
 
57
57
  const MODIFIER_TOKENS = new Set(WORD_CLASS_MODIFIERS.map((item) => item.token));
58
58
 
@@ -159,7 +159,21 @@ export function checkLanguageWords(note, { index } = {}) {
159
159
  return out;
160
160
  }
161
161
 
162
- const glossOf = pieceGlosses(data);
162
+ // The language's own entries, then each ancestor's through `data.family`.
163
+ const ownAddress = ownKey(note, index);
164
+ const dataAt = (key) => {
165
+ const hit = index?.addressHit?.(key);
166
+ const target = hit?.fm ?? hit;
167
+ return target?.subType === LANGUAGE_SUBTYPE && isMap(target.data) ? target.data : undefined;
168
+ };
169
+ const ancestors =
170
+ index?.addressHit && stated("family") ?
171
+ lineageChain(addressKeyOf(data.family, index, "skill"), {
172
+ has: (key) => key !== ownAddress && dataAt(key) !== undefined,
173
+ parentOf: (key) => addressKeyOf(dataAt(key).family, index, "skill"),
174
+ }).map((key) => dataAt(key))
175
+ : [];
176
+ const glossOf = pieceGlosses(data, ...ancestors);
163
177
  const used = new Set();
164
178
  const forms = new Map();
165
179
  listOf(data.words).forEach((word, i) => {
@@ -246,7 +260,7 @@ export function checkLanguageWords(note, { index } = {}) {
246
260
  [...at("built"), j],
247
261
  "warning",
248
262
  `the piece \`${piece}\` of \`${word.form}\` matches no word or affix of this ` +
249
- "language, so it prints with no gloss",
263
+ "language or its ancestors, so it prints with no gloss",
250
264
  ),
251
265
  );
252
266
  });
@@ -695,7 +695,7 @@ export function loadPackConfig() {
695
695
 
696
696
  const explicit = process.env.PACKAGE_BUILD_CONFIG;
697
697
  const found = explicit ? undefined : resolveConfigFile();
698
- const configPath = explicit ? path.resolve(explicit) : found.path;
698
+ let configPath = explicit ? path.resolve(explicit) : found.path;
699
699
 
700
700
  if (!configPath || !fs.existsSync(configPath)) {
701
701
  throw new Error(
@@ -713,6 +713,11 @@ export function loadPackConfig() {
713
713
  );
714
714
  }
715
715
 
716
+ // An explicit name is followed through any symlink, so the configuration's
717
+ // root agrees with `process.cwd()`, which is always a real path, and
718
+ // diagnostic paths relative to one read relative to the other.
719
+ if (explicit) configPath = fs.realpathSync(configPath);
720
+
716
721
  // Two different files, one of which is about to be ignored. Said out loud
717
722
  // because the alternative is a build that reads the parent
718
723
  // checkout's configuration, compiles the parent's tree, and reports it only
@@ -32,11 +32,15 @@ import { encodeAddresses, flattenPublishedAddresses } from "./address-values.mjs
32
32
  import { EVENT_COLUMNS, EVENT_SORT_COLUMNS, eventRows } from "./event-rows.mjs";
33
33
  import {
34
34
  GLOSSARY_COLUMNS,
35
+ AFFIX_COLUMNS,
36
+ FIELD_COLUMNS,
35
37
  LINEAGE_COLUMNS,
36
38
  ROW_INTEGER_COLUMNS,
37
39
  WORD_COLUMNS,
38
40
  glossaryRows,
39
41
  lineageRows,
42
+ affixRows,
43
+ fieldRows,
40
44
  wordRows,
41
45
  } from "./word-rows.mjs";
42
46
  import { DICTIONARY_COLUMNS, TERM_COLUMNS, dictionaryAndTermRows } from "./dictionary-rows.mjs";
@@ -94,6 +98,14 @@ export const SQL_FENCE_ATTRIBUTES = Object.freeze({
94
98
  "The heading level given to each group a `_section` column creates, and to each " +
95
99
  "letter of a dictionary or a glossary.",
96
100
  }),
101
+ key: Object.freeze({
102
+ value: "`true` or `false`",
103
+ default: "`true`",
104
+ summary:
105
+ "Whether a dictionary or a glossary prints its key, the line of abbreviations it " +
106
+ "uses. `false` leaves the key out, for a page that states it once above several " +
107
+ "fences.",
108
+ }),
97
109
  layout: Object.freeze({
98
110
  value: "`table`, `dictionary` or `glossary`",
99
111
  default: "`table`",
@@ -123,7 +135,7 @@ const escapeCell = (text) =>
123
135
  *
124
136
  * @param {string} markdown - The note body, frontmatter already stripped.
125
137
  * @returns {Array<{line: number, close: number, indent: string, query: string,
126
- * allowEmpty: boolean, sectionLevel: number, layout: string, block: string}>} One entry per
138
+ * allowEmpty: boolean, sectionLevel: number, showKey: boolean, layout: string, block: string}>} One entry per
127
139
  * fence, in document order.
128
140
  */
129
141
  export function findSqlBlocks(markdown) {
@@ -158,6 +170,14 @@ export function findSqlBlocks(markdown) {
158
170
  errors.push(error.message);
159
171
  }
160
172
  }
173
+ let showKey = true;
174
+ if (Object.hasOwn(args, "key")) {
175
+ try {
176
+ showKey = booleanAttribute(args.key, "key");
177
+ } catch (error) {
178
+ errors.push(error.message);
179
+ }
180
+ }
161
181
  if (!Number.isInteger(level) || level < 1 || level > 6)
162
182
  errors.push("section-level needs an integer from 1 through 6");
163
183
  const layout = String(args.layout ?? "table");
@@ -170,6 +190,7 @@ export function findSqlBlocks(markdown) {
170
190
  query: lines.slice(i + 1, close).join("\n"),
171
191
  allowEmpty,
172
192
  sectionLevel: level,
193
+ showKey,
173
194
  layout,
174
195
  problems: errors,
175
196
  args,
@@ -310,6 +331,8 @@ export async function openNotesDatabase(
310
331
  for (const relation of [
311
332
  "events",
312
333
  "words",
334
+ "affixes",
335
+ "fields",
313
336
  "glossary",
314
337
  "language_lineage",
315
338
  "dictionary",
@@ -522,11 +545,14 @@ async function createTableFromFile(connection, name, select) {
522
545
  }
523
546
 
524
547
  /**
525
- * Create the `words`, `glossary`, `language_lineage`, `dictionary` and `terms`
526
- * relations: one row per language note's word, one per culture's or
548
+ * Create the `words`, `affixes`, `fields`, `glossary`, `language_lineage`,
549
+ * `dictionary` and `terms` relations: one row per language note's word, one per
550
+ * its affix, one per its field, one per culture's or
527
551
  * affiliation's glossary entry as authored, one per language and ancestor, one
528
552
  * per word per language it is listed under, and one per term per owning note,
529
553
  * with the columns {@link module:engine/word-rows.WORD_COLUMNS},
554
+ * {@link module:engine/word-rows.AFFIX_COLUMNS},
555
+ * {@link module:engine/word-rows.FIELD_COLUMNS},
530
556
  * {@link module:engine/word-rows.GLOSSARY_COLUMNS},
531
557
  * {@link module:engine/word-rows.LINEAGE_COLUMNS},
532
558
  * {@link module:engine/dictionary-rows.DICTIONARY_COLUMNS} and
@@ -550,10 +576,24 @@ async function createWordRelations(connection, stem, records, prefix, audience)
550
576
  await createRowRelation(
551
577
  connection,
552
578
  `${stem}-words.jsonl`,
553
- wordRows(visible),
579
+ wordRows(visible, records),
554
580
  `${prefix}words`,
555
581
  WORD_COLUMNS,
556
582
  );
583
+ await createRowRelation(
584
+ connection,
585
+ `${stem}-affixes.jsonl`,
586
+ affixRows(visible),
587
+ `${prefix}affixes`,
588
+ AFFIX_COLUMNS,
589
+ );
590
+ await createRowRelation(
591
+ connection,
592
+ `${stem}-fields.jsonl`,
593
+ fieldRows(visible),
594
+ `${prefix}fields`,
595
+ FIELD_COLUMNS,
596
+ );
557
597
  await createRowRelation(
558
598
  connection,
559
599
  `${stem}-glossary.jsonl`,
@@ -870,6 +910,7 @@ export async function prepareSqlTables(db, sources, { linkable, records, pageLan
870
910
  setAs(result, {
871
911
  ...options,
872
912
  labelOf,
913
+ showKey: block.showKey,
873
914
  pageLanguage: languageOf(source, frontmatter),
874
915
  })
875
916
  : renderSqlTable(result, options),
@@ -70,7 +70,7 @@ export const WORD_CLASS_BASES = Object.freeze([
70
70
  base("adv", "adv.", "adverb", true),
71
71
  base("pron", "pron.", "pronoun", true),
72
72
  base("num", "num.", "numeral", true),
73
- base("part", "part.", "particle", true),
73
+ base("part", "ptcl.", "particle", true),
74
74
  base("art", "art.", "article", true),
75
75
  base("conj", "conj.", "conjunction", true),
76
76
  base("dem", "dem.", "demonstrative", true),
@@ -33,7 +33,13 @@
33
33
 
34
34
  import { renderAddress } from "./address.mjs";
35
35
  import { glossaryLanguage } from "./glossary-language.mjs";
36
- import { GLOSSARY_ENTRY_KEYS, LANGUAGE_SUBTYPE, WORD_KEYS } from "./word-terms.mjs";
36
+ import {
37
+ AFFIX_KEYS,
38
+ FIELD_KEYS,
39
+ GLOSSARY_ENTRY_KEYS,
40
+ LANGUAGE_SUBTYPE,
41
+ WORD_KEYS,
42
+ } from "./word-terms.mjs";
37
43
 
38
44
  /** The columns a word row carries before the word's own keys. */
39
45
  const WORD_IDENTITY_COLUMNS = Object.freeze(["language", "position"]);
@@ -51,6 +57,12 @@ export const WORD_COLUMNS = Object.freeze([
51
57
  /** Every column of the `glossary` relation, in order. */
52
58
  export const GLOSSARY_COLUMNS = Object.freeze(["note", "position", ...GLOSSARY_ENTRY_KEYS]);
53
59
 
60
+ /** Every column of the `affixes` relation, in order. */
61
+ export const AFFIX_COLUMNS = Object.freeze([...WORD_IDENTITY_COLUMNS, ...AFFIX_KEYS]);
62
+
63
+ /** Every column of the `fields` relation, in order. */
64
+ export const FIELD_COLUMNS = Object.freeze([...WORD_IDENTITY_COLUMNS, ...FIELD_KEYS]);
65
+
54
66
  /** Every column of the `language_lineage` relation, in order. */
55
67
  export const LINEAGE_COLUMNS = Object.freeze(["language", "ancestor", "depth"]);
56
68
 
@@ -64,17 +76,20 @@ const isMap = (value) => Boolean(value) && typeof value === "object" && !Array.i
64
76
  const bare = (form) => String(form).replace(/^-+|-+$/g, "");
65
77
 
66
78
  /**
67
- * A lookup from a built piece to its gloss, over one language's words (stems
68
- * included) and affixes.
79
+ * A lookup from a built piece to its gloss, over the words (stems included)
80
+ * and affixes of one or more languages.
69
81
  *
70
- * A piece matches a word or an affix written exactly as it is, and failing
82
+ * Languages are given nearest first: a language's own words, then its own
83
+ * affixes, then the next language's, so a daughter's entry wins over its
84
+ * parent's. A piece matches an entry written exactly as it is, and failing
71
85
  * 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.
86
+ * `wulf-` finds the word `wulf`. Within a language a word's own gloss wins over
87
+ * an affix's.
73
88
  *
74
- * @param {object} data - The language note's `data`.
89
+ * @param {...object} datas - Each language note's `data`, nearest first.
75
90
  * @returns {(piece: string) => string|null} The gloss, or `null` where none resolves.
76
91
  */
77
- export function pieceGlosses(data) {
92
+ export function pieceGlosses(...datas) {
78
93
  const exact = new Map();
79
94
  const loose = new Map();
80
95
  const take = (entries) => {
@@ -85,11 +100,64 @@ export function pieceGlosses(data) {
85
100
  if (!loose.has(bare(entry.form))) loose.set(bare(entry.form), entry.gloss);
86
101
  }
87
102
  };
88
- take(data?.words);
89
- take(data?.affixes);
103
+ for (const data of datas) {
104
+ take(data?.words);
105
+ take(data?.affixes);
106
+ }
90
107
  return (piece) => exact.get(piece) ?? loose.get(bare(piece)) ?? null;
91
108
  }
92
109
 
110
+ /**
111
+ * The chain of languages from one up through `data.family`: the language
112
+ * itself, then its parent, and so on. The chain stops at a parent that is no
113
+ * language, and before a language it has already reached, so a cycle the lint
114
+ * refuses still yields a finite chain. `language_lineage`, the dictionary and
115
+ * the resolution of built pieces all walk it.
116
+ *
117
+ * @param {string|undefined} start - The language's key.
118
+ * @param {object} reader
119
+ * @param {(key: string) => boolean} reader.has - Whether a key is a language.
120
+ * @param {(key: string) => string|undefined} reader.parentOf - A language's
121
+ * parent key, where it states one.
122
+ * @returns {string[]} The keys, nearest first.
123
+ */
124
+ export function lineageChain(start, { has, parentOf }) {
125
+ const chain = [];
126
+ const seen = new Set();
127
+ for (let current = start; current && has(current) && !seen.has(current);) {
128
+ seen.add(current);
129
+ chain.push(current);
130
+ current = parentOf(current) || undefined;
131
+ }
132
+ return chain;
133
+ }
134
+
135
+ /** A language record's `data.family` where it is a plain Address string. */
136
+ const familyKey = (record) => {
137
+ const family = record?.data?.family;
138
+ return typeof family === "string" && family ? family : undefined;
139
+ };
140
+
141
+ /**
142
+ * A lookup of language records by Address, and the chain walk over them.
143
+ *
144
+ * @param {Iterable<Record<string, any>>} records - Index records.
145
+ * @returns {{languages: Map<string, object>, chain: (key: string) => string[]}}
146
+ */
147
+ export function languageLineages(records) {
148
+ const languages = new Map();
149
+ for (const record of records)
150
+ if (isLanguageRecord(record)) {
151
+ const key = noteAddressOf(record);
152
+ if (key && !languages.has(key)) languages.set(key, record);
153
+ }
154
+ const reader = {
155
+ has: (key) => languages.has(key),
156
+ parentOf: (key) => familyKey(languages.get(key)),
157
+ };
158
+ return { languages, chain: (key) => lineageChain(key, reader) };
159
+ }
160
+
93
161
  /**
94
162
  * Whether a record is a language note's.
95
163
  *
@@ -124,9 +192,12 @@ function noteAddressOf(record) {
124
192
  *
125
193
  * @param {Iterable<Record<string, any>>} records - Index records, every
126
194
  * Address a string, already narrowed to the audience being published to.
195
+ * @param {Iterable<Record<string, any>>} [all] - Every record of the package,
196
+ * for resolving a built piece through an ancestor the audience does not see.
127
197
  * @returns {object[]} One row per word, in record then entry order.
128
198
  */
129
- export function wordRows(records) {
199
+ export function wordRows(records, all = records) {
200
+ const { languages, chain } = languageLineages(all);
130
201
  const rows = [];
131
202
  for (const record of records) {
132
203
  if (!isLanguageRecord(record)) continue;
@@ -134,7 +205,7 @@ export function wordRows(records) {
134
205
  if (!Array.isArray(words) || !words.length) continue;
135
206
  const language = noteAddressOf(record);
136
207
  if (!language) continue;
137
- const glossOf = pieceGlosses(record.data);
208
+ const glossOf = pieceGlosses(...chain(language).map((key) => languages.get(key).data));
138
209
  words.forEach((entry, position) => {
139
210
  if (!isMap(entry)) return;
140
211
  const row = { language, position };
@@ -150,6 +221,57 @@ export function wordRows(records) {
150
221
  return rows;
151
222
  }
152
223
 
224
+ /**
225
+ * The rows of one list a language note states beside its words: one per entry,
226
+ * keyed by the note, with every key of the entry a column.
227
+ *
228
+ * @param {Iterable<Record<string, any>>} records - Index records.
229
+ * @param {string} list - The `data` key holding the entries.
230
+ * @param {readonly string[]} keys - The keys an entry carries.
231
+ * @returns {object[]} One row per entry, in record then entry order.
232
+ */
233
+ function languageListRows(records, list, keys) {
234
+ const rows = [];
235
+ for (const record of records) {
236
+ if (!isLanguageRecord(record)) continue;
237
+ const entries = record.data?.[list];
238
+ if (!Array.isArray(entries) || !entries.length) continue;
239
+ const language = noteAddressOf(record);
240
+ if (!language) continue;
241
+ entries.forEach((entry, position) => {
242
+ if (!isMap(entry)) return;
243
+ const row = { language, position };
244
+ for (const key of keys) row[key] = entry[key] ?? null;
245
+ rows.push(row);
246
+ });
247
+ }
248
+ return rows;
249
+ }
250
+
251
+ /**
252
+ * The `affixes` rows of a set of index records: one per `data.affixes` entry
253
+ * of a language note, which is the row's `language`.
254
+ *
255
+ * @param {Iterable<Record<string, any>>} records - Index records, every
256
+ * Address a string, already narrowed to the audience being published to.
257
+ * @returns {object[]} One row per affix, in record then entry order.
258
+ */
259
+ export function affixRows(records) {
260
+ return languageListRows(records, "affixes", AFFIX_KEYS);
261
+ }
262
+
263
+ /**
264
+ * The `fields` rows of a set of index records: one per `data.fields` entry of
265
+ * a language note, which is the row's `language`.
266
+ *
267
+ * @param {Iterable<Record<string, any>>} records - Index records, every
268
+ * Address a string, already narrowed to the audience being published to.
269
+ * @returns {object[]} One row per field, in record then entry order.
270
+ */
271
+ export function fieldRows(records) {
272
+ return languageListRows(records, "fields", FIELD_KEYS);
273
+ }
274
+
153
275
  /**
154
276
  * The `glossary` rows of a set of index records: one per entry of a culture's
155
277
  * or an affiliation's `data.glossary`, with `name_sort` the name and `common`
@@ -209,25 +331,13 @@ export function glossaryRows(records, all = records) {
209
331
  * @returns {object[]} One row per language and ancestor, in record then depth order.
210
332
  */
211
333
  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
- }
334
+ const { chain } = languageLineages(all);
218
335
  const rows = [];
219
336
  for (const record of records) {
220
337
  if (!isLanguageRecord(record)) continue;
221
338
  const language = noteAddressOf(record);
222
339
  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
- }
340
+ chain(language).forEach((ancestor, depth) => rows.push({ language, ancestor, depth }));
231
341
  }
232
342
  return rows;
233
343
  }
@@ -226,8 +226,20 @@ export const AFFIX_FIELDS = Object.freeze([
226
226
  entries: Object.freeze({ kind: "string", shape: "a string" }),
227
227
  describe: "The names the affix is read from.",
228
228
  }),
229
+ Object.freeze({
230
+ name: "tones",
231
+ kind: "string",
232
+ shape: "a tone pattern, such as `L`",
233
+ describe: "The tone pattern the affix carries, written as a word's `tones` is.",
234
+ }),
229
235
  ]);
230
236
 
237
+ /** The keys of {@link WORD_FIELD_FIELDS}, in declared order. */
238
+ export const FIELD_KEYS = Object.freeze(WORD_FIELD_FIELDS.map((field) => field.name));
239
+
240
+ /** The keys of {@link AFFIX_FIELDS}, in declared order. */
241
+ export const AFFIX_KEYS = Object.freeze(AFFIX_FIELDS.map((field) => field.name));
242
+
231
243
  /**
232
244
  * The keys one entry of a culture's or an affiliation's `data.glossary`
233
245
  * 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.6.1",
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,7 @@
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} [showKey=true] - Whether the key is printed.
9
10
  * @property {string} [pageLanguage] - The language of the page the fence is
10
11
  * on, which no entry is tagged with.
11
12
  */
@@ -61,6 +62,10 @@ export type LayoutOptions = {
61
62
  * `name.full`, by its Address.
62
63
  */
63
64
  labelOf?: ((address: string) => string | undefined) | undefined;
65
+ /**
66
+ * - Whether the key is printed.
67
+ */
68
+ showKey?: boolean | undefined;
64
69
  /**
65
70
  * - The language of the page the fence is
66
71
  * on, which no entry is tagged with.
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * @param {string} markdown - The note body, frontmatter already stripped.
9
9
  * @returns {Array<{line: number, close: number, indent: string, query: string,
10
- * allowEmpty: boolean, sectionLevel: number, layout: string, block: string}>} One entry per
10
+ * allowEmpty: boolean, sectionLevel: number, showKey: boolean, layout: string, block: string}>} One entry per
11
11
  * fence, in document order.
12
12
  */
13
13
  export function findSqlBlocks(markdown: string): Array<{
@@ -17,6 +17,7 @@ export function findSqlBlocks(markdown: string): Array<{
17
17
  query: string;
18
18
  allowEmpty: boolean;
19
19
  sectionLevel: number;
20
+ showKey: boolean;
20
21
  layout: string;
21
22
  block: string;
22
23
  }>;
@@ -1,15 +1,46 @@
1
1
  /**
2
- * A lookup from a built piece to its gloss, over one language's words (stems
3
- * included) and affixes.
2
+ * A lookup from a built piece to its gloss, over the words (stems included)
3
+ * and affixes of one or more languages.
4
4
  *
5
- * A piece matches a word or an affix written exactly as it is, and failing
5
+ * Languages are given nearest first: a language's own words, then its own
6
+ * affixes, then the next language's, so a daughter's entry wins over its
7
+ * parent's. A piece matches an entry written exactly as it is, and failing
6
8
  * that one written with the same letters and different joining hyphens, so
7
- * `wulf-` finds the word `wulf`. A word's own gloss wins over an affix's.
9
+ * `wulf-` finds the word `wulf`. Within a language a word's own gloss wins over
10
+ * an affix's.
8
11
  *
9
- * @param {object} data - The language note's `data`.
12
+ * @param {...object} datas - Each language note's `data`, nearest first.
10
13
  * @returns {(piece: string) => string|null} The gloss, or `null` where none resolves.
11
14
  */
12
- export function pieceGlosses(data: object): (piece: string) => string | null;
15
+ export function pieceGlosses(...datas: object[]): (piece: string) => string | null;
16
+ /**
17
+ * The chain of languages from one up through `data.family`: the language
18
+ * itself, then its parent, and so on. The chain stops at a parent that is no
19
+ * language, and before a language it has already reached, so a cycle the lint
20
+ * refuses still yields a finite chain. `language_lineage`, the dictionary and
21
+ * the resolution of built pieces all walk it.
22
+ *
23
+ * @param {string|undefined} start - The language's key.
24
+ * @param {object} reader
25
+ * @param {(key: string) => boolean} reader.has - Whether a key is a language.
26
+ * @param {(key: string) => string|undefined} reader.parentOf - A language's
27
+ * parent key, where it states one.
28
+ * @returns {string[]} The keys, nearest first.
29
+ */
30
+ export function lineageChain(start: string | undefined, { has, parentOf }: {
31
+ has: (key: string) => boolean;
32
+ parentOf: (key: string) => string | undefined;
33
+ }): string[];
34
+ /**
35
+ * A lookup of language records by Address, and the chain walk over them.
36
+ *
37
+ * @param {Iterable<Record<string, any>>} records - Index records.
38
+ * @returns {{languages: Map<string, object>, chain: (key: string) => string[]}}
39
+ */
40
+ export function languageLineages(records: Iterable<Record<string, any>>): {
41
+ languages: Map<string, object>;
42
+ chain: (key: string) => string[];
43
+ };
13
44
  /**
14
45
  * Whether a record is a language note's.
15
46
  *
@@ -24,9 +55,29 @@ export function isLanguageRecord(record: Record<string, any>): boolean;
24
55
  *
25
56
  * @param {Iterable<Record<string, any>>} records - Index records, every
26
57
  * Address a string, already narrowed to the audience being published to.
58
+ * @param {Iterable<Record<string, any>>} [all] - Every record of the package,
59
+ * for resolving a built piece through an ancestor the audience does not see.
27
60
  * @returns {object[]} One row per word, in record then entry order.
28
61
  */
29
- export function wordRows(records: Iterable<Record<string, any>>): object[];
62
+ export function wordRows(records: Iterable<Record<string, any>>, all?: Iterable<Record<string, any>>): object[];
63
+ /**
64
+ * The `affixes` rows of a set of index records: one per `data.affixes` entry
65
+ * of a language note, which is the row's `language`.
66
+ *
67
+ * @param {Iterable<Record<string, any>>} records - Index records, every
68
+ * Address a string, already narrowed to the audience being published to.
69
+ * @returns {object[]} One row per affix, in record then entry order.
70
+ */
71
+ export function affixRows(records: Iterable<Record<string, any>>): object[];
72
+ /**
73
+ * The `fields` rows of a set of index records: one per `data.fields` entry of
74
+ * a language note, which is the row's `language`.
75
+ *
76
+ * @param {Iterable<Record<string, any>>} records - Index records, every
77
+ * Address a string, already narrowed to the audience being published to.
78
+ * @returns {object[]} One row per field, in record then entry order.
79
+ */
80
+ export function fieldRows(records: Iterable<Record<string, any>>): object[];
30
81
  /**
31
82
  * The `glossary` rows of a set of index records: one per entry of a culture's
32
83
  * or an affiliation's `data.glossary`, with `name_sort` the name and `common`
@@ -58,6 +109,10 @@ export function lineageRows(records: Iterable<Record<string, any>>, all?: Iterab
58
109
  export const WORD_COLUMNS: readonly string[];
59
110
  /** Every column of the `glossary` relation, in order. */
60
111
  export const GLOSSARY_COLUMNS: readonly string[];
112
+ /** Every column of the `affixes` relation, in order. */
113
+ export const AFFIX_COLUMNS: readonly string[];
114
+ /** Every column of the `fields` relation, in order. */
115
+ export const FIELD_COLUMNS: readonly string[];
61
116
  /** Every column of the `language_lineage` relation, in order. */
62
117
  export const LINEAGE_COLUMNS: readonly string[];
63
118
  /** The columns of any of these relations that hold a whole number. */
@@ -57,6 +57,10 @@ export const WORD_FIELD_FIELDS: readonly import("./data-keys.mjs").InnerKeySpec[
57
57
  * @type {readonly import("./data-keys.mjs").InnerKeySpec[]}
58
58
  */
59
59
  export const AFFIX_FIELDS: readonly import("./data-keys.mjs").InnerKeySpec[];
60
+ /** The keys of {@link WORD_FIELD_FIELDS}, in declared order. */
61
+ export const FIELD_KEYS: readonly string[];
62
+ /** The keys of {@link AFFIX_FIELDS}, in declared order. */
63
+ export const AFFIX_KEYS: readonly string[];
60
64
  /**
61
65
  * The keys one entry of a culture's or an affiliation's `data.glossary`
62
66
  * carries.