@heroiclands/package-build 22.20.0 → 22.22.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 (118) hide show
  1. package/CHANGELOG-content-build.md +0 -8
  2. package/CHANGELOG.md +204 -0
  3. package/assets/styles/content-base.css +162 -0
  4. package/bin/content-commands.mjs +26 -3
  5. package/bin/package-build.mjs +15 -2
  6. package/ci/content-format-reference.mjs +68 -1
  7. package/config.mjs +26 -0
  8. package/content-config.mjs +73 -4
  9. package/docs/api.md +46 -28
  10. package/docs/authoring/dates-and-calendars.md +4 -2
  11. package/docs/authoring/frontmatter.md +2 -2
  12. package/docs/authoring/links-and-markup.md +237 -28
  13. package/docs/configuration.md +27 -1
  14. package/docs/content-format.md +3 -1
  15. package/docs/guides/reference-pages.md +15 -0
  16. package/docs/reference/format-details.md +265 -42
  17. package/docs/reference/note-types.md +45 -28
  18. package/engine/actor-compiler.mjs +23 -0
  19. package/engine/anchored-sections.mjs +35 -4
  20. package/engine/anchors.mjs +10 -20
  21. package/engine/base-compiler.mjs +86 -21
  22. package/engine/base-styles.mjs +149 -0
  23. package/engine/bundles.mjs +0 -1
  24. package/engine/compendiums.mjs +8 -1
  25. package/engine/content-blocks.mjs +410 -0
  26. package/engine/content-captions.mjs +57 -8
  27. package/engine/content-footnotes.mjs +189 -0
  28. package/engine/content-format-check.mjs +15 -12
  29. package/engine/content-index.mjs +2 -2
  30. package/engine/content-links.mjs +14 -6
  31. package/engine/content-tables.mjs +45 -4
  32. package/engine/content-tasklists.mjs +189 -0
  33. package/engine/docs-checks.mjs +4 -4
  34. package/engine/extension-attributes.mjs +48 -2
  35. package/engine/frontmatter-lint.mjs +62 -66
  36. package/engine/frontmatter.mjs +3 -3
  37. package/engine/heading-attributes.mjs +308 -0
  38. package/engine/helpers.mjs +19 -13
  39. package/engine/homepage.mjs +8 -8
  40. package/engine/index.mjs +3 -0
  41. package/engine/infobox-render.mjs +21 -4
  42. package/engine/infobox.mjs +167 -7
  43. package/engine/journals.mjs +36 -17
  44. package/engine/macros.mjs +1 -1
  45. package/engine/markdown-expressions.mjs +178 -53
  46. package/engine/note-addresses.mjs +17 -8
  47. package/engine/note-dates.mjs +49 -0
  48. package/engine/note-frontmatter.mjs +7 -16
  49. package/engine/note-vocabulary.mjs +216 -42
  50. package/engine/page-lists.mjs +358 -0
  51. package/engine/pdf-build.mjs +76 -42
  52. package/engine/pdf-render.mjs +422 -49
  53. package/engine/rank-ladder.mjs +198 -0
  54. package/engine/reckoning-markers.mjs +1 -1
  55. package/engine/reference-targets.mjs +13 -1
  56. package/engine/retired-fields.mjs +8 -13
  57. package/engine/scenes.mjs +10 -3
  58. package/engine/site-build.mjs +41 -12
  59. package/engine/site-config.mjs +10 -1
  60. package/engine/site-index.mjs +39 -4
  61. package/engine/site-preview.mjs +18 -7
  62. package/engine/social-ties.mjs +52 -14
  63. package/engine/sql-tables.mjs +66 -9
  64. package/engine/standing-terms.mjs +19 -0
  65. package/engine/standings.mjs +538 -0
  66. package/engine/svg-theme.mjs +15 -4
  67. package/engine/system-block.mjs +12 -14
  68. package/engine/web-wikilinks.mjs +48 -1
  69. package/engine/wikilinks.mjs +3 -1
  70. package/hm3/actors.mjs +6 -3
  71. package/manifest.mjs +13 -0
  72. package/package.json +1 -1
  73. package/sohl/actors.mjs +82 -103
  74. package/sohl/item-fields.mjs +4 -5
  75. package/sohl/items.mjs +1 -1
  76. package/sohl/note-schemas.mjs +11 -21
  77. package/types/config.d.mts +9 -0
  78. package/types/engine/actor-compiler.d.mts +11 -0
  79. package/types/engine/anchored-sections.d.mts +24 -0
  80. package/types/engine/base-compiler.d.mts +19 -0
  81. package/types/engine/base-styles.d.mts +93 -0
  82. package/types/engine/content-blocks.d.mts +87 -0
  83. package/types/engine/content-captions.d.mts +14 -1
  84. package/types/engine/content-footnotes.d.mts +73 -0
  85. package/types/engine/content-format-check.d.mts +8 -6
  86. package/types/engine/content-tables.d.mts +12 -4
  87. package/types/engine/content-tasklists.d.mts +72 -0
  88. package/types/engine/extension-attributes.d.mts +22 -1
  89. package/types/engine/frontmatter.d.mts +1 -1
  90. package/types/engine/heading-attributes.d.mts +174 -0
  91. package/types/engine/helpers.d.mts +7 -4
  92. package/types/engine/homepage.d.mts +7 -7
  93. package/types/engine/index.d.mts +1 -0
  94. package/types/engine/infobox-render.d.mts +6 -1
  95. package/types/engine/journals.d.mts +14 -7
  96. package/types/engine/markdown-expressions.d.mts +23 -0
  97. package/types/engine/note-frontmatter.d.mts +1 -4
  98. package/types/engine/note-vocabulary.d.mts +98 -8
  99. package/types/engine/page-lists.d.mts +126 -0
  100. package/types/engine/pdf-render.d.mts +72 -2
  101. package/types/engine/rank-ladder.d.mts +32 -0
  102. package/types/engine/retired-fields.d.mts +4 -6
  103. package/types/engine/site-build.d.mts +13 -1
  104. package/types/engine/site-config.d.mts +9 -0
  105. package/types/engine/site-index.d.mts +13 -1
  106. package/types/engine/site-preview.d.mts +1 -1
  107. package/types/engine/sql-tables.d.mts +31 -7
  108. package/types/engine/standing-terms.d.mts +16 -0
  109. package/types/engine/standings.d.mts +147 -0
  110. package/types/engine/system-block.d.mts +11 -12
  111. package/types/engine/web-wikilinks.d.mts +3 -1
  112. package/types/engine/wikilinks.d.mts +2 -1
  113. package/types/hm3/actors.d.mts +3 -3
  114. package/types/sohl/actors.d.mts +25 -7
  115. package/engine/content-admonitions.mjs +0 -114
  116. package/engine/content-secrets.mjs +0 -112
  117. package/types/engine/content-admonitions.d.mts +0 -24
  118. package/types/engine/content-secrets.d.mts +0 -17
