@heroiclands/package-build 23.0.0 → 23.1.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 (153) hide show
  1. package/CHANGELOG.md +58 -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 +17 -1
  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/hugo-theme/layouts/partials/infobox/entry.html +4 -1
  89. package/hugo-theme/layouts/partials/page-body.html +0 -3
  90. package/hugo-theme/static/css/style.css +25 -55
  91. package/package.json +1 -2
  92. package/packagebuild-metadata.jsonl +13 -13
  93. package/sohl/note-schemas.mjs +8 -73
  94. package/types/engine/address-values.d.mts +50 -3
  95. package/types/engine/address.d.mts +21 -0
  96. package/types/engine/anchors.d.mts +49 -2
  97. package/types/engine/art-slots.d.mts +1 -1
  98. package/types/engine/base-compiler.d.mts +13 -4
  99. package/types/engine/calendar-notes.d.mts +7 -0
  100. package/types/engine/content-format.d.mts +52 -0
  101. package/types/engine/content-images.d.mts +5 -1
  102. package/types/engine/content-tables.d.mts +9 -1
  103. package/types/engine/data-keys.d.mts +145 -0
  104. package/types/engine/derived-sections.d.mts +73 -0
  105. package/types/engine/diagnostics.d.mts +15 -0
  106. package/types/engine/event-fields.d.mts +99 -0
  107. package/types/engine/event-rows.d.mts +12 -0
  108. package/types/engine/event-views.d.mts +67 -0
  109. package/types/engine/exported-scene.d.mts +5 -2
  110. package/types/engine/frontmatter-lint.d.mts +3 -3
  111. package/types/engine/generated-sections.d.mts +143 -0
  112. package/types/engine/holdings.d.mts +6 -20
  113. package/types/engine/ids.d.mts +15 -12
  114. package/types/engine/index.d.mts +1 -1
  115. package/types/engine/infobox.d.mts +13 -3
  116. package/types/engine/journals.d.mts +24 -11
  117. package/types/engine/literature-works.d.mts +1 -2
  118. package/types/engine/map-graphviz.d.mts +2 -10
  119. package/types/engine/markdown-expressions.d.mts +12 -3
  120. package/types/engine/near-miss.d.mts +42 -0
  121. package/types/engine/note-addresses.d.mts +7 -1
  122. package/types/engine/note-event-terms.d.mts +68 -0
  123. package/types/engine/note-events.d.mts +71 -0
  124. package/types/engine/note-vocabulary.d.mts +37 -8
  125. package/types/engine/office-holders.d.mts +3 -0
  126. package/types/engine/pdf-index.d.mts +35 -0
  127. package/types/engine/pdf-render.d.mts +8 -2
  128. package/types/engine/place-relation-terms.d.mts +83 -0
  129. package/types/engine/place-relations.d.mts +11 -66
  130. package/types/engine/rank-ladder.d.mts +2 -0
  131. package/types/engine/region-events.d.mts +6 -12
  132. package/types/engine/scenes.d.mts +6 -32
  133. package/types/engine/site-build.d.mts +8 -22
  134. package/types/engine/site-maps.d.mts +15 -7
  135. package/types/engine/sql-tables.d.mts +77 -2
  136. package/types/engine/standing-terms.d.mts +37 -4
  137. package/types/engine/standings.d.mts +14 -9
  138. package/types/engine/wikilink-syntax.d.mts +20 -0
  139. package/types/engine/wikilinks.d.mts +3 -2
  140. package/docs/guides/government-migration.md +0 -52
  141. package/engine/itinerary-scenes.mjs +0 -166
  142. package/engine/lore-events.mjs +0 -197
  143. package/engine/map-notes.mjs +0 -1309
  144. package/engine/map-raster.mjs +0 -51
  145. package/hugo-theme/layouts/partials/holdings/entry.html +0 -21
  146. package/hugo-theme/layouts/partials/holdings/kind-groups.html +0 -71
  147. package/hugo-theme/layouts/partials/holdings.html +0 -61
  148. package/hugo-theme/layouts/partials/literature-works.html +0 -29
  149. package/hugo-theme/layouts/partials/place-map.html +0 -9
  150. package/types/engine/itinerary-scenes.d.mts +0 -29
  151. package/types/engine/lore-events.d.mts +0 -14
  152. package/types/engine/map-notes.d.mts +0 -297
  153. package/types/engine/map-raster.d.mts +0 -8
