@heroiclands/package-build 22.13.0 → 22.14.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.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @heroiclands/package-build
2
2
 
3
+ ## 22.14.0
4
+
5
+ ### Minor Changes
6
+
7
+ Content notes accept the documented top-level fields, with clear errors for other keys. Formatting keeps those fields in order and writes YAML collections as short lines or full blocks.
8
+
9
+ ## 22.13.1
10
+
11
+ ### Patch Changes
12
+
13
+ Content authors can keep pinned document identities and compendium placement alongside shared note facts.
14
+
3
15
  ## 22.13.0
4
16
 
5
17
  ### Minor Changes
package/docs/api.md CHANGED
@@ -119,28 +119,29 @@ Deterministic document ids, derived by hashing rather than stored, so compile pa
119
119
 
120
120
  The per-system frontmatter block: how one note feeds more than one game system through properties named after that system (`<system>.system`, `<system>.type`, `<system>.img`, `<system>.items` on actors). Resolves a field's value through the block, the shared top level, and a retiring position in that order, and merges an authored `<system>.system` onto a compiler-built one without disturbing what the builder already wrote.
121
121
 
122
- | Export | Signature | Returns | Use it when |
123
- | --------------------------- | --------------------------------------------- | -------------------------------------- | --------------------------------------------------------------------------------------------- |
124
- | `SYSTEM_DATA_KEY` | `const SYSTEM_DATA_KEY` | — | naming the key inside a system block whose contents are the system's own vocabulary |
125
- | `BLOCK_DOCUMENT_PROPERTIES` | `const BLOCK_DOCUMENT_PROPERTIES` | — | mapping a system-block key to the document property it becomes |
126
- | `BLOCK_DIRECTIVES` | `const BLOCK_DIRECTIVES` | — | recognizing a build directive (e.g. `pack`) that names no document property |
127
- | `SYSTEM_BLOCK_KEYS` | `const SYSTEM_BLOCK_KEYS` | — | enumerating every key any system block may carry |
128
- | `setPath` | `setPath(target, dotted, value)` | `object` — `target`, for chaining | writing a value at a dotted path in a document's `system` block |
129
- | `systemBlock` | `systemBlock(fm, block)` | `Record<string, unknown> \| undefined` | reading one system's whole block off a note, or nothing when it is absent or malformed |
130
- | `carriesSystemBlock` | `carriesSystemBlock(fm, block)` | `boolean` | deciding whether a pack that declares a system should compile a note at all |
131
- | `systemData` | `systemData(fm, block)` | `Record<string, unknown>` | reading a system block's `system` sub-block, `{}` when absent |
132
- | `blockField` | `blockField(fm, block, key, defaultValue)` | the value | reading a key from one system's block, falling back to the top level |
133
- | `sharedProperty` | `sharedProperty(fm, source, defaultValue)` | the value | reading a shared top-level property by a possibly-dotted path, blind to every system block |
134
- | `blockProperty` | `blockProperty(fm, block, key, defaultValue)` | the value | reading a property a system block may override, else the shared top-level one |
135
- | `legacyKeyOf` | `legacyKeyOf(field)` | `string \| undefined` | finding the key a field is authored at inside a system block |
136
- | `retiredTopLevelKey` | `retiredTopLevelKey(field)` | `string \| undefined` | finding the bare top-level key a `data:`-sourced field is being swept off |
137
- | `authoredKey` | `authoredKey(name)` | `string` | reading a shared source as the key an author writes, without the `data:` container holding it |
138
- | `resolveFieldValue` | `resolveFieldValue(field, fm, ...)` | `{value, from}` | resolving one declared field against a note, in the declared source order |
139
- | `systemDataPaths` | `systemDataPaths(data, prefix)` | `string[]` | listing every path a note authors under `<system>.system`, containers included |
140
- | `undeclaredPaths` | `undeclaredPaths(data, declared, prefix)` | `string[]` | finding authored paths a system's published schema does not declare |
141
- | `unknownBlockKeys` | `unknownBlockKeys(fm, block, ...)` | `string[]` | finding keys directly under a system block that neither this format nor the system recognizes |
142
- | `claimedPaths` | `claimedPaths(fields)` | `Set<string>` | listing the exact `system` paths a field declaration writes, for `mergeSystemData` |
143
- | `mergeSystemData` | `mergeSystemData(built, fm, ...)` | `object` — `built`, for chaining | merging a note's `<system>.system` verbatim onto a built `system` block |
122
+ | Export | Signature | Returns | Use it when |
123
+ | --------------------------- | ------------------------------------------------- | -------------------------------------- | --------------------------------------------------------------------------------------------- |
124
+ | `SYSTEM_DATA_KEY` | `const SYSTEM_DATA_KEY` | — | naming the key inside a system block whose contents are the system's own vocabulary |
125
+ | `BLOCK_DOCUMENT_PROPERTIES` | `const BLOCK_DOCUMENT_PROPERTIES` | — | mapping a system-block key to the document property it becomes |
126
+ | `BLOCK_DIRECTIVES` | `const BLOCK_DIRECTIVES` | — | recognizing a build directive (e.g. `pack`) that names no document property |
127
+ | `SYSTEM_BLOCK_KEYS` | `const SYSTEM_BLOCK_KEYS` | — | enumerating every key any system block may carry |
128
+ | `setPath` | `setPath(target, dotted, value)` | `object` — `target`, for chaining | writing a value at a dotted path in a document's `system` block |
129
+ | `systemBlock` | `systemBlock(fm, block)` | `Record<string, unknown> \| undefined` | reading one system's whole block off a note, or nothing when it is absent or malformed |
130
+ | `carriesSystemBlock` | `carriesSystemBlock(fm, block)` | `boolean` | deciding whether a pack that declares a system should compile a note at all |
131
+ | `systemData` | `systemData(fm, block)` | `Record<string, unknown>` | reading a system block's `system` sub-block, `{}` when absent |
132
+ | `blockField` | `blockField(fm, block, key, defaultValue)` | the value | reading a key from one system's block, falling back to the top level |
133
+ | `sharedProperty` | `sharedProperty(fm, source, defaultValue)` | the value | reading a shared top-level property by a possibly-dotted path, blind to every system block |
134
+ | `blockProperty` | `blockProperty(fm, block, key, defaultValue)` | the value | reading a property a system block may override, else the shared top-level one |
135
+ | `blockDataProperty` | `blockDataProperty(fm, block, key, defaultValue)` | the value | reading a system override, then shared `data`, then a top-level value |
136
+ | `legacyKeyOf` | `legacyKeyOf(field)` | `string \| undefined` | finding the key a field is authored at inside a system block |
137
+ | `retiredTopLevelKey` | `retiredTopLevelKey(field)` | `string \| undefined` | finding the bare top-level key a `data:`-sourced field is being swept off |
138
+ | `authoredKey` | `authoredKey(name)` | `string` | reading a shared source as the key an author writes, without the `data:` container holding it |
139
+ | `resolveFieldValue` | `resolveFieldValue(field, fm, ...)` | `{value, from}` | resolving one declared field against a note, in the declared source order |
140
+ | `systemDataPaths` | `systemDataPaths(data, prefix)` | `string[]` | listing every path a note authors under `<system>.system`, containers included |
141
+ | `undeclaredPaths` | `undeclaredPaths(data, declared, prefix)` | `string[]` | finding authored paths a system's published schema does not declare |
142
+ | `unknownBlockKeys` | `unknownBlockKeys(fm, block, ...)` | `string[]` | finding keys directly under a system block that neither this format nor the system recognizes |
143
+ | `claimedPaths` | `claimedPaths(fields)` | `Set<string>` | listing the exact `system` paths a field declaration writes, for `mergeSystemData` |
144
+ | `mergeSystemData` | `mergeSystemData(built, fm, ...)` | `object` — `built`, for chaining | merging a note's `<system>.system` verbatim onto a built `system` block |
144
145
 