@@ -64,6 +64,26 @@ export { slugify };
64
64
  import { authoredLabel, WIKILINK, isSamePage, parseWikilink } from "./wikilink-syntax.mjs";
65
65
  import { resolveEmbeds } from "./content-embeds.mjs";
66
66
 
67
+ /**
68
+ * Whether a page's declared anchors include the slug a link names.
69
+ *
70
+ * A local page's entry carries a `Set` (built by {@link module:engine/site-index});
71
+ * a foreign one carries the `{slug: uuid}` map a fetched manifest publishes.
72
+ * `undefined` means neither build recorded an answer, which keeps a target
73
+ * this check cannot speak for exactly as unchecked as it always was.
74
+ *
75
+ * @param {Set<string>|Record<string, string>|undefined} anchors - The page's
76
+ * declared anchors, or `undefined` where none were recorded.
77
+ * @param {string} slug - The anchor a link names.
78
+ * @returns {boolean} Whether the anchor is declared, or `true` when `anchors`
79
+ * is `undefined` — which reads as "nothing to check against" rather than
80
+ * "declares none".
81
+ */
82
+ function hasAnchor(anchors, slug) {
83
+ if (!anchors) return true;
84
+ return anchors instanceof Set ? anchors.has(slug) : Object.hasOwn(anchors, slug);
85
+ }
86
+
67
87
  /**
68
88
  * The index key a **piped** target resolves to, or `null` when it does not
69
89
  * parse as an address at all.
@@ -315,7 +335,9 @@ function isPlainMap(value) {
315
335
  * resolves against. `src` is the page's display
316
336
  * path and `file` the source file a diagnostic should name — absent, `src`
317
337
  * stands in. `resolved`, when supplied, is the array every resolved
318
- * target's index entry is appended to.
338
+ * target's index entry is appended to. `anchors` is the `{#slug}` set
339
+ * *this* page declares, which a `[[#slug]]` self-link is checked against;
340
+ * absent, a self-link is not checked, exactly as before this existed.
319
341
  * @returns {string} The body with embeds and wikilinks rewritten.
320
342
  */
