@heroiclands/package-build 23.0.0 → 23.1.1

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 (157) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/README.md +1 -1
  3. package/bin/content-commands.mjs +1 -1
  4. package/ci/content-format-reference.mjs +46 -0
  5. package/docs/api.md +124 -141
  6. package/docs/authoring/assets.md +1 -1
  7. package/docs/authoring/dates-and-calendars.md +12 -6
  8. package/docs/authoring/frontmatter.md +2 -2
  9. package/docs/authoring/links-and-markup.md +145 -29
  10. package/docs/commands.md +8 -10
  11. package/docs/configuration.md +13 -11
  12. package/docs/content-format.md +2 -2
  13. package/docs/guides/book.md +59 -4
  14. package/docs/guides/icons.md +4 -5
  15. package/docs/guides/packs.md +6 -0
  16. package/docs/guides/reference-pages.md +21 -1
  17. package/docs/guides/release-and-deploy.md +39 -7
  18. package/docs/guides/site.md +17 -18
  19. package/docs/reference/format-details.md +877 -276
  20. package/docs/reference/note-types.md +278 -91
  21. package/docs/reference/theme-pages.md +58 -97
  22. package/engine/address-values.mjs +124 -4
  23. package/engine/address.mjs +44 -3
  24. package/engine/anchors.mjs +103 -3
  25. package/engine/art-slots.mjs +2 -9
  26. package/engine/base-compiler.mjs +25 -6
  27. package/engine/calendar-artifacts.mjs +4 -1
  28. package/engine/calendar-notes.mjs +166 -102
  29. package/engine/content-format-check.mjs +2 -3
  30. package/engine/content-format.mjs +44 -1
  31. package/engine/content-format.yaml +79 -16
  32. package/engine/content-images.mjs +10 -1
  33. package/engine/content-index.mjs +37 -9
  34. package/engine/content-links.mjs +24 -5
  35. package/engine/content-tables.mjs +24 -3
  36. package/engine/data-keys.mjs +347 -0
  37. package/engine/derived-sections.mjs +322 -0
  38. package/engine/diagnostics.mjs +28 -0
  39. package/engine/event-fields.mjs +187 -0
  40. package/engine/event-rows.mjs +126 -0
  41. package/engine/event-views.mjs +305 -0
  42. package/engine/exported-scene.mjs +29 -53
  43. package/engine/frontmatter-lint.mjs +103 -77
  44. package/engine/frontmatter.mjs +7 -2
  45. package/engine/generate.mjs +53 -0
  46. package/engine/generated-sections.mjs +223 -0
  47. package/engine/heading-attributes.mjs +4 -2
  48. package/engine/helpers.mjs +12 -1
  49. package/engine/holdings.mjs +12 -33
  50. package/engine/ids.mjs +17 -11
  51. package/engine/index.mjs +4 -4
  52. package/engine/infobox-render.mjs +21 -6
  53. package/engine/infobox.mjs +217 -99
  54. package/engine/journals.mjs +104 -47
  55. package/engine/literature-notes.mjs +4 -1
  56. package/engine/literature-works.mjs +21 -13
  57. package/engine/map-graphviz.mjs +4 -18
  58. package/engine/markdown-expressions.mjs +116 -11
  59. package/engine/metadata-index.mjs +26 -0
  60. package/engine/near-miss.mjs +90 -0
  61. package/engine/note-addresses.mjs +75 -19
  62. package/engine/note-dates.mjs +5 -3
  63. package/engine/note-event-terms.mjs +364 -0
  64. package/engine/note-events.mjs +823 -0
  65. package/engine/note-vocabulary.mjs +184 -88
  66. package/engine/office-holders.mjs +20 -54
  67. package/engine/packagebuild-index.mjs +16 -1
  68. package/engine/pdf-build.mjs +97 -60
  69. package/engine/pdf-index.mjs +263 -0
  70. package/engine/pdf-render.mjs +47 -5
  71. package/engine/place-relation-terms.mjs +174 -0
  72. package/engine/place-relations.mjs +72 -160
  73. package/engine/rank-ladder.mjs +14 -41
  74. package/engine/reckoning-markers.mjs +2 -0
  75. package/engine/region-events.mjs +6 -12
  76. package/engine/scenes.mjs +73 -468
  77. package/engine/site-build.mjs +75 -101
  78. package/engine/site-index.mjs +3 -1
  79. package/engine/site-maps.mjs +43 -29
  80. package/engine/site-preview.mjs +40 -31
  81. package/engine/social-ties.mjs +2 -1
  82. package/engine/sql-tables.mjs +212 -9
  83. package/engine/standing-terms.mjs +145 -6
  84. package/engine/standings.mjs +37 -49
  85. package/engine/web-wikilinks.mjs +21 -1
  86. package/engine/wikilink-syntax.mjs +34 -4
  87. package/engine/wikilinks.mjs +27 -2
  88. package/engine/yaml-lint.mjs +7 -4
  89. package/hugo-theme/layouts/partials/infobox/entry.html +4 -1
  90. package/hugo-theme/layouts/partials/page-body.html +0 -3
  91. package/hugo-theme/static/css/style.css +25 -55
  92. package/package.json +1 -2
  93. package/packagebuild-metadata.jsonl +13 -13
  94. package/release.mjs +62 -4
  95. package/sohl/note-schemas.mjs +8 -73
  96. package/types/engine/address-values.d.mts +50 -3
  97. package/types/engine/address.d.mts +21 -0
  98. package/types/engine/anchors.d.mts +49 -2
  99. package/types/engine/art-slots.d.mts +1 -1
  100. package/types/engine/base-compiler.d.mts +13 -4
  101. package/types/engine/calendar-notes.d.mts +7 -0
  102. package/types/engine/content-format.d.mts +52 -0
  103. package/types/engine/content-images.d.mts +5 -1
  104. package/types/engine/content-tables.d.mts +9 -1
  105. package/types/engine/data-keys.d.mts +145 -0
  106. package/types/engine/derived-sections.d.mts +73 -0
  107. package/types/engine/diagnostics.d.mts +15 -0
  108. package/types/engine/event-fields.d.mts +99 -0
  109. package/types/engine/event-rows.d.mts +12 -0
  110. package/types/engine/event-views.d.mts +67 -0
  111. package/types/engine/exported-scene.d.mts +5 -2
  112. package/types/engine/frontmatter-lint.d.mts +3 -3
  113. package/types/engine/generated-sections.d.mts +143 -0
  114. package/types/engine/holdings.d.mts +6 -20
  115. package/types/engine/ids.d.mts +15 -12
  116. package/types/engine/index.d.mts +1 -1
  117. package/types/engine/infobox.d.mts +13 -3
  118. package/types/engine/journals.d.mts +24 -11
  119. package/types/engine/literature-works.d.mts +1 -2
  120. package/types/engine/map-graphviz.d.mts +2 -10
  121. package/types/engine/markdown-expressions.d.mts +12 -3
  122. package/types/engine/near-miss.d.mts +42 -0
  123. package/types/engine/note-addresses.d.mts +7 -1
  124. package/types/engine/note-event-terms.d.mts +68 -0
  125. package/types/engine/note-events.d.mts +71 -0
  126. package/types/engine/note-vocabulary.d.mts +37 -8
  127. package/types/engine/office-holders.d.mts +3 -0
  128. package/types/engine/pdf-index.d.mts +35 -0
  129. package/types/engine/pdf-render.d.mts +8 -2
  130. package/types/engine/place-relation-terms.d.mts +83 -0
  131. package/types/engine/place-relations.d.mts +11 -66
  132. package/types/engine/rank-ladder.d.mts +2 -0
  133. package/types/engine/region-events.d.mts +6 -12
  134. package/types/engine/scenes.d.mts +6 -32
  135. package/types/engine/site-build.d.mts +8 -22
  136. package/types/engine/site-maps.d.mts +15 -7
  137. package/types/engine/sql-tables.d.mts +77 -2
  138. package/types/engine/standing-terms.d.mts +37 -4
  139. package/types/engine/standings.d.mts +14 -9
  140. package/types/engine/wikilink-syntax.d.mts +20 -0
  141. package/types/engine/wikilinks.d.mts +3 -2
  142. package/types/engine/yaml-lint.d.mts +5 -4
  143. package/types/release.d.mts +4 -0
  144. package/docs/guides/government-migration.md +0 -52
  145. package/engine/itinerary-scenes.mjs +0 -166
  146. package/engine/lore-events.mjs +0 -197
  147. package/engine/map-notes.mjs +0 -1309
  148. package/engine/map-raster.mjs +0 -51
  149. package/hugo-theme/layouts/partials/holdings/entry.html +0 -21
  150. package/hugo-theme/layouts/partials/holdings/kind-groups.html +0 -71
  151. package/hugo-theme/layouts/partials/holdings.html +0 -61
  152. package/hugo-theme/layouts/partials/literature-works.html +0 -29
  153. package/hugo-theme/layouts/partials/place-map.html +0 -9
  154. package/types/engine/itinerary-scenes.d.mts +0 -29
  155. package/types/engine/lore-events.d.mts +0 -14
  156. package/types/engine/map-notes.d.mts +0 -297
  157. package/types/engine/map-raster.d.mts +0 -8
