@heroiclands/package-build 18.2.0 → 20.0.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.
Files changed (67) hide show
  1. package/CHANGELOG.md +692 -0
  2. package/CONTENT.md +81 -10
  3. package/bin/content-build.mjs +7 -1
  4. package/ci/ci-docker.mjs +21 -0
  5. package/content-config.mjs +26 -24
  6. package/docs/content-format.md +408 -85
  7. package/engine/actor-compiler.mjs +197 -7
  8. package/engine/address-charset.mjs +23 -5
  9. package/engine/base-compiler.mjs +65 -2
  10. package/engine/bundles.mjs +9 -0
  11. package/engine/content-address.mjs +92 -1
  12. package/engine/content-format.mjs +102 -0
  13. package/engine/content-index.mjs +11 -8
  14. package/engine/content-links.mjs +37 -21
  15. package/engine/field-reference.mjs +57 -5
  16. package/engine/field-spec.mjs +214 -7
  17. package/engine/folder-notes.mjs +88 -1
  18. package/engine/foreign-catalog.mjs +4 -1
  19. package/engine/foundry-entries.mjs +16 -0
  20. package/engine/frontmatter-lint.mjs +215 -28
  21. package/engine/frontmatter.mjs +12 -12
  22. package/engine/generate.mjs +78 -46
  23. package/engine/helpers.mjs +87 -128
  24. package/engine/index.mjs +3 -0
  25. package/engine/item-compiler.mjs +44 -9
  26. package/engine/journals.mjs +27 -16
  27. package/engine/macros.mjs +8 -0
  28. package/engine/map-notes.mjs +7 -7
  29. package/engine/note-ids.mjs +25 -1
  30. package/engine/note-vocabulary.mjs +76 -9
  31. package/engine/retired-fields.mjs +57 -16
  32. package/engine/runtime-only-fields.mjs +204 -0
  33. package/engine/scenes.mjs +19 -28
  34. package/engine/schema-check.mjs +23 -1
  35. package/engine/site-index.mjs +17 -0
  36. package/engine/subtype-registry.mjs +30 -0
  37. package/engine/system-block.mjs +81 -3
  38. package/engine/web-wikilinks.mjs +33 -27
  39. package/engine/wikilink-syntax.mjs +7 -0
  40. package/engine/wikilinks.mjs +74 -16
  41. package/hm3/actors.mjs +70 -23
  42. package/package.json +2 -2
  43. package/sohl/actors.mjs +106 -7
  44. package/sohl/item-fields.mjs +203 -0
  45. package/sohl/note-schemas.mjs +6 -3
  46. package/types/content-config.d.mts +0 -7
  47. package/types/engine/actor-compiler.d.mts +83 -3
  48. package/types/engine/address-charset.d.mts +22 -4
  49. package/types/engine/base-compiler.d.mts +54 -3
  50. package/types/engine/content-address.d.mts +64 -0
  51. package/types/engine/content-format.d.mts +9 -0
  52. package/types/engine/field-spec.d.mts +271 -3
  53. package/types/engine/folder-notes.d.mts +59 -0
  54. package/types/engine/foundry-entries.d.mts +6 -0
  55. package/types/engine/frontmatter-lint.d.mts +18 -2
  56. package/types/engine/frontmatter.d.mts +11 -11
  57. package/types/engine/generate.d.mts +27 -0
  58. package/types/engine/helpers.d.mts +37 -38
  59. package/types/engine/index.d.mts +1 -0
  60. package/types/engine/map-notes.d.mts +2 -2
  61. package/types/engine/note-ids.d.mts +14 -0
  62. package/types/engine/retired-fields.d.mts +29 -13
  63. package/types/engine/runtime-only-fields.d.mts +102 -0
  64. package/types/engine/schema-check.d.mts +10 -1
  65. package/types/engine/subtype-registry.d.mts +21 -0
  66. package/types/engine/system-block.d.mts +28 -2
  67. package/types/sohl/actors.d.mts +3 -3
package/CONTENT.md CHANGED
@@ -85,7 +85,7 @@ paths:
85
85
  # order is worked out separately, from what each pass reads (see "Declaration
86
86
  # order is presentation" below).
87
87
  packs:
88
- - { name: items, type: Item, label: Items, folders: item-folders.yaml }
88
+ - { name: items, type: Item, label: Items }
89
89
  - { name: journals, type: JournalEntry, label: Journals }