@@ -124,9 +124,15 @@ function namedBlockLines(markdown) {
124
124
  * calling that page "Introduction" would label the description as a preamble to
125
125
  * nothing.
126
126
  *
127
- * A figure fence carries its own record along onto the page it starts, as
128
- * `figure` — `null` for a page a heading started. {@link buildPages} reads it
129
- * to build the `image` or `text` page a fence begins; nothing else sets it.
127
+ * A captioned item's page holds the item alone, and carries the item's record
128
+ * as `figure` — `null` for every other page. {@link buildPages} reads it to
129
+ * build the `image` or `text` page; nothing else sets it. The page is named
130
+ * for the item's number, or else its caption's visible text.
131
+ *
132
+ * Body that follows an item, up to the next page opening, resumes in a
133
+ * **continuation** of the section the item interrupted: the section's name,
134
+ * level and classes, with `continues` naming the item it follows. A
135
+ * continuation holding nothing is not emitted.
130
136
  *
131
137
  * @param {string} body - Markdown body to split.
132
138
  * @param {string} [leadName] - Name of the page before the first heading.
@@ -135,8 +141,8 @@ function namedBlockLines(markdown) {
135
141
  * name reads `Map 1` rather than `Figure 1` where it is due — see
136
142
  * {@link module:engine/content-figures.scanFigures}.
137
143
  * @returns {Array<{name: string, anchorSlug: string|null, level: number,
138
- * classes: string[], figure: object|null, markdown: string}>} Pages in
139
- * document order.
144
+ * classes: string[], figure: object|null, continues?: string,
145
+ * markdown: string}>} Pages in document order.
140
146
  */