@@ -9,12 +9,12 @@ subType: reference
9
9
 
10
10
  `package-build site` writes a page's front matter; the shared Hugo theme reads
11
11
  it and draws the page around it. This reference describes that second half —
12
- the infobox rail, the hero band, the government and related cards, the place
13
- map, search, and the markup an image or a figure renders as — and names the
12
+ the infobox rail, the hero band, the generated sections, the related card,
13
+ search, and the markup an image or a figure renders as — and names the
14
14
  classes a consumer styles against.
15
15
 
16
16
  **None of the sections below is an authoring instruction.** `infoboxes`,
17
- `related`, `contains`, `governed_by`, `governed_places`, `works`, `map`, `package`, `slug`, `url`
17
+ `related`, `package`, `slug`, `url`
18
18
  and `title` cannot be written by a note at all: a note's top-level region is
19
19
  closed and does not admit them, so each arrives already resolved by the time
20
20
  the theme sees it. Read this page when a panel is missing or a class needs
@@ -47,11 +47,18 @@ infoboxes:
47
47
  - label: Name
48
48
  kind: text
49
49
  value: Brànwâal Dôrgaar
50
- - label: Affiliations
50
+ - label: Homes
51
51
  kind: links
52
52
  value:
53
- - text: The Silent Talon Company
54
- url: /thalorna/affiliation-slntlncmpny/
53
+ - text: Vylar
54
+ url: /thalorna/place-vylar/
55
+ - id: affiliations
56
+ label: Affiliations
57
+ layout: list
58
+ entries:
59
+ - lead: "Captain, Blade (4), of "
60
+ text: The Silent Talon Company
61
+ url: /thalorna/affiliation-slntlncmpny/
55
62
  - id: sohl