145
146
  ### `engine.codeFences`
146
147
 
@@ -287,29 +287,57 @@ infoboxes:
287
287
 
288
288
  ### Frontmatter has three regions
289
289
 
290
- A note's frontmatter divides into three parts, and the difference matters because
291
- only one of them is open:
290
+ A HeroicLands note has opening YAML frontmatter with nonempty `shortcode` and
291
+ `type` values. Other Markdown files are ordinary Markdown. A note's frontmatter
292
+ has three regions:
292
293
 
293
- | region | describes | unknown keys |
294
- | -------------------- | ---------------------------------------------------------------------------------------------- | -------------------------- |
295
- | **top level** | the note as a published artefact — `id`, `type`, `subType`, `shortcode`, `description`, `tags` | **passed through to Hugo** |
296
- | **`data:`** | the subject itself — system-agnostic, specific to the note type | **an error** |
297
- | **`sohl:` / `hm3:`** | the subject as one system's documents | **an error** |
294
+ | Region | Describes | Unknown keys |
295
+ | -------------------- | ------------------------------------------------------- | ------------ |
296
+ | **Top level** | The note's identity, name, description, and publication | An error |
297
+ | **`data:`** | The subject itself, shared by systems | An error |
298
+ | **`hm3:` / `sohl:`** | The subject as one system's documents | An error |
298
299
 
299
- **Top level is deliberately open.** Every key is copied into the generated web
300
- page's front matter, so an unrecognised key is a Hugo or theme parameter this
301
- build has no standing to refuse. `description` is the everyday case: it is not a
302
- document field at all, it is the page's description.
300
+ The complete top-level allowlist, in its required order, is `shortcode`, `name`,
301
+ `type`, `subType`, `description`, `tags`, `data`, `hm3`, `sohl`. Omit fields that
302
+ do not apply. The first fields of a typical note look like this:
303
303
 
304
- **`data:` is deliberately closed.** It holds the type-specific facts about the
304
+ ```yaml
305
+ ---
306
+ shortcode: example
307
+ name: { full: Example }
308
+ type: lore
309
+ subType: concept
310
+ description: A short page description.
311
+ tags: [draft]
312
+ data: {}
313
+ sohl: {}
314
+ ---
315
+ ```
316
+
317
+ Any other top-level key is an error. To keep an author-only note beside the
318
+ content, comment out its YAML lines with `#`. Comments remain valid YAML text
319
+ and do not become data in the JSONL index, generated pages, or Foundry documents.
320
+ There is no custom site metadata. `description` supplies the page's short
321
+ description; `tags` carries publication and classification tags.
322
+
323
+ Arrays and objects use one-line YAML flow form when the complete line is under
324
+ 100 characters. At 100 characters or more, they use full block form. A multiline
325
+ flow collection is formatted into one of those forms; folded collections are
326
+ not used. `content-build format --write` applies this rule and the top-level
327
+ order without changing the Markdown body.
328
+
329
+ **`data:` is closed.** It holds the type-specific facts about the
305
330
  subject — a weapon's weight, an affliction's transmission, a being's species —
306
- and every note type declares which keys it may carry. A misspelled key there is a
307
- finding that names the key you meant; the same misspelling at top level would
308
- silently become a theme parameter, which is exactly why these cannot live
309
- together.
331
+ and every note type declares which keys it may carry. A misspelled key is a
332
+ finding in either region.
310
333
 
311
- **Tags are open, except the ones that classify.** `tags:` shares the top level's
312
- openness: a tag naming a theme or a region — `underworld`,
334
+ Every note type may put a pinned document id in `data.id`, a shared compendium
335
+ route in `data.pack`, a shared folder Address in `data.packFolder`, and source
336
+ details in `data.harnworld`. A system block's `pack` or `packFolder` overrides
337
+ the shared value for that system. A `being` may also describe its social profile
338
+ in `data.social`.
339
+
340
+ **Tags are open, except the ones that classify.** A tag naming a theme or a region — `underworld`,
313
341
  `byzaria`, `riverlands` — is the author's own and this build has no opinion about it.
314
342
  A tag that classifies the subject is different, because something queries it. A
315
343
  settlement tagged `village` appears in the list of villages and an untagged one
@@ -459,14 +487,11 @@ in-block spelling, and `hm3.system.gender` is the native destination.
459
487
  The native destination wins, followed by the in-block value and the shared
460
488
  source. Field declarations name both source and destination explicitly.
461
489
 
