@heroiclands/package-build 22.21.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 (110) hide show
  1. package/CHANGELOG.md +191 -0
  2. package/assets/styles/content-base.css +162 -0
  3. package/bin/content-commands.mjs +26 -3
  4. package/bin/package-build.mjs +15 -2
  5. package/ci/content-format-reference.mjs +68 -1
  6. package/config.mjs +26 -0
  7. package/content-config.mjs +73 -4
  8. package/docs/api.md +36 -18
  9. package/docs/authoring/dates-and-calendars.md +4 -2
  10. package/docs/authoring/frontmatter.md +2 -2
  11. package/docs/authoring/links-and-markup.md +237 -28
  12. package/docs/configuration.md +27 -1
  13. package/docs/content-format.md +3 -1
  14. package/docs/guides/reference-pages.md +15 -0
  15. package/docs/reference/format-details.md +224 -46
  16. package/docs/reference/note-types.md +21 -4
  17. package/engine/actor-compiler.mjs +23 -0
  18. package/engine/anchored-sections.mjs +35 -4
  19. package/engine/anchors.mjs +10 -20
  20. package/engine/base-compiler.mjs +86 -21
  21. package/engine/base-styles.mjs +149 -0
  22. package/engine/bundles.mjs +0 -1
  23. package/engine/compendiums.mjs +8 -1
  24. package/engine/content-blocks.mjs +410 -0
  25. package/engine/content-captions.mjs +57 -8
  26. package/engine/content-footnotes.mjs +189 -0
  27. package/engine/content-format-check.mjs +15 -12
  28. package/engine/content-index.mjs +2 -2
  29. package/engine/content-links.mjs +14 -6
  30. package/engine/content-tables.mjs +45 -4
  31. package/engine/content-tasklists.mjs +189 -0
  32. package/engine/docs-checks.mjs +4 -4
  33. package/engine/extension-attributes.mjs +48 -2
  34. package/engine/frontmatter-lint.mjs +47 -64
  35. package/engine/frontmatter.mjs +3 -3
  36. package/engine/heading-attributes.mjs +308 -0
  37. package/engine/helpers.mjs +14 -13
  38. package/engine/homepage.mjs +8 -8
  39. package/engine/index.mjs +3 -0
  40. package/engine/infobox-render.mjs +15 -3
  41. package/engine/infobox.mjs +83 -8
  42. package/engine/journals.mjs +36 -17
  43. package/engine/macros.mjs +1 -1
  44. package/engine/markdown-expressions.mjs +178 -53
  45. package/engine/note-dates.mjs +49 -0
  46. package/engine/note-frontmatter.mjs +7 -16
  47. package/engine/note-vocabulary.mjs +172 -37
  48. package/engine/page-lists.mjs +358 -0
  49. package/engine/pdf-build.mjs +76 -42
  50. package/engine/pdf-render.mjs +422 -49
  51. package/engine/rank-ladder.mjs +198 -0
  52. package/engine/reckoning-markers.mjs +1 -1
  53. package/engine/retired-fields.mjs +8 -13
  54. package/engine/scenes.mjs +10 -3
  55. package/engine/site-build.mjs +41 -12
  56. package/engine/site-config.mjs +10 -1
  57. package/engine/site-index.mjs +18 -2
  58. package/engine/site-preview.mjs +18 -7
  59. package/engine/social-ties.mjs +52 -14
  60. package/engine/sql-tables.mjs +66 -9
  61. package/engine/standings.mjs +89 -6
  62. package/engine/system-block.mjs +12 -14
  63. package/engine/web-wikilinks.mjs +48 -1
  64. package/hm3/actors.mjs +6 -3
  65. package/manifest.mjs +13 -0
  66. package/package.json +1 -1
  67. package/sohl/actors.mjs +22 -99
  68. package/sohl/item-fields.mjs +4 -5
  69. package/sohl/items.mjs +1 -1
  70. package/sohl/note-schemas.mjs +11 -21
  71. package/types/config.d.mts +9 -0
  72. package/types/engine/actor-compiler.d.mts +11 -0
  73. package/types/engine/anchored-sections.d.mts +24 -0
  74. package/types/engine/base-compiler.d.mts +19 -0
  75. package/types/engine/base-styles.d.mts +93 -0
  76. package/types/engine/content-blocks.d.mts +87 -0
  77. package/types/engine/content-captions.d.mts +14 -1
  78. package/types/engine/content-footnotes.d.mts +73 -0
  79. package/types/engine/content-format-check.d.mts +8 -6
  80. package/types/engine/content-tables.d.mts +12 -4
  81. package/types/engine/content-tasklists.d.mts +72 -0
  82. package/types/engine/extension-attributes.d.mts +22 -1
  83. package/types/engine/frontmatter.d.mts +1 -1
  84. package/types/engine/heading-attributes.d.mts +174 -0
  85. package/types/engine/helpers.d.mts +7 -4
  86. package/types/engine/homepage.d.mts +7 -7
  87. package/types/engine/index.d.mts +1 -0
  88. package/types/engine/infobox-render.d.mts +6 -1
  89. package/types/engine/journals.d.mts +14 -7
  90. package/types/engine/markdown-expressions.d.mts +23 -0
  91. package/types/engine/note-frontmatter.d.mts +1 -4
  92. package/types/engine/note-vocabulary.d.mts +82 -6
  93. package/types/engine/page-lists.d.mts +126 -0
  94. package/types/engine/pdf-render.d.mts +72 -2
  95. package/types/engine/rank-ladder.d.mts +32 -0
  96. package/types/engine/retired-fields.d.mts +4 -6
  97. package/types/engine/site-build.d.mts +13 -1
  98. package/types/engine/site-config.d.mts +9 -0
  99. package/types/engine/site-index.d.mts +13 -1
  100. package/types/engine/site-preview.d.mts +1 -1
  101. package/types/engine/sql-tables.d.mts +31 -7
  102. package/types/engine/standings.d.mts +21 -3
  103. package/types/engine/system-block.d.mts +11 -12
  104. package/types/engine/web-wikilinks.d.mts +3 -1
  105. package/types/hm3/actors.d.mts +3 -3
  106. package/types/sohl/actors.d.mts +6 -7
  107. package/engine/content-admonitions.mjs +0 -114
  108. package/engine/content-secrets.mjs +0 -112
  109. package/types/engine/content-admonitions.d.mts +0 -24
  110. package/types/engine/content-secrets.d.mts +0 -17