56
63
  kind: system
57
64
  system: sohl
@@ -84,6 +91,11 @@ Each layout draws under its own class:
84
91
 
85
92
  A `link` value renders as an anchor where the build reached the page, and as
86
93
  its own words where it did not; a whole `number` is set with digit grouping.
94
+ An entry of a `runin` group or a `list` section is `{lead, text, url}`: `lead`,
95
+ where present, is plain text set before the link, so a membership line —
96
+ `Captain, Blade (4), of The Silent Talon Company` — links the body's name and
97
+ nothing else. A being's memberships arrive as a `list` section of their own,
98
+ one entry per membership, because a line already holds commas.
87
99
 
88
100
  **How the boxes are drawn.**
89
101
 
@@ -166,97 +178,46 @@ artwork where the considered answer was "none". See
166
178
  declines one; the spelling differs from the hand-authored page's, and the two
167
179
  are not interchangeable.
168
180
 
169
- ## The containment and government card
170
-
171
- `partials/holdings.html` renders a bordered card below the body and above the
172
- Related card, holding up to three small tables. The build derives this front
173
- matter from what every _other_ page says, so the three keys are refused in a
174
- note and dropped if one carries them:
175
-
176
- ```yaml
177
- ---
178
- contains: [{ title: …, url: …, type: place, subType: … }] # places within this one
179
- governed_by: [{ title: …, url: …, type: affiliation, subType: … }] # the governing affiliation of this place
180
- governed_places: [{ title: …, url: …, type: place, subType: … }] # places explicitly governed by this affiliation
181
- ---
182
- ```
183
-
184
- A place page carries `contains` and/or `governed_by`; an affiliation page carries
185
- `governed_places`. Each key is tested for independently — a page carrying none of the
186
- three renders no card, and a page carrying one renders one table.
187
-
188
- **`url` is absent for an entry with no page of its own** — a stub note with an
189
- empty body, which still appears in the lists of the pages that name it. Such
190
- an entry's title renders as plain text; every other entry renders as a link.
191
- `partials/holdings/entry.html` carries the guard, the same one
192
- `partials/infobox/entry.html` already applies to its own `{text, url}` shape.
193
-
194
- **Within** (`contains`) and **Governed places** (`governed_places`) list places, and each is
195
- broken into groups by `subType`, in the closed order region, settlement, site,
196
- structure, feature — a `subType` outside that order, or missing entirely,
197
- groups last, alphabetically by its rendered label. **Governed by** (`governed_by`)
198
- lists affiliations, whose kinds have no fixed hierarchy to group by, so it
199
- stays one flat grid with the kind printed beside each name.
200
-
201
- Every grid is `repeat(auto-fill, minmax(14em, 1fr))`, the one the Related card
202
- uses, so a region with forty settlements stays scannable.
203
-
204
- | class | drawn as |
205
- | ----------------------- | ------------------------------------------------------------------------------- |
206
- | `.holdings` | the card |
207
- | `.holdings-table` | one of the three tables, label and body together |
208
- | `.holdings-table-label` | "Within" / "Governed by" / "Governed places" |
209
- | `.holdings-table-grid` | the grid of entries (Governed by; the innermost grid of Within/Governed places) |
210
- | `.holdings-kind-group` | one kind's block within Within or Governed places |
211
- | `.holdings-kind-label` | the kind label heading a group |
212
- | `.holdings-entry-name` | an entry's title, linked or plain |
213
- | `.holdings-entry-kind` | the kind printed beside a Governed by entry |
214
-
215
- A page whose front matter declares none of `contains`, `governed_by` or
216
- `governed_places` renders no card at all.
217
-
218
- ## The "In song and story" card
219
-
220
- `partials/literature-works.html` renders a second card directly below the
221
- government card, listing the works of literature that name the page's subject.
222
- The build derives `works` from every `literature` lore note's `data.subjects`,
223
- including works a fetched index carries, so no note writes it:
224
-
225
- ```yaml
226
- ---
227
- works: [{ title: …, url: …, form: … }] # works naming this page, sorted by title
228
- ---
229
- ```
230
-
231
- `form` is the work's `data.form`, printed in parentheses beside its title, and is
232
- absent when the work states none. A work naming one subject twice lists once, and
233
- a work naming itself is not listed on its own page. An entry's `url` is absent
234
- for a work that publishes no page, which renders as plain text through
235
- `partials/holdings/entry.html`. A page no work names carries no `works` and
236
- renders no card.
237
-
238
- The card reuses the government card's classes, so a consumer styling one styles
239
- both; `.literature-works` on the `<section>` tells them apart.
240
-
241
- | class | drawn as |
242
- | ----------------------- | --------------------------------- |
243
- | `.literature-works` | the card, beside `.holdings` |
244
- | `.holdings-table-label` | "In song and story" |
245
- | `.holdings-table-grid` | the grid of works |
246
- | `.holdings-entry-name` | a work's title, linked or plain |
247
- | `.holdings-entry-kind` | the work's form, beside its title |
248
-
249
- ## The place map
250
-
251
- A place page with `map: from-<shortcode>.svg` displays a **From here** panel
252
- between Holdings and Related. The map is a page-bundle SVG the build draws
253
- from the place's authored borders and routes, and `map:` is not a key a note
254
- may write. Its place names link to their pages. A page with no `map` value or
255
- no matching bundle resource displays no panel.
256
-
257
- The drawing fits the content column and scrolls horizontally when the
258
- available width is narrow. The relevant classes are `.place-map`,
259
- `.place-map-heading`, and `.place-map-drawing`.
181
+ ## Generated sections
182
+
183
+ The build appends sections to the end of a note's body before the page is
184
+ written: what lies within a place, who governs it and what an affiliation
185
+ governs, the events concerning the note, the works naming it, and the map
186
+ from a place. Each is an H1 with a fixed anchor followed by ordinary
187
+ Markdown, so the theme draws it as it draws any authored section — a heading
188
+ in the page, an entry in the table of contents — and no partial, class or
189
+ front-matter key is involved. The same Markdown is a page of the Foundry
190
+ journal and a section of the book.
191
+
192
+ They follow the author's text in this order, each only where it has
193
+ something to show:
194
+
195
+ | Section | Anchor | On | Shows |
196
+ | ----------------- | ---------------- | ------------------------------------------------------ | -------------------------------------------------------------------------------------------- |
197
+ | Within | `within` | a place | the places whose `data.parents` names it, a paragraph to each kind of place |
198
+ | Governed by | `governedby` | a place | the affiliation its `data.government` names, with that affiliation's kind |
199
+ | Governed places | `governedplaces` | an affiliation | the places whose `data.government` names it, a paragraph to each kind of place |
200
+ | Chronology | `chronology` | a place | the [event views](format-details.md#event-views) |
201
+ | Events | `events` | a being, an affiliation or a people | the event views |
202
+ | Accounts | `accounts` | an affiliation, a place or a people | the event views |
203
+ | What followed | `followed` | a note holding events | the event views |
204
+ | In song and story | `insongandstory` | any note a work names | the `literature` works whose `data.subjects` name the note or one of its events, with a form |
205
+ | From here | `fromhere` | a place that states, or is named in, a border or route | the map drawn from the place |
206
+
207
+ An entry naming a note with a page links to it and shows the note's name; an
208
+ entry naming a stub, which publishes no page, is its name as plain text.
209
+
210
+ **The map is set inline.** The website places the drawing inside the **From
211
+ here** section as an SVG within a `figure.note-image.note-image-full-width`,
212
+ so its place names stay links to their pages. The drawing fits the content
213
+ column and scrolls sideways on a narrow screen. `site.maps: false` in the
214
+ configuration gives no place the section.
215
+
216
+ **An author's own section replaces a generated one.** A note whose body
217
+ declares a prose anchor with a section's slug — a heading such as
218
+ `# Within {#within}`, or a block or span with that `id` — gets no generated
219
+ section for it, and its own section is published exactly as written. This
220
+ is the way to choose different contents for one, or to turn one off.
260
221
 
261
222
  ## The related card
262
223
 
@@ -5,7 +5,7 @@
5
5
  * SPDX-License-Identifier: GPL-3.0-or-later
6
6
  */
7
7
 
8
- import { isAddressTuple, renderAddress } from "./address.mjs";
8
+ import { isAddressTuple, readCanonicalKey, renderAddress } from "./address.mjs";
9
9
 
10
10
  /** Address-keyed properties hold typed entries rather than string object keys. */
11
11
  export class AddressEntries {
@@ -17,17 +17,137 @@ export class AddressEntries {
17
17
  }
18
18
  }
19
19
 
20
- /** An Address and the section within its document. */
20
+ /** An Address and an anchor within its note, with the anchor's kind where it is known. */
21
21
  export class AddressLink {
22
- /** @param {object} target - Complete tuple. @param {string} anchor - Section slug. */
23
- constructor(target, anchor) {
22
+ /**
23
+ * @param {object} target - Complete tuple.
24
+ * @param {string} anchor - The anchor's slug.
25
+ * @param {"prose"|"event"|null} [kind] - The anchor's kind, or `null` where the
26
+ * position that holds the link does not fix one.
27
+ */
28
+ constructor(target, anchor, kind = null) {
24
29
  if (!isAddressTuple(target)) throw new TypeError("An Address link needs a complete tuple");
25
30
  this.target = target;
26
31
  this.anchor = anchor;
32
+ this.kind = kind;
27
33
  Object.freeze(this);
28
34
  }
29
35
  }
30
36
 
37
+ /** The keys of an Address as the published index writes it, and no others. */
38
+ export const PUBLISHED_ADDRESS_KEYS = Object.freeze(["address", "anchor", "anchorKind"]);
39
+
40
+ /**
41
+ * Whether a parsed JSON value is an Address in its published form.
42
+ *
43
+ * @param {unknown} value - A value read from a published index.
44
+ * @returns {boolean} Whether it is a map of exactly `address`, `anchor` and
45
+ * `anchorKind`, with `address` a string.
46
+ */
47
+ export function isPublishedAddress(value) {
48
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
49
+ const keys = Object.keys(value);
50
+ return (
51
+ keys.length === PUBLISHED_ADDRESS_KEYS.length &&
52
+ PUBLISHED_ADDRESS_KEYS.every((key) => keys.includes(key)) &&
53
+ typeof value.address === "string"
54
+ );
55
+ }
56
+
57
+ /**
58
+ * Write typed properties in the published index's form.
59
+ *
60
+ * Every Address becomes `{ address, anchor, anchorKind }`: `address` the full
61
+ * canonical Address, `anchor` the anchor's slug or `null`, `anchorKind` its
62
+ * kind or `null`. An Address-keyed map keeps string keys — JSON has no other —
63
+ * and a key takes no anchor, so nothing is lost.
64
+ *
65
+ * @param {any} value - A tuple-bearing tree.
66
+ * @param {WeakMap<object, object>} [seen] - Shared containers in this output.
67
+ * @returns {any} A serializable tree.
68
+ */
69
+ export function publishAddresses(value, seen = new WeakMap()) {
70
+ if (value && typeof value === "object" && seen.has(value)) return seen.get(value);
71
+ if (isAddressTuple(value))
72
+ return { address: renderAddress(value), anchor: null, anchorKind: null };
73
+ if (value instanceof AddressLink)
74
+ return {
75
+ address: renderAddress(value.target),
76
+ anchor: value.anchor,
77
+ anchorKind: value.kind ?? null,
78
+ };
79
+ if (value instanceof AddressEntries) {
80
+ const out = {};
81
+ seen.set(value, out);
82
+ for (const { target, value: entry } of value.entries) {
83
+ const key = renderAddress(target);
84
+ if (Object.hasOwn(out, key)) throw new Error(`Repeated Address map key: ${key}`);
85
+ out[key] = publishAddresses(entry, seen);
86
+ }
87
+ return out;
88
+ }
89
+ if (Array.isArray(value)) {
90
+ const out = [];
91
+ seen.set(value, out);
92
+ for (const entry of value) out.push(publishAddresses(entry, seen));
93
+ return out;
94
+ }
95
+ if (!value || typeof value !== "object" || Object.getPrototypeOf(value) !== Object.prototype)
96
+ return value;
97
+ const out = {};
98
+ seen.set(value, out);
99
+ for (const [key, entry] of Object.entries(value)) out[key] = publishAddresses(entry, seen);
100
+ return out;
101
+ }
102
+
103
+ /**
104
+ * Read a published index record back into typed properties: every published
105
+ * Address becomes a tuple, or an {@link AddressLink} where it carries an anchor.
106
+ *
107
+ * @param {any} value - A parsed record.
108
+ * @returns {any} The same tree, typed.
109
+ * @throws {Error} When a published Address is not a complete canonical one.
110
+ */
111
+ export function readPublishedAddresses(value) {
112
+ // A record already typed in memory keeps its tuples: each is identified by
113
+ // the object itself, which a copy is not.
114
+ if (isAddressTuple(value) || value instanceof AddressLink || value instanceof AddressEntries)
115
+ return value;
116
+ if (isPublishedAddress(value)) {
117
+ const target = readCanonicalKey(value.address);
118
+ if (!isAddressTuple(target))
119
+ throw new Error(`Published Address is not complete: ${JSON.stringify(value.address)}`);
120
+ return value.anchor == null ?
121
+ target
122
+ : new AddressLink(target, value.anchor, value.anchorKind ?? null);
123
+ }
124
+ if (Array.isArray(value)) return value.map(readPublishedAddresses);
125
+ if (!value || typeof value !== "object" || Object.getPrototypeOf(value) !== Object.prototype)
126
+ return value;
127
+ return Object.fromEntries(
128
+ Object.entries(value).map(([key, entry]) => [key, readPublishedAddresses(entry)]),
129
+ );
130
+ }
131
+
132
+ /**
133
+ * Write every published Address in a parsed record as a plain string — the
134
+ * canonical Address, with `#<anchor>` where it carries one. What the SQL tables
135
+ * present, so a fence compares an Address as the text an author writes.
136
+ *
137
+ * @param {any} value - A parsed record.
138
+ * @returns {any} The same tree with each Address a string.
139
+ */
140
+ export function flattenPublishedAddresses(value) {
141
+ if (isPublishedAddress(value))
142
+ return value.anchor == null ? value.address : `${value.address}#${value.anchor}`;
143
+ if (Array.isArray(value)) return value.map(flattenPublishedAddresses);
144
+ if (!value || typeof value !== "object" || Object.getPrototypeOf(value) !== Object.prototype)
145
+ return value;
146
+ return Object.fromEntries(
147
+ Object.entries(value).map(([key, entry]) => [key, flattenPublishedAddresses(entry)]),
148
+ );
149
+ }
150
+
31
151
  /**
32
152
  * Render typed properties at a generated-output boundary.
33
153
  * @param {any} value - A tuple-bearing tree.
@@ -34,9 +34,23 @@
34
34
  * this module and nothing here qualifies one. A field the corpus writes at
35
35
  * both lengths is an Address; a field never written qualified is a shortcode.
36
36
  * - A **Wikilink** is `[[<Address>[#<anchor>]|<text>]]` — a different thing that
37
- * *contains* an Address. The anchor and the label belong to the Wikilink and
38
- * are stripped before anything here sees the value; a frontmatter field holds
39
- * the same tuple with no Wikilink around it.
37
+ * *contains* an Address. The label belongs to the Wikilink.
38
+ *
39
+ * ## An anchor names a place inside what an Address names
40
+ *
41
+ * Any written form may end in `#<anchor>`: `place-ironfells#sack`,
42
+ * `thalorna-note-place-ironfells#sack`, or `ironfells#sack` where the
43
+ * position supplies the type. {@link splitAnchor} is the one reader of that
44
+ * suffix — a Wikilink's anchor and a frontmatter field's are read by it alike —
45
+ * and it hands back the Address part, which {@link parseAddress} reads as it
46
+ * reads any other. The anchor is never part of the tuple: it names a place
47
+ * *within* the note the tuple names, and every anchor a note declares has one
48
+ * of two kinds — `prose` for one its body declares, `event` for an event's `id`
49
+ * ({@link module:engine/anchors}). A wikilink names a `prose` anchor only.
50
+ *
51
+ * **A frontmatter field accepts an anchor only where it declares the kinds it
52
+ * takes** (`anchors: ["event"]`). Every other Address field refuses one with
53
+ * a finding at that value.
40
54
  *
41
55
  * **How an author writes an Address is any suffix of the tuple. At the code
42
56
  * level there is one shape.**
@@ -315,6 +329,33 @@ export function parseAddress(
315
329
  return tuple;
316
330
  }
317
331
 
332
+ /**
333
+ * Split a written value at its anchor.
334
+ *
335
+ * The anchor is everything after the first `#`, and the Address everything
336
+ * before it; both are trimmed. A value with no `#` has no `anchor` key at
337
+ * all, which is how a caller tells "no anchor" from `place-x#`, an empty one.
338
+ *
339
+ * @param {string} written - The value as authored, any Wikilink label removed.
340
+ * @returns {{address: string, anchor?: string}} The two parts.
341
+ */
342
+ export function splitAnchor(written) {
343
+ const text = String(written ?? "");
344
+ const hash = text.indexOf("#");
345
+ if (hash === -1) return { address: text.trim() };
346
+ return { address: text.slice(0, hash).trim(), anchor: text.slice(hash + 1).trim() };
347
+ }
348
+
349
+ /**
350
+ * Whether a written value carries an anchor.
351
+ *
352
+ * @param {unknown} written - The value as authored.
353
+ * @returns {boolean} Whether it is a string holding a `#`.
354
+ */
355
+ export function hasAnchor(written) {
356
+ return typeof written === "string" && written.includes("#");
357
+ }
358
+
318
359
  /** The values created by the Address reader, distinct from authored mappings. */
319
360
  const TUPLES = new WeakSet();
320
361
 
@@ -12,7 +12,13 @@
12
12
  */
13
13
 
14
14
  /**
15
- * The anchors a note declares on headings, captions, divs, poems and spans.
15
+ * The anchors a note declares on headings, captions, divs, poems and spans,
16
+ * and on its events.
17
+ *
18
+ * **Every anchor has a kind** — one of {@link ANCHOR_KINDS} — so a position
19
+ * that accepts an anchor can say which kinds it takes: a `follows` edge names
20
+ * an `event`, and a wikilink a `prose` anchor. **A note has one namespace**: its event `id`s
21
+ * sit beside its body anchors, and a slug two of them share is a finding.
16
22
  *
17
23
  * **A leaf, deliberately.** This is asked by the link checker, by the content
18
24
  * index, and by the builds that emit a link, and they cannot all import one
@@ -37,6 +43,14 @@ import { codeRegions } from "./code-fences.mjs";
37
43
  import { slugify } from "./content-slug.mjs";
38
44
  import { scanFigures } from "./content-figures.mjs";
39
45
  import { HEADING_LINE, splitHeadingAttributes } from "./heading-attributes.mjs";
46
+ import { positionOfFrontmatterPath } from "./diagnostics.mjs";
47
+
48
+ /**
49
+ * What an anchor marks. `prose` is every anchor the body declares — a heading,
50
+ * a caption, a block, an alert, a poem or a span — and `event` is the `id` of
51
+ * an entry in `data.events`.
52
+ */
53
+ export const ANCHOR_KINDS = Object.freeze(["prose", "event"]);
40
54
 
41
55
  /**
42
56
  * The identifiers a note declares on headings, captions, divs, poems and spans.
@@ -52,8 +66,8 @@ import { HEADING_LINE, splitHeadingAttributes } from "./heading-attributes.mjs";
52
66
  * picture's address to the role its asset declares, so a figure anchor's
53
67
  * `name` reads `Map 1` rather than `Figure 1` where it is due — see
54
68
  * {@link module:engine/content-figures.scanFigures}.
55
- * @returns {Array<{slug: string, name: string, level: number, line: number}>}
56
- * In document order.
69
+ * @returns {Array<{slug: string, name: string, level: number, line: number,
70
+ * kind: string}>} In document order.
57
71
  */
58
72
  export function collectAnchors(body, bodyLine = 1, resolveRole) {
59
73
  const anchors = [];
@@ -76,6 +90,7 @@ export function collectAnchors(body, bodyLine = 1, resolveRole) {
76
90
  name: attributes.text,
77
91
  level: heading[1].length,
78
92
  line: bodyLine + i,
93
+ kind: "prose",
79
94
  });
80
95
  }
81
96
  for (const figure of scanFigures(body, { resolveRole }).figures) {
@@ -85,6 +100,7 @@ export function collectAnchors(body, bodyLine = 1, resolveRole) {
85
100
  name: figure.label || figure.caption,
86
101
  level: 1,
87
102
  line: bodyLine + figure.line - 1,
103
+ kind: "prose",
88
104
  });
89
105
  }