462
- **A field whose spelling means something else at the note level has no shared
463
- source.** The fallback assumes the two vocabularies agree about what a name
464
- means, and they do not always: a note's top-level `title` is the heading its page
465
- publishes under, while an `affiliation` item's `system.title` is the style of
466
- address an office carries. Where they diverge, the field declares what the
467
- top-level key means instead, and the top level stops being read for it — leaving
468
- `<system>.system.<field>` and the legacy in-block position, which describe the
469
- document rather than the note. Native species is independent of the shared lore Address in `data.species`.
490
+ **A field without a shared source reads its system declaration.** An
491
+ `affiliation` item's `system.title` is the style of address an office carries;
492
+ the note's heading reads `name.full`. The field reads
493
+ `<system>.system.<field>` and the in-block position, which describe the
494
+ document. Native species is independent of the shared lore Address in `data.species`.
470
495
  `subType` has the same meaning at both levels.
471
496
 
472
497
  **Some of a system's fields are runtime state, and a note may not write any of
@@ -569,8 +594,8 @@ type.
569
594
  | ----------------------- | ------------------------- | ---------------------------- |
570
595
  | `name.full` | `name` | `name` |
571
596
  | `data.icon` | `img` | `img` |
572
- | `id` | `_id` | `_id` |
573
- | `packFolder` | `folder` | `folder` |
597
+ | `data.id` | `_id` | `_id` |
598
+ | `data.packFolder` | `folder` | `folder` |
574
599
  | `shortcode` | `system.shortcode` | NA |
575
600
  | `data.templatePriority` | `system.templatePriority` | `flags.hm3.templatePriority` |
576
601
  | `actionDefs` | `system.actionDefs` | NA |
@@ -657,16 +682,20 @@ refused at all four slots: a sound is not art, and a value naming one — `icon:
657
682
  audio-thunder`, say — is an error naming the set the slot accepts, never a
658
683
  silent fall back to the note's default art.
659
684
 
660
- **Two of them are legal on _every_ note**, whatever its type. `data:` is a
685
+ **These properties are legal on _every_ note**, whatever its type. `data:` is a
661
686
  closed container and the per-type vocabularies below are the only lists there
662
687
  are, so a key legal everywhere is stated once here rather than repeated in
663
688
  twenty-five tables where the one that was mistyped would be the one nobody
664
689
  noticed:
665
690
 
666
- | shared `data` property | Values | Description |
667
- | ---------------------- | --------- | -------------------------------------------------------------------- |
668
- | `icon` | `Address` | The document's profile art — an `icon` address, resolved into `img`. |
669
- | `banner` | `Address` | The page's hero image — an `image` address. |
691
+ | shared `data` property | Values | Description |
692
+ | ---------------------- | ---------------------- | -------------------------------------------------------------------- |
693
+ | `id` | `string` | A pinned document identity. |
694
+ | `pack` | `string` | The shared compendium route. |
695
+ | `packFolder` | `Address` or pack map | The shared compendium folder Address. |
696
+ | `harnworld` | `Map<string, unknown>` | HârnWorld source details. |
697
+ | `icon` | `Address` | The document's profile art — an `icon` address, resolved into `img`. |
698
+ | `banner` | `Address` | The page's hero image — an `image` address. |
670
699
 
671
700
  `icon` is legal everywhere because most types compile into a document that
672
701
  carries one; where a type's passes emit none, the lint says so and the value is
@@ -847,10 +876,11 @@ on any type, and the frontmatter lint never reports it.
847
876
 
848
877
  #### The pack a note compiles into
849
878
 
850
- `pack` names which configured compendium receives the note's document.
879
+ `data.pack` names which configured compendium receives the note's document.
851
880
 
852
881
  ```yaml
853
- pack: items-hm3
882
+ data:
883
+ pack: items-hm3
854
884
  ```
855
885
 
856
886
  It is deliberately close to the retired `package:` and deliberately not the same
@@ -867,7 +897,8 @@ system: each block the note carries sends its own document where that block
867
897
  says, and a note carrying two blocks may place them in two packs.
868
898
 
869
899
  ```yaml
870
- pack: items-sohl
900
+ data:
901
+ pack: items-sohl
871
902
  hm3:
872
903
  pack: items-hm3
873
904
  ```
@@ -898,10 +929,11 @@ and in no compendium — a page of developer documentation, a page about the
898
929
  package itself:
899
930
 
900
931
  ```yaml
932
+ shortcode: architecture
901
933
  type: doc
902
934
  subType: concept
903
- shortcode: architecture
904
- pack: none
935
+ data:
936
+ pack: none
905
937
  ```
906
938
 
907
939
  A link to such a note resolves everywhere the note is addressable. On the web
@@ -1046,16 +1078,17 @@ none. It is the same principle that turned `folder: ONXsqZAIZr2qzxTb` into
1046
1078
  `packFolder: <address>` above: an opaque derived identity does not belong in
1047
1079
  authored content.
1048
1080
 
1049
- **An authored `id` still wins**, and that is how a document's identity is
1081
+ **An authored `data.id` wins**, and that is how a document's identity is
1050
1082
  **pinned**:
1051
1083
 
1052
1084
  ```yaml
1053
- type: miscgear
1054
1085
  shortcode: bowlcer
1055
- id: plaiQQm2T5zVK5mO # pinned: this document keeps this id
1086
+ type: miscgear
1087
+ data:
1088
+ id: plaiQQm2T5zVK5mO # pinned: this document keeps this id
1056
1089
  ```
1057
1090
 
1058
- A blank `id:` is not a pin — it is a deleted value with the key left behind, and
1091
+ A blank `data.id:` is not a pin — it is a deleted value with the key left behind, and
1059
1092
  is treated as absent.
1060
1093
 
1061
1094
  **A rename moves the id, and that is the trade.** The address carries the
@@ -1066,85 +1099,28 @@ identity across one pins its `id`, which is what the pin is for. One diagnostic
1066
1099
  narrows — `content-build` tells a **rename** from a **withdrawal** by matching
1067
1100
  document ids across releases, and for an unpinned note both sides now move
1068
1101
  together. It never reports a _wrong_ successor, and it stays exact for a pinned
1069
- note; for the rest, the note **declares** the rename (below).
1102
+ note; for the rest, the diff reports the vacated address.
1070
1103
 