141
147
  export function splitPages(body, leadName = "Introduction", resolveRole) {
142
148
  const { markdown, definitions } = separateFootnotes(body);
@@ -150,16 +156,26 @@ export function splitPages(body, leadName = "Introduction", resolveRole) {
150
156
  const beforeFirstH1 = [];
151
157
  let current = null;
152
158
  let codeFence = null;
159
+ /** The page a heading last opened, which a captioned item interrupts. */
160
+ let section = { name: leadName, level: 1, classes: [] };
153
161
 
154
162
  const closeCurrent = () => {
155
163
  if (!current) return;
164
+ const markdown = current.lines.join("\n").trim();
165
+ // A continuation exists only to carry what follows its item; one
166
+ // holding nothing is not a page.
167
+ if (current.continues && !markdown) {
168
+ current = null;
169
+ return;
170
+ }
156
171
  pages.push({
157
172
  name: current.name,
158
173
  anchorSlug: current.anchorSlug,
159
174
  level: current.level,
160
175
  classes: current.classes,
161
176
  figure: current.figure ?? null,
162
- markdown: current.lines.join("\n").trim(),
177
+ ...(current.continues ? { continues: current.continues } : {}),
178
+ markdown,
163
179
  });
164
180
  current = null;
165
181
  };
@@ -190,16 +206,32 @@ export function splitPages(body, leadName = "Introduction", resolveRole) {
190
206
  // lines it claims here are collected by `pageOpenings`, which also
191
207
  // skips a fence and a named block. A figure fence opens a page of its
192
208
  // own, named for the figure's label.
209
+ // A captioned item's page holds the item alone. The body after it
210
+ // resumes in a continuation of the section it interrupted, keyed on
211
+ // the item so its id holds still when pages move around it.
212
+ if (current?.figure && lineIndex > current.figure.close) {
213
+ const item = current.figure;
214
+ closeCurrent();
215
+ current = {
216
+ name: section.name,
217
+ anchorSlug: null,
218
+ level: section.level,
219
+ classes: section.classes,
220
+ continues: item.slug || item.label || captionPageName(item.caption),
221
+ lines: [],
222
+ };
223
+ }
224
+
193
225
  const opening = openings.get(lineIndex);
194
226
  const figure =
195
227
  !inCodeBlock && !blockLines.has(lineIndex) ? figureStarts.get(lineIndex) : null;
196
228
  if (figure) {
197
229
  closeCurrent();
198
230
  current = {
199
- name: figure.label || figure.caption,
231
+ name: figure.label || captionPageName(figure.caption),
200
232
  anchorSlug: figure.id || null,
201
233
  level: 1,
202
- classes: [],
234
+ classes: section.classes.includes(WITHHELD_CLASS) ? [WITHHELD_CLASS] : [],
203
235
  figure,
204
236
  lines: [line],
205
237
  };
@@ -214,6 +246,7 @@ export function splitPages(body, leadName = "Introduction", resolveRole) {
214
246
  classes: opening.classes,
215
247
  lines: [],
216
248
  };
249
+ section = current;
217
250
  continue;
218
251
  }
219
252
 
@@ -270,7 +303,18 @@ export function splitPages(body, leadName = "Introduction", resolveRole) {
270
303
  export function assertUniquePages(rawPages, noteName) {
271
304
  const anchors = new Set();
272
305
  const names = new Set();
306
+ const continuations = new Set();
273
307
  for (const page of rawPages) {
308
+ if (page.continues) {
309
+ const key = `${page.name}\u0000${page.continues}`;
310
+ if (continuations.has(key)) {
311
+ throw new Error(
312
+ `note "${noteName}" has two captioned items both captioned "${page.continues}" in "${page.name}"; give one an {#anchor}`,
313
+ );
314
+ }
315
+ continuations.add(key);
316
+ continue;
317
+ }
274
318
  if (page.anchorSlug) {
275
319
  if (anchors.has(page.anchorSlug)) {
276
320
  throw new Error(
@@ -321,16 +365,49 @@ export function assertUniqueAnchors(rawPages, noteName) {
321
365
  * a lint error. {@link assertUniquePages} states the same thing at compile
322
366
  * time, where the packer would otherwise report only an opaque duplicate key.
323
367
  *
368
+ * A continuation page shares its section's name, so it is keyed by that name
369
+ * and the captioned item it follows.
370
+ *
324
371
  * @param {string} entryId - The owning JournalEntry's `_id`.
325
- * @param {{anchorSlug: string|null, name: string}} page - From {@link splitPages}.
372
+ * @param {{anchorSlug: string|null, name: string, continues?: string}} page -
373
+ * From {@link splitPages}.
326
374
  * @returns {string} A 16-character Foundry id.
327
375
  */
328
376
  export function journalPageId(entryId, page) {
329
- return page.anchorSlug ?
330
- anchorPageId(entryId, page.anchorSlug)
377
+ if (page.anchorSlug) return anchorPageId(entryId, page.anchorSlug);
378
+ return page.continues ?
379
+ makeId("journal-page", `${entryId}:${page.name}:after:${page.continues}`)
331
380
  : makeId("journal-page", `${entryId}:${page.name}`);
332
381
  }
333
382
 
383
+ /**
384
+ * The name of the page a caption begins: the caption's visible text.
385
+ *
386
+ * Foundry shows a page name as plain text, so a caption's markup — emphasis, a
387
+ * code span, a Markdown link, or the `@UUID[...]{label}` a resolved wikilink
388
+ * has already become, inside its wrapping `<span>` — reduces to the text a
389
+ * reader sees, the same text a reference to the caption displays. The markup
390
+ * itself stays in the page's content, where it renders.
391
+ *
392
+ * @param {string} caption - The figure's caption, as the journal pass sees it.
393
+ * @returns {string} Its visible text, whitespace collapsed.
394
+ */
395
+ function captionPageName(caption) {
396
+ if (!caption) return "";
397
+ const [inline] = md.parseInline(caption, {});
398
+ const text = (inline?.children ?? [])
399
+ .map((token) =>
400
+ token.type === "text" || token.type === "code_inline" ? token.content
401
+ : token.type === "softbreak" || token.type === "hardbreak" ? " "
402
+ : "",
403
+ )
404
+ .join("");
405
+ return text
406
+ .replace(/@UUID\[[^\]]*\]\{([^}]*)\}/g, "$1")
407
+ .replace(/\s+/g, " ")
408
+ .trim();
409
+ }
410
+
334
411
  /**
335
412
  * Whether a caption carries markup an `image` page's `caption` field cannot
336
413
  * hold.
@@ -381,36 +458,13 @@ function soleFigurePicture(figure, page) {
381
458
  return IMAGE_EXTENSIONS.includes(extension) ? images[0].src : null;
382
459
  }
383
460
 
384
- /**
385
- * Whether a figure fence is the whole of the page {@link splitPages} gave it.
386
- *
387
- * A figure always starts a page of its own, but nothing closes one: trailing
388
- * prose before the next heading or figure stays on it, exactly as it would
389
- * behind an ordinary heading. An `image` page holds a `src` and a caption and
390
- * nowhere else to put that prose, so it is refused the type and kept a `text`
391
- * page, where the prose renders after the figure's own div as it always has.
392
- *
393
- * @param {object} figure - The figure record.
394
- * @param {object} page - From {@link splitPages}.
395
- * @returns {boolean} Whether nothing follows the fence's closing `:::`.
396
- */
397
- function figureIsWholePage(figure, page) {
398
- const offset = figure.line - 1;
399
- const after = page.markdown
400
- .split("\n")
401
- .slice(figure.close - offset + 1)
402
- .join("\n");
403
- return after.trim() === "";
404
- }
405
-
406
461
  /**
407
462
  * The fields particular to the Foundry page a captioned item begins.
408
463
  *
409
- * A fence holding exactly one picture, captioned with no inline markup, and
410
- * standing alone on its page becomes a page of type `image`: `src` the
411
- * picture, `image.caption` the caption text, the page named for the figure's
412
- * own number. Every other fence — grouped, an audio embed, a caption
413
- * `image.caption` cannot hold, or one trailing prose keeps company with —
464
+ * A captioned item holding exactly one picture, captioned with no inline
465
+ * markup, becomes a page of type `image`: `src` the picture, `image.caption`
466
+ * the caption text, the page named for the figure's own number. Every other
467
+ * item — grouped, an audio embed, or a caption `image.caption` cannot hold —
414
468
  * becomes a page of type `text`.
415
469
  *
416
470
  * The `text` page's content is rendered and then relabeled. The render sees
@@ -433,7 +487,7 @@ function figureIsWholePage(figure, page) {
433
487
  */
434
488
  function buildFigurePageFields(figure, page, pageId, footnoteNumbers, resolveRole) {
435
489
  const picture = soleFigurePicture(figure, page);
436
- if (picture && figureIsWholePage(figure, page) && !captionCarriesMarkup(figure.caption)) {
490
+ if (picture && !captionCarriesMarkup(figure.caption)) {
437
491
  let src = picture;
438
492
  try {
439
493
  src = resolveImg(picture) ?? picture;
@@ -442,7 +496,7 @@ function buildFigurePageFields(figure, page, pageId, footnoteNumbers, resolveRol
442
496
  // image's own render takes when no configuration resolves it.
443
497
  }
444
498
  return {
445
- name: figure.label || figure.caption,
499
+ name: figure.label || captionPageName(figure.caption),
446
500
  type: "image",
447
501
  src,
448
502
  image: figure.hasCaption ? { caption: figure.caption } : {},
@@ -460,7 +514,7 @@ function buildFigurePageFields(figure, page, pageId, footnoteNumbers, resolveRol
460
514
  resolveRole,
461
515
  );
462
516
  return {
463
- name: figure.label || figure.caption,
517
+ name: figure.label || captionPageName(figure.caption),
464
518
  type: "text",
465
519
  text: {
466
520
  format: 1,
@@ -478,11 +532,12 @@ function buildFigurePageFields(figure, page, pageId, footnoteNumbers, resolveRol
478
532
  * @param {Array<object>} rawPages - From {@link splitPages}.
479
533
  * @param {string} entryId - The owning JournalEntry's `_id`.
480
534
  * @param {string} noteName - The note, for error messages.
481
- * A page whose heading carries `.secret` is given to the GM alone, by the one
482
- * ownership value Foundry reads as "nobody but a GM". Every other page states
483
- * no ownership and inherits the journal's. A figure-started page states none
484
- * either: {@link scanFigures} validates a figure's classes against its own
485
- * closed vocabulary, which carries no `.secret`.
535
+ * A page whose opening heading carries `.secret` is given to the GM alone, by
536
+ * the one ownership value Foundry reads as "nobody but a GM"; so is a captioned
537
+ * item's page and a continuation page inside such a section. Every other page
538
+ * states no ownership and inherits the journal's. A caption's own classes carry
539
+ * no `.secret`, because {@link scanFigures} validates them against a closed
540
+ * vocabulary without it.
486
541
  *
487
542
  * @param {Array<object>} figures - From {@link scanFigures}, numbered across
488
543
  * the whole note. Read by a page a heading started; a page a figure started
@@ -536,7 +591,9 @@ export function buildPages(rawPages, entryId, noteName, figures, resolveRole) {
536
591
  return {
537
592
  _id: pageId,
538
593
  ...fields,
539
- title: { show: true, level: page.level ?? 1 },
594
+ // A continuation resumes its section, whose title its first
595
+ // page already shows.
596
+ title: { show: !page.continues, level: page.level ?? 1 },
540
597
  ...(withheld ? { ownership: { default: WITHHELD_OWNERSHIP } } : {}),
541
598
  _key: `!journal.pages!${entryId}.${pageId}`,
542
599
  };
@@ -93,7 +93,10 @@ export const LITERATURE_FIELDS = Object.freeze([
93
93
  shape: "list of Addresses",
94
94
  kind: "list",
95
95
  entryKind: "address",
96
- describe: "The beings, places, gods, events and other notes the work concerns.",
96
+ anchors: Object.freeze(["event"]),
97
+ describe:
98
+ "The beings, places, gods and other notes the work concerns, and each " +
99
+ "event it concerns, named as `place-ironfells#sack`.",
97
100
  },
98
101
  {
99
102
  name: "language",
@@ -13,26 +13,25 @@
13
13
 
14
14
  /**
15
15
  * The works of literature that concern each page, derived from what the works
16
- * say. A `literature` lore note names its subjects in `data.subjects`; the site
17
- * build inverts that into a `works` list on every subject's page, so a reader on
18
- * a hero's, a place's or a god's page reaches the epics and legends about it.
19
- * The subject note writes nothing.
16
+ * say. A `literature` lore note names its subjects in `data.subjects`; the build
17
+ * inverts that into the **In song and story** section of every subject — see
18
+ * {@link module:engine/derived-sections} — so a reader of a hero, a place or a
19
+ * god reaches the epics and legends about it. The subject note writes nothing.
20
20
  *
21
- * A work in a fetched index lists on this package's subject pages when that
22
- * index carries its `subjects`. A subject in another package has no page here,
21
+ * A subject naming an event lists the work on the page of the note holding
22
+ * that event. A work in a fetched index lists on this package's subject pages
23
+ * when that index carries its `subjects`. A subject in another package has no page here,
23
24
  * so a local work naming one lists nowhere.
24
25
  *
25
26
  * @module
26
27
  */
27
28
 
28
- import { parseAddress, renderAddress } from "./address.mjs";
29
+ import { parseAddress, renderAddress, splitAnchor } from "./address.mjs";
30
+ import { AddressLink } from "./address-values.mjs";
29
31
  import { readCanonicalKey } from "./content-address.mjs";
30
32
  import { LITERATURE_SUBTYPE } from "./literature-notes.mjs";
31
33
 
32
- /** The page front-matter key the site build writes. */
33
- export const WORKS_KEY = "works";
34
-
35
- /** @typedef {{title: string, url?: string, form?: string}} WorkEntry */
34
+ /** @typedef {{title: string, url?: string, address: string, form?: string}} WorkEntry */
36
35
  /**
37
36
  * @typedef {object} WorksNode
38
37
  * @property {string} key Own Address, normalized to the `note` system.
@@ -43,10 +42,18 @@ export const WORKS_KEY = "works";
43
42
  * @property {unknown[]} subjects A work's `data.subjects`; empty for any other note.
44
43
  */
45
44
 
46
- /** The note-system key of one Address, or "" where it names nothing. */
45
+ /**
46
+ * The note-system key of one Address, or "" where it names nothing. A subject
47
+ * naming an event — `place-ironfells#sack`, or an {@link AddressLink} read
48
+ * from one — keys the note that holds the event.
49
+ */
47
50
  function addressKey(value, pkg, types) {
48
51
  if (value == null) return "";
49
- const tuple = parseAddress(value, { package: pkg ?? "local", system: "note", types });
52
+ const note =
53
+ value instanceof AddressLink ? value.target
54
+ : typeof value === "string" ? splitAnchor(value).address
55
+ : value;
56
+ const tuple = parseAddress(note, { package: pkg ?? "local", system: "note", types });
50
57
  if (tuple.reason) return "";
51
58
  return renderAddress({ ...tuple, system: "note" }).toLowerCase();
52
59
  }
@@ -143,6 +150,7 @@ export function worksPages(nodes, { types }) {
143
150
  block.works.push({
144
151
  title: work.title,
145
152
  ...(work.url ? { url: work.url } : {}),
153
+ address: work.key,
146
154
  ...(work.form ? { form: work.form } : {}),
147
155
  });
148
156
  }
@@ -4,7 +4,6 @@
4
4
 
5
5
  import fs from "node:fs";
6
6
  import { instance } from "@viz-js/viz";
7
- import { Resvg } from "@resvg/resvg-js";
8
7
 
9
8
  /** The layout engines accepted by the map command. */
10
9
  export const GRAPHVIZ_ENGINES = Object.freeze(["dot", "twopi", "neato"]);
@@ -12,22 +11,16 @@ export const GRAPHVIZ_ENGINES = Object.freeze(["dot", "twopi", "neato"]);
12
11
  const graphviz = await instance();
13
12
 
14
13
  /**
15
- * Render a DOT source file to SVG, plain positions, or a PNG background.
16
- *
17
- * A PNG uses the same Graphviz SVG layout as the vector map. SVG dimensions
18
- * are in points; resvg starts at 96 pixels per inch, so the zoom converts
19
- * the requested DPI to the same physical size as Graphviz's PNG output.
14
+ * Render a DOT source file to SVG.
20
15
  *
21
16
  * @param {string} dotPath - The saved DOT source.
22
17
  * @param {string} outPath - The output file.
23
18
  * @param {object} opts
24
19
  * @param {"dot"|"twopi"|"neato"} opts.engine - Layout engine.
25
- * @param {"svg"|"plain"|"png"} [opts.format="svg"] - Output format.
26
20
  * @param {2} [opts.nop] - Preserve positioned nodes as `neato -n2` does.
27
- * @param {number} [opts.dpi=96] - PNG resolution.
28
21
  * @returns {{warnings: string}} Renderer warnings.
29
22
  */
30
- export function renderDot(dotPath, outPath, { engine, format = "svg", nop, dpi = 96 }) {
23
+ export function renderDot(dotPath, outPath, { engine, nop }) {
31
24
  if (!GRAPHVIZ_ENGINES.includes(engine)) {
32
25
  throw new Error(`Unsupported Graphviz engine "${engine}" for ${dotPath}`);
33
26
  }
@@ -35,19 +28,12 @@ export function renderDot(dotPath, outPath, { engine, format = "svg", nop, dpi =
35
28
  try {
36
29
  const result = graphviz.render(dot, {
37
30
  engine: nop === 2 ? "nop2" : engine,
38
- format: format === "png" ? "svg" : format,
31
+ format: "svg",
39
32
  });
40
33
  if (result.status !== "success" || !result.output) {
41
34
  throw new Error(result.errors.map((error) => error.message).join("; ") || "no output");
42
35
  }
43
- if (format === "png") {
44
- const image = new Resvg(result.output, {
45
- fitTo: { mode: "zoom", value: dpi / 96 },
46
- }).render();
47
- fs.writeFileSync(outPath, image.asPng());
48
- } else {
49
- fs.writeFileSync(outPath, result.output);
50
- }
36
+ fs.writeFileSync(outPath, result.output);
51
37
  // The embedded renderer uses Times metrics for the generated bold
52
38
  // headings; its warning repeats for every map and needs no action.
53
39
  return {
@@ -8,6 +8,7 @@ import { formatDateInCalendar, parseNoteDate } from "./note-dates.mjs";
8
8
  import { numberWords, numberDigits } from "./number-words.mjs";
9
9
  import { slugify } from "./content-slug.mjs";
10
10
  import { WIKILINK, authoredLabel, isSamePage, parseWikilink } from "./wikilink-syntax.mjs";
11
+ import { EVENT_REFERENCE_FIELDS, eventFieldText } from "./event-fields.mjs";
11
12
 
12
13
  /**
13
14
  * An inline expression, as an author writes one.
@@ -152,7 +153,9 @@ const HELPERS = Object.freeze([
152
153
  }),
153
154
  Object.freeze({
154
155
  name: "ref",
155
- params: 'address [form="number"|"full"|"title"]',
156
+ params:
157
+ 'address [form="number"|"full"|"title"] ' +
158
+ '| address field="when"|"until"|"kind"|"summary"|"name"',
156
159
  summary:
157
160
  'A link to a captioned item, by its anchor — `"#thorn"` on this note, ' +
158
161
  '`"note-address#thorn"` on another. `form` is `number` (the default, and ' +
@@ -160,11 +163,21 @@ const HELPERS = Object.freeze([
160
163
  "caption; or `title`, the caption alone. Always renders as a link to the " +
161
164
  "figure, and a link inside the caption contributes only its label text. " +
162
165
  "The number rendered is the one the figure carries on the surface doing " +
163
- "the rendering, whichever note names it.",
166
+ "the rendering, whichever note names it. With `field` in place of `form`, " +
167
+ 'the address names an event — `"place-ironfells#sack"`, `"#sack"` on this ' +
168
+ 'note, or `"lore-founding"` for a note holding one event — and the reference ' +
169
+ "prints that event's `when`, `until`, `kind`, `summary` or `name` as plain " +
170
+ "text, never a link; a date prints as the note format prints any date.",
164
171
  make:
165
- ({ figures }) =>
172
+ ({ figures, events, fm, dates }) =>
166
173
  (address, options) => {
167
174
  const hash = options?.hash ?? {};
175
+ if (Object.hasOwn(hash, "field")) {
176
+ if (Object.hasOwn(hash, "form")) throw new RangeError(REF_BOTH_FAULT);
177
+ const result = eventReference(address, hash.field, { events, fm, dates });
178
+ if (result.problem) throw new RangeError(result.problem);
179
+ return result.text;
180
+ }
168
181
  const form = Object.hasOwn(hash, "form") ? hash.form : "number";
169
182
  const formFault = refFormFault(form);
170
183
  if (formFault) throw new RangeError(formFault);
@@ -184,6 +197,74 @@ const HELPERS = Object.freeze([
184
197
  }),
185
198
  ]);
186
199
 
200
+ /** What a reference writing both `form` and `field` is told. */
201
+ const REF_BOTH_FAULT = "ref takes form= for a figure or field= for an event, not both";
202
+
203
+ /**
204
+ * The text one event reference prints, or why it prints nothing.
205
+ *
206
+ * The address is read through the wikilink grammar, as a figure reference's
207
+ * is: `"#sack"` names an event of this note, `"place-ironfells#sack"` one of
208
+ * another, and `"lore-founding"` a note holding exactly one event. The anchor
209
+ * must be one the note declares, and an `event` one.
210
+ *
211
+ * @param {unknown} address - The reference's address.
212
+ * @param {unknown} field - The field to print, one of {@link EVENT_REFERENCE_FIELDS}.
213
+ * @param {{events?: {note: (written: string) => object|undefined,
214
+ * own?: (fm: object) => object|undefined}, fm?: object,
215
+ * dates?: object}} context - Every note's events, this note's frontmatter,
216
+ * and the reckoning context.
217
+ * @returns {{text: string}|{problem: string}} The text, with any link reduced to
218
+ * its label, or the finding's message.
219
+ */
220
+ function eventReference(address, field, { events, fm, dates } = {}) {
221
+ if (!EVENT_REFERENCE_FIELDS.includes(field))
222
+ return { problem: `ref's field must be one of ${EVENT_REFERENCE_FIELDS.join(", ")}` };
223
+ if (typeof address !== "string" || !address.trim())
224
+ return { problem: "ref needs an address naming an event" };
225
+ if (!events?.note)
226
+ return { problem: `ref "${address}" names an event, and this build resolves none` };
227
+ const parsed = parseWikilink(address);
228
+ const same = isSamePage(parsed);
229
+ const note = same ? events.own?.(fm) : events.note(parsed.target);
230
+ if (!note)
231
+ return {
232
+ problem:
233
+ same ?
234
+ `ref "${address}" names an event of this note, which this build does not resolve`
235
+ : `ref "${address}" addresses "${parsed.target}", which names no note this build resolves`,
236
+ };
237
+ let event;
238
+ if (parsed.anchor) {
239
+ const anchor = note.anchors.find((one) => one?.slug === parsed.anchor);
240
+ event = note.events.find((one) => one.id === parsed.anchor);
241
+ if (anchor && anchor.kind !== "event")
242
+ return {
243
+ problem:
244
+ `ref "${address}" names "#${parsed.anchor}", a ${anchor.kind ?? "prose"} ` +
245
+ "anchor — field= reads an event",
246
+ };
247
+ if (!event)
248
+ return {
249
+ problem: `ref "${address}" names no anchor "#${parsed.anchor}" in "${note.target}"`,
250
+ };
251
+ } else {
252
+ if (note.events.length !== 1)
253
+ return {
254
+ problem:
255
+ note.events.length === 0 ?
256
+ `ref "${address}" names "${note.target}", which holds no events`
257
+ : `ref "${address}" names "${note.target}", which holds ` +
258
+ `${note.events.length} events — name one as "${parsed.target}#<id>"`,
259
+ };
260
+ event = note.events[0];
261
+ }
262
+ const value = eventFieldText(event, field, dates);
263
+ if (value.missing)
264
+ return { problem: `ref "${address}" field="${field}": that event states no ${field}` };
265
+ return { text: flattenCaptionLinks(value.text) };
266
+ }
267
+
187
268
  /** The closed set of forms a figure reference may render as; `number` is the default. */
188
269
  const REF_FORMS = Object.freeze(["number", "full", "title"]);
189
270
 
@@ -382,10 +463,25 @@ export function sqlQueriesInMarkdown(body, fm = {}) {
382
463
  * @param {object} node - A `MustacheStatement`/`SubExpression` AST node.
383
464
  * @param {object} [figures] - This note's figures, and how to reach another
384
465
  * note's — see {@link refFigure}.
466
+ * @param {object} [eventContext] - What an event reference reads — see
467
+ * {@link eventReference}.
385
468
  * @returns {string|null} A finding message, or `null` when the call is sound.
386
469
  */
387
- function refCallFault(node, figures) {
470
+ function refCallFault(node, figures, eventContext) {
388
471
  const pair = node.hash?.pairs?.find((entry) => entry.key === "form");
472
+ const fieldPair = node.hash?.pairs?.find((entry) => entry.key === "field");
473
+ if (fieldPair) {
474
+ if (fieldPair.value.type !== "StringLiteral")
475
+ return (
476
+ "ref's field must be a quoted string — one of " + EVENT_REFERENCE_FIELDS.join(", ")
477
+ );
478
+ if (pair) return REF_BOTH_FAULT;
479
+ const addressParam = node.params?.[0];
480
+ if (addressParam?.type !== "StringLiteral") return null;
481
+ return (
482
+ eventReference(addressParam.value, fieldPair.value.value, eventContext).problem ?? null
483
+ );
484
+ }
389
485
  if (pair && pair.value.type !== "StringLiteral")
390
486
  return `ref's form must be a quoted string — one of ${REF_FORMS.join(", ")}`;
391
487
  const form = pair ? pair.value.value : "number";
@@ -413,10 +509,12 @@ function refCallFault(node, figures) {
413
509
  * @param {string} body
414
510
  * @param {object} [figures] - This note's figures, and how to reach another
415
511
  * note's — see {@link refFigure}.
512
+ * @param {object} [eventContext] - What an event reference reads — see
513
+ * {@link eventReference}.
416
514
  * @returns {Array<{offset: number, message: string}>} One entry per faulty
417
515
  * `{{...}}` expression, `offset` into `body`.
418
516
  */
419
- function refFaults(body, figures) {
517
+ function refFaults(body, figures, eventContext) {
420
518
  const faults = [];
421
519
  for (const match of matchAllOutsideCode(body, EXPRESSION)) {
422
520
  let program;
@@ -428,7 +526,7 @@ function refFaults(body, figures) {
428
526
  const visit = (node) => {
429
527
  if (!node || typeof node !== "object") return;
430
528
  if (node.path?.original === "ref") {
431
- const message = refCallFault(node, figures);
529
+ const message = refCallFault(node, figures, eventContext);
432
530
  if (message) faults.push({ offset: match.index, message });
433
531
  }
434
532
  for (const parameter of node.params ?? []) visit(parameter);
@@ -452,22 +550,29 @@ function positionAt(body, offset, bodyLine) {
452
550
  * figures?: {get: (id: string) => {label: string, caption: string,
453
551
  * hasCaption: boolean}|undefined, note?: (address: string) =>
454
552
  * {url: string|null, figures: Map<string, object>}|undefined,
455
- * link?: Function}, file?: string, bodyLine?: number}} [options] -
553
+ * link?: Function}, events?: {note: (written: string) => object|undefined,
554
+ * own?: (fm: object) => object|undefined},
555
+ * file?: string, bodyLine?: number}} [options] -
456
556
  * `figures` is the `ref` helper's own view of the corpus: `get` reads this
457
557
  * note's captioned items by id, and `note` resolves another note's the
458
558
  * way a wikilink would — omitted, a cross-note address is refused rather
459
559
  * than looked up. `link` overrides how a resolved reference renders; the
460
- * default is a Markdown link to the target's own anchor.
560
+ * default is a Markdown link to the target's own anchor. `events` is what an
561
+ * event reference reads: every note's events by Address, from
562
+ * {@link module:engine/event-fields.eventNoteIndex}; omitted, an event
563
+ * reference is refused.
461
564
  */
462
565
  export function renderMarkdownExpressions(
463
566
  body,
464
- { fm, dates, sqlResults, figures, file, bodyLine = 1 } = {},
567
+ { fm, dates, sqlResults, figures, events, file, bodyLine = 1 } = {},
465
568
  ) {
466
569
  const findings = [];
467
570
  const engine = Handlebars.create();
468
571
  for (const helper of HELPERS)
469
- engine.registerHelper(helper.name, helper.make({ sqlResults, dates, figures }));
470
- const staticFaults = new Map(refFaults(body, figures).map((f) => [f.offset, f.message]));
572
+ engine.registerHelper(helper.name, helper.make({ sqlResults, dates, figures, events, fm }));
573
+ const staticFaults = new Map(
574
+ refFaults(body, figures, { events, fm, dates }).map((f) => [f.offset, f.message]),
575
+ );
471
576
  const markdown = replaceOutsideCode(body, EXPRESSION, (source, _inside, offset) => {
472
577
  const fault = staticFaults.get(offset);
473
578
  if (fault) {
@@ -307,6 +307,27 @@ function newestIndex(dirs) {
307
307
  return path.join(dir, entries[0]);
308
308
  }
309
309
 
310
+ /**
311
+ * A published record's events, each with its `id`, the dates the publishing
312
+ * package resolved for it, and the entry as published, which an inline
313
+ * reference to one of its fields reads.
314
+ *
315
+ * @param {Record<string, any>} record - An index record.
316
+ * @returns {Array<{id?: string, when?: object, until?: object, entry: object}>|undefined}
317
+ * In entry order, or `undefined` for a note with none.
318
+ */
319
+ function publishedEvents(record) {
320
+ const events = record.data?.events;
321
+ if (!Array.isArray(events) || events.length === 0) return undefined;
322
+ const resolved = record.resolvedDates?.events ?? [];
323
+ return events.map((entry, position) => ({
324
+ ...(typeof entry?.id === "string" ? { id: entry.id } : {}),
325
+ ...(resolved[position]?.when ? { when: resolved[position].when } : {}),
326
+ ...(resolved[position]?.until ? { until: resolved[position].until } : {}),
327
+ entry,
328
+ }));
329
+ }
330
+
310
331
  /**
311
332
  * Resolve every foreign address this build can cite, from the fetched indexes.
312
333
  *
@@ -437,6 +458,11 @@ export function loadForeignIndexes(config, localPackages, bases = PACKAGE_BASE)
437
458
  ...(Object.hasOwn(record.data ?? {}, "government") ?
438
459
  { government: record.data.government }
439
460
  : {}),
461
+ // Every anchor the note declares, with its kind, and each of its
462
+ // events with its id and resolved date, so a consumer's
463
+ // `follows` edge into this package is resolved and ordered here.
464
+ noteAnchors: record.anchors ?? undefined,
465
+ events: publishedEvents(record),
440
466
  // What a work of literature concerns and what kind of work it
441
467
  // is, so a consumer's subject pages list a dependency's works.
442
468
  ...(parts.type === "lore" && record.subType === "literature" ?