90
90
  # A companion is written by its parent's pass rather than one of its own.
91
91
  - name: scenes
@@ -96,7 +96,7 @@ packs:
96
96
  # A pack whose per-document JSON is already built — checked in rather than
97
97
  # generated. `prebuilt` names where it lives, generation is skipped for it,
98
98
  # and `cleanPackEntry` and the Scene/Level integrity check still run. It may
99
- # not carry `folders`, `companions` or `default`, and may not be a companion:
99
+ # not carry `companions` or `default`, and may not be a companion:
100
100
  # each of those describes a generation pass a prebuilt pack does not have.
101
101
  # When every configured pack is prebuilt the content walk is skipped
102
102
  # entirely, so a package with no `assets/content` builds.
@@ -388,6 +388,24 @@ exactly once, here, and read through `loadPackConfig()` everywhere. There is one
388
388
  resolved set at runtime; the compilers and the link-manifest emitter cannot come
389
389
  to disagree about which notes carry documentation.
390
390
 
391
+ **Every note compiling into a system-bearing document carries documentation, and
392
+ that includes actors** (#337). `docEntryTypes` is `itemTypes` plus the actor
393
+ types the shipped subtype maps declare (`ACTOR_TYPES`, derived from them rather
394
+ than listed again), plus `macro` and the map types. Only `doc` is outside it, for
395
+ the reason that actually applies to it: its single document _is_ the prose.
396
+
397
+ A being therefore has the same two addresses an item has — `<pkg>-<system>-being-<shortcode>`
398
+ for the Actor and `<pkg>-none-docbeing-<shortcode>` for the page — where before
399
+ it had only the first, and was the one system-bearing note a prose link could not
400
+ name.
401
+
402
+ **An actor keeps its prose inline as well, and that asymmetry is deliberate.** An
403
+ item's description is an `@UUID` pointer into its journal, because one item is
404
+ embedded across hundreds of beings and baking long prose into every copy bloats
405
+ the compendium by the length of the text times the number of carriers. An actor
406
+ is singular, so the same indirection costs a reader a click and saves nothing:
407
+ `system.appearance` and `system.dossier` stay as rendered prose.
408
+
391
409
  The Item compiler **dispatches through that same resolved table**, via
392
410
  `engine/item-registry.mjs` (`itemTypes()` and `itemBuilder(type)`), so the types a
393
411
  consumer's notes are accepted for and the builders they compile with are one
@@ -534,9 +552,46 @@ Both spellings are equal; the difference is only whether the type brings art.
534
552
  whitelist without a builder behind it.
535
553
 
536
554
  **The path is spelled the way a note spells it.** Registry art goes through the
537
- same `resolveImg` rule as a note's `img:`, so `icons/relic.svg` means _this_
538
- repository's asset root — `modules/sohl-relics/assets/icons/relic.svg` — and an
539
- already-served path (`systems/sohl/assets/icons/…`) passes through untouched.
555
+ same `resolveImg` rule as a note's `img:`, so one spelling means one thing
556
+ wherever it is written.
557
+
558
+ #### An asset path's first segment says which package owns it
559
+
560
+ Every authored asset path — a registry `img:`, a note's `img:`, an actor's
561
+ `portrait:` — answers "which package holds this file?" in its **first segment**,
562
+ and there are exactly three answers:
563
+
564
+ | Authored path starts with | Owner | Emitted |
565
+ | ------------------------- | --------------------- | -------------------- |
566
+ | `systems/` | a separate **system** | unchanged |
567
+ | `modules/` | a separate **module** | unchanged |
568
+ | anything else | **this package** | `<assetRoot>/<path>` |
569
+
570
+ `assetRoot` is derived, never authored: it is
571
+ `<packageKind>/<foundryPackage>/assets`, and it is the one place `systems/sohl`
572
+ (or `modules/sohl-thalorna`) is ever spelled. So `icons/relic.svg` in a module's
573
+ registry compiles to `modules/sohl-relics/assets/icons/relic.svg`, and the same
574
+ string in the system's compiles to `systems/sohl/assets/icons/relic.svg`. An
575
+ already-served `systems/sohl/assets/icons/…` passes through untouched — which is
576
+ what lets a module pair a SoHL default with one of its own types.
577
+
578
+ **"Anything else" is the rule, not a list of directories.** A package owns its
579
+ whole `assets/` tree, so a directory this toolchain has never heard of is still
580
+ that package's: art under `assets/artwork/` is addressed `artwork/deity.webp`
581
+ and rooted exactly as `icons/…` and `images/…` are. An address naming no package
582
+ at all — an absolute URL, a `data:` URI, a `/`-rooted path — passes through, on
583
+ the same rule rather than as an exception.
584
+
585
+ `worlds/` is deliberately not exempt: a package may not ship art out of a world,
586
+ so prefixing such a path produces a plainly broken one rather than a plausible
587
+ one that 404s in Foundry unreported.
588
+
589
+ **`banner:` is a path that does not follow this rule.** It reaches no compiled
590
+ document; it is a top-level key the Hugo theme reads, and the theme prefixes a
591
+ relative value with `images/` and joins it onto `params.cdnBaseURL`. The two
592
+ address different places — `img:` a file Foundry serves, `banner:` a file the
593
+ CDN serves — so they are stated apart rather than reconciled. See the
594
+ [content format specification](docs/content-format.md#banner-addresses-the-cdn-not-the-foundry-install).
540
595
 
541
596
  #### "Names no art" and "wants no art" are different (#218)
542
597
 
@@ -570,12 +625,20 @@ rather than two.
570
625
  > (`sohl/item-fields.mjs`, "the style of address the office carries"), so one
571
626
  > authored key fed two unrelated destinations that disagreed about what empty
572
627
  > means — and `title: null` stringified into the compiled document as the literal
573
- > `"null"`. That collision is gone: the field declares `topLevelMeans`, and the
574
- > top-level key is no longer a source for it (#218).
628
+ > `"null"`. The field declares `topLevelMeans` now, so the top-level key is no
629
+ > longer a source for it (#218).
575
630
  >
576
- > So `title: null` is now a note declining to state a heading, and the site
577
- > emitter's `fm.title ?? name` falls back to `name.full`. `title: ""` still
578
- > publishes a deliberately blank heading, and nothing warns about that yet.
631
+ > So `title: null` is a note declining to state a heading, and the site emitter's
632
+ > `fm.title ?? name` falls back to `name.full`. `title: ""` publishes a
633
+ > deliberately blank heading and is warned about on its own account — as the
634
+ > page's heading, not as an art path.
635
+ >
636
+ > **That warning reads the note's top level only.** The two spellings still name
637
+ > unrelated quantities, so a `sohl.title` — the office's style of address — must
638
+ > not answer for the page's heading. Twenty-eight `sohl-kethira-basic`
639
+ > affiliations write `sohl.title: ""`, meaning an office with no style of
640
+ > address, and every one of them was reported as publishing a blank heading
641
+ > until the check honoured the declaration (#312).
579
642
 
580
643
  Because `""` used to mean "unset", a note still carrying that spelling has
581
644
  quietly changed meaning, and the frontmatter lint says so — for either art
@@ -712,6 +775,14 @@ state the same quantity, which is nearly everywhere: `subType` is the other
712
775
  declared item field spelled like a note-level key, and there the two agree by
713
776
  design.
714
777
 
778
+ **The statement is symmetric, and the frontmatter linter reads it from the other
779
+ side too.** If the two positions hold unrelated quantities, then the _in-block_
780
+ position is not the note-level field either — so a check about a note-level field
781
+ (the page's heading, an art path) reads past a block key the note's own type
782
+ claims for something else. Declaring `topLevelMeans` settles both directions at
783
+ once; it was read for the emitted field alone until #312, which is how an
784
+ affiliation's office style came to answer for its page heading.
785
+
715
786
  **An exempted field is still authorable**, at the two positions that describe the
716
787
  document rather than the note:
717
788
 
@@ -136,7 +136,7 @@ import {
136
136
  locateAddressFinding,
137
137
  addressFindingMessage,
138
138
  } from "../engine/address-diff.mjs";
139
- import { itemPackJsonDirs } from "../engine/generate.mjs";
139
+ import { emittedArtFor, itemPackJsonDirs } from "../engine/generate.mjs";
140
140
 
141
141
  /**
142
142
  * The packs `unpack` extracts.
@@ -799,6 +799,12 @@ function lintCommand() {
799
799
  // the compile asks the map for whichever pack it is
800
800
  // writing, and a companion is a pack it writes.
801
801
  packs: config.packDirectories,
802
+ // What art each type actually reaches its document through,
803
+ // asked of the passes rather than listed here (#349). This
804
+ // is the one place that decides what a tree is held to, so
805
+ // it is where the derivation is handed over — the linter
806
+ // states no list of iconless types of its own.
807
+ emittedArt: emittedArtFor,
802
808
  references: argv.references,
803
809
  });
804
810
 
package/ci/ci-docker.mjs CHANGED
@@ -110,6 +110,27 @@ function exportHead() {
110
110
  }
111
111
  const untar = spawnSync("tar", ["-x", "-C", dir], { input: archive.stdout });
112
112
  if (untar.status !== 0) throw new Error("could not unpack the export");
113
+
114
+ // Make the export a repository. `git archive` yields a bare directory, and
115
+ // a workflow step that shells out to git — `git ls-files` in a
116
+ // tracked-artifact check, say — then fails for want of a `.git`, which
117
+ // reads as the check failing rather than as this harness lacking something
118
+ // the runner has. GitHub's own step is a *checkout*, so the faithful export
119
+ // is one too.
120
+ //
121
+ // Initialised and staged rather than committed: `git ls-files` lists the
122
+ // index, so staging every extracted file reproduces exactly the set the
123
+ // runner would see, without needing an identity configured to commit with.
124
+ const init = spawnSync("git", ["init", "-q"], { cwd: dir });
125
+ if (init.status === 0) {
126
+ spawnSync("git", ["add", "-A"], { cwd: dir });
127
+ } else {
128
+ console.error(
129
+ "ci-docker: NOTE — could not make the export a git repository, so a " +
130
+ "workflow step that shells out to git will fail here in a way it " +
131
+ "would not on the runner.",
132
+ );
133
+ }
113
134
  return dir;
114
135
  }
115
136
 
@@ -27,7 +27,7 @@
27
27
  * itemBuilders: sohl
28
28
  * skipDirectories: [Templates]
29
29
  * packs:
30
- * - { name: items, type: Item, folders: item-folders.yaml }
30
+ * - { name: items, type: Item }
31
31
  * - { name: journals, type: JournalEntry, label: Journals }
32
32
  * packageBuild:
33
33
  * assets:
@@ -69,6 +69,7 @@ import path from "node:path";
69
69
  // a cycle around a consumer's config file (see `engine/pack-config.mjs`).
70
70
  import { ADDRESS_SEGMENT_PATTERN, isAddressSegment } from "./engine/address-charset.mjs";
71
71
  import { MAP_TYPES, PACK_BY_TYPE } from "./engine/ids.mjs";
72
+ import { ACTOR_TYPES } from "./engine/subtype-registry.mjs";
72
73
  import { NOTE_VOCABULARY } from "./engine/note-vocabulary.mjs";
73
74
 
74
75
  /**
@@ -258,9 +259,6 @@ export function publishesContentPages(config) {
258
259
  * @property {PackDocumentType} type Foundry document type the pack holds.
259
260
  * @property {string} [label] Human-readable label. Defaults to `name`.
260
261
  * @property {boolean} [private] Whether the pack is GM-only. Default `false`.
261
- * @property {string|null} [folders] The pack's folder-hierarchy file, relative
262
- * to `paths.content`. Default `null` — no
263
- * folder documents are emitted.
264
262
  * @property {string} [prebuilt] Directory holding this pack's per-document
265
263
  * JSON, already built. Declaring it skips
266
264
  * generation for the pack and compiles from
@@ -295,7 +293,6 @@ export function publishesContentPages(config) {
295
293
  * @property {PackDocumentType} type
296
294
  * @property {string} label
297
295
  * @property {boolean} private
298
- * @property {string|null} folders
299
296
  * @property {string|null} prebuilt
300
297
  * @property {string|null} system
301
298
  * @property {readonly Readonly<ResolvedPackSpec>[]} companions
@@ -658,7 +655,6 @@ const PACK_KEYS = [
658
655
  "type",
659
656
  "label",
660
657
  "private",
661
- "folders",
662
658
  "companions",
663
659
  "mayBeEmpty",
664
660
  "default",
@@ -854,6 +850,19 @@ function optionalString(value, field) {
854
850
  function normalizePack(value, where, nested = false) {
855
851
  if (!isPlainObject(value)) fail(where, "must be an object");
856
852
  const pack = /** @type {Record<string, unknown>} */ (value);
853
+ // Retired with the YAML it named (#260). Refused explicitly rather than
854
+ // left to the unknown-key check, because the useful thing to say is not
855
+ // "no such key" but where the folders went: they are notes, and a pack
856
+ // materialises the ones its documents reference.
857
+ if (pack.folders !== undefined) {
858
+ fail(
859
+ `${where}.folders`,
860
+ "is retired — delete it. A folder is a note (`type: folder`) now, " +
861
+ "and a pack materialises the folders its documents reference " +
862
+ "through `packFolder`, so there is no per-pack hierarchy file " +
863
+ "to name",
864
+ );
865
+ }
857
866
  rejectUnknownKeys(pack, PACK_KEYS, `${where}.`);
858
867
 
859
868
  const name = requireNonEmptyString(pack.name, `${where}.name`);
@@ -865,10 +874,6 @@ function normalizePack(value, where, nested = false) {
865
874
  fail(`${where}.type`, `must be one of: ${PACK_DOCUMENT_TYPES.join(", ")}`);
866
875
  }
867
876
 
868
- if (pack.folders !== undefined && pack.folders !== null) {
869
- requireNonEmptyString(pack.folders, `${where}.folders`);
870
- }
871
-
872
877
  const companionsInput = pack.companions;
873
878
  if (companionsInput !== undefined && !Array.isArray(companionsInput)) {
874
879
  fail(`${where}.companions`, "must be an array");
@@ -893,8 +898,8 @@ function normalizePack(value, where, nested = false) {
893
898
 
894
899
  // A prebuilt pack's per-document JSON already exists, so it has no
895
900
  // generation pass. Every key below describes one, which is why none of them
896
- // may accompany it: silently ignoring a `folders` file that can never be
897
- // read is worse than refusing the configuration that declares it.
901
+ // may accompany it: silently ignoring a key that can never be read is
902
+ // worse than refusing the configuration that declares it.
898
903
  const prebuilt =
899
904
  pack.prebuilt === undefined || pack.prebuilt === null ?
900
905
  null
@@ -907,13 +912,6 @@ function normalizePack(value, where, nested = false) {
907
912
  "another pack's pass, and a prebuilt pack has no pass",
908
913
  );
909
914
  }
910
- if (pack.folders !== undefined && pack.folders !== null) {
911
- fail(
912
- `${where}.folders`,
913
- "may not accompany `prebuilt`: the folder hierarchy is built " +
914
- "during generation, which a prebuilt pack skips",
915
- );
916
- }
917
915
  if (Array.isArray(companionsInput) && companionsInput.length) {
918
916
  fail(
919
917
  `${where}.companions`,
@@ -946,10 +944,6 @@ function normalizePack(value, where, nested = false) {
946
944
  label:
947
945
  pack.label === undefined ? name : requireNonEmptyString(pack.label, `${where}.label`),
948
946
  private: optionalBoolean(pack.private, `${where}.private`, false),
949
- folders:
950
- pack.folders === undefined || pack.folders === null ?
951
- null
952
- : /** @type {string} */ (pack.folders),
953
947
  companions: Object.freeze(companions),
954
948
  mayBeEmpty: optionalBoolean(pack.mayBeEmpty, `${where}.mayBeEmpty`, false),
955
949
  // Which pack of a type receives a note that declares none. Validated
@@ -1960,7 +1954,15 @@ export function defineConfig(config) {
1960
1954
  // holds every key any of them declares, so this stays "the registry's keys"
1961
1955
  // rather than becoming a second list to keep in step (#1504).
1962
1956
  const itemTypes = Object.freeze(new Set(Object.keys(itemBuilders)));
1963
- const docEntryTypes = Object.freeze(new Set([...itemTypes, "macro", ...MAP_TYPES]));
1957
+ // Every note that compiles into a *system-bearing* document publishes its
1958
+ // prose as a documentation JournalEntry, and that includes actors (#337).
1959
+ // A being was the one such note with no `none` address — its only address
1960
+ // named the Actor — so nothing a prose link wrote could land on its page.
1961
+ // `doc` stays out for the reason that actually applies to it: its single
1962
+ // document *is* the prose.
1963
+ const docEntryTypes = Object.freeze(
1964
+ new Set([...itemTypes, ...ACTOR_TYPES, "macro", ...MAP_TYPES]),
1965
+ );
1964
1966
 
1965
1967
  return Object.freeze({
1966
1968
  rootDir,