1071
1104
  **A note with no address gets no id, and no document.** `type` and `shortcode`
1072
1105
  are what a note is addressed by, so a note missing either cannot be filed and
1073
1106
  the build refuses it by that name rather than by a missing `id:`.
1074
1107
 
1075
- #### Declaring a rename
1076
-
1077
- A note names the shortcode it used to be published under:
1078
-
1079
- ```yaml
1080
- type: weapongear
1081
- shortcode: Taburi
1082
- renamedFrom: Tabri
1083
- ```
1084
-
1085
- `(type, shortcode)` is a **published interface** — every satellite that declares
1086
- `itemCatalog: true` assembles its beings out of those addresses — so renaming a
1087
- shortcode breaks other repositories, and `content-build addresses diff` exists to
1088
- say so before a release does. To be useful it has to name where the address
1089
- _went_, and since ids are derived it can no longer work that out for an unpinned
1090
- note: both sides of the match move together, and the rename reads as a
1091
- withdrawal.
1092
-
1093
- **Pinning an `id` needs foresight; a declaration needs only hindsight.** A pin
1094
- has to be written _before_ the rename, by an author who does not yet know they
1095
- will make one. An author who has just renamed a shortcode knows exactly what the
1096
- old one was, and that is the only moment anyone does — so this is the key to
1097
- reach for, and `id:` stays what it is for: keeping a document's identity across
1098
- the rename, which is a different question from explaining it.
1099
-
1100
- **It takes one shortcode or a list**, because renames chain: the diff runs
1101
- against a released baseline, and a shortcode may have been renamed more than once
1102
- since. List every name the baseline might still know it by.
1103
-
1104
- ```yaml
1105
- shortcode: Taburin
1106
- renamedFrom:
1107
- - Tabri
1108
- - Taburi
1109
- ```
1108
+ #### Address changes
1110
1109
 
1111
- **It is transient.** Once every baseline a build is compared against post-dates
1112
- the rename, the declaration has nothing left to say and should be deleted. That
1113
- is what separates it from an `id:` pin, which is permanent.
1114
-
1115
- **One key per note, at the top level**, however many systems the note compiles
1116
- into: a shortcode is the note's rather than a system block's, so a note carrying
1117
- `sohl:` and `hm3:` blocks compiles two documents that share one shortcode, and
1118
- one declaration covers both.
1119
-
1120
- **What a declaration changes is what the diagnostic can say, not what it says
1121
- about you.** A finding reports which of the two joins it had, because they are
1122
- not equally checkable — a matched id is a fact a reader can verify in both
1123
- artefacts, while a declaration is the author's word:
1124
-
1125
- ```text
1126
- since sohl@0.8.2, weapongear:Tabri is no longer published; the note now
1127
- published as weapongear:Taburi declares it was renamed from Tabri. Every
1128
- package that resolves weapongear:Tabri breaks when it moves past sohl@0.8.2
1129
- ```
1130
-
1131
- A rename that is neither pinned nor declared is still reported as a
1132
- **withdrawal**. Nothing infers a successor from a similar-looking string: a wrong
1133
- one sends the reader to the wrong fix, which is worse than saying nothing.
1134
-
1135
- `content-lint` holds a declaration to the same rules a current address is held
1136
- to. An entry must be a well-formed shortcode, must not be the note's own, and
1137
- must name an address the package actually **vacated** — an entry naming an
1138
- address some note still publishes is refused, as are two notes claiming one
1139
- predecessor, since an address had one holder and so has one successor. A repeat
1140
- of the same entry is a warning; the declaration still works.
1110
+ `(type, shortcode)` is a published interface. Other packages can link to it or
1111
+ use it as an Item model, so changing either part requires those references to
1112
+ change too. `content-build addresses diff` compares the package with a released
1113
+ baseline and reports addresses whose published documents are absent. A pinned
1114
+ `data.id` preserves document identity across a shortcode change; it does not
1115
+ keep the old address available for links.
1141
1116
 
1142
1117
  #### The compendium folder
1143
1118
 
1144
1119
  A note says which folder of its pack it lands in:
1145
1120
 
1146
1121
  ```yaml
1147
- packFolder: poisonsandtoxins # a folder note's address
1122
+ data:
1123
+ packFolder: poisonsandtoxins # a folder note's address
1148
1124
  ```
1149
1125
 
1150
1126
  **`packFolder` is a folder note's address** — an ordinary address, resolved the
@@ -2481,6 +2457,7 @@ Generates a living (or undead, or spirit) being.
2481
2457
  | `templatePriority` | `number` | Template priority, _null_ = not a template |
2482
2458
  | `archetypes` | `Archetype[]` | What sort of character this is. **Always an array** — `[]` where none apply; `null` is an error. |
2483
2459
  | `occupation` | `string` | Name of the character's occupation |
2460
+ | `social` | `Map<string, unknown>` | The being's social profile |
2484
2461
  | `stations` | `Address[]` | Name of the stations the character belongs to |
2485
2462
  | `lore` | `Address[]` | Lore concerning this being — the people it is of, the standing it holds, the law it lives under |
2486
2463
  | `homes` | `Address[]` | Place the being calls home |
@@ -2743,9 +2720,9 @@ no Foundry document: it is a page and nothing else.
2743
2720
  It declares a `shortcode` — conventionally `root` — because that is what a
2744
2721
  link is written with: `[[homepage-root|Text]]` is an ordinary wikilink, and it
2745
2722
  resolves to `/<package>/`. The shortcode names the page in links; its address
2746
- is the package root. `title` defaults to `packageBuild.manifest.title`; a
2747
- `description` and a `banner` are read by the theme's hero; any other
2748
- top-level key is passed through to the page as a theme parameter.
2723
+ is the package root. The page heading reads `name.full` and defaults to
2724
+ `packageBuild.manifest.title`. `description` supplies the page's short
2725
+ description.
2749
2726
 
2750
2727
  The page is its body, published verbatim: no wikilink is resolved on it and
2751
2728
  no table expanded, so its links are markdown links, package-relative
@@ -2753,9 +2730,8 @@ no table expanded, so its links are markdown links, package-relative
2753
2730
  written here and is not generated anywhere — it is a `doc` note carrying a