321
343
  export function resolveWebWikilinks(body, ctx) {
@@ -399,6 +421,16 @@ export function resolveWebWikilinks(body, ctx) {
399
421
 
400
422
  // `[[#section-slug|Text]]` — a section of this same page.
401
423
  if (isSamePage({ target, anchor })) {
424
+ // Checked against this page's own anchors, exactly as a link into
425
+ // another page is: a self-link naming a section the page does not
426
+ // declare is as dead as one naming a section nowhere does.
427
+ if (!hasAnchor(ctx.anchors, anchor)) {
428
+ return report(
429
+ all,
430
+ { target, reason: "unknown-anchor", anchor, addressed: true },
431
+ label ?? anchor,
432
+ );
433
+ }
402
434
  return `[${label ?? ctx.captionLabels?.get(anchor) ?? anchor}](#${slugify(anchor)})`;
403
435
  }
404
436
 
@@ -419,6 +451,21 @@ export function resolveWebWikilinks(body, ctx) {
419
451
  lookupRead(ctx.foreign, read, ctx.contentPackage)
420
452
  : undefined);
421
453
  if (hit) {
454
+ // A `#section` the target declares no heading for. Checked only
455
+ // where there is a page to check against — a pack-only hit has no
456
+ // `url` and therefore nothing for the anchor to address, which is
457
+ // the asset-like case this must not touch. `anchorPageId` on the
458
+ // pack build and the KB manifest's own `{slug: uuid}` map both
459
+ // hash any slug into something that resolves, so an undeclared
460
+ // one otherwise joins onto the URL unchecked and dead-ends for
461
+ // the reader.
462
+ if (anchor && hit.url && !hasAnchor(hit.anchors, anchor)) {
463
+ return report(
464
+ all,
465
+ { target, reason: "unknown-anchor", anchor, addressed: true },
466
+ label ?? hit.name ?? target,
467
+ );
468
+ }
422
469
  // The edge, for a caller reading the link graph off this pass.
423
470
  ctx.resolved?.push(hit);
424
471
  // An address with an *empty* label has no prose to show (a
@@ -583,7 +583,7 @@ export function convertWikilinks(markdown, { type, id, pack, docPack, index, cap
583
583
  * @param {object} index - From {@link buildWikilinkIndex}.
584
584
  * @param {unknown} ref - The reference, as authored.
585
585
  * @param {object} [hint] - `{type}`, where the caller knows what it expects.
586
- * @returns {{name?: string, uuid?: string, address?: import("./address.mjs").AddressTuple, subType?: string}|undefined}
586
+ * @returns {{name?: string, uuid?: string, address?: import("./address.mjs").AddressTuple, subType?: string, standings?: object}|undefined}
587
587
  * The target, or `undefined` where nothing answers.
588
588
  */
589
589
  export function resolveReference(index, ref, hint) {
@@ -611,6 +611,7 @@ export function resolveReference(index, ref, hint) {
611
611
  return {
612
612
  name: published.name,
613
613
  subType: published.subType,
614
+ ...(published.standings ? { standings: published.standings } : {}),
614
615
  ...(published.uuid ? { uuid: published.uuid } : {}),
615
616
  address: tuple,
616
617
  };
@@ -638,6 +639,7 @@ export function resolveReference(index, ref, hint) {
638
639
  return {
639
640
  name: local.name,
640
641
  subType: local.subType,
642
+ ...(local.standings ? { standings: local.standings } : {}),
641
643
  ...(uuid ? { uuid } : {}),
642
644
  address: tuple,
643
645
  };
package/hm3/actors.mjs CHANGED
@@ -227,9 +227,9 @@ export class Hm3Actors extends SystemActorCompiler {
227
227
  /**
228
228
  * Build every embedded item an HM3 actor carries, from `hm3.items`.
229
229
  *
230
- * One list, not two: HM3 keeps a character's abilities in `system.abilities`
231
- * rather than as embedded documents, so there is no attributes map to
232
- * expand the way SoHL's pass expands `sohl.attributes`.
230
+ * HM3 keeps a character's abilities in `system.abilities` rather than as
231
+ * embedded documents, so an ability reaches the document through the
232
+ * `hm3.system` passthrough and never through this list.
233
233
  *
234
234
  * @param {Map<string, object>} itemsMap - The predefined items, by address.
235
235
  * @param {string} actorId - The owning actor's id, seeding embedded ids.
@@ -333,6 +333,9 @@ export class Hm3Actors extends SystemActorCompiler {
333
333
  isAddress: true,
334
334
  });
335
335
 
336
+ this.checkAnchoredSection(body || "", "appearance", ctx);
337
+ this.checkAnchoredSection(body || "", "dossier", ctx);
338
+
336
339
  const system = {
337
340
  // `system.bioImage` is **not** written here, for the reason SoHL's
338
341
  // `system.portrait` is not: a being's portrait is the lead image of
package/manifest.mjs CHANGED
@@ -52,6 +52,7 @@ import fs from "node:fs/promises";
52
52
  import fsSync from "node:fs";
53
53
  import path from "node:path";
54
54
 
55
+ import { withBaseStyle } from "./engine/base-styles.mjs";
55
56
  import { emitDiagnostic, formatDiagnostic, positionOfYamlPath } from "./engine/diagnostics.mjs";
56
57
  import { metadataFileName } from "./engine/metadata-index.mjs";
57
58
 
@@ -548,6 +549,18 @@ export function buildManifest({ config, packageJson, artifact, flags }) {
548
549
 
549
550
  const merged = { ...declared, ...derived };
550
551
 
552
+ // The shared base stylesheet, named ahead of whatever the package declares.
553
+ // Derived rather than declared, because every package needs it and four of
554
+ // the five that do have no stylesheet of their own to name it from. See
555
+ // `engine/base-styles.mjs` for the layer it loads into and why that is what
556
+ // lets a package override it.
557
+ const styles = withBaseStyle({
558
+ declared: declared.styles,
559
+ artifact: /** @type {"system"|"module"} */ (artifact),
560
+ enabled: config.packageBuild?.baseStyles !== false,
561
+ });
562
+ if (styles !== undefined) merged.styles = styles;
563
+
551
564
  // The index every consumer resolves this package's addresses through.
552
565
  // Written unconditionally, because a package that publishes no
553
566
  // index is one nothing can link into — and the failure of an absent one is
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heroiclands/package-build",
3
- "version": "22.20.0",
3
+ "version": "22.22.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",
package/sohl/actors.mjs CHANGED
@@ -18,10 +18,9 @@
18
18
  * reference translation through the system's map, embedding and its stable ids,
19
19
  * the anchored prose sections — lives in
20
20
  * {@link module:engine/actor-compiler}, where a second system reaches it.
21
- * What is left here is SoHL's data model: the body structure and its
22
- * movement profiles, the attributes-and-items frontmatter that becomes embedded
23
- * documents, the opening mastery level a skill is baked with, and the `system`
24
- * block itself.
21
+ * What is left here is SoHL's data model: the `sohl.items` frontmatter that
22
+ * becomes embedded documents, the opening mastery level a skill is baked with,
23
+ * and the `system` block itself.
25
24
  *
26
25
  * One content type today, and the actor subtype it produces is **declared**
27
26
  * rather than assumed to be the same word: `sohl/document-subtypes.mjs` maps
@@ -29,15 +28,24 @@
29
28
  * `ACTOR_VAULT_TYPE = "being"` here and emitted `type: "being"` several hundred
30
29
  * lines below, which made the two vocabularies agree by coincidence.
31
30
  *
32
- * It was two content types — `character` and `creature` — which compiled to the
33
- * same `being` with no branch anywhere between them; they were retired in
34
- * retired, and are reported by `assertTypeNotRetired` in
35
- * `engine/ids.mjs`.
31
+ * Each entry in `sohl.items` is resolved by `(type, shortcode)` and deep-merged
32
+ * with the entry's other properties, so an attribute carries its `scoreBase`
33
+ * and a skill its `masteryLevelBase` as that entry's own `system` overlay.
36
34
  *
37
- * Attributes (`sohl.attributes` map) become embedded attribute items with
38
- * `scoreBase` set from the map value. Each entry in `sohl.items` is similarly
39
- * resolved by `(type, shortcode)` and deep-merged with the entry's other
40
- * properties. `sohl.skills` is ignored.
35
+ * **A being's own document fields are authored under `sohl.system`**, which
36
+ * {@link module:engine/system-block.mergeSystemData} writes at the DataModel's
37
+ * own paths: a being's body is `sohl.system.body`, with
38
+ * `sohl.system.currentMoveMedium` and `sohl.system.movementProfiles` beside it.
39
+ * So this pass writes no body itself, and an incorporeal being authors none and
40
+ * keeps the schema's empty one.
41
+ *
42
+ * **A membership is derived, never authored here.** `data.affiliations` names
43
+ * the bodies a being belongs to and the standing it holds in each, and each
44
+ * entry becomes one embedded affiliation item: the entry's `rank` is that
45
+ * item's `system.level` and its `office` is `system.office`. A standing is a
46
+ * fact about the setting rather than about one system — it does not become true
47
+ * because SoHL is installed — so the system block states none of it, and a
48
+ * sheet and a page cannot come to disagree.
41
49
  *
42
50
  * Not a standalone script — exports the `Actors` compiler class, imported and
43
51
  * driven by `engine/generate.mjs`. Must run after the items passes, since it
@@ -58,6 +66,10 @@ import { SOHL_DOCUMENT_SUBTYPES } from "./document-subtypes.mjs";
58
66
  // verbatim, and `sohl.img` / `sohl.effects` / `sohl.flags` overriding their
59
67
  // shared top-level forms for this system alone.
60
68
  import { blockProperty, mergeSystemData } from "../engine/system-block.mjs";
69
+ // A being's memberships and the standing it holds in each, read from the one
70
+ // place they are authored. The rank *is* the affiliation item's `level`, so
71
+ // there is nothing to map and nowhere for a second authoring to disagree.
72
+ import { readStandings } from "../engine/standings.mjs";
61
73
 
62
74
  /**
63
75
  * The system this pass compiles for — the block its notes write.
@@ -98,60 +110,12 @@ function defaultActorImg(subType) {
98
110
  return img;
99
111
  }
100
112
 
101
- /**
102
- * Normalize a being's persisted `system.body` from a `sohl.body` block. The
103
- * authoring frontmatter mirrors the schema field-for-field: `sohl.body` nests
104
- * `structure` / `weight` / `reachBase` / `bodyScaleBase` / `personalFatigue`,
105
- * exactly like `system.body`.
106
- */
107
- function normalizeBody(bodyObj) {
108
- const b = bodyObj && typeof bodyObj === "object" ? bodyObj : {};
109
- const weight = b.weight || {};
110
- return {
111
- structure: b.structure ?? { parts: [], adjacent: [] },
112
- weight: {
113
- base: weight.base == null ? null : Number(weight.base),
114
- calc: String(weight.calc ?? "0"),
115
- },
116
- reachBase: Number(b.reachBase ?? 0) || 0,
117
- bodyScaleBase: Number(b.bodyScaleBase ?? 1) || 1,
118
- personalFatigue: String(b.personalFatigue ?? "enc"),
119
- };
120
- }
121
-
122
- /** Normalize per-medium movement profiles from a `sohl.movementProfiles` list. */
123
- function normalizeMovementProfiles(list) {
124
- return (Array.isArray(list) ? list : []).map((p) => ({
125
- medium: String(p.medium ?? "terrestrial"),
126
- feetPerRound: Number(p.feetPerRound ?? 0) || 0,
127
- leaguesPerWatch: Number(p.leaguesPerWatch ?? 0) || 0,
128
- encumbrance: String(p.encumbrance ?? "0"),
129
- strMod: String(p.strMod ?? "0"),
130
- disabled: Boolean(p.disabled ?? false),
131
- }));
132
- }
133
-
134
- /**
135
- * Extract a being's body (+ its movement) from a `sohl` block that mirrors the
136
- * schema: `sohl.body` (nested → `system.body`) and the flat
137
- * `sohl.currentMoveMedium` / `sohl.movementProfiles` (→ the base-actor movement
138
- * fields; movement is a universal actor capability, not part of the body).
139
- */
140
- function extractBodyAndMovement(fm) {
141
- return {
142
- body: normalizeBody(sohlField(fm, "body", {})),
143
- currentMoveMedium: String(sohlField(fm, "currentMoveMedium", "none")),
144
- movementProfiles: normalizeMovementProfiles(sohlField(fm, "movementProfiles", [])),
145
- };
146
- }
147
-
148
113
  /**
149
114
  * SoHL's Actor compile pass.
150
115
  *
151
116
  * Declares SoHL's note-type → document-subtype map and builds a `being` note's
152
- * document: the body structure and its movement profiles, the embedded
153
- * attribute and item documents the frontmatter names, and the `system` block.
154
- * Everything else is {@link module:engine/actor-compiler}'s.
117
+ * document: the embedded item documents the frontmatter names, and the `system`
118
+ * block. Everything else is {@link module:engine/actor-compiler}'s.
155
119
  */
156
120
  export class Actors extends SystemActorCompiler {
157
121
  /**
@@ -177,30 +141,12 @@ export class Actors extends SystemActorCompiler {
177
141
  }
178
142
 
179
143
  /**
180
- * Build all embedded items for an actor: one per `sohl.attributes`
181
- * entry plus one per `sohl.items` entry. `sohl.skills` is ignored.
144
+ * Build all embedded items for an actor: one per `sohl.items` entry, plus
145
+ * one per body the being belongs to.
182
146
  */
183
147
  buildEmbeddedItems(itemsMap, actorId, fm, ctx) {
184
148
  const items = [];
185
149
 
186
- const attributes = sohlField(fm, "attributes", null);
187
- if (attributes && typeof attributes === "object") {
188
- for (const [shortcode, value] of Object.entries(attributes)) {
189
- const overlay = { system: { scoreBase: Number(value) || 0 } };
190
- const embedded = this.resolveEmbedded(
191
- itemsMap,
192
- actorId,
193
- "attribute",
194
- shortcode,
195
- overlay,
196
- `attr:${shortcode}`,
197
- ctx,
198
- { fmKey: "attributes" },
199
- );
200
- if (embedded) items.push(embedded);
201
- }
202
- }
203
-
204
150
  const sohlItems = sohlField(fm, "items", null);
205
151
  if (Array.isArray(sohlItems)) {
206
152
  sohlItems.forEach((entry, index) => {
@@ -256,10 +202,59 @@ export class Actors extends SystemActorCompiler {
256
202
  });
257
203
  }
258
204
 
205
+ for (const embedded of this.buildMemberships(itemsMap, actorId, fm, ctx)) {
206
+ items.push(embedded);
207
+ }
208
+
259
209
  this.openUnopenedSkills(items, ctx);
260
210
  return items;
261
211
  }
262
212
 
213
+ /**
214
+ * One embedded affiliation item per body the being belongs to.
215
+ *
216
+ * The body's own compiled item is the model, so a membership carries
217
+ * whatever that body declares — its subtype, its common skills, its
218
+ * standings toward others — and the being adds only what is true of *this*
219
+ * membership: the rung it stands on and the post it holds.
220
+ *
221
+ * A being naming a body that compiles to no item is a finding rather than a
222
+ * silently dropped membership: the note says the being belongs somewhere,
223
+ * and a sheet that shows nothing says it does not.
224
+ *
225
+ * @param {Map<string, object>} itemsMap - The predefined-item catalogue.
226
+ * @param {string} actorId - The owning actor's id.
227
+ * @param {object} fm - The note's frontmatter.
228
+ * @param {string} ctx - Diagnostic context.
229
+ * @returns {object[]} The embedded items, in the order the note names them.
230
+ */
231
+ buildMemberships(itemsMap, actorId, fm, ctx) {
232
+ const { entries } = readStandings(fm?.data?.affiliations);
233
+ const items = [];
234
+ for (const { body, standing } of entries) {
235
+ const shortcode = body?.shortcode;
236
+ if (!shortcode) continue;
237
+ const level = Number(standing?.rank);
238
+ const office = typeof standing?.office === "string" ? standing.office.trim() : "";
239
+ const system = {
240
+ ...(Number.isFinite(level) ? { level } : {}),
241
+ ...(office ? { office } : {}),
242
+ };
243
+ const embedded = this.resolveEmbedded(
244
+ itemsMap,
245
+ actorId,
246
+ "affiliation",
247
+ shortcode,
248
+ Object.keys(system).length ? { system } : {},
249
+ `affiliations:${shortcode}`,
250
+ ctx,
251
+ { fmKey: "affiliations", modelPackage: body.package ?? null },
252
+ );
253
+ if (embedded) items.push(embedded);
254
+ }
255
+ return items;
256
+ }
257
+
263
258
  /**
264
259
  * Bake each unopened skill's opening mastery level into the document.
265
260
  *
@@ -277,8 +272,8 @@ export class Actors extends SystemActorCompiler {
277
272
  *
278
273
  * **The scores used are the ones just written.** `SkillLogic` resolves
279
274
  * `attr.<code>` to an attribute's *effective* score, after active effects;
280
- * all this pass has is the `scoreBase` it set from `sohl.attributes`. For a
281
- * compiled being carrying no attribute-altering effects the two agree,
275
+ * all this pass has is the `scoreBase` on each embedded attribute item. For
276
+ * a compiled being carrying no attribute-altering effects the two agree,
282
277
  * which is every being in content today. One that did carry such an effect
283
278
  * would bake a Skill Base its client then disagrees with — that is the
284
279
  * limit of doing this at build time, and the point to revisit if it bites.
@@ -331,6 +326,9 @@ export class Actors extends SystemActorCompiler {
331
326
  const { value: authoredFolder, isAddress } = folderField(fm);
332
327
  const folder = this.folderResolver(authoredFolder, { isAddress });
333
328
 
329
+ this.checkAnchoredSection(body || "", "appearance", ctx);
330
+ this.checkAnchoredSection(body || "", "dossier", ctx);
331
+
334
332
  const system = {
335
333
  // The frontmatter shortcode is the actor's stable `(type, shortcode)`
336
334
  // key — and, for a being that is an archetype, its archetype
@@ -355,25 +353,6 @@ export class Actors extends SystemActorCompiler {
355
353
  dossier: withDraftNotice(fm, renderSection(body || "", "dossier")),
356
354
  };
357
355
 
358
- // Fill `system.body` (+ the base-actor movement fields) from the being's
359
- // frontmatter, rather than embedding a corpus item. The `sohl`
360
- // block mirrors `system` field-for-field: `sohl.body` nests the body
361
- // (`structure` / `weight` / …), with `currentMoveMedium` /
362
- // `movementProfiles` flat alongside it. An **incorporeal** being omits
363
- // `sohl.body` and keeps the schema's empty body.
364
- const bodyField = sohlField(fm, "body", null);
365
- if (bodyField && typeof bodyField === "object") {
366
- const bodyData = extractBodyAndMovement(fm);
367
- system.body = bodyData.body;
368
- system.currentMoveMedium = bodyData.currentMoveMedium;
369
- system.movementProfiles = bodyData.movementProfiles;
370
- } else if (bodyField != null) {
371
- this.noteError(
372
- `${ctx}: sohl.body must be an inline object (structure/weight/…), got ${typeof bodyField}`,
373
- );
374
- this.errorCount++;
375
- }
376
-
377
356
  // Being-only combat grouping (mirrors `system.defaultCombatGroup`).
378
357
  const defaultCombatGroup = sohlField(fm, "defaultCombatGroup", undefined);
379
358
  if (defaultCombatGroup !== undefined) {
@@ -391,11 +391,10 @@ export const ITEM_FIELDS = Object.freeze({
391
391
  ...STRING,
392
392
  default: "",
393
393
  topLevelMeans:
394
- "the note's own title — the heading its page is published under, " +
395
- "which has nothing to do with the style of address an office " +
396
- "carries. Author this on the membership instead: `sohl.system.title` " +
397
- "on the item, or the `system.title` of the entry in a being's " +
398
- "`sohl.items`.",
394
+ "An office's style of address belongs to the membership that " +
395
+ "holds the office, so nothing outside the item supplies it. " +
396
+ "Author it at `sohl.system.title` on the item, or at the " +
397
+ "`system.title` of the entry in a being's `sohl.items`.",
399
398
  describe: "The style of address the office carries.",
400
399
  },
401
400
  {
package/sohl/items.mjs CHANGED
@@ -93,7 +93,7 @@ export class Items extends SystemItemCompiler {
93
93
  // it — an undeclared `system` key is discarded at construction
94
94
  // without a warning.
95
95
  templatePriority: systemTemplatePriority(fm, label),
96
- actionDefs: Array.isArray(fm.actionDefs) ? fm.actionDefs : [],
96
+ actionDefs: [],
97
97
  notes: "",
98
98
  docHtml: description || "",
99
99
  };
@@ -99,37 +99,27 @@ const MACRO_FIELDS = Object.freeze([
99
99
  const VEHICLE_FIELDS = Object.freeze([]);
100
100
 
101
101
  /**
102
- * A `being` note — an actor, with its body, movement and embedded items.
102
+ * A `being` note — an actor, with its embedded items and its combat grouping.
103
+ *
104
+ * **A being's own document fields are authored under `sohl.system`**, which the
105
+ * passthrough writes at the DataModel's own paths — a body and the movement that
106
+ * goes with it are `sohl.system.body`, `sohl.system.currentMoveMedium` and
107
+ * `sohl.system.movementProfiles`, written together — so this declaration states
108
+ * none of them. **Attributes and skills are embedded items**, each named by the
109
+ * catalogue entry it copies, so both are `sohl.items` entries with the score or
110
+ * the mastery level as that entry's own `system` overlay.
111
+ *
112
+ * What is left is the two keys the pass reads off the block itself.
103
113
  *
104
114
  * @type {readonly import("../engine/field-spec.mjs").FieldSpec[]}
105
115
  */
106
116
  const BEING_FIELDS = Object.freeze([
107
- {
108
- name: "body",
109
- ...MAP,
110
- describe: "The being's body structure, by part shortcode.",
111
- },
112
- {
113
- name: "attributes",
114
- ...MAP,
115
- describe: "Attribute scores, by attribute shortcode.",
116
- },
117
117
  {
118
118
  name: "items",
119
119
  ...LIST,
120
120
  describe:
121
121
  "Items embedded on the being, each addressed by (type, shortcode) or carrying enough fields to stand alone.",
122
122
  },
123
- {
124
- name: "currentMoveMedium",
125
- ...STRING,
126
- describe: "Which medium the being is currently moving through.",
127
- },
128
- {
129
- name: "movementProfiles",
130
- ...LIST,
131
- describe: "Movement rates, one profile per medium.",
132
- },
133
123
  {
134
124
  name: "defaultCombatGroup",
135
125
  ...AS_AUTHORED,
@@ -102,6 +102,9 @@ export const DERIVED_MANIFEST_KEYS: Readonly<Record<string, string>>;
102
102
  * @property {string} stageDir The staged package root, relative to
103
103
  * `rootDir`. Every asset `to:` lands under it.
104
104
  * @property {readonly Readonly<AssetSpec>[]} assets
105
+ * @property {boolean} baseStyles Whether the package takes the shared base
106
+ * stylesheet, staged into `stageDir` and
107
+ * named first in the manifest's `styles`.
105
108
  * @property {Readonly<Record<string, unknown>>} manifest The manifest
106
109
  * specification, emitted as declared.
107
110
  * @property {string|null} manifestFlags Module to load a `flags` function
@@ -235,6 +238,12 @@ export type PackageBuildConfig = {
235
238
  */
236
239
  stageDir: string;
237
240
  assets: readonly Readonly<AssetSpec>[];
241
+ /**
242
+ * Whether the package takes the shared base
243
+ * stylesheet, staged into `stageDir` and
244
+ * named first in the manifest's `styles`.
245
+ */
246
+ baseStyles: boolean;
238
247
  /**
239
248
  * The manifest
240
249
  * specification, emitted as declared.
@@ -248,6 +248,17 @@ export class SystemActorCompiler extends BasePackCompiler {
248
248
  img: string;
249
249
  token: string;
250
250
  };
251
+ /**
252
+ * Report an `{#appearance}` or `{#dossier}` anchor on a heading
253
+ * {@link extractAnchorSection} cannot read, rather than letting the field
254
+ * it feeds compile blank with nothing to say why.
255
+ *
256
+ * @param {string} body - The note body.
257
+ * @param {string} anchorId - `"appearance"` or `"dossier"`.
258
+ * @param {string} ctx - The actor's label, for the message.
259
+ * @returns {void}
260
+ */
261
+ checkAnchoredSection(body: string, anchorId: string, ctx: string): void;
251
262
  /** @type {readonly string[]} */
252
263
  itemsSourceDirs: readonly string[];
253
264
  foreignSourceDirs: any;
@@ -6,6 +6,11 @@
6
6
  * heading exists. Fenced code blocks are respected so `# foo` inside
7
7
  * ``` blocks does not trigger a match.
8
8
  *
9
+ * An anchor declared on a lower heading is found by {@link collectAnchors}
10
+ * but not by the `#\s+` match below, so it extracts as absent rather than as
11
+ * wrong — {@link misplacedAnchorSection} is the check a caller runs first to
12
+ * tell the two apart.
13
+ *
9
14
  * @param {string} body - The note body.
10
15
  * @param {string} anchorId - The anchor to find.
11
16
  * @returns {string} The section's markdown, or "".
@@ -19,3 +24,22 @@ export function extractAnchorSection(body: string, anchorId: string): string;
19
24
  * @returns {string} The rendered HTML, or "".
20
25
  */
21
26
  export function renderSection(body: string, anchorId: string): string;
27
+ /**
28
+ * Where an `{#appearance}` or `{#dossier}` anchor sits on a heading below the
29
+ * top level, which {@link extractAnchorSection} cannot read.
30
+ *
31
+ * {@link module:engine/anchors.collectAnchors} finds the anchor at any
32
+ * level, because a lower heading legitimately starts a journal page. It does
33
+ * not feed an actor field: an H1-only extractor reads nothing from one, and a
34
+ * caller that trusts the empty result as "no such section" rather than
35
+ * "found, but unreadable" compiles a blank field with nothing to say why.
36
+ *
37
+ * @param {string} body - The note body.
38
+ * @param {string} anchorId - The anchor to check, case-insensitively.
39
+ * @param {number} [bodyLine=1] - The 1-based file line the body starts on.
40
+ * @returns {{line: number}|null} The misplaced anchor's line, or `null` when
41
+ * it is absent or already on an H1.
42
+ */
43
+ export function misplacedAnchorSection(body: string, anchorId: string, bodyLine?: number): {
44
+ line: number;
45
+ } | null;
@@ -378,6 +378,25 @@ export class BasePackCompiler {
378
378
  linkIndex: any;
379
379
  sqlTables: any;
380
380
  worldPresent: unknown;
381
+ /**
382
+ * Report every finding {@link BasePackCompiler#convertBody} collected
383
+ * across its passes, and decline the note if there were any.
384
+ *
385
+ * Reported directly, one `noteError` per finding, rather than thrown as a
386
+ * single wrapped message: the site build and the book report the same way,
387
+ * each finding at its own line, and a note with three problems meets all
388
+ * three in the run that found them instead of one per rebuild. The thrown
389
+ * sentinel carries `alreadyReported` so the compile loop's generic catch
390
+ * does not add a fourth, vaguer line on top of the three already said.
391
+ *
392
+ * @param {readonly {message: string, line?: number, column?: number}[]} findings
393
+ * @throws {Error} When `findings` is non-empty, after reporting all of them.
394
+ */
395
+ reportConvertBodyFindings(findings: readonly {
396
+ message: string;
397
+ line?: number;
398
+ column?: number;
399
+ }[]): void;
381
400
  /**
382
401
  * The body {@link BasePackCompiler#buildEntry} receives.
383
402
  *