@heroiclands/package-build 22.20.0 → 22.21.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.
- package/CHANGELOG-content-build.md +0 -8
- package/CHANGELOG.md +13 -0
- package/docs/api.md +10 -10
- package/docs/reference/format-details.md +51 -6
- package/docs/reference/note-types.md +28 -28
- package/engine/frontmatter-lint.mjs +15 -2
- package/engine/helpers.mjs +5 -0
- package/engine/infobox-render.mjs +6 -1
- package/engine/infobox.mjs +85 -0
- package/engine/note-addresses.mjs +17 -8
- package/engine/note-vocabulary.mjs +46 -7
- package/engine/reference-targets.mjs +13 -1
- package/engine/site-index.mjs +21 -2
- package/engine/standing-terms.mjs +19 -0
- package/engine/standings.mjs +455 -0
- package/engine/svg-theme.mjs +15 -4
- package/engine/wikilinks.mjs +3 -1
- package/package.json +1 -1
- package/sohl/actors.mjs +61 -5
- package/types/engine/note-vocabulary.d.mts +16 -2
- package/types/engine/standing-terms.d.mts +16 -0
- package/types/engine/standings.d.mts +129 -0
- package/types/engine/wikilinks.d.mts +2 -1
- package/types/sohl/actors.d.mts +19 -0
|
@@ -62,7 +62,6 @@
|
|
|
62
62
|
pass, which reads them exactly as it reads a local pack.
|
|
63
63
|
|
|
64
64
|
Three things it does deliberately:
|
|
65
|
-
|
|
66
65
|
- **Pins, rather than following `latest`.** A published
|
|
67
66
|
`releases/latest/download/…` URL is rewritten to the declared
|
|
68
67
|
`compatibility.verified` version, so a build names one particular dependency
|
|
@@ -177,7 +176,6 @@
|
|
|
177
176
|
|
|
178
177
|
`content-build lint` now checks frontmatter as well as addresses. Five classes,
|
|
179
178
|
each previously reported somewhere other than where it was made, or not at all:
|
|
180
|
-
|
|
181
179
|
- **Unknown or retired type**, told what replaced it.
|
|
182
180
|
- **Missing required property** — `dimensions` on a map, `subType` on a skill.
|
|
183
181
|
- **Wrong value shape** — `weight: heavy` where a number belongs.
|
|
@@ -215,7 +213,6 @@
|
|
|
215
213
|
configures (#69).
|
|
216
214
|
|
|
217
215
|
Two new commands:
|
|
218
|
-
|
|
219
216
|
- `content-build format [paths..] [--write]` — Prettier, with the shared
|
|
220
217
|
configuration.
|
|
221
218
|
- `content-build markdown [paths..] [--fix]` — markdownlint, with a narrow,
|
|
@@ -310,7 +307,6 @@ version` regenerates it in every repository here.
|
|
|
310
307
|
entries) exactly as the scripts it replaces emit them, on the same toolchain.
|
|
311
308
|
|
|
312
309
|
Also new:
|
|
313
|
-
|
|
314
310
|
- `paths.manifestOut` (default `build/manifests`) — where the manifest is
|
|
315
311
|
written. Deliberately not `paths.manifests`, which is the _inbound_ directory
|
|
316
312
|
of vendored foreign manifests that `links` consumes.
|
|
@@ -377,7 +373,6 @@ compile`. Publishing the _same tree_ as a website was a script each consumer
|
|
|
377
373
|
exotic, so neither is left to care.
|
|
378
374
|
|
|
379
375
|
Also new:
|
|
380
|
-
|
|
381
376
|
- `engine/site-build.mjs` exports each stage (`collectContentPages`,
|
|
382
377
|
`collectTreePages`, `siteGates`, `renderPages`, `writeSectionLandings`) for a
|
|
383
378
|
consumer that needs a step rather than the whole command.
|
|
@@ -422,7 +417,6 @@ compile`. Publishing the _same tree_ as a website was a script each consumer
|
|
|
422
417
|
stale by `--check` on every clean checkout — the two undoing each other forever.
|
|
423
418
|
Three things were making that happen, and all three are fixed at the source
|
|
424
419
|
rather than by adding a formatting pass:
|
|
425
|
-
|
|
426
420
|
- Table columns are padded to their widest cell, which is what Prettier's
|
|
427
421
|
alignment comes to for this content.
|
|
428
422
|
- The worked example's fence said `yaml`, but the block is a whole note —
|
|
@@ -632,7 +626,6 @@ compile`. Publishing the _same tree_ as a website was a script each consumer
|
|
|
632
626
|
hide the next drift instead of surfacing it.
|
|
633
627
|
|
|
634
628
|
Two deliberate differences from the code it replaces:
|
|
635
|
-
|
|
636
629
|
- **The `corpus` derivation is dropped.** `corpus` is not a registered item
|
|
637
630
|
type, so nothing can compile to one and the branch matched nothing — the same
|
|
638
631
|
class of dead code as the gate that caused the bug. It was in SoHL's copy and
|
|
@@ -654,7 +647,6 @@ compile`. Publishing the _same tree_ as a website was a script each consumer
|
|
|
654
647
|
`gh release create`, because cutting the Release is what published. Nothing
|
|
655
648
|
enforced the last step, so on 2026-08-21 `main` carried 0.5.1 while npm served
|
|
656
649
|
0.4.0: two versions merged and never published, with no check red.
|
|
657
|
-
|
|
658
650
|
- Every pull request now declares its bump as a `.changeset/*.md` file, and CI's
|
|
659
651
|
**Changeset declared** job fails one that does not. `npx changeset add --empty`
|
|
660
652
|
is how a change says it needs no release — explicitly, rather than by omission.
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# @heroiclands/package-build
|
|
2
2
|
|
|
3
|
+
## 22.21.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
**Standing in a body** — A being's `affiliations` is now a map keyed by the body, and each entry says what the being is there: `rank`, a level on that body's own ladder, and `office`, one of the posts it names. A page prints the standing closing on the body it belongs to — "War Chief, Hárár, of Vrystwald Tribes" — and an office links to the description the body already carries. In SoHL a being now arrives carrying one affiliation item per body it belongs to, standing included.
|
|
8
|
+
|
|
9
|
+
**Offices read the right way round** — An affiliation's offices each take their own row, with the post's name as the label and its description beside it, and each row can be linked to directly.
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- Icons drawn with outlines follow the colour scheme instead of keeping a black rim in dark mode.
|
|
14
|
+
- An explicitly black outline also moves to ink in light mode, matching the icon's filled shapes.
|
|
15
|
+
|
|
3
16
|
## 22.20.0
|
|
4
17
|
|
|
5
18
|
### Minor Changes
|
package/docs/api.md
CHANGED
|
@@ -1288,15 +1288,15 @@ The content tree, built into a book. The I/O half of the PDF surface: it reads t
|
|
|
1288
1288
|
|
|
1289
1289
|
Journals pack compiler — produces JSON pack files for the "journals" Foundry compendium from markdown notes in the `assets/content/` tree. The content root (`contentBase`) is walked recursively; any `.md` file whose frontmatter declares either `type: doc` or a **doc-carrying type** ({@link sohl.utils.packs.docEntryTypes} — every item type, plus `macro`) is compiled into one JournalEntry document. Each note's body is split on top-level H1 headings; the optional content before the first H1 becomes a lead page, and each subsequent H1 starts a new page named after its heading text. All page bodies are rendered to HTML.
|
|
1290
1290
|
|
|
1291
|
-
| Export | Signature | Returns
|
|
1292
|
-
| --------------------- | --------------------------------------------------------------------------- |
|
|
1293
|
-
| `splitPages` | `function splitPages(body, leadName` | —
|
|
1294
|
-
| `assertUniquePages` | `function assertUniquePages(rawPages, noteName)` | —
|
|
1295
|
-
| `assertUniqueAnchors` | `function assertUniqueAnchors(rawPages, noteName)` | —
|
|
1296
|
-
| `journalPageId` | `function journalPageId(entryId, page)` | {string} A 16-character Foundry id.
|
|
1297
|
-
| `buildPages` | `function buildPages(rawPages, entryId, noteName)` | {Array<{_id: string, name: string, type: string, title: {show: boolean, level: number}, text: {format: number, content: string}, _key: string}>} The page documents, in order. | Compile split pages into JournalEntryPage documents. |
|
|
1298
|
-
| `buildJournalEntry` | `buildJournalEntry({ id, name, markdown, leadName, folder, flags, stats })` | {object} The JournalEntry document, keyed for the pack.
|
|
1299
|
-
| `Journals` | `class Journals extends BasePackCompiler` | —
|
|
1291
|
+
| Export | Signature | Returns | Use it when |
|
|
1292
|
+
| --------------------- | --------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
|
|
1293
|
+
| `splitPages` | `function splitPages(body, leadName` | — | Splits a markdown body into pages by top-level H1 headings. |
|
|
1294
|
+
| `assertUniquePages` | `function assertUniquePages(rawPages, noteName)` | — | Two pages in one note that would derive the same id, which the LevelDB packer reports only as an opaque duplicate-key collision. |
|
|
1295
|
+
| `assertUniqueAnchors` | `function assertUniqueAnchors(rawPages, noteName)` | — | The anchor half of {@link assertUniquePages}, under its former name. |
|
|
1296
|
+
| `journalPageId` | `function journalPageId(entryId, page)` | {string} A 16-character Foundry id. | The id of one page within its entry. |
|
|
1297
|
+
| `buildPages` | `function buildPages(rawPages, entryId, noteName)` | {Array<{\_id: string, name: string, type: string, title: {show: boolean, level: number}, text: {format: number, content: string}, \_key: string}>} The page documents, in order. | Compile split pages into JournalEntryPage documents. |
|
|
1298
|
+
| `buildJournalEntry` | `buildJournalEntry({ id, name, markdown, leadName, folder, flags, stats })` | {object} The JournalEntry document, keyed for the pack. | Assemble one JournalEntry document from a note's converted markdown. |
|
|
1299
|
+
| `Journals` | `class Journals extends BasePackCompiler` | — | not called directly — imported and driven by `engine/generate.mjs` as the "journals" pack compiler |
|
|
1300
1300
|
|
|
1301
1301
|
### `engine.macros`
|
|
1302
1302
|
|
|
@@ -1361,7 +1361,7 @@ Bundles, as notes — the Foundry `Adventure` a `type: bundle` note compiles int
|
|
|
1361
1361
|
| `BUNDLE_TYPE` | `const BUNDLE_TYPE` | — | The note type a bundle is authored as. |
|
|
1362
1362
|
| `ADVENTURE_CONTENT_FIELD` | `const ADVENTURE_CONTENT_FIELD` | — | Foundry's `Adventure.contentFields`, keyed by the document class each holds. |
|
|
1363
1363
|
| `CONTENTS_FIELD` | `const CONTENTS_FIELD` | — | The `data:` key a bundle lists its members under. |
|
|
1364
|
-
| `stripAdventureKeys` | `function stripAdventureKeys(value)` | {
|
|
1364
|
+
| `stripAdventureKeys` | `function stripAdventureKeys(value)` | {\*} The same shape with every `_key` removed. | Strip the LevelDB keys from a document tree. |
|
|
1365
1365
|
| `bareAddress` | `function bareAddress(value)` | {string\|null} The bare address, or `null` for a blank entry. | An authored address with any wikilink brackets and label stripped. |
|
|
1366
1366
|
| `bundleContents` | `function bundleContents(fm)` | {string[]} The bare addresses. | The addresses a bundle note names, in the order it names them. |
|
|
1367
1367
|
| `missingMemberVerdict` | `function missingMemberVerdict(packSystem)` | {"omit"\|"fail"} What to do about a member the sources lack. | What a pass should do about a member its sources do not hold. |
|
|
@@ -2487,7 +2487,7 @@ Generates a living (or undead, or spirit) being.
|
|
|
2487
2487
|
| `lore` | `Address[]` | Other lore concerning this being, such as social standing or law |
|
|
2488
2488
|
| `culture` | `Address` | Primary culture, naming a lore note with `subType: culture` |
|
|
2489
2489
|
| `homes` | `Address[]` | Place the being calls home |
|
|
2490
|
-
| `affiliations` | `Address
|
|
2490
|
+
| `affiliations` | `Map<Address, Membership>` | Bodies the being belongs to, keyed by Address, each entry the standing it holds there |
|
|
2491
2491
|
| `socialTies` | `Map<Address, Tie>` | Defining support and opposition, directed from this being to others |
|
|
2492
2492
|
| `gender` | `male \| female \| other` | Gender of the character |
|
|
2493
2493
|
| `species` | `Address` | Being's species (lore) |
|
|
@@ -2517,15 +2517,60 @@ describe a being's body. Item gear weight remains a numeric `data.weight`.
|
|
|
2517
2517
|
`data.culture` names the culture in which a character belongs. It is one
|
|
2518
2518
|
Address to a `type: lore`, `subType: culture` note. A named character with a
|
|
2519
2519
|
known culture states it here; generic beings and templates can omit it.
|
|
2520
|
-
`data.lore` holds
|
|
2521
|
-
|
|
2520
|
+
`data.lore` holds the law the being lives under, the customs it is subject to
|
|
2521
|
+
and the traditions it was raised in. Record the primary culture in
|
|
2522
|
+
`data.culture` only, and a standing in `data.affiliations`.
|
|
2522
2523
|
|
|
2523
2524
|
```yaml
|
|
2524
2525
|
data:
|
|
2525
2526
|
culture: lore-vedyariclt
|
|
2526
|
-
lore: [lore-
|
|
2527
|
+
lore: [lore-marchlaw]
|
|
2527
2528
|
```
|
|
2528
2529
|
|
|
2530
|
+
#### The bodies a being belongs to, and its standing in each
|
|
2531
|
+
|
|
2532
|
+
**Membership**: `{rank: number, office: string}`, both optional.
|
|
2533
|
+
|
|
2534
|
+
`data.affiliations` is a **map keyed by the body's Address**, and each entry is
|
|
2535
|
+
the standing the being holds in that body. The key is what makes the pair
|
|
2536
|
+
expressible: a being in two bodies holds a standing in each, and two bodies may
|
|
2537
|
+
call a rung the same word.
|
|
2538
|
+
|
|
2539
|
+
An entry carries two optional keys and nothing else:
|
|
2540
|
+
|
|
2541
|
+
- **`rank`** — a **number**, the level on that body's own `governance.ranks`
|
|
2542
|
+
ladder. The body declares `{level, title, description}` for every rung, so a
|
|
2543
|
+
number indexes into it and the body stays the single source for what its
|
|
2544
|
+
level 5 is called.
|
|
2545
|
+
- **`office`** — a **string**, matched against the keys of that body's
|
|
2546
|
+
`governance.offices` map.
|
|
2547
|
+
|
|
2548
|
+
An entry with neither is valid and is the ordinary shape: it says the being
|
|
2549
|
+
belongs to the body and nothing more.
|
|
2550
|
+
|
|
2551
|
+
```yaml
|
|
2552
|
+
data:
|
|
2553
|
+
affiliations:
|
|
2554
|
+
affiliation-vrystwldtrbs: { rank: 5, office: War Chief }
|
|
2555
|
+
affiliation-greenwardens: { rank: 1 }
|
|
2556
|
+
affiliation-silverguild: {}
|
|
2557
|
+
```
|
|
2558
|
+
|
|
2559
|
+
Both values are checked against the body the entry is keyed by. A `rank` that
|
|
2560
|
+
is not a level the body confers and an `office` that is not one of its keys are
|
|
2561
|
+
each an error naming the file, line and column — so a renamed rung or a renamed
|
|
2562
|
+
post fails on every being pointing at it. Naming one body twice, by its short
|
|
2563
|
+
and full Address, still names one body and is an error.
|
|
2564
|
+
|
|
2565
|
+
The infobox prints the standing closing on its body — `War Chief, Hárár, of
|
|
2566
|
+
Vrystwald Tribes` — and links an office to the description the body itself
|
|
2567
|
+
declares, so recording the post needs no note about the post.
|
|
2568
|
+
|
|
2569
|
+
For SoHL, a being embeds one affiliation item per entry: `rank` **is** that
|
|
2570
|
+
item's `system.level` and `office` its `system.office`. A standing is a fact
|
|
2571
|
+
about the setting rather than about one system, so the `sohl:` block states
|
|
2572
|
+
none of it.
|
|
2573
|
+
|
|
2529
2574
|
#### The people who matter to a being
|
|
2530
2575
|
|
|
2531
2576
|
`data.socialTies` records relationships that define the subject's dossier, not
|
|
@@ -2558,8 +2603,8 @@ an error.
|
|
|
2558
2603
|
Each tie is one-sided. The target need not name the subject back, even when
|
|
2559
2604
|
`patron` and `dependent` would describe opposite ends of the same support.
|
|
2560
2605
|
Kinship, household membership, organizational membership, rank, and office are
|
|
2561
|
-
distinct facts. Their relevant effects may appear as ties, while
|
|
2562
|
-
belong in
|
|
2606
|
+
distinct facts. Their relevant effects may appear as ties, while a membership
|
|
2607
|
+
and the standing held in it belong in `data.affiliations`. A tie has no
|
|
2563
2608
|
mechanical modifier.
|
|
2564
2609
|
|
|
2565
2610
|
#### When a being was born, when it died, and how old it is
|
|
@@ -30,34 +30,34 @@ These fields are accepted by every note type. A field's value can still be irrel
|
|
|
30
30
|
|
|
31
31
|
**Subtypes:** `npc`, `character`, `creature`.
|
|
32
32
|
|
|
33
|
-
| Field | Shape
|
|
34
|
-
| -------------------------------- |
|
|
35
|
-
| `data.social` | map
|
|
36
|
-
| `data.tokenIcon` | an Address
|
|
37
|
-
| `data.templatePriority` | number
|
|
38
|
-
| `data.archetypes` | list
|
|
39
|
-
| `data.occupation` | string
|
|
40
|
-
| `data.stations` | list of Addresses
|
|
41
|
-
| `data.lore` | list of Addresses
|
|
42
|
-
| `data.culture` | an Address
|
|
43
|
-
| `data.homes` | list of Addresses
|
|
44
|
-
| `data.affiliations` | list of Addresses
|
|
45
|
-
| `data.socialTies` | a map keyed by Address
|
|
46
|
-
| `data.gender` | string
|
|
47
|
-
| `data.species` | an Address
|
|
48
|
-
| `data.born` | string
|
|
49
|
-
| `data.calendar` | an Address
|
|
50
|
-
| `data.died` | string
|
|
51
|
-
| `data.age` | string
|
|
52
|
-
| `data.ageYears` | number
|
|
53
|
-
| `data.height` | metres or feet and inches
|
|
54
|
-
| `data.weight` | kilograms or pounds
|
|
55
|
-
| `data.frame` | string
|
|
56
|
-
| `data.appearance.eye_color` | string
|
|
57
|
-
| `data.appearance.hair_color` | string
|
|
58
|
-
| `data.appearance.skin_color` | string
|
|
59
|
-
| `data.appearance.complexion` | string
|
|
60
|
-
| `data.appearance.extra_features` | list
|
|
33
|
+
| Field | Shape | Meaning |
|
|
34
|
+
| -------------------------------- | ------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
35
|
+
| `data.social` | map | The being's social profile. |
|
|
36
|
+
| `data.tokenIcon` | an Address | What a token on the canvas wears — an `icon` address; unset, it follows `icon`. |
|
|
37
|
+
| `data.templatePriority` | number | Template priority; unset means the note is not a template. |
|
|
38
|
+
| `data.archetypes` | list | Archetypal behaviours the being fits. |
|
|
39
|
+
| `data.occupation` | string | What the being does for a living. |
|
|
40
|
+
| `data.stations` | list of Addresses | Stations the being holds. |
|
|
41
|
+
| `data.lore` | list of Addresses | Lore concerning this being — the law it lives under, the customs it is subject to, the traditions it was raised in. |
|
|
42
|
+
| `data.culture` | an Address | The being's primary culture, as a culture lore note. |
|
|
43
|
+
| `data.homes` | list of Addresses | Places the being calls home. |
|
|
44
|
+
| `data.affiliations` | a standing map keyed by Address, or a list of Addresses | Bodies the being belongs to, keyed by Address, each entry holding the standing it holds there — `rank`, a level on that body's own ladder, and `office`, a post that body names. |
|
|
45
|
+
| `data.socialTies` | a map keyed by Address | Defining ties directed from this being to others: `patron` (Supports the subject from greater power, wealth, or status.); `friend` (Supports the subject out of goodwill.); `dependent` (Relies on the subject's support or protection.); `acquaintance` (Knows the subject without a strong disposition.); `rival` (Opposes the subject without implacable hostility.); `nemesis` (Opposes the subject personally and implacably.) |
|
|
46
|
+
| `data.gender` | string | `male`, `female` or `other`. |
|
|
47
|
+
| `data.species` | an Address | The being's species, as a lore note. |
|
|
48
|
+
| `data.born` | string | When the being was born — a date, or `unknown` where the birth is unrecorded. Absent, the being was never born. |
|
|
49
|
+
| `data.calendar` | an Address | Calendar used to display this being's dates. |
|
|
50
|
+
| `data.died` | string | When the being died — a date, or `unknown` where the death is unrecorded. Absent, the being is alive. |
|
|
51
|
+
| `data.age` | string | Age in years, stated only to override what `born` says — `34`, or `~34` for an estimate. Unstated beside a dated `born` it is computed; unstated beside an unknown or absent `born` the age is unknown. |
|
|
52
|
+
| `data.ageYears` | number | Written by the compiler beside an `age` estimate — the `~` stripped, the magnitude alone. A note never authors this. |
|
|
53
|
+
| `data.height` | metres or feet and inches | Height in metres or feet and inches. |
|
|
54
|
+
| `data.weight` | kilograms or pounds | Body weight in kilograms or pounds. |
|
|
55
|
+
| `data.frame` | string | Relative frame — `scant`, `light`, `medium`, `large` or `massive`. |
|
|
56
|
+
| `data.appearance.eye_color` | string | Eye colour. |
|
|
57
|
+
| `data.appearance.hair_color` | string | Hair colour. |
|
|
58
|
+
| `data.appearance.skin_color` | string | Skin colour. |
|
|
59
|
+
| `data.appearance.complexion` | string | Complexion. |
|
|
60
|
+
| `data.appearance.extra_features` | list | Anything else a stranger would notice. |
|
|
61
61
|
|
|
62
62
|
**System mappings**
|
|
63
63
|
|
|
@@ -357,6 +357,13 @@ export function matchesKind(value, kind, context = {}) {
|
|
|
357
357
|
// this answers is whether the value has one of the two shapes the
|
|
358
358
|
// field admits. A list has neither.
|
|
359
359
|
return matchesKind(value, "string") || matchesKind(value, "map");
|
|
360
|
+
case "list-or-map":
|
|
361
|
+
// A list, or a map. The two carry different facts rather than
|
|
362
|
+
// being two spellings of one — a map keyed by Address says what is
|
|
363
|
+
// true *of* each key, which a list has nowhere to put — so the
|
|
364
|
+
// field's own check is what reads the entries, and all this
|
|
365
|
+
// answers is whether the value has one of the two shapes.
|
|
366
|
+
return Array.isArray(value) || matchesKind(value, "map");
|
|
360
367
|
default:
|
|
361
368
|
return true;
|
|
362
369
|
}
|
|
@@ -539,7 +546,13 @@ function checkDataContainer(note, { type, fields, packs, addressContext, index }
|
|
|
539
546
|
function checkDataReferences(note, field, value, segments, context, index) {
|
|
540
547
|
const checks = [];
|
|
541
548
|
if (field.kind === "address") checks.push({ value, path: [], kind: "address" });
|
|
542
|
-
|
|
549
|
+
// A `list-or-map` field declares both halves, and each describes one shape:
|
|
550
|
+
// `entryKind` the list's entries, `keyKind` the map's keys. Reading both of
|
|
551
|
+
// one value would check a map's standings as if they were Addresses and a
|
|
552
|
+
// list's indices as if they were its keys.
|
|
553
|
+
const dual = field.kind === "list-or-map";
|
|
554
|
+
const written = Array.isArray(value) ? "list" : "map";
|
|
555
|
+
if (field.entryKind && !(dual && written === "map")) {
|
|
543
556
|
if (Array.isArray(value)) {
|
|
544
557
|
value.forEach((entry, i) =>
|
|
545
558
|
checks.push({ value: entry, path: [i], kind: field.entryKind }),
|
|
@@ -551,7 +564,7 @@ function checkDataReferences(note, field, value, segments, context, index) {
|
|
|
551
564
|
});
|
|
552
565
|
} else checks.push({ value, path: [], kind: field.entryKind });
|
|
553
566
|
}
|
|
554
|
-
if (field.keyKind) {
|
|
567
|
+
if (field.keyKind && !(dual && written === "list")) {
|
|
555
568
|
if (value instanceof AddressEntries) {
|
|
556
569
|
for (const entry of value.entries)
|
|
557
570
|
checks.push({
|
package/engine/helpers.mjs
CHANGED
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
28
|
import { buildReferenceTargets } from "./reference-targets.mjs";
|
|
29
|
+
import { standingsDigest } from "./standings.mjs";
|
|
29
30
|
|
|
30
31
|
import { decodeNoteAddresses } from "./note-addresses.mjs";
|
|
31
32
|
import { positionOfYamlPath } from "./diagnostics.mjs";
|
|
@@ -834,6 +835,10 @@ export function buildContentLinkIndex(
|
|
|
834
835
|
// can group by the family its target declares rather than only by
|
|
835
836
|
// where the target lives.
|
|
836
837
|
subType: fm.subType ?? null,
|
|
838
|
+
// The rungs and posts this body confers, carried for the same
|
|
839
|
+
// reason: a being holds a rank as a number, and the body is where
|
|
840
|
+
// what that number is called is declared.
|
|
841
|
+
standings: standingsDigest(fm) ?? null,
|
|
837
842
|
name: fm.name?.full ?? base,
|
|
838
843
|
// Whether the note is tagged `draft`. Read from the tag
|
|
839
844
|
// vocabulary that declares it, and used for one thing: a link
|
|
@@ -139,7 +139,12 @@ function sectionToHtml(section, link) {
|
|
|
139
139
|
if (section.layout === "rows") {
|
|
140
140
|
out.push('<dl class="infobox-rows">');
|
|
141
141
|
for (const row of section.rows ?? []) {
|
|
142
|
-
|
|
142
|
+
// A row that declares an anchor carries it on the `<dt>`, so the
|
|
143
|
+
// label is the destination a link to that row arrives at. A row
|
|
144
|
+
// that declares none emits none: an id nobody wrote would be
|
|
145
|
+
// invented once per renderer, and the two would not agree.
|
|
146
|
+
const id = row.id ? ` id="${escapeHtml(row.id)}"` : "";
|
|
147
|
+
out.push(`<dt${id}>${escapeHtml(row.label)}</dt><dd>${valueToHtml(row, link)}</dd>`);
|
|
143
148
|
}
|
|
144
149
|
out.push("</dl>");
|
|
145
150
|
} else if (section.layout === "grid") {
|
package/engine/infobox.mjs
CHANGED
|
@@ -121,6 +121,7 @@ import { subtypeRow } from "./document-subtypes.mjs";
|
|
|
121
121
|
import { authoredKey } from "./system-block.mjs";
|
|
122
122
|
import { formatDateInCalendar, formatNoteDate, parseNoteDate } from "./note-dates.mjs";
|
|
123
123
|
import { displayBeingHeight, displayBeingWeight } from "./being-measurements.mjs";
|
|
124
|
+
import { officeAnchor, officeRoster, readStandings, standingPhrase } from "./standings.mjs";
|
|
124
125
|
|
|
125
126
|
/**
|
|
126
127
|
* How a section arranges what it holds.
|
|
@@ -553,8 +554,92 @@ function rowValue(kind, raw, resolve, hint) {
|
|
|
553
554
|
return presentValue(raw);
|
|
554
555
|
}
|
|
555
556
|
|
|
557
|
+
/**
|
|
558
|
+
* A being's memberships, each with the standing it holds there.
|
|
559
|
+
*
|
|
560
|
+
* One row, one entry per body, and the standing reads inside the entry rather
|
|
561
|
+
* than beside it — "War Chief, of Vrystwald Tribes" rather than a rank link
|
|
562
|
+
* with nothing saying which body confers it.
|
|
563
|
+
*
|
|
564
|
+
* **The office carries the link where the body's page anchors it.** An office's
|
|
565
|
+
* description is already declared in the body's own `governance.offices`, and
|
|
566
|
+
* the row that prints it is anchored by the same derivation, so the string a
|
|
567
|
+
* being wrote reaches the description without anyone authoring a note about the
|
|
568
|
+
* post. In a medium with no page to reach — a compendium journal links by
|
|
569
|
+
* document UUID — the entry keeps the body's own document, so the reader still
|
|
570
|
+
* lands somewhere.
|
|
571
|
+
*
|
|
572
|
+
* @param {object} field - The declaration.
|
|
573
|
+
* @param {unknown} raw - The authored value, in either form.
|
|
574
|
+
* @param {(ref: unknown, hint?: object) => object|undefined} resolve - The
|
|
575
|
+
* medium's resolver.
|
|
576
|
+
* @param {string} label - The row's label.
|
|
577
|
+
* @returns {object[]} The row, or none where nothing is held.
|
|
578
|
+
*/
|
|
579
|
+
function standingRows(field, raw, resolve, label) {
|
|
580
|
+
const { entries } = readStandings(raw);
|
|
581
|
+
const value = [];
|
|
582
|
+
for (const { body, standing } of entries) {
|
|
583
|
+
if (!hasValue(body)) continue;
|
|
584
|
+
const link = linkValue(body, resolve, { type: field.ref });
|
|
585
|
+
const digest = resolve?.(body, { type: field.ref })?.standings;
|
|
586
|
+
const text = standingPhrase(standing, link.text, digest);
|
|
587
|
+
const anchor =
|
|
588
|
+
(
|
|
589
|
+
typeof standing?.office === "string" &&
|
|
590
|
+
digest?.offices?.[standing.office] !== undefined
|
|
591
|
+
) ?
|
|
592
|
+
officeAnchor(standing.office)
|
|
593
|
+
: "";
|
|
594
|
+
value.push({
|
|
595
|
+
...link,
|
|
596
|
+
text,
|
|
597
|
+
...(anchor && link.url ? { url: `${link.url}#${anchor}` } : {}),
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
return value.length ? [{ label, kind: "links", value }] : [];
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* A map of named posts, one row each, anchored by its name.
|
|
605
|
+
*
|
|
606
|
+
* The **name is the label and its description is the value**, which is the way
|
|
607
|
+
* round a reader needs: a field name belongs in the label column, and a whole
|
|
608
|
+
* sentence there leaves the post itself standing where a value goes.
|
|
609
|
+
*
|
|
610
|
+
* Each row carries the anchor its name derives, so the description is a
|
|
611
|
+
* destination rather than merely text on a page — which is what lets a being's
|
|
612
|
+
* `office` string link to the post it names without anyone authoring a note
|
|
613
|
+
* about the post.
|
|
614
|
+
*
|
|
615
|
+
* @param {unknown} raw - The authored map.
|
|
616
|
+
* @returns {object[]|null} One row per post, in the order the body declared
|
|
617
|
+
* them, or `null` where the value is not a map of them — a note writing
|
|
618
|
+
* something else keeps its row and states what it wrote.
|
|
619
|
+
*/
|
|
620
|
+
function rosterRows(raw) {
|
|
621
|
+
if (!isMapping(raw) || isAddressTuple(raw)) return null;
|
|
622
|
+
const rows = [];
|
|
623
|
+
for (const [post, description] of officeRoster(raw)) {
|
|
624
|
+
if (!hasValue(post)) continue;
|
|
625
|
+
const anchor = officeAnchor(post);
|
|
626
|
+
rows.push({
|
|
627
|
+
...(anchor ? { id: anchor } : {}),
|
|
628
|
+
label: String(post),
|
|
629
|
+
kind: "text",
|
|
630
|
+
value: hasValue(description) ? String(description) : String(post),
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
return rows;
|
|
634
|
+
}
|
|
635
|
+
|
|
556
636
|
/** Expand structured references using the existing linked-row shape. */
|
|
557
637
|
function structuredRows(field, raw, resolve, label, fm) {
|
|
638
|
+
if (field.standings) return standingRows(field, raw, resolve, label);
|
|
639
|
+
if (field.roster) {
|
|
640
|
+
const roster = rosterRows(raw);
|
|
641
|
+
if (roster) return roster;
|
|
642
|
+
}
|
|
558
643
|
if (Array.isArray(raw) && raw.some(isMapping)) {
|
|
559
644
|
const links = raw
|
|
560
645
|
.filter((entry) => isMapping(entry) && hasValue(entry.to))
|
|
@@ -62,8 +62,12 @@ export const STRUCTURED_ADDRESSES = Object.freeze([
|
|
|
62
62
|
export function addressPositions(fm, context = {}) {
|
|
63
63
|
const out = [];
|
|
64
64
|
for (const field of dataFields(fm.type) ?? []) {
|
|
65
|
+
// A field that admits both a list and a map declares both halves, and
|
|
66
|
+
// which one a note wrote is not knowable from the declaration — so the
|
|
67
|
+
// position says "either" and the conversion reads the value's shape.
|
|
65
68
|
const shape =
|
|
66
69
|
field.kind === "address" ? "value"
|
|
70
|
+
: field.kind === "list-or-map" ? "keys-or-list"
|
|
67
71
|
: field.keyKind === "address" ? "keys"
|
|
68
72
|
: field.entryKind === "address" ? field.kind
|
|
69
73
|
: undefined;
|
|
@@ -197,7 +201,7 @@ export function decodeNoteAddresses(fm, context) {
|
|
|
197
201
|
`\`${path.join(".")}\` ${description}: ${JSON.stringify(value)}`,
|
|
198
202
|
);
|
|
199
203
|
error.keyPath = path;
|
|
200
|
-
error.addressKey = position.shape === "keys";
|
|
204
|
+
error.addressKey = position.shape === "keys" || position.shape === "keys-or-list";
|
|
201
205
|
throw error;
|
|
202
206
|
};
|
|
203
207
|
const read = (value, path) => {
|
|
@@ -217,7 +221,16 @@ export function decodeNoteAddresses(fm, context) {
|
|
|
217
221
|
};
|
|
218
222
|
const convert = (value, path) => {
|
|
219
223
|
if (value == null || value === "") return value;
|
|
220
|
-
|
|
224
|
+
// Whichever of the two shapes the note wrote, read as that one. An
|
|
225
|
+
// empty list is the property editor's emptied map and converts to
|
|
226
|
+
// nothing either way.
|
|
227
|
+
const shape =
|
|
228
|
+
position.shape === "keys-or-list" ?
|
|
229
|
+
Array.isArray(value) && value.length ?
|
|
230
|
+
"list"
|
|
231
|
+
: "keys"
|
|
232
|
+
: position.shape;
|
|
233
|
+
if (shape === "keys") {
|
|
221
234
|
if (value instanceof AddressEntries) return value;
|
|
222
235
|
if (Array.isArray(value) && value.length === 0) return value;
|
|
223
236
|
if (typeof value !== "object" || Array.isArray(value))
|
|
@@ -230,15 +243,11 @@ export function decodeNoteAddresses(fm, context) {
|
|
|
230
243
|
})),
|
|
231
244
|
);
|
|
232
245
|
}
|
|
233
|
-
if (
|
|
246
|
+
if (shape === "list") {
|
|
234
247
|
if (!Array.isArray(value)) fail(value, path, "must be a list of Addresses");
|
|
235
248
|
return value.map((v, i) => read(v, [...path, i]));
|
|
236
249
|
}
|
|
237
|
-
if (
|
|
238
|
-
position.shape === "scalar-or-map" &&
|
|
239
|
-
typeof value === "object" &&
|
|
240
|
-
!isAddressTuple(value)
|
|
241
|
-
) {
|
|
250
|
+
if (shape === "scalar-or-map" && typeof value === "object" && !isAddressTuple(value)) {
|
|
242
251
|
if (Array.isArray(value)) {
|
|
243
252
|
if (!value.length) return value;
|
|
244
253
|
fail(value, path, "must be an Address or pack map");
|