2754
2731
  content table, linked from this page like any other.
2755
2732
 
2756
- Two top-level keys are refused, because neither decides anything on a page:
2757
- `id`, the Foundry document id a compendium UUID is built from, and `landing`,
2758
- a card block — the homepage is a page with a body, rendered as one.
2733
+ The homepage uses the same top-level allowlist as every addressed note. It has
2734
+ no Foundry document id; a pinned `data.id` has no role on this type.
2759
2735
 
2760
2736
  ### type: vehicle
2761
2737
 
@@ -2981,14 +2957,10 @@ mapping for a field no schema declares is the drift these tables exist to catch.
2981
2957
  likewise absent here: they are filled on an embedded membership, never from a
2982
2958
  catalogue note's `data:`.
2983
2959
 
2984
- **`system.title` is not the note's `title`.** The two are unrelated quantities
2985
- that share a spelling. A note's top-level `title` is _the title of the note_ —
2986
- the heading its page is published under; an affiliation's `system.title` is _the
2987
- style of address the office carries_, Ajaw or Warden, which a being holds by
2988
- virtue of its rank. So the top-level key is **not** a shared source for this
2989
- field, and a note that writes one is stating its own heading and nothing else
2990
- . Author the style of address on the membership — the `system.title` of the
2991
- entry in a being's `sohl.items` — or, on a catalogue note that genuinely carries
2960
+ **`system.title` is the style of address the office carries**, Ajaw or Warden,
2961
+ which a being holds by virtue of its rank. The note's page heading reads
2962
+ `name.full`. Author the style of address on the membership — the `system.title`
2963
+ of the entry in a being's `sohl.items` — or, on a catalogue note that carries
2992
2964
  one, at `sohl.system.title`.
2993
2965
 
2994
2966
  ### type: affliction