90
106
  const blockAnchors = (source, firstLine) => {
@@ -96,6 +112,7 @@ export function collectAnchors(body, bodyLine = 1, resolveRole) {
96
112
  name: block.title || block.id,
97
113
  level: 0,
98
114
  line: firstLine + block.start,
115
+ kind: "prose",
99
116
  });
100
117
  blockAnchors(
101
118
  sourceLines.slice(block.start + 1, block.end).join("\n"),
@@ -111,6 +128,7 @@ export function collectAnchors(body, bodyLine = 1, resolveRole) {
111
128
  name: alert.title,
112
129
  level: 0,
113
130
  line: bodyLine + alert.start,
131
+ kind: "prose",
114
132
  });
115
133
  }
116
134
  for (const poem of scanPoetry(body).blocks) {
@@ -120,6 +138,7 @@ export function collectAnchors(body, bodyLine = 1, resolveRole) {
120
138
  name: poem.id,
121
139
  level: 0,
122
140
  line: bodyLine + poem.start,
141
+ kind: "prose",
123
142
  });
124
143
  }
125
144
  for (const span of scanSpans(body).spans) {
@@ -129,6 +148,7 @@ export function collectAnchors(body, bodyLine = 1, resolveRole) {
129
148
  name: span.text,
130
149
  level: 0,
131
150
  line: bodyLine + span.line - 1,
151
+ kind: "prose",
132
152
  });