@@ -9,6 +9,7 @@
9
9
  */
10
10
 
11
11
  import { acceptsType, parseAddress, renderAddress } from "./address.mjs";
12
+ import { AddressEntries } from "./address-values.mjs";
12
13
  import { positionOfFrontmatterPath } from "./diagnostics.mjs";
13
14
  import { SOCIAL_TIES, SOCIAL_TIE_TARGET_TYPES } from "./social-tie-terms.mjs";
14
15
 
@@ -16,14 +17,51 @@ export { SOCIAL_TIES, SOCIAL_TIE_TARGET_TYPES };
16
17
 
17
18
  const TERMS = new Set(SOCIAL_TIES.map(({ term }) => term));
18
19
 
20
+ /**
21
+ * A being's defining ties, in one shape whichever shape they reach here in.
22
+ *
23
+ * Two spellings reach this check, and both read the same entries from here
24
+ * on:
25
+ *
26
+ * - an **{@link AddressEntries}** — the map after the note boundary decoded
27
+ * its keys into typed Addresses, the shape every compile sees;
28
+ * - a **plain map**, the shape a caller reading raw YAML sees.
29
+ *
30
+ * `sourceKey` is always the authored key text, so a finding locates on what
31
+ * the file actually says even when `key` already carries a parsed Address.
32
+ *
33
+ * @param {unknown} value - The authored `data.socialTies`.
34
+ * @returns {{form: "absent"|"map"|"malformed",
35
+ * entries: Array<{key: unknown, term: unknown, sourceKey: string}>}}
36
+ */
37
+ function readSocialTies(value) {
38
+ if (value === undefined || value === null) return { form: "absent", entries: [] };
39
+ if (value instanceof AddressEntries) {
40
+ return {
41
+ form: "map",
42
+ entries: value.entries.map((entry) => ({
43
+ key: entry.target,
44
+ term: entry.value,
45
+ sourceKey: entry.sourceKey,
46
+ })),
47
+ };
48
+ }
49
+ if (typeof value !== "object" || Array.isArray(value))
50
+ return { form: "malformed", entries: [] };
51
+ return {
52
+ form: "map",
53
+ entries: Object.entries(value).map(([key, term]) => ({ key, term, sourceKey: key })),
54
+ };
55
+ }
56
+
19
57
  /**
20
58
  * Validate a being's map of defining relationships.
21
59
  * @param {object} note
22
60
  * @param {{index?: object}} [options]
23
61
  */
