@heroiclands/package-build 24.0.2 → 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.
@@ -1026,12 +1026,12 @@ ships as the Foundry module `harn-ensemble`) writing `images/map.webp`:
1026
1026
  And for the same note writing `sohl/assets/icons/noun/shieldnn.svg`, a file the
1027
1027
  system ships and this repository does not hold:
1028
1028
 
1029
- | Surface | Address |
1030
- | ----------- | ------------------------------------------------------------- |
1031
- | **Foundry** | `systems/sohl/icons/shieldnn.svg` |
1032
- | **Local** | `assets/icons/noun/shieldnn.svg`, in the `sohl` repository |
1033
- | **Web** | `https://cdn.heroiclands.org/sohl/icons/shieldnn.svg` |
1034
- | **Book** | not carried, unless a declared replacement carries it instead |
1029
+ | Surface | Address |
1030
+ | ----------- | ---------------------------------------------------------- |
1031
+ | **Foundry** | `systems/sohl/icons/shieldnn.svg` |
1032
+ | **Local** | `assets/icons/noun/shieldnn.svg`, in the `sohl` repository |
1033
+ | **Web** | `https://cdn.heroiclands.org/sohl/icons/shieldnn.svg` |
1034
+ | **Book** | not carried |
1035
1035
 
1036
1036
  **The content package name and Foundry id are separate values.**
1037
1037
  `harnensemble` is what the content is called, what a note writes, and what the
@@ -1049,7 +1049,7 @@ does not know still resolves on the website and in the book — those need only
1049
1049
  the name and the suffix — and has no Foundry address, which is refused rather
1050
1050
  than guessed.
1051
1051
 
1052
- An art package that replaces another package's images can declare
1052
+ A package whose image files belong to another package's notes can declare
1053
1053
  `packageBuild.assets[].bindsTo` on the relevant asset copy. Each file's basename
1054
1054
  then acts as a shortcode for the declared package and type. The related
1055
1055
  `relationships.requires[].contentIndex` supplies the address index; with that