133
153
  }
134
154
  return anchors.sort((a, b) => a.line - b.line);
@@ -158,3 +178,83 @@ export function markupAnchorFindings(body) {
158
178
  }
159
179
  return errors;
160
180
  }
181
+
182
+ /**
183
+ * The anchors a note's events declare: one per `data.events` entry carrying
184
+ * an `id`, at the line its `id` is written on.
185
+ *
186
+ * The `id` is taken as written — {@link module:engine/note-events} holds it to
187
+ * the address-segment charset — and named by the note's own name for the event
188
+ * when one is given, else by its summary.
189
+ *
190
+ * @param {object} fm - The note's frontmatter.
191
+ * @param {string} [raw] - The note's full text, for the line; without it the
192
+ * line is omitted rather than guessed.
193
+ * @returns {Array<{slug: string, name: string, level: number, line?: number,
194
+ * kind: "event"}>} In entry order.
195
+ */
196
+ export function eventAnchors(fm, raw) {
197
+ const events = fm?.data?.events;
198
+ if (!Array.isArray(events)) return [];
199
+ const out = [];
200
+ events.forEach((entry, position) => {
201
+ if (!entry || typeof entry !== "object" || typeof entry.id !== "string" || !entry.id)
202
+ return;
203
+ const named =
204
+ Array.isArray(entry.names) ? entry.names.find((n) => n?.name)?.name : undefined;
205
+ const { line } =
206
+ raw ? positionOfFrontmatterPath(raw, ["data", "events", position, "id"]) : {};
207
+ out.push({
208
+ slug: entry.id,
209
+ name: String(named ?? entry.summary ?? entry.id),
210
+ level: 0,
211
+ ...(line ? { line } : {}),
212
+ kind: "event",
213
+ });
214
+ });
215
+ return out;
216
+ }
217
+
218
+ /**
219
+ * Event `id`s that repeat one of the note's body anchors.
220
+ *
221
+ * A note's anchors are one namespace, so `place-ironfells#sack` names one
222
+ * thing whether `sack` is a heading or an event. Two body anchors that collide
223
+ * are reported by the scanners that read them, and two events sharing an `id`
224
+ * by the event check; this reports the remaining case, an event against a body
225
+ * anchor, at the event's `id`.
226
+ *
227
+ * @param {{file: string, fm: object, raw?: string, body?: string}} note - The
228
+ * note, as the link index hands it over.
229
+ * @returns {object[]} Findings, each at the colliding `id`.
230
+ */
231
+ export function noteAnchorFindings(note) {
232
+ const events = eventAnchors(note.fm, note.raw);
233
+ if (!events.length) return [];
234
+ const body =
235
+ typeof note.body === "string" ?
236
+ note.body
237
+ : String(note.raw ?? "").replace(/^---\n[\s\S]*?\n---\n?/, "");
238
+ const taken = new Map();
239
+ for (const anchor of collectAnchors(body)) {
240
+ const slug = slugify(anchor.slug);
241
+ if (!taken.has(slug)) taken.set(slug, anchor);
242
+ }
243
+ const findings = [];
244
+ const entries = note.fm.data.events;
245
+ for (const event of events) {
246
+ const other = taken.get(slugify(event.slug));
247
+ if (!other) continue;
248
+ const position = entries.findIndex((entry) => entry?.id === event.slug);
249
+ findings.push({
250
+ file: note.file,
251
+ ...positionOfFrontmatterPath(note.raw ?? "", ["data", "events", position, "id"]),
252
+ severity: "error",
253
+ message:
254
+ `\`data.events.${position}.id\` ${event.slug} is already a prose anchor ` +
255
+ "in this note — a note's anchors are one namespace, so an event's `id` " +
256
+ "differs from every heading, caption and block slug",
257
+ });
258
+ }
259
+ return findings;
260
+ }
@@ -29,7 +29,7 @@
29
29
  /**
30
30
  * What every art slot accepts, whichever type it defaults to.
31
31
  *
32
- * A sound is not art: `audio` is refused at all four slots, and every slot
32
+ * A sound is not art: `audio` is refused at every slot, and every slot
33
33
  * that defaults to `icon` or to `image` accepts either — the corpus is why:
34
34
  * `sohl-kethira-basic` writes a deity's profile art as `icon: image-…` twenty
35
35
  * times, one full illustration per faith tradition, and a slot refusing the
@@ -40,7 +40,7 @@
40
40
  const ART_TYPES = Object.freeze(["icon", "image"]);
41
41
 
42
42
  /**
43
- * The four art slots, in the order the specification tabulates them.
43
+ * The art slots, in the order the specification tabulates them.
44
44
  *
45
45
  * `banner` is the one that reaches no compiled document: it is the page's hero
46
46
  * image, read by the site and by nothing else. The book's section plates read a
@@ -65,13 +65,6 @@ export const ART_SLOTS = Object.freeze([
65
65
  document: true,
66
66
  describe: "What a token on the canvas wears; unset, it follows `icon`.",
67
67
  }),
68
- Object.freeze({
69
- key: "bgImage",
70
- type: "image",
71
- accepts: ART_TYPES,
72
- document: true,
73
- describe: "A map's background art, resolved into `background.src`.",
74
- }),
75
68
  Object.freeze({
76
69
  key: "banner",
77
70
  type: "image",