@@ -274,7 +274,8 @@ export function collectFolderNotes(notes, pkg) {
274
274
 
275
275
  const { parent, color } = folderFields(fm);
276
276
  const address = folderAddress(pkg, shortcode);
277
- const authoredId = fm.id == null ? "" : String(fm.id).trim();
277
+ const pinnedId = fm.data?.id ?? fm.id;
278
+ const authoredId = pinnedId == null ? "" : String(pinnedId).trim();
278
279
 
279
280
  folders.push({
280
281
  shortcode,
@@ -55,6 +55,7 @@
55
55
  */
56
56
 
57
57
  import { isAddressTuple } from "./address.mjs";
58
+ import { authoredNoteKeys, NOTE_TOP_LEVEL_KEY_SET } from "./note-frontmatter.mjs";
58
59
  import { AddressEntries } from "./address-values.mjs";
59
60
  import { authoredFields, readsLegacyKey } from "./field-spec.mjs";
60
61
  import {
@@ -1443,6 +1444,24 @@ export function lintNote(
1443
1444
  });
1444
1445
  }
1445
1446
 
1447
+ for (const key of authoredNoteKeys(raw())) {
1448
+ if (NOTE_TOP_LEVEL_KEY_SET.has(key)) continue;
1449
+ const position = positionInFrontmatter(raw(), key, undefined, { topLevel: true });
1450
+ if (
1451
+ findings.some(
1452
+ (finding) => finding.severity === "error" && finding.line === position.line,
1453
+ )
1454
+ ) {
1455
+ continue;
1456
+ }
1457
+ findings.push({
1458
+ file: note.file,
1459
+ ...position,
1460
+ severity: "error",
1461
+ message: `unknown top-level frontmatter key ${JSON.stringify(key)}; use shortcode, name, type, subType, description, tags, data, hm3, or sohl`,
1462
+ });
1463
+ }
1464
+
1446
1465
  // The type's charset, before anything that looks the type up. A
1447
1466
  // hyphenated type is unaddressable, and every lookup below would report it
1448
1467
  // as a type nobody declared — true, but not the reason, and it would send
@@ -348,7 +348,7 @@ export function parseValueDesc(raw) {
348
348
  /**
349
349
  * The compendium folder a note names.
350
350
  *
351
- * **There is one spelling.** `packFolder:` is a folder note's **address**
351
+ * `data.packFolder` is a folder note's **address**
352
352
  * (`folder-poisonsandtoxins`), resolved through the address index the whole
353
353
  * build shares. The `folder:` Foundry-id spelling this function once read
354
354
  * beside it, and the per-pack `*-folders.yaml` that id was resolved against,
@@ -372,7 +372,7 @@ export function parseValueDesc(raw) {
372
372
  * two spellings, and there is only one left to be.
373
373
  */
374
374
  export function folderField(fm) {
375
- const asAddress = sohlField(fm, "packFolder", null);
375
+ const asAddress = fm?.sohl?.packFolder ?? fm?.data?.packFolder ?? fm?.packFolder ?? null;
376
376
  if (asAddress != null && asAddress !== "") return { value: asAddress, isAddress: true };
377
377
  return { value: null, isAddress: true };
378
378
  }
@@ -340,8 +340,8 @@ function positionOfType(file) {
340
340
  * The one defaulted value on the page, and it defaults to the package's own
341
341
  * `packageBuild.manifest.title` — the name Foundry already shows for the
342
342
  * package — so a homepage that adds nothing to it need not restate it. An
343
- * authored `title` wins, because a front page is allowed to greet a reader
344
- * differently from a package browser.
343
+ * authored `name.full` wins, because a front page may greet a reader
344
+ * differently from a package browser. A top-level `title` is also readable.
345
345
  *
346
346
  * Falls back to `contentPackage` last, so a package that has no manifest of its
347
347
  * own still yields a titled page rather than a blank heading.
@@ -351,7 +351,7 @@ function positionOfType(file) {
351
351
  * @returns {string} The title.
352
352
  */
353
353
  export function homepageTitle(fm, config) {
354
- const authored = fm?.title;
354
+ const authored = fm?.name?.full ?? fm?.title;
355
355
  if (typeof authored === "string" && authored.trim()) return authored;
356
356
  const manifest = /** @type {Record<string, unknown>|undefined} */ (
357
357
  config?.packageBuild?.manifest
@@ -64,7 +64,12 @@ import { documentSubtype, subtypeRow } from "./document-subtypes.mjs";
64
64
  // The note-level `<system>:` block: `<system>.system` onto the document's
65
65
  // `system` verbatim, and `<system>.img` / `.effects` / `.flags` overriding
66
66
  // their shared top-level forms for this system alone.
67
- import { blockField, blockProperty, claimedPaths, mergeSystemData } from "./system-block.mjs";
67
+ import {
68
+ blockDataProperty,
69
+ blockProperty,
70
+ claimedPaths,
71
+ mergeSystemData,
72
+ } from "./system-block.mjs";
68
73
  // The other direction of the same declaration: a field the *document* writes in
69
74
  // play, which a note may not author and the builder does not emit.
70
75
  import { assertNoDerivedFields } from "./derived-fields.mjs";
@@ -379,7 +384,7 @@ export class SystemItemCompiler extends BasePackCompiler {
379
384
  // one spelling: `packFolder` names a folder note by its address, the
380
385
  // `folder:` id spelling having been retired with the per-pack YAML it
381
386
  // resolved against.
382
- const folder = this.folderResolver(blockField(fm, system, "packFolder", null), {
387
+ const folder = this.folderResolver(blockDataProperty(fm, system, "packFolder", null), {
383
388
  isAddress: true,
384
389
  });
385
390
 
@@ -207,7 +207,9 @@ export function placesFromRecords(records, { contentBase, base }) {
207
207
  folder: record.file?.folder ?? "",
208
208
  package: String(record.package ?? ""),
209
209
  local: true,
210
- hasPackFolder: Object.hasOwn(record, "packFolder"),
210
+ hasPackFolder:
211
+ Object.hasOwn(record.data ?? {}, "packFolder") ||
212
+ Object.hasOwn(record, "packFolder"),
211
213
  });
212
214
  } else if (record.type === "affiliation" && record.subType === "polity") {
213
215
  polities.push({
@@ -0,0 +1,100 @@
1
+ /*
2
+ * This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
3
+ * Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
4
+ *
5
+ * This work is licensed under the GNU General Public License v3.0 (GPLv3).
6
+ * You may copy, modify, and distribute it under the terms of that license.
7
+ *
8
+ * For full terms, see the LICENSE.md file in the project root or visit:
9
+ * https://www.gnu.org/licenses/gpl-3.0.html
10
+ *
11
+ * SPDX-License-Identifier: GPL-3.0-or-later
12
+ */
13
+
14
+ import YAML from "yaml";
15
+ import { NOTE_TOP_LEVEL_KEYS } from "./note-frontmatter.mjs";
16
+
17
+ const MAX_FLOW_LINE = 99;
18
+
19
+ /**
20
+ * Format one addressed note's YAML without changing its Markdown body.
21
+ * Invalid YAML remains the YAML linter's finding.
22
+ *
23
+ * @param {string} markdown - The complete Markdown source.
24
+ * @returns {string} The source with canonical frontmatter.
25
+ */
26
+ export function formatNoteFrontmatter(markdown) {
27
+ const match = markdown.match(/^(---\r?\n)([\s\S]*?)(\r?\n---)([\s\S]*)$/);
28
+ if (!match) return markdown;
29
+ const [, opening, source, closing, body] = match;
30
+ const document = YAML.parseDocument(source, { keepSourceTokens: true });
31
+ if (document.errors.length || !YAML.isMap(document.contents)) return markdown;
32
+ const root = document.contents;
33
+ const values = Object.fromEntries(
34
+ root.items.map((pair) => [String(pair.key?.value), pair.value]),
35
+ );
36
+ if (!nonempty(values.shortcode) || !nonempty(values.type)) return markdown;
37
+
38
+ const order = new Map(NOTE_TOP_LEVEL_KEYS.map((key, index) => [key, index]));
39
+ const rank = (key) => order.get(key) ?? NOTE_TOP_LEVEL_KEYS.length;
40
+ root.items.sort((left, right) => {
41
+ const a = String(left.key?.value);
42
+ const b = String(right.key?.value);
43
+ const difference = rank(a) - rank(b);
44
+ return difference;
45
+ });
46
+ for (const pair of root.items) {
47
+ const key = String(pair.key?.value);
48
+ formatCollection(pair.value, key.length + 2, 0);
49
+ }
50
+ const formatted = document.toString({ lineWidth: 0, flowCollectionPadding: false }).trimEnd();
51
+ return `${opening}${formatted}${closing}${body}`;
52
+ }
53
+
54
+ function nonempty(node) {
55
+ return YAML.isScalar(node) && typeof node.value === "string" && node.value.trim() !== "";
56
+ }
57
+
58
+ /** Format nested collections before testing the enclosing line. */
59
+ function formatCollection(node, prefix, depth) {
60
+ if (!YAML.isMap(node) && !YAML.isSeq(node)) return;
61
+ if (YAML.isMap(node)) {
62
+ for (const pair of node.items) {
63
+ formatCollection(
64
+ pair.value,
65
+ 2 * (depth + 1) + String(pair.key?.value).length + 2,
66
+ depth + 1,
67
+ );
68
+ }
69
+ } else {
70
+ for (const item of node.items) formatCollection(item, 2 * (depth + 1) + 2, depth + 1);
71
+ }
72
+ if (hasBlockScalar(node) || hasAlias(node)) {
73
+ node.flow = false;
74
+ return;
75
+ }
76
+ node.flow = true;
77
+ const candidate = new YAML.Document(node)
78
+ .toString({
79
+ lineWidth: 0,
80
+ flowCollectionPadding: false,
81
+ })
82
+ .trimEnd();
83
+ if (candidate.includes("\n") || Array.from(candidate).length + prefix > MAX_FLOW_LINE) {
84
+ node.flow = false;
85
+ }
86
+ }
87
+
88
+ function hasBlockScalar(node) {
89
+ if (YAML.isScalar(node)) return node.type === "BLOCK_LITERAL" || node.type === "BLOCK_FOLDED";
90
+ if (YAML.isMap(node)) return node.items.some((pair) => hasBlockScalar(pair.value));
91
+ if (YAML.isSeq(node)) return node.items.some(hasBlockScalar);
92
+ return false;
93
+ }
94
+
95
+ function hasAlias(node) {
96
+ if (YAML.isAlias(node)) return true;
97
+ if (YAML.isMap(node)) return node.items.some((pair) => hasAlias(pair.value));
98
+ if (YAML.isSeq(node)) return node.items.some(hasAlias);
99
+ return false;
100
+ }
@@ -0,0 +1,33 @@
1
+ /*
2
+ * This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
3
+ * Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
4
+ *
5
+ * SPDX-License-Identifier: GPL-3.0-or-later
6
+ */
7
+
8
+ import YAML from "yaml";
9
+
10
+ /** The complete ordered top-level vocabulary for addressed content notes. */
11
+ export const NOTE_TOP_LEVEL_KEYS = Object.freeze([
12
+ "shortcode",
13
+ "name",
14
+ "type",
15
+ "subType",
16
+ "description",
17
+ "tags",
18
+ "data",
19
+ "hm3",
20
+ "sohl",
21
+ ]);
22
+
23
+ /** Membership test for the top-level vocabulary. */
24
+ export const NOTE_TOP_LEVEL_KEY_SET = new Set(NOTE_TOP_LEVEL_KEYS);
25
+
26
+ /** Return authored keys from an opening YAML frontmatter map. */
27
+ export function authoredNoteKeys(markdown) {
28
+ const source = markdown.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/)?.[1];
29
+ if (source === undefined) return [];
30
+ const document = YAML.parseDocument(source);
31
+ if (document.errors.length || !YAML.isMap(document.contents)) return [];
32
+ return document.contents.items.map((pair) => String(pair.key?.value));
33
+ }
@@ -105,7 +105,7 @@ function text(value) {
105
105
  */
106
106
  export function noteDocId(fm, { pkg, maps = KNOWN_DOCUMENT_SUBTYPE_MAPS } = {}) {
107
107
  if (!fm || typeof fm !== "object") return undefined;
108
- const pinned = text(fm.id);
108
+ const pinned = text(fm.data?.id) ?? text(fm.id);
109
109
  if (pinned) return pinned;
110
110
  const type = text(fm.type);
111
111
  const shortcode = text(fm.shortcode);
@@ -277,6 +277,29 @@ const TOKEN_ICON = Object.freeze({
277
277
  * @type {readonly DataFieldSpec[]}
278
278
  */
279
279
  export const SHARED_DATA_FIELDS = Object.freeze([
280
+ Object.freeze({
281
+ name: "id",
282
+ ...TEXT,
283
+ describe: "A fixed document identity when the derived identity is unsuitable.",
284
+ }),
285
+ Object.freeze({
286
+ name: "pack",
287
+ ...TEXT,
288
+ describe: "The shared compendium route, with a system block overriding it.",
289
+ }),
290
+ Object.freeze({
291
+ name: "packFolder",
292
+ ...LINK_BY_PACK,
293
+ ref: "folder",
294
+ accepts: ["folder"],
295
+ describe: "The shared compendium folder Address, with a system block overriding it.",
296
+ }),
297
+ Object.freeze({
298
+ name: "harnworld",
299
+ shape: "map",
300
+ kind: "map",
301
+ describe: "HârnWorld source details shared by every system.",
302
+ }),
280
303
  Object.freeze({
281
304
  name: "icon",
282
305
  ...LINK,
@@ -605,6 +628,7 @@ export const NOTE_VOCABULARY = Object.freeze({
605
628
  // package's declared present compute — see `engine/being-age.mjs`.
606
629
  check: checkBeingAge,
607
630
  data: Object.freeze([
631
+ { name: "social", shape: "map", kind: "map", describe: "The being's social profile." },
608
632
  TOKEN_ICON,
609
633
  TEMPLATE_PRIORITY,
610
634
  { name: "archetypes", ...LIST, describe: "Archetypal behaviours the being fits." },
@@ -82,7 +82,7 @@
82
82
 
83
83
  import { NO_PACK, packForType } from "./ids.mjs";
84
84
  import { loadPackConfig } from "./pack-config.mjs";
85
- import { blockProperty, systemBlock } from "./system-block.mjs";
85
+ import { blockDataProperty, systemBlock } from "./system-block.mjs";
86
86
 
87
87
  /**
88
88
  * A note that cannot be routed to a pack. Thrown rather than returned so no
@@ -131,7 +131,9 @@ export { NO_PACK };
131
131
  */
132
132
  export function declaresNoPack(fm, system) {
133
133
  const declared =
134
- system === undefined ? fm?.[PACK_FIELD] : blockProperty(fm, system, PACK_FIELD);
134
+ system === undefined ?
135
+ (fm?.data?.[PACK_FIELD] ?? fm?.[PACK_FIELD])
136
+ : blockDataProperty(fm, system, PACK_FIELD);
135
137
  return declared === NO_PACK;
136
138
  }
137
139
 
@@ -270,7 +272,9 @@ export function createPackRouter(packs) {
270
272
  const inBlock = system === undefined ? undefined : systemBlock(fm, system)?.[PACK_FIELD];
271
273
  const authoredInBlock = inBlock != null && inBlock !== "";
272
274
  let declared =
273
- system === undefined ? fm?.[PACK_FIELD] : blockProperty(fm, system, PACK_FIELD);
275
+ system === undefined ?
276
+ (fm?.data?.[PACK_FIELD] ?? fm?.[PACK_FIELD])
277
+ : blockDataProperty(fm, system, PACK_FIELD);
274
278
  // The declaration names where the note's *own* document goes. A pass
275
279
  // writing a document derived from it — an item's prose becoming a
276
280
  // JournalEntry — is not what the author was addressing.
@@ -33,6 +33,7 @@
33
33
 
34
34
  import fs from "node:fs";
35
35
  import path from "node:path";
36
+ import { formatNoteFrontmatter } from "./note-format.mjs";
36
37
 
37
38
  import {
38
39
  MARKDOWNLINT_CONFIG,
@@ -202,7 +203,7 @@ export async function checkFormatting(root, opts = {}) {
202
203
  let formatted = source;
203
204
  let converged = false;
204
205
  for (let pass = 0; pass < MAX_FORMAT_PASSES; pass += 1) {
205
- const next = await prettier.format(formatted, options);
206
+ const next = formatNoteFrontmatter(await prettier.format(formatted, options));
206
207
  if (next === formatted) {
207
208
  converged = true;
208
209
  break;
@@ -236,7 +237,7 @@ export async function checkFormatting(root, opts = {}) {
236
237
  continue;
237
238
  }
238
239
 
239
- if (!(await prettier.check(source, options))) {
240
+ if (formatNoteFrontmatter(await prettier.format(source, options)) !== source) {
240
241
  findings.push({
241
242
  file,
242
243
  severity: "error",
@@ -361,6 +361,25 @@ export function blockProperty(fm, block, key, defaultValue = undefined) {
361
361
  return shared === undefined || shared === null ? defaultValue : shared;
362
362
  }
363
363
 
364
+ /**
365
+ * A system override followed by a shared `data:` value. The top-level value
366
+ * remains readable while content repositories move their authored fields.
367
+ *
368
+ * @param {object} fm - The note's frontmatter.
369
+ * @param {string} block - The system block key.
370
+ * @param {string} key - The shared property.
371
+ * @param {any} [defaultValue] - Returned when no position declares it.
372
+ * @returns {any} The selected value.
373
+ */
374
+ export function blockDataProperty(fm, block, key, defaultValue = undefined) {
375
+ const declared = systemBlock(fm, block)?.[key];
376
+ if (declared !== undefined && declared !== null) return declared;
377
+ const shared = isMapping(fm?.data) ? fm.data[key] : undefined;
378
+ if (shared !== undefined && shared !== null) return shared;
379
+ const legacy = isMapping(fm) ? fm[key] : undefined;
380
+ return legacy === undefined || legacy === null ? defaultValue : legacy;
381
+ }
382
+
364
383
  /**
365
384
  * The key a field is authored at **inside** a system block — step 2.
366
385
  *
package/hm3/actors.mjs CHANGED
@@ -63,7 +63,12 @@ import {
63
63
  // The note-level `hm3:` block: `hm3.system` onto the document's `system`
64
64
  // verbatim, and `hm3.img` / `hm3.items` / `hm3.effects` / `hm3.flags`
65
65
  // overriding their shared top-level forms for this system alone.
66
- import { blockField, blockProperty, mergeSystemData } from "../engine/system-block.mjs";
66
+ import {
67
+ blockDataProperty,
68
+ blockField,
69
+ blockProperty,
70
+ mergeSystemData,
71
+ } from "../engine/system-block.mjs";
67
72
 
68
73
  /**
69
74
  * Native HM3 facts shared by its actor subtypes.
@@ -324,7 +329,7 @@ export class Hm3Actors extends SystemActorCompiler {
324
329
  // by its address. This pass once read only the Foundry id, so an HM3
325
330
  // tree could not file an actor by address at all — which its own sweep
326
331
  // needs; the id spelling is retired outright.
327
- const folder = this.folderResolver(blockField(fm, block, "packFolder", null), {
332
+ const folder = this.folderResolver(blockDataProperty(fm, block, "packFolder", null), {
328
333
  isAddress: true,
329
334
  });
330
335
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heroiclands/package-build",
3
- "version": "22.13.0",
3
+ "version": "22.14.0",
4
4
  "description": "Shared toolchain for building and shipping a HeroicLands Foundry VTT package — content compilation, manifest, localization, staging, bundle, release and deployment.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "type": "module",
@@ -149,7 +149,7 @@ export function parseValueDesc(raw: any): {
149
149
  /**
150
150
  * The compendium folder a note names.
151
151
  *
152
- * **There is one spelling.** `packFolder:` is a folder note's **address**
152
+ * `data.packFolder` is a folder note's **address**
153
153
  * (`folder-poisonsandtoxins`), resolved through the address index the whole
154
154
  * build shares. The `folder:` Foundry-id spelling this function once read
155
155
  * beside it, and the per-pack `*-folders.yaml` that id was resolved against,
@@ -119,8 +119,8 @@ export function checkHomepageCount(found: ReadonlyArray<{
119
119
  * The one defaulted value on the page, and it defaults to the package's own
120
120
  * `packageBuild.manifest.title` — the name Foundry already shows for the
121
121
  * package — so a homepage that adds nothing to it need not restate it. An
122
- * authored `title` wins, because a front page is allowed to greet a reader
123
- * differently from a package browser.
122
+ * authored `name.full` wins, because a front page may greet a reader
123
+ * differently from a package browser. A top-level `title` is also readable.
124
124
  *
125
125
  * Falls back to `contentPackage` last, so a package that has no manifest of its
126
126
  * own still yields a titled page rather than a blank heading.
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Format one addressed note's YAML without changing its Markdown body.
3
+ * Invalid YAML remains the YAML linter's finding.
4
+ *
5
+ * @param {string} markdown - The complete Markdown source.
6
+ * @returns {string} The source with canonical frontmatter.
7
+ */
8
+ export function formatNoteFrontmatter(markdown: string): string;
@@ -0,0 +1,6 @@
1
+ /** Return authored keys from an opening YAML frontmatter map. */
2
+ export function authoredNoteKeys(markdown: any): string[];
3
+ /** The complete ordered top-level vocabulary for addressed content notes. */
4
+ export const NOTE_TOP_LEVEL_KEYS: readonly string[];
5
+ /** Membership test for the top-level vocabulary. */
6
+ export const NOTE_TOP_LEVEL_KEY_SET: Set<string>;
@@ -93,6 +93,17 @@ export function sharedProperty(fm: object, source: string, defaultValue?: any):
93
93
  * @returns {any} The value.
94
94
  */
95
95
  export function blockProperty(fm: object, block: string, key: string, defaultValue?: any): any;
96
+ /**
97
+ * A system override followed by a shared `data:` value. The top-level value
98
+ * remains readable while content repositories move their authored fields.
99
+ *
100
+ * @param {object} fm - The note's frontmatter.
101
+ * @param {string} block - The system block key.
102
+ * @param {string} key - The shared property.
103
+ * @param {any} [defaultValue] - Returned when no position declares it.
104
+ * @returns {any} The selected value.
105
+ */
106
+ export function blockDataProperty(fm: object, block: string, key: string, defaultValue?: any): any;
96
107
  /**
97
108
  * The key a field is authored at **inside** a system block — step 2.
98
109
  *