@@ -1057,29 +1057,6 @@ index enabled, an asset with no matching note is a located build error. See
1057
1057
  [asset bindings](../configuration.md#packagebuildstagedir-and-packagebuildassets)
1058
1058
  for the configuration and cache requirements.
1059
1059
 
1060
- **A package declared as a replacement is carried into the book, from its
1061
- fetched archive, when it answers the address; every other foreign package
1062
- stays not carried.** A relationship declaring `assetReplacement: true` names a
1063
- dependency whose asset tree answers this package's own addresses first — see
1064
- [`relationships`](../configuration.md#relationships) — and the book is the one
1065
- surface that otherwise refuses a foreign file outright, so it is the one
1066
- surface this changes. A `thalorna` note embedding
1067
- `![[thalornaaltart-none-icon-anvil|An anvil]]` names `thalornaaltart`'s own
1068
- address, exactly as a note citing `sohl`'s shared bestiary art would; when
1069
- `thalornaaltart` is declared with `assetReplacement: true` and its fetched
1070
- archive carries `icon-anvil` under its own package, the book stages that file
1071
- from the archive instead of printing the caption with no picture. A note
1072
- citing a different package's own address this way — `sohl`'s bestiary art,
1073
- say — stays refused exactly as the row above states, because `sohl` was never
1074
- declared as a replacement: a package answers in place of another only when it
1075
- opted in, never because its archive happens to carry a file at the same type
1076
- and shortcode.
1077
-
1078
- A relationship declaring `assetReplacement: true` whose archive was never
1079
- fetched is a configuration mistake, not an ordinary unresolved address: the
1080
- book reports a located error naming the relationship and instructing
1081
- `package-build deps fetch`, rather than printing a caption with no picture.
1082
-
1083
1060
  **The host is configuration.** `site.assets` in `package-build.config.yaml` is
1084
1061
  the root the web form is joined onto, and a package-owned image on a page with
1085
1062
  none set is an error naming that key.
@@ -1670,16 +1647,16 @@ An **`Address` field** takes the address with no brackets:
1670
1647
  ```yaml
1671
1648
  data:
1672
1649
  parents:
1673
- - hexhodai
1650
+ - zemorhodhis
1674
1651
  seat: tashal
1675
1652
  ```
1676
1653
 
1677
- not `[[hexhodai]]`. The field is declared as an `Address`, so the schema knows
1654
+ not `[[zemorhodhis]]`. The field is declared as an `Address`, so the schema knows
1678
1655
  what the value is and reads it as one; brackets are a wikilink's punctuation,
1679
1656
  which the reader would have to strip before it could do anything.
1680
1657
 
1681
1658
  A frontmatter value is parsed by the address grammar above, so a single-segment
1682
- value such as `hexhodai` names the target by its shortcode alone, with every
1659
+ value such as `zemorhodhis` names the target by its shortcode alone, with every
1683
1660
  other segment supplied by the position. Frontmatter is structure rather than
1684
1661
  prose: a field value is a reference something else will compile against, and it
1685
1662
  should say exactly what it points at.
@@ -2822,37 +2799,6 @@ and keeps the excluded picture's address in its flag or `data-asset`. A
2822
2799
  package that leaves out a picture a note references and ships no stand-in of
2823
2800
  that type fails the build with an error at the referencing note.
2824
2801
 
2825
- ##### A declared replacement resolves first
2826
-
2827
- A package may declare, on a `relationships.<kind>[]` entry, that a
2828
- dependency's asset tree answers this package's own asset addresses ahead of
2829
- its local record — `assetReplacement: true` in `package-build.config.yaml`.
2830
- Once declared, an asset address resolves in this order:
2831
-
2832
- 1. **Every declared replacement, in declaration order.** The package segment
2833
- of each relationship declaring `assetReplacement: true` is tried in the
2834
- order those relationships are written, and the first one carrying the
2835
- address wins.
2836
- 2. **Falling through to the package's own record** when no declared
2837
- replacement carries the address — the resolution this package already
2838
- performs with no `assetReplacement` declared at all.
2839
-
2840
- **This reaches asset types only.** A being, an item or a lore note is never
2841
- replaced this way, whatever a relationship declares: the rule tests the
2842
- address's type, not the relationship. A replacement answers only `icon`,
2843
- `image` and `audio` addresses whose package segment is this package's own; an
2844
- address naming any other type, or naming a different package, is untouched.
2845
-
2846
- **A replacement's own output address is never itself looked up against
2847
- another replacement.** Resolving `thalornaaltart-none-image-thorn` never asks
2848
- whether something replaces `thalornaaltart` — resolution is a single pass over
2849
- the declared list, not a chain, which forecloses a cycle by construction.
2850
-
2851
- This is distinct from a `foreign` lookup, which serves a declared dependency's
2852
- own content for an address this package's local index does not carry. A
2853
- replacement is consulted **before** the local address; a foreign lookup only
2854
- **after** it misses locally.
2855
-
2856
2802
  #### A font is not an asset
2857
2803
 
2858
2804
  `assets/fonts` is not a root, and a font has no address.
@@ -3592,7 +3538,7 @@ for the keys.
3592
3538
  | `joining` | `data.sounds.joining` | `first` and `second` (lists), `written`, `example` |
3593
3539
  | `joins` | `data.sounds.joins` | `meeting`, `rule`, `letters` (a list), `where` |
3594
3540
  | `marks` | `data.sounds.marks` | `mark`, `letters` (a list), `kind`, `limit`; a spelling guide's rows too, keyed by the guide |
3595
- | `skeleton` | `data.sounds.skeleton` | `second` (a list, `[any]` for every other), `between` |
3541
+ | `skeleton` | `data.sounds.skeleton` | `second` (a list, `[any]` for every other), `third` (the same), `between` |
3596
3542
  | `frames` | `data.sounds.frames` | `frame`, `shape`, `class` (a list), `makes`, `examples` (a list of `{ root, form }`) |
3597
3543
  | `read_past` | `data.sounds.readPast` | `written`, `readAs`, `what` |
3598
3544
  | `sound_changes` | `data.soundChanges` | One row per step: `position` (the set's), `name`, `from_language`, `to_language`, `takes`, `step_position` (the step's in its set), `step`, `change`, `where` |
@@ -6272,10 +6218,11 @@ SoHL sets `system.quantity` to `1` for each compendium item.
6272
6218
  | `sounds.marks` | `{ mark, letters, kind, limit }[]` | The marks the language writes on its letters |
6273
6219
  | `sounds.marks[].mark` | `string`, required | The mark's name, as `acute` |
6274
6220
  | `sounds.marks[].letters` | `string[]`, required | The letters that carry it |
6275
- | `sounds.marks[].kind` | `stress` or `length`, required | What it says of its letter |
6221
+ | `sounds.marks[].kind` | `stress`, `length` or `apart`, required | What it says of its letter |
6276
6222
  | `sounds.marks[].limit` | `one` or `any`, required | How many a word may carry |
6277
- | `sounds.skeleton` | `{ second, between? }[]` | The vowel set between a root's second and third consonants |
6223
+ | `sounds.skeleton` | `{ second, third?, between? }[]` | The vowel set between a root's second and third consonants |
6278
6224
  | `sounds.skeleton[].second` | `string[]` or `any`, required | The second consonants the row holds for; `any` for every other |
6225
+ | `sounds.skeleton[].third` | `string[]` or `any` | The third consonants the row holds for; every third consonant where absent |
6279
6226
  | `sounds.skeleton[].between` | `string` | The vowel set between; nothing where absent or empty |
6280
6227
  | `sounds.frames` | `{ frame, shape, class, makes, examples? }[]` | The patterns a root is set in |
6281
6228
  | `sounds.frames[].frame` | `string`, required | The frame's name, which a word's `frame` names |
@@ -6690,9 +6637,18 @@ The lint holds each to what it means, each finding located at the key at fault:
6690
6637
  Where no note states `data.concepts`, every id is unknown, and the message
6691
6638
  says so.
6692
6639
 
6693
- A language reads the words of its `data.family` chain as its own, so a parent's
6694
- tagged word covers the daughter's concept. `data.core` is not inherited: each
6695
- 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.
6696
6652
 
6697
6653
  `package-build core` is the report. A concept no word serves is never a lint
6698
6654
  finding; the report lists it so the language can be filled in. See
@@ -6917,10 +6873,11 @@ closed list where its values are words:
6917
6873
  - `joins`: what happens where a consonant or a vowel meets another (`C+C`,
6918
6874
  `V+C`, `V+V`): `merge`, `insert` the `letters` between, `drop` the first, or
6919
6875
  `double` the consonant, everywhere or `where` stated.
6920
- - `marks`: the marks written on letters, each saying `stress` or `length`, and
6876
+ - `marks`: the marks written on letters, each saying `stress`, `length`, or `apart` (the mark sets a vowel to be said apart from its neighbour, as a diaeresis does), and
6921
6877
  whether a word carries `one` at most or `any`.
6922
6878
  - `skeleton`: the vowel set between a root's second and third consonants, by
6923
- the second consonant; nothing where none is stated.
6879
+ the second consonant and, where a row states `third`, the third; nothing
6880
+ where none is stated.
6924
6881
  - `frames`: the patterns a root is set in, each with its shape, the word
6925
6882
  classes it makes, what it makes, and the form each root takes in it.
6926
6883
  - `mutations`: the sounds a word changes where a named mutation holds — a stop
@@ -7108,7 +7065,7 @@ data:
7108
7065
  tongue: [skill-tantsoulng, skill-jurthatlng],
7109
7066
  }
7110
7067
  marks:
7111
- - { mark: diaeresis, letters: [ë], kind: length, limit: any }
7068
+ - { mark: diaeresis, letters: [ë], kind: apart, limit: any }
7112
7069
  ```
7113
7070
 
7114
7071
  A guide declares no tags, so a letter row's `tongue` names language notes by
@@ -8311,7 +8268,7 @@ Content prepared to be played — a situation with its cast, places, and possibl
8311
8268
  | `sounds.marks` | `{ mark, letters, kind, limit }[]` | The marks written on the letters, each row's keys as a language note's `sounds.marks` |
8312
8269
  | `sounds.marks[].mark` | `string`, required | The mark's name, as `acute` |
8313
8270
  | `sounds.marks[].letters` | `string[]`, required | The letters that carry it |
8314
- | `sounds.marks[].kind` | `stress` or `length`, required | What it says of its letter |
8271
+ | `sounds.marks[].kind` | `stress`, `length` or `apart`, required | What it says of its letter |
8315
8272
  | `sounds.marks[].limit` | `one` or `any`, required | How many a word may carry |
8316
8273
 
8317
8274
  A setting guide introduces one culture's view of the setting, so it names that
@@ -631,10 +631,11 @@ These fields are accepted by every note type. A field's value can still be irrel
631
631
  | `data.sounds.marks` | list of `{ mark, letters, kind, limit }` | no | The marks the language writes on its letters. |
632
632
  | `data.sounds.marks[].mark` | a string, such as `acute` | yes | The mark's name. |
633
633
  | `data.sounds.marks[].letters` | a list of letters | yes | The letters that carry it. |
634
- | `data.sounds.marks[].kind` | one of stress, length | yes | What the mark says of its letter. |
634
+ | `data.sounds.marks[].kind` | one of stress, length, apart | yes | What the mark says of its letter. |
635
635
  | `data.sounds.marks[].limit` | one of one, any | yes | How many a word may carry: `one` at most, or `any`. |
636
636
  | `data.sounds.skeleton` | list of `{ second, between? }` | no | The vowel set between a root's second and third consonants. |
637
637
  | `data.sounds.skeleton[].second` | a list of sounds, or `any` | yes | The second consonants of a root the row holds for; `any` for every other. |
638
+ | `data.sounds.skeleton[].third` | a list of sounds, or `any` | no | The third consonants of a root the row holds for; every third consonant where absent. |
638
639
  | `data.sounds.skeleton[].between` | a vowel, such as `a` | no | The vowel set between the second and third consonants; nothing where absent. |
639
640
  | `data.sounds.frames` | list of `{ frame, shape, class, makes, examples? }` | no | The patterns a root is set in. |
640
641
  | `data.sounds.frames[].frame` | a string, such as `bare` | yes | The frame's name, which a word's `frame` names. |
@@ -1026,7 +1027,7 @@ Lore records in-world knowledge. A `culture` describes a people; a `custom` desc
1026
1027
  | `data.sounds.marks` | list of `{ mark, letters, kind, limit }` | no | The marks the language writes on its letters. |
1027
1028
  | `data.sounds.marks[].mark` | a string, such as `acute` | yes | The mark's name. |
1028
1029
  | `data.sounds.marks[].letters` | a list of letters | yes | The letters that carry it. |
1029
- | `data.sounds.marks[].kind` | one of stress, length | yes | What the mark says of its letter. |
1030
+ | `data.sounds.marks[].kind` | one of stress, length, apart | yes | What the mark says of its letter. |
1030
1031
  | `data.sounds.marks[].limit` | one of one, any | yes | How many a word may carry: `one` at most, or `any`. |
1031
1032
  | `data.map.name` | string | yes | What `map=` names the map by: lowercase letters, digits and `-`, unique in the package. |
1032
1033
  | `data.map.logographic` | `true` | no | The script writes meanings: a mapped run's words are written by the language's lexicon alone, and the rows write only the text between them. |
@@ -172,6 +172,7 @@ const SCRIPT_BLOCKS = Object.freeze([
172
172
  [0xf0000, 0xf2fff], // plane 15: Pirzath, Thuravark, Semaron, the reserved alphabets, Teng Chou
173
173
  [0x1e960, 0x1ec6f], // Zethu overflow, Qalezu, Zethu
174
174
  [0x1ecc0, 0x1ecff], // Qalezu catch-before forms
175
+ [0x1ed50, 0x1ed7f], // Veldikko
175
176
  [0x13430, 0x1345f], // the Egyptian format controls Zethu uses
176
177
  ]);
177
178
 
@@ -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);
@@ -68,6 +68,19 @@ export const COMMON_MARK = "•";
68
68
  /** The word-entry span class whose Foundry rendering is a native TOC heading. */
69
69
  export const DICTIONARY_ENTRY_CLASS = "dictionary-entry";
70
70
 
71
+ /**
72
+ * The class on every link an entry writes to another note, so a site can set
73
+ * the link as plain text where its target is not a page a reader can reach.
74
+ */
75
+ export const LEXICAL_LINK_CLASS = "lexical-ref";
76
+
77
+ /** Wikilinks in a gloss, marked with {@link LEXICAL_LINK_CLASS}. */
78
+ const markLinks = (text) =>
79
+ String(text ?? "").replace(
80
+ /\[\[[^\]]*\]\](?!\{)/g,
81
+ (link) => `${link}{.${LEXICAL_LINK_CLASS}}`,
82
+ );
83
+
71
84
  /**
72
85
  * Render a dictionary entry's marked headword as an inline native heading in
73
86
  * Foundry. Its paragraph becomes a div, so the heading has a valid container.
@@ -327,7 +340,7 @@ function render(
327
340
  const text = label(address);
328
341
  const ref = parseAddress(address, addressContext ?? {}, { declared: true });
329
342
  if (ref.reason || !linkable(ref)) return text;
330
- return `[[${renderAddress(ref)}|${String(text).replace(/\|/g, "/")}]]`;
343
+ return `[[${renderAddress(ref)}|${String(text).replace(/\|/g, "/")}]]{.${LEXICAL_LINK_CLASS}}`;
331
344
  };
332
345
  const tag = (language, text) => {
333
346
  if (!text) return "";
@@ -367,7 +380,7 @@ function render(
367
380
  const { address, anchor } = splitAnchor(row.word);
368
381
  const ref = readCanonicalKey(address);
369
382
  if (ref && anchor && linkable(ref))
370
- headword = `**[[${renderAddress(ref)}#${anchor}|${String(row.name).replace(/\|/g, "/")}]]**`;
383
+ headword = `**[[${renderAddress(ref)}#${anchor}|${String(row.name).replace(/\|/g, "/")}]]{.${LEXICAL_LINK_CLASS}}**`;
371
384
  }
372
385
  parts.push(headword);
373
386
  if (row.pronunciation) parts.push(`/${row.pronunciation}/`);
@@ -401,7 +414,7 @@ function render(
401
414
  const bodies = Array.isArray(sense?.bodies) ? sense.bodies : [];
402
415
  const by =
403
416
  bodies.length && !sense.omitBody ? `(_${bodies.map(label).join(", ")}_) ` : "";
404
- return `${by}${sentence(sense?.text)}`;
417
+ return `${by}${markLinks(sentence(sense?.text))}`;
405
418
  };
406
419
  if (senses.length === 1) parts.push(senseText(senses[0]));
407
420
  else senses.forEach((sense, i) => parts.push(`**${i + 1}** ${senseText(sense)}`));
@@ -38,12 +38,6 @@
38
38
  * local pack's output — the resolution logic needs no knowledge of where an
39
39
  * item came from.
40
40
  *
41
- * `assetArchive: true` is the sibling flag, for a dependency whose bytes are
42
- * wanted and whose items are not: an asset-replacement source such as
43
- * `thalornaaltart` ships no Item packs at all. It unpacks the same release
44
- * archive without extracting anything from it, so a relationship declaring
45
- * both flags on one entry is fetched once rather than twice.
46
- *
47
41
  * **The network is never touched by a compile.** Fetching is its own command
48
42
  * (`package-build deps fetch`), and a compile whose cache is cold fails saying
49
43
  * so. A build that silently downloads is not reproducible, fails strangely
@@ -78,22 +72,17 @@ import {
78
72
  const STAMP = ".complete";
79
73
 
80
74
  /**
81
- * Every declared relationship carrying a given flag, in declaration order.
82
- *
83
- * Shared by {@link itemCatalogRelationships} and
84
- * {@link assetArchiveRelationships}: both read the same shape out of the same
85
- * walk, filtered on a different key.
75
+ * Every declared relationship that opted into supplying an item catalogue.
86
76
  *
87
77
  * @param {object} config - The resolved build configuration.
88
- * @param {"itemCatalog"|"assetArchive"} flag - Which boolean to filter on.
89
78
  * @returns {Array<{id: string, manifest: string, kind: string, verified: string|undefined}>}
90
- * The opted-in relationships.
79
+ * The opted-in relationships, in declaration order.
91
80
  */
92
- function flaggedRelationships(config, flag) {
81
+ export function itemCatalogRelationships(config) {
93
82
  const out = [];
94
83
  for (const [kind, entries] of Object.entries(config.relationships ?? {})) {
95
84
  for (const rel of entries ?? []) {
96
- if (rel[flag]) {
85
+ if (rel.itemCatalog) {
97
86
  out.push({
98
87
  id: rel.id,
99
88
  manifest: rel.manifest,
@@ -106,29 +95,6 @@ function flaggedRelationships(config, flag) {
106
95
  return out;
107
96
  }
108
97
 
109
- /**
110
- * Every declared relationship that opted into supplying an item catalogue.
111
- *
112
- * @param {object} config - The resolved build configuration.
113
- * @returns {Array<{id: string, manifest: string, kind: string, verified: string|undefined}>}
114
- * The opted-in relationships, in declaration order.
115
- */
116
- export function itemCatalogRelationships(config) {
117
- return flaggedRelationships(config, "itemCatalog");
118
- }
119
-
120
- /**
121
- * Every declared relationship that opted into supplying its release archive
122
- * for asset bytes alone, with no item catalogue built from it.
123
- *
124
- * @param {object} config - The resolved build configuration.
125
- * @returns {Array<{id: string, manifest: string, kind: string, verified: string|undefined}>}
126
- * The opted-in relationships, in declaration order.
127
- */
128
- export function assetArchiveRelationships(config) {
129
- return flaggedRelationships(config, "assetArchive");
130
- }
131
-
132
98
  /**
133
99
  * The cache directory for one dependency at one version.
134
100
  *
@@ -401,21 +367,14 @@ export function pinnedManifestUrl(url, verified) {
401
367
  }
402
368
 
403
369
  /**
404
- * Resolve a dependency's manifest, pinned to `compatibility.verified` the way
405
- * both fetch paths require.
406
- *
407
- * Shared by {@link fetchCatalog} and {@link fetchAssetArchive}: both read the
408
- * same manifest before deciding what to unpack from it.
370
+ * Resolve a dependency's manifest, pinned to `compatibility.verified`.
409
371
  *
410
372
  * @param {{id: string, manifest: string, verified?: string}} rel - The
411
373
  * declared relationship.
412
- * @param {string} subject - What floats when no version is verified —
413
- * "catalogue" or "archive" — named in the warning a reader would otherwise
414
- * get no word for.
415
374
  * @returns {Promise<{manifest: object, version: string}>} The manifest and
416
375
  * its resolved version.
417
376
  */
418
- async function resolveManifest(rel, subject) {
377
+ async function resolveManifest(rel) {
419
378
  const { url, pinned } = pinnedManifestUrl(rel.manifest, rel.verified);
420
379
  const manifest = await fetchManifest(url);
421
380
  const version = manifest.version;
@@ -432,7 +391,7 @@ async function resolveManifest(rel, subject) {
432
391
  }
433
392
  if (!rel.verified) {
434
393
  log.warn(
435
- `${rel.id}: no \`compatibility.verified\`, so its ${subject} floats ` +
394
+ `${rel.id}: no \`compatibility.verified\`, so its catalogue floats ` +
436
395
  `with whatever ${url} currently serves`,
437
396
  );
438
397
  }
@@ -449,7 +408,7 @@ async function resolveManifest(rel, subject) {
449
408
  * @returns {Promise<string>} The dependency's cache directory.
450
409
  */
451
410
  export async function fetchCatalog(config, rel) {
452
- const { manifest, version } = await resolveManifest(rel, "catalogue");
411
+ const { manifest, version } = await resolveManifest(rel);
453
412
  const dir = catalogDir(config, rel.id, version);
454
413
  if (isComplete(dir)) {
455
414
  log.info(`${rel.id}@${version}: already cached`);
@@ -475,61 +434,6 @@ export async function fetchCatalog(config, rel) {
475
434
  return dir;
476
435
  }
477
436
 
478
- /**
479
- * Whether an asset-archive-only cache is present and complete.
480
- *
481
- * No item catalogue is built on this path, so completeness never asks for
482
- * `item-packs.json` — an asset-only dependency, such as `thalornaaltart`,
483
- * ships none.
484
- *
485
- * @param {string} dir - The dependency's cache directory.
486
- * @returns {boolean} True when the archive was fetched to completion.
487
- */
488
- const isArchiveComplete = (dir) => fs.existsSync(path.join(dir, STAMP));
489
-
490
- /**
491
- * Fetch one dependency's release archive for its asset bytes alone, building
492
- * no item catalogue from it.
493
- *
494
- * Idempotent: a complete cache for the resolved version is left alone. Where a
495
- * relationship also declares `itemCatalog: true`, {@link fetchCatalog} already
496
- * unpacked this same archive into this same cache directory, and
497
- * {@link fetchAllCatalogs} does not call this function for it — the two never
498
- * download the same bytes twice.
499
- *
500
- * @param {object} config - The resolved build configuration.
501
- * @param {{id: string, manifest: string, verified?: string}} rel - The
502
- * declared relationship.
503
- * @returns {Promise<string>} The dependency's cache directory.
504
- */
505
- export async function fetchAssetArchive(config, rel) {
506
- const { manifest, version } = await resolveManifest(rel, "archive");
507
- const dir = catalogDir(config, rel.id, version);
508
- if (isArchiveComplete(dir)) {
509
- log.info(`${rel.id}@${version}: already cached`);
510
- return dir;
511
- }
512
-
513
- const download = manifest.download;
514
- if (!download) {
515
- throw new Error(`${rel.id}@${version}: its manifest declares no \`download\``);
516
- }
517
-
518
- // Rebuild from empty: a previous run may have died partway, and a stale
519
- // half-tree is worse than no tree.
520
- fs.rmSync(dir, { recursive: true, force: true });
521
- const raw = path.join(dir, "package");
522
- fs.mkdirSync(raw, { recursive: true });
523
-
524
- log.info(`${rel.id}@${version}: downloading ${download}`);
525
- await downloadAndUnzip(download, raw);
526
-
527
- cacheSchemaArtifact(raw, dir);
528
- // Last, so a fetch that died partway is never mistaken for a complete one.
529
- fs.writeFileSync(path.join(dir, STAMP), `${version}\n`);
530
- return dir;
531
- }
532
-
533
437
  /**
534
438
  * Locate a declared pack inside an unpacked archive.
535
439
  *
@@ -811,33 +715,18 @@ export async function fetchAllMetadata(config) {
811
715
  /**
812
716
  * Fetch every opted-in dependency. The `deps fetch` command.
813
717
  *
814
- * A relationship declaring both `itemCatalog: true` and `assetArchive: true`
815
- * is fetched once: {@link fetchCatalog} already unpacks the archive on its
816
- * way to extracting the item catalogue, so this skips the archive-only fetch
817
- * for any id {@link fetchCatalog} already handled.
818
718
  *
819
719
  * @param {object} config - The resolved build configuration.
820
720
  * @returns {Promise<number>} How many dependencies were fetched.
821
721
  */
822
722
  export async function fetchAllCatalogs(config) {
823
723
  const itemRels = itemCatalogRelationships(config);
824
- const archiveRels = assetArchiveRelationships(config);
825
- if (!itemRels.length && !archiveRels.length) {
826
- log.info(
827
- "No relationship declares `itemCatalog: true` or `assetArchive: true`; " +
828
- "nothing to fetch.",
829
- );
724
+ if (!itemRels.length) {
725
+ log.info("No relationship declares `itemCatalog: true`; nothing to fetch.");
830
726
  return 0;
831
727
  }
832
728
  for (const rel of itemRels) await fetchCatalog(config, rel);
833
- const alreadyFetched = new Set(itemRels.map((rel) => rel.id));
834
- let archiveFetched = 0;
835
- for (const rel of archiveRels) {
836
- if (alreadyFetched.has(rel.id)) continue;
837
- await fetchAssetArchive(config, rel);
838
- archiveFetched++;
839
- }
840
- return itemRels.length + archiveFetched;
729
+ return itemRels.length;
841
730
  }
842
731
 
843
732
  /**
@@ -253,7 +253,7 @@ export const LANGUAGE_COLUMN_TYPES = Object.freeze({
253
253
  joining: { position: "BIGINT", first: LIST, second: LIST },
254
254
  joins: { position: "BIGINT", letters: LIST },
255
255
  marks: { position: "BIGINT", letters: LIST },
256
- skeleton: { position: "BIGINT", second: LIST },
256
+ skeleton: { position: "BIGINT", second: LIST, third: LIST },
257
257
  frames: {
258
258
  position: "BIGINT",
259
259
  class: LIST,
@@ -911,14 +911,14 @@ export function markRows(records) {
911
911
  }
912
912
 
913
913
  /**
914
- * The `skeleton` rows: one per `data.sounds.skeleton` entry, `second` a list.
914
+ * The `skeleton` rows: one per `data.sounds.skeleton` entry, `second` and `third` lists.
915
915
  *
916
916
  * @param {Iterable<Record<string, any>>} records - Index records.
917
917
  * @returns {object[]}
918
918
  */
919
919
  export function skeletonRows(records) {
920
920
  return listRows(records, (data) => soundsOf(data).skeleton, SKELETON_KEYS, {
921
- lists: ["second"],
921
+ lists: ["second", "third"],
922
922
  });
923
923
  }
924
924
 
@@ -152,7 +152,7 @@ export const JOIN_MEETINGS = Object.freeze(["C+C", "V+C", "V+V"]);
152
152
  export const JOIN_RULES = Object.freeze(["merge", "insert", "drop", "double"]);
153
153
 
154
154
  /** What a written mark says of its letter. */
155
- export const MARK_KINDS = Object.freeze(["stress", "length"]);
155
+ export const MARK_KINDS = Object.freeze(["stress", "length", "apart"]);
156
156
 
157
157
  /** How many of a mark a word may carry: `one` at most, or `any` number. */
158
158
  export const MARK_LIMITS = Object.freeze(["one", "any"]);
@@ -831,6 +831,12 @@ export const SKELETON_FIELDS = Object.freeze([
831
831
  required: true,
832
832
  describe: "The second consonants of a root the row holds for; `any` for every other.",
833
833
  }),
834
+ Object.freeze({
835
+ name: "third",
836
+ ...SOUNDS_OR_ANY,
837
+ describe:
838
+ "The third consonants of a root the row holds for; every third consonant where absent.",
839
+ }),
834
840
  Object.freeze({
835
841
  name: "between",
836
842
  kind: "string",