24
62
  export function checkSocialTies(note, { index } = {}) {
25
- const ties = note.fm?.data?.socialTies;
26
- if (ties === undefined || ties === null) return [];
63
+ const { form, entries } = readSocialTies(note.fm?.data?.socialTies);
64
+ if (form === "absent") return [];
27
65
  const at = (key) => ({
28
66
  file: note.file,
29
67
  ...positionOfFrontmatterPath(
@@ -33,7 +71,7 @@ export function checkSocialTies(note, { index } = {}) {
33
71
  ),
34
72
  severity: "error",
35
73
  });
36
- if (typeof ties !== "object" || Array.isArray(ties))
74
+ if (form === "malformed")
37
75
  return [
38
76
  {
39
77
  ...at(),
@@ -42,11 +80,11 @@ export function checkSocialTies(note, { index } = {}) {
42
80
  ];
43
81
  const findings = [];
44
82
  const seen = new Map();
45
- for (const [key, term] of Object.entries(ties)) {
83
+ for (const { key, term, sourceKey } of entries) {
46
84
  const tuple = parseAddress(
47
85
  key,
48
86
  {
49
- package: index?.contentPackage ?? note.fm?.package,
87
+ package: index?.contentPackage,
50
88
  system: "note",
51
89
  types: index?.types,
52
90
  packages: index?.packages,
@@ -55,43 +93,43 @@ export function checkSocialTies(note, { index } = {}) {
55
93
  );
56
94
  if (tuple.reason) {
57
95
  findings.push({
58
- ...at(key),
59
- message: `data.socialTies key ${JSON.stringify(key)} is not a complete Address (${tuple.reason}); write being or affiliation as its type`,
96
+ ...at(sourceKey),
97
+ message: `data.socialTies key ${JSON.stringify(sourceKey)} is not a complete Address (${tuple.reason}); write being or affiliation as its type`,
60
98
  });
61
99
  } else if (!acceptsType(tuple, SOCIAL_TIE_TARGET_TYPES)) {
62
100
  findings.push({
63
- ...at(key),
101
+ ...at(sourceKey),
64
102
  message: `data.socialTies accepts being or affiliation, not ${tuple.type}`,
65
103
  });
66
104
  } else {
67
105
  const target = renderAddress(tuple);
68
106
  if (seen.has(target)) {
69
107
  findings.push({
70
- ...at(key),
108
+ ...at(sourceKey),
71
109
  message: `data.socialTies names the same target ${target} as ${JSON.stringify(seen.get(target))}; keep one tie per target`,
72
110
  });
73
111
  } else {
74
- seen.set(target, key);
112
+ seen.set(target, sourceKey);
75
113
  }
76
114
  if (
77
- tuple.package === (index?.contentPackage ?? note.fm?.package) &&
115
+ tuple.package === index?.contentPackage &&
78
116
  tuple.type === "being" &&
79
117
  tuple.shortcode === note.fm?.shortcode
80
118
  ) {
81
119
  findings.push({
82
- ...at(key),
120
+ ...at(sourceKey),
83
121
  message: `data.socialTies cannot address this being itself`,
84
122
  });
85
123
  } else if (index?.addressHit && !index.addressHit(target)) {
86
124
  findings.push({
87
- ...at(key),
125
+ ...at(sourceKey),
88
126
  message: `data.socialTies target ${target} does not resolve`,
89
127
  });
90
128
  }
91
129
  }
92
130
  if (!TERMS.has(term))
93
131
  findings.push({
94
- ...at(key),
132
+ ...at(sourceKey),
95
133
  message: `data.socialTies value ${JSON.stringify(term)} must be one of ${SOCIAL_TIES.map(({ term: value }) => value).join(", ")}`,
96
134
  });
97
135
  }
@@ -35,6 +35,7 @@ import path from "node:path";
35
35
 
36
36
  import { FENCE_LINE, parseHeaderArgs } from "./code-fences.mjs";
37
37
  import { booleanAttribute } from "./extension-attributes.mjs";
38
+ import { findPageListBlocks, preparePageLists } from "./page-lists.mjs";
38
39
  import { MARKET_CLASSES } from "./market-class.mjs";
39
40
  import { parseMarkdownFile } from "./helpers.mjs";
40
41
  import { sqlQueriesInMarkdown } from "./markdown-expressions.mjs";
@@ -53,6 +54,31 @@ const EMPTY_CELL = "—";
53
54
  */
54
55
  export const RENDER_ALIASES = Object.freeze({ ref: "_ref", section: "_section" });
55
56
 
57
+ /**
58
+ * The attributes an SQL fence takes, with what each one is for.
59
+ *
60
+ * **This is the one list.** The fence is validated against it and the authoring
61
+ * document is checked against it, so an attribute added here is one the document
62
+ * must describe, and anything else an author writes is reported by name.
63
+ *
64
+ * @type {Readonly<Record<string, Readonly<{value: string, default: string, summary: string}>>>}
65
+ */
66
+ export const SQL_FENCE_ATTRIBUTES = Object.freeze({
67
+ "allow-empty": Object.freeze({
68
+ value: "`true` or `false`",
69
+ default: "`false`",
70
+ summary:
71
+ "Whether a query selecting no rows is allowed. A zero-row result is a finding " +
72
+ "otherwise, because a table that silently prints nothing is the commonest way a " +
73
+ "query goes wrong unnoticed.",
74
+ }),
75
+ "section-level": Object.freeze({
76
+ value: "an integer from 1 through 6",
77
+ default: "`2`",
78
+ summary: "The heading level given to each group a `_section` column creates.",
79
+ }),
80
+ });
81
+
56
82
  /** A table cell may not carry a raw `|` or a line break. */
57
83
  const escapeCell = (text) =>
58
84
  String(text)
@@ -92,7 +118,7 @@ export function findSqlBlocks(markdown) {
92
118
  const level = Number(args["section-level"] ?? 2);
93
119
  const errors = [...problems];
94
120
  for (const key of Object.keys(args)) {
95
- if (!["allow-empty", "section-level"].includes(key))
121
+ if (!Object.hasOwn(SQL_FENCE_ATTRIBUTES, key))
96
122
  errors.push(`${key} is not an SQL fence attribute`);
97
123
  }
98
124
  let allowEmpty = false;
@@ -569,6 +595,10 @@ export function renderSqlTable(
569
595
  * @param {object} [opts]
570
596
  * @param {(ref: string) => boolean} [opts.linkable] - Passed to
571
597
  * {@link renderSqlTable}.
598
+ * @param {object[]} [opts.records] - Content-index records, narrowed to the
599
+ * audience this surface publishes to. Given them, the result also carries
600
+ * `pageLists`, so one prepared object answers every corpus-reading directive
601
+ * a body can hold and a caller looks results up in one place.
572
602
  * **Keyed by note, then by the directive's ordinal within it** — not by its
573
603
  * line. The passes do not agree on what a body is: `walkMarkdownTree` trims it,
574
604
  * while the link checker strips the frontmatter fence and leaves the newlines
@@ -578,8 +608,9 @@ export function renderSqlTable(
578
608
  * @returns {Promise<Map<string, object[]>>} Note to results, in document order,
579
609
  * each carrying either a rendered `markdown` and its `rows`, or a `reason`.
580
610
  */
581
- export async function prepareSqlTables(db, sources, { linkable } = {}) {
611
+ export async function prepareSqlTables(db, sources, { linkable, records } = {}) {
582
612
  const prepared = new Map();
613
+ if (records) prepared.pageLists = preparePageLists(records, sources);
583
614
  for (const { source, markdown } of sources) {
584
615
  const blocks = findSqlBlocks(markdown);
585
616
  if (!blocks.length) continue;
@@ -658,10 +689,14 @@ export async function prepareInlineSqlExpressions(db, sources) {
658
689
  * shared index
659
690
  * describes, where N passes each derive the corpus their own way.
660
691
  *
661
- * **Nothing is opened for a tree with no `sql` directive.** The corpus is still
662
- * written entirely in the retiring language, so until a table is converted this
663
- * costs one walk and no database at all — which is what lets every pass call it
664
- * unconditionally.
692
+ * **Every corpus-reading directive is answered here**, which is why one call
693
+ * precedes every pass: the `sql` tables, the inline scalar queries, and the
694
+ * page lists, whose results ride on the same object as `pageLists`.
695
+ *
696
+ * **Nothing is opened for a tree that asks no query.** A tree with no `sql`
697
+ * directive costs one walk and no database at all, and so does one whose only
698
+ * directive is a page list — a page list is a filter over the records. That is
699
+ * what lets every pass call this unconditionally.
665
700
  *
666
701
  * @param {string} contentBase - Root of the content tree.
667
702
  * @param {object} [opts]
@@ -671,8 +706,9 @@ export async function prepareInlineSqlExpressions(db, sources) {
671
706
  * A command that also builds a link index holds them already, and deriving
672
707
  * them twice is the duplicated-corpus failure this closes.
673
708
  * @param {"all"|"public"} [opts.audience] - Whether to exclude GM notes.
674
- * @returns {Promise<Map<string, object[]>|undefined>} Results by note path, or
675
- * nothing when the tree has no such directive.
709
+ * @returns {Promise<Map<string, object[]>|undefined>} Results by note path,
710
+ * carrying `inline` and `pageLists` beside them, or nothing when the tree
711
+ * holds no such directive.
676
712
  */
677
713
  export async function prepareTreeSqlTables(
678
714
  contentBase,
@@ -701,10 +737,30 @@ export async function prepareTreeSqlTables(
701
737
  if (!isNoteRecord(record)) continue;
702
738
  const absPath = noteFile(contentBase, record);
703
739
  const { body, frontmatter } = parseMarkdownFile(absPath);
704
- if (body && (findSqlBlocks(body).length || sqlQueriesInMarkdown(body, frontmatter).length))
740
+ if (
741
+ body &&
742
+ (findSqlBlocks(body).length ||
743
+ findPageListBlocks(body).length ||
744
+ sqlQueriesInMarkdown(body, frontmatter).length)
745
+ )
705
746
  sources.push({ source: absPath, markdown: body, frontmatter });
706
747
  }
707
748
  if (!sources.length) return undefined;
749
+
750
+ // A page list is a filter over the records, so it needs no database. A tree
751
+ // whose only corpus-reading directive is one therefore opens none — which
752
+ // is the same bargain the early return above makes for a tree with no
753
+ // directive at all.
754
+ const needsDatabase = sources.some(
755
+ ({ markdown, frontmatter }) =>
756
+ findSqlBlocks(markdown).length || sqlQueriesInMarkdown(markdown, frontmatter).length,
757
+ );
758
+ if (!needsDatabase) {
759
+ const pageOnly = new Map();
760
+ pageOnly.inline = new Map();
761
+ pageOnly.pageLists = preparePageLists(indexRecords, sources);
762
+ return pageOnly;
763
+ }
708
764
  // A cell links only where the address it would emit resolves, so a table
709
765
  // never ships a link the wikilink pass will then report dead.
710
766
  //
@@ -736,6 +792,7 @@ export async function prepareTreeSqlTables(
736
792
  try {
737
793
  const prepared = await prepareSqlTables(db, sources, {
738
794
  linkable: (ref) => addresses.has(renderAddress(ref)),
795
+ records: indexRecords,
739
796
  });
740
797
  prepared.inline = await prepareInlineSqlExpressions(db, sources);
741
798
  return prepared;
@@ -40,6 +40,14 @@
40
40
  * what lets each be checked — a rung the body does not confer and an office it
41
41
  * does not hold are both findings naming the file, line and column.
42
42
  *
43
+ * **An entry states a rung, and `rank` is required.** Belonging to a body is
44
+ * holding some standing in it, so `1` is the ordinary member and `0` is the rung
45
+ * for someone cast out — between them they cover every standing a member can
46
+ * hold, which is what makes the requirement writable. `office` is the optional
47
+ * half, and an entry carrying one and no rung is the same fault rather than a
48
+ * second: an office distinguishes a person within a standing rather than
49
+ * standing in for one.
50
+ *
43
51
  * @module
44
52
  */
45
53
 
@@ -122,6 +130,10 @@ export function readStandings(value) {
122
130
  * than by its position in the list: a body may write its rungs in any order,
123
131
  * and several start at zero.
124
132
  *
133
+ * One title per level, which is what the rung check holds a ladder to: two
134
+ * rungs claiming one level is a finding on the second, so a ladder that reaches
135
+ * here carries at most one title for any number a member's `rank` can hold.
136
+ *
125
137
  * @param {unknown} ranks - An affiliation's `data.governance.ranks`.
126
138
  * @returns {Map<number, string>} Level → the title that level is called.
127
139
  */
@@ -177,6 +189,22 @@ export function officeAnchor(office) {
177
189
  return slug ? `office-${slug}` : "";
178
190
  }
179
191
 
192
+ /**
193
+ * The anchor a rung's row is addressed by.
194
+ *
195
+ * The ladder's parallel to {@link officeAnchor}, prefixed apart from it so a
196
+ * body naming an office the same word as one of its ranks still gets two
197
+ * destinations rather than one row answering for both.
198
+ *
199
+ * @param {string} title - The rung's title, as the body wrote it.
200
+ * @returns {string} The anchor, or `""` where the title carries nothing
201
+ * URL-safe.
202
+ */
203
+ export function rankAnchor(title) {
204
+ const slug = slugify(title);
205
+ return slug ? `rank-${slug}` : "";
206
+ }
207
+
180
208
  /**
181
209
  * What an affiliation lends to a reference to it.
182
210
  *
@@ -234,12 +262,14 @@ export function standingPhrase(standing, body, digest) {
234
262
  /**
235
263
  * Validate a being's memberships and the standing it holds in each.
236
264
  *
237
- * Four questions, and the last two are what keying the entry by its body buys:
265
+ * Five questions, and the last three are what keying the entry by its body
266
+ * buys:
238
267
  *
239
268
  * 1. the value is a map keyed by Address (or the list this window still takes);
240
269
  * 2. every key names an affiliation that resolves, once;
241
- * 3. a `rank` is a level the named body's `governance.ranks` declares;
242
- * 4. an `office` is a key of that body's `governance.offices`.
270
+ * 3. the entry states a `rank`;
271
+ * 4. a `rank` is a level the named body's `governance.ranks` declares;
272
+ * 5. an `office` is a key of that body's `governance.offices`.
243
273
  *
244
274
  * Both of the last two are read from the named body's own frontmatter as the
245
275
  * lint runs, so a renamed rung or a renamed post fails on every being pointing
@@ -287,9 +317,18 @@ export function checkStandings(note, { index } = {}) {
287
317
  seen.set(resolved.address, sourceKey);
288
318
  }
289
319
  if (!isKey) continue;
290
- if (standing === undefined || standing === null) continue;
291
- if (Array.isArray(standing) && standing.length === 0) continue;
292
320
  const named = resolved.address ?? String(body);
321
+ // An entry with nothing after its key, and an emptied map arriving from
322
+ // the property editor as `[]`, say exactly what an authored `{}` says:
323
+ // this being belongs to this body, and no standing in it is stated.
324
+ if (
325
+ standing === undefined ||
326
+ standing === null ||
327
+ (Array.isArray(standing) && standing.length === 0)
328
+ ) {
329
+ findings.push(...absentRankFindings(note, {}, named, path));
330
+ continue;
331
+ }
293
332
  if (!mapping(standing)) {
294
333
  findings.push({
295
334
  ...position(note, path),
@@ -309,6 +348,7 @@ export function checkStandings(note, { index } = {}) {
309
348
  `standing holds ${STANDING_KEYS.map((name) => `\`${name}\``).join(" and ")}`,
310
349
  });
311
350
  }
351
+ findings.push(...absentRankFindings(note, standing, named, path));
312
352
  findings.push(...rankFindings(note, standing, named, path, resolved));
313
353
  findings.push(...officeFindings(note, standing, named, path, resolved));
314
354
  }
@@ -344,7 +384,7 @@ function resolveBody(note, index, body) {
344
384
  const tuple = parseAddress(
345
385
  body,
346
386
  {
347
- package: index?.contentPackage ?? note.fm?.package,
387
+ package: index?.contentPackage,
348
388
  system: "note",
349
389
  type: "affiliation",
350
390
  types: index?.types,
@@ -382,6 +422,49 @@ function bodyFindings(note, body, path, isKey, found) {
382
422
  return [];
383
423
  }
384
424
 
425
+ /**
426
+ * What an entry states no `rank` earns.
427
+ *
428
+ * Every member of a body holds some standing in it, and the ladders say so at
429
+ * both ends: `1` is the ordinary member, and `0` is a real rung rather than a
430
+ * default — `0 Níding`, `0 Outlaw`, `0 Vrystrith`, `0 Struck from the Roll` all
431
+ * name someone cast out, who still has a standing. So an entry naming a body
432
+ * and no rung is incomplete, and it is an **error**.
433
+ *
434
+ * **The message carries the rule, not just the gap.** "States no `rank`" sends a
435
+ * reader to find out which number to write; naming `1` and `0` is the whole
436
+ * answer, and the two ends are what makes the requirement writable — there is no
437
+ * standing a member can hold that one of them does not cover.
438
+ *
439
+ * **One finding per entry.** An entry carrying an office and no rung is the same
440
+ * fault and not a second one: an office distinguishes a person *within* a
441
+ * standing rather than standing in for one, so the office is named here as the
442
+ * reason the entry looked complete, rather than reported on its own line as well.
443
+ *
444
+ * @param {object} note - The note.
445
+ * @param {object} standing - The authored entry.
446
+ * @param {string} body - How the body is named in a message.
447
+ * @param {Array<string|number>} path - The entry's frontmatter path.
448
+ * @returns {object[]} The finding, or nothing.
449
+ */
450
+ function absentRankFindings(note, standing, body, path) {
451
+ if (standing.rank !== undefined && standing.rank !== null) return [];
452
+ const office = typeof standing.office === "string" ? standing.office.trim() : "";
453
+ return [
454
+ {
455
+ ...position(note, path, true),
456
+ message:
457
+ `\`data.affiliations\` entry for ${body} states no \`rank\`` +
458
+ (office ?
459
+ `, and the office ${JSON.stringify(office)} is not one — an office ` +
460
+ `distinguishes a person within a standing rather than standing in ` +
461
+ `for one`
462
+ : " — a being that belongs to a body holds some standing in it") +
463
+ ". An ordinary member is `1`, and `0` is the rung for someone cast out",
464
+ },
465
+ ];
466
+ }
467
+
385
468
  /** Whether a `rank` is a rung the named body confers. */
386
469
  function rankFindings(note, standing, body, path, resolved) {
387
470
  if (standing.rank === undefined || standing.rank === null) return [];
@@ -339,17 +339,18 @@ export function sharedProperty(fm, source, defaultValue = undefined) {
339
339
  }
340
340
 
341
341
  /**
342
- * A property a system block may override, else the shared top-level one.
342
+ * A property a system block declares, else the default.
343
343
  *
344
- * This is what gives `pack`, `effects`, `flags` and `img` their per-system form
345
- * without inventing a mechanism for each: a note that wants one value for both
346
- * systems says it once at the top, and a note that needs them to differ says so
347
- * in the block that differs.
344
+ * This is what gives `effects` and `flags` their per-system form without
345
+ * inventing a mechanism for each: a note states them inside the block whose
346
+ * document carries them, which is the only position the format accepts for
347
+ * either. A note needing one value for both systems writes it in both blocks —
348
+ * they are properties of a document, and there are two documents.
348
349
  *
349
350
  * @param {object} fm - The note's frontmatter.
350
351
  * @param {string} block - The block key.
351
352
  * @param {string} key - The property.
352
- * @param {any} [defaultValue] - Returned when neither declares it.
353
+ * @param {any} [defaultValue] - Returned when the block does not declare it.
353
354
  * @returns {any} The value.
354
355
  */
355
356
  export function blockProperty(fm, block, key, defaultValue = undefined) {
@@ -357,8 +358,7 @@ export function blockProperty(fm, block, key, defaultValue = undefined) {
357
358
  if (declared && declared[key] !== undefined && declared[key] !== null) {
358
359
  return declared[key];
359
360
  }
360
- const shared = isMapping(fm) ? /** @type {Record<string, unknown>} */ (fm)[key] : undefined;
361
- return shared === undefined || shared === null ? defaultValue : shared;
361
+ return defaultValue;
362
362
  }
363
363
 
364
364
  /**
@@ -411,12 +411,10 @@ const DATA_PREFIX = "data.";
411
411
  /**
412
412
  * The bare top-level key a `data:`-sourced field is being swept off — step 3b.
413
413
  *
414
- * `data:` did not invent the facts it holds; it *gathered* them, out of
415
- * the note's open top level where each was a sibling of `img` and `shortcode`.
416
- * So the retiring spelling of `data.portrait` is not a second declaration
417
- * anyone has to write — it is `portrait`, mechanically, and the same holds for
418
- * every other key that move relocated. Deriving it is what keeps the two
419
- * spellings of one field from disagreeing the way two declarations would.
414
+ * The retiring spelling of `data.portrait` is not a second declaration anyone
415
+ * has to write — it is `portrait`, mechanically, and the same holds for every
416
+ * other key the container gathered. Deriving it is what keeps the two spellings
417
+ * of one field from disagreeing the way two declarations would.
420
418
  *
421
419
  * **Only a `data.` source has one.** `protection.blunt` and `impact.die` are
422
420
  * paths into containers a note has always written at the top level; they were
@@ -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
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.21.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",