@heroiclands/package-build 22.28.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 (156) hide show
  1. package/CHANGELOG.md +72 -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 +125 -142
  6. package/docs/authoring/assets.md +1 -1
  7. package/docs/authoring/dates-and-calendars.md +12 -6
  8. package/docs/authoring/first-note.md +1 -1
  9. package/docs/authoring/frontmatter.md +2 -2
  10. package/docs/authoring/links-and-markup.md +145 -29
  11. package/docs/commands.md +8 -10
  12. package/docs/configuration.md +13 -11
  13. package/docs/content-format.md +3 -3
  14. package/docs/guides/book.md +59 -4
  15. package/docs/guides/icons.md +4 -5
  16. package/docs/guides/packs.md +6 -0
  17. package/docs/guides/reference-pages.md +21 -1
  18. package/docs/guides/release-and-deploy.md +17 -1
  19. package/docs/guides/site.md +17 -18
  20. package/docs/reference/format-details.md +904 -275
  21. package/docs/reference/note-types.md +282 -91
  22. package/docs/reference/theme-pages.md +58 -66
  23. package/engine/address-values.mjs +124 -4
  24. package/engine/address.mjs +44 -3
  25. package/engine/anchors.mjs +103 -3
  26. package/engine/art-slots.mjs +2 -9
  27. package/engine/base-compiler.mjs +25 -6
  28. package/engine/calendar-artifacts.mjs +4 -1
  29. package/engine/calendar-notes.mjs +166 -102
  30. package/engine/content-format-check.mjs +2 -3
  31. package/engine/content-format.mjs +44 -1
  32. package/engine/content-format.yaml +84 -16
  33. package/engine/content-images.mjs +10 -1
  34. package/engine/content-index.mjs +37 -9
  35. package/engine/content-links.mjs +24 -5
  36. package/engine/content-tables.mjs +24 -3
  37. package/engine/data-keys.mjs +347 -0
  38. package/engine/derived-sections.mjs +322 -0
  39. package/engine/diagnostics.mjs +28 -0
  40. package/engine/event-fields.mjs +187 -0
  41. package/engine/event-rows.mjs +126 -0
  42. package/engine/event-views.mjs +305 -0
  43. package/engine/exported-scene.mjs +29 -53
  44. package/engine/frontmatter-lint.mjs +103 -77
  45. package/engine/frontmatter.mjs +7 -2
  46. package/engine/generate.mjs +53 -0
  47. package/engine/generated-sections.mjs +223 -0
  48. package/engine/heading-attributes.mjs +4 -2
  49. package/engine/helpers.mjs +12 -1
  50. package/engine/holdings.mjs +12 -33
  51. package/engine/ids.mjs +17 -11
  52. package/engine/index.mjs +4 -4
  53. package/engine/infobox-render.mjs +21 -6
  54. package/engine/infobox.mjs +217 -99
  55. package/engine/journals.mjs +104 -47
  56. package/engine/literature-notes.mjs +136 -0
  57. package/engine/literature-works.mjs +161 -0
  58. package/engine/map-graphviz.mjs +4 -18
  59. package/engine/markdown-expressions.mjs +116 -11
  60. package/engine/metadata-index.mjs +34 -0
  61. package/engine/near-miss.mjs +90 -0
  62. package/engine/note-addresses.mjs +75 -19
  63. package/engine/note-dates.mjs +5 -3
  64. package/engine/note-event-terms.mjs +364 -0
  65. package/engine/note-events.mjs +823 -0
  66. package/engine/note-vocabulary.mjs +195 -92
  67. package/engine/office-holders.mjs +20 -54
  68. package/engine/packagebuild-index.mjs +16 -1
  69. package/engine/pdf-build.mjs +97 -60
  70. package/engine/pdf-index.mjs +263 -0
  71. package/engine/pdf-render.mjs +47 -5
  72. package/engine/place-relation-terms.mjs +174 -0
  73. package/engine/place-relations.mjs +72 -160
  74. package/engine/rank-ladder.mjs +14 -41
  75. package/engine/reckoning-markers.mjs +2 -0
  76. package/engine/region-events.mjs +6 -12
  77. package/engine/scenes.mjs +73 -468
  78. package/engine/site-build.mjs +74 -85
  79. package/engine/site-index.mjs +3 -1
  80. package/engine/site-maps.mjs +43 -29
  81. package/engine/site-preview.mjs +40 -31
  82. package/engine/social-ties.mjs +2 -1
  83. package/engine/sql-tables.mjs +212 -9
  84. package/engine/standing-terms.mjs +145 -6
  85. package/engine/standings.mjs +37 -49
  86. package/engine/stub-lint.mjs +6 -0
  87. package/engine/web-wikilinks.mjs +21 -1
  88. package/engine/wikilink-syntax.mjs +34 -4
  89. package/engine/wikilinks.mjs +27 -2
  90. package/hugo-theme/layouts/partials/infobox/entry.html +4 -1
  91. package/hugo-theme/layouts/partials/page-body.html +0 -2
  92. package/hugo-theme/static/css/style.css +25 -55
  93. package/package.json +1 -2
  94. package/packagebuild-metadata.jsonl +13 -13
  95. package/sohl/default-item-art.mjs +6 -6
  96. package/sohl/note-schemas.mjs +8 -73
  97. package/types/engine/address-values.d.mts +50 -3
  98. package/types/engine/address.d.mts +21 -0
  99. package/types/engine/anchors.d.mts +49 -2
  100. package/types/engine/art-slots.d.mts +1 -1
  101. package/types/engine/base-compiler.d.mts +13 -4
  102. package/types/engine/calendar-notes.d.mts +7 -0
  103. package/types/engine/content-format.d.mts +52 -0
  104. package/types/engine/content-images.d.mts +5 -1
  105. package/types/engine/content-tables.d.mts +9 -1
  106. package/types/engine/data-keys.d.mts +145 -0
  107. package/types/engine/derived-sections.d.mts +73 -0
  108. package/types/engine/diagnostics.d.mts +15 -0
  109. package/types/engine/event-fields.d.mts +99 -0
  110. package/types/engine/event-rows.d.mts +12 -0
  111. package/types/engine/event-views.d.mts +67 -0
  112. package/types/engine/exported-scene.d.mts +5 -2
  113. package/types/engine/frontmatter-lint.d.mts +3 -3
  114. package/types/engine/generated-sections.d.mts +143 -0
  115. package/types/engine/holdings.d.mts +6 -20
  116. package/types/engine/ids.d.mts +15 -12
  117. package/types/engine/index.d.mts +1 -1
  118. package/types/engine/infobox.d.mts +13 -3
  119. package/types/engine/journals.d.mts +24 -11
  120. package/types/engine/literature-notes.d.mts +29 -0
  121. package/types/engine/literature-works.d.mts +67 -0
  122. package/types/engine/map-graphviz.d.mts +2 -10
  123. package/types/engine/markdown-expressions.d.mts +12 -3
  124. package/types/engine/near-miss.d.mts +42 -0
  125. package/types/engine/note-addresses.d.mts +7 -1
  126. package/types/engine/note-event-terms.d.mts +68 -0
  127. package/types/engine/note-events.d.mts +71 -0
  128. package/types/engine/note-vocabulary.d.mts +37 -8
  129. package/types/engine/office-holders.d.mts +3 -0
  130. package/types/engine/pdf-index.d.mts +35 -0
  131. package/types/engine/pdf-render.d.mts +8 -2
  132. package/types/engine/place-relation-terms.d.mts +83 -0
  133. package/types/engine/place-relations.d.mts +11 -66
  134. package/types/engine/rank-ladder.d.mts +2 -0
  135. package/types/engine/region-events.d.mts +6 -12
  136. package/types/engine/scenes.d.mts +6 -32
  137. package/types/engine/site-build.d.mts +8 -22
  138. package/types/engine/site-maps.d.mts +15 -7
  139. package/types/engine/sql-tables.d.mts +77 -2
  140. package/types/engine/standing-terms.d.mts +37 -4
  141. package/types/engine/standings.d.mts +14 -9
  142. package/types/engine/wikilink-syntax.d.mts +20 -0
  143. package/types/engine/wikilinks.d.mts +3 -2
  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/place-map.html +0 -9
  153. package/types/engine/itinerary-scenes.d.mts +0 -29
  154. package/types/engine/lore-events.d.mts +0 -14
  155. package/types/engine/map-notes.d.mts +0 -297
  156. package/types/engine/map-raster.d.mts +0 -8
@@ -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,19 @@ 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),
466
+ // What a work of literature concerns and what kind of work it
467
+ // is, so a consumer's subject pages list a dependency's works.
468
+ ...(parts.type === "lore" && record.subType === "literature" ?
469
+ {
470
+ subjects: record.data?.subjects ?? undefined,
471
+ form: record.data?.form ?? undefined,
472
+ }
473
+ : {}),
440
474
  package: pkg,
441
475
  });
442
476
  const entry = index.get(key);
@@ -0,0 +1,90 @@
1
+ /*
2
+ * This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
3
+ * Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
4
+ *
5
+ * This work is licensed under the GNU General Public License v3.0 (GPLv3).
6
+ * You may copy, modify, and distribute it under the terms of that license.
7
+ *
8
+ * For full terms, see the LICENSE.md file in the project root or visit:
9
+ * https://www.gnu.org/licenses/gpl-3.0.html
10
+ *
11
+ * SPDX-License-Identifier: GPL-3.0-or-later
12
+ */
13
+
14
+ /**
15
+ * The near miss a closed vocabulary names when it refuses a key: the declared
16
+ * key an unknown one was most likely meant to be, and the declared list as an
17
+ * English sentence.
18
+ *
19
+ * Shared by every closed region — the top level, `data:`, a system block, and
20
+ * the inner keys of a `data:` field — so a misspelling is answered the same
21
+ * way at every depth.
22
+ *
23
+ * @module
24
+ */
25
+
26
+ /**
27
+ * Edit distance, capped — enough to answer "did you mean".
28
+ *
29
+ * A misspelled property is the failure class this check exists for, and a
30
+ * finding that names the key the author *meant* turns a hunt through the
31
+ * reference into a one-character fix.
32
+ *
33
+ * @param {string} a - One string.
34
+ * @param {string} b - The other.
35
+ * @returns {number} The Levenshtein distance.
36
+ */
37
+ export function distance(a, b) {
38
+ const rows = a.length + 1;
39
+ const cols = b.length + 1;
40
+ let prev = Array.from({ length: cols }, (_, j) => j);
41
+ for (let i = 1; i < rows; i += 1) {
42
+ const row = [i];
43
+ for (let j = 1; j < cols; j += 1) {
44
+ row[j] = Math.min(
45
+ prev[j] + 1,
46
+ row[j - 1] + 1,
47
+ prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1),
48
+ );
49
+ }
50
+ prev = row;
51
+ }
52
+ return prev[cols - 1];
53
+ }
54
+
55
+ /**
56
+ * A closed vocabulary as an English list, for the message that names it.
57
+ *
58
+ * Written from the declaration rather than spelled into the message, so a key
59
+ * the vocabulary gains cannot go unmentioned by the finding that refuses its
60
+ * neighbours.
61
+ *
62
+ * @param {readonly string[]} keys - The declared keys, in declared order.
63
+ * @returns {string} `"a, b, or c"`.
64
+ */
65
+ export function listed(keys) {
66
+ if (keys.length < 2) return keys.join("");
67
+ return `${keys.slice(0, -1).join(", ")}, or ${keys[keys.length - 1]}`;
68
+ }
69
+
70
+ /**
71
+ * The declared key an unknown one was most likely meant to be.
72
+ *
73
+ * @param {string} key - The unknown key.
74
+ * @param {Iterable<string>} candidates - The declared keys.
75
+ * @returns {string|undefined} The nearest, when it is near enough to suggest.
76
+ */
77
+ export function nearest(key, candidates) {
78
+ let best;
79
+ let bestAt = Infinity;
80
+ for (const candidate of candidates) {
81
+ const d = distance(key.toLowerCase(), candidate.toLowerCase());
82
+ if (d < bestAt) {
83
+ bestAt = d;
84
+ best = candidate;
85
+ }
86
+ }
87
+ // A third of the key's length, so a suggestion is a plausible typo rather
88
+ // than the least-bad of a list of unrelated words.
89
+ return bestAt <= Math.max(1, Math.floor(key.length / 3)) ? best : undefined;
90
+ }
@@ -12,15 +12,25 @@ import {
12
12
  parseAddress,
13
13
  renderAddress,
14
14
  acceptsType,
15
+ hasAnchor,
15
16
  readCanonicalKey,
17
+ splitAnchor,
16
18
  } from "./address.mjs";
17
19
  import { NOTE_VOCABULARY, dataFields } from "./note-vocabulary.mjs";
18
20
  import { ASSET_TYPE_NAMES } from "./asset-types.mjs";
19
21
  import { ART_SLOTS } from "./art-slots.mjs";
20
22
  import { SHIPPED_SYSTEMS } from "./subtype-registry.mjs";
23
+ import { EVENT_ADDRESS_POSITIONS } from "./note-events.mjs";
21
24
 
22
- import { AddressEntries, AddressLink } from "./address-values.mjs";
23
- export { AddressEntries, encodeAddresses, cloneAddressState } from "./address-values.mjs";
25
+ import { AddressEntries, AddressLink, readPublishedAddresses } from "./address-values.mjs";
26
+ export {
27
+ AddressEntries,
28
+ encodeAddresses,
29
+ cloneAddressState,
30
+ publishAddresses,
31
+ readPublishedAddresses,
32
+ flattenPublishedAddresses,
33
+ } from "./address-values.mjs";
24
34
 
25
35
  /** Structured positions whose Address is nested inside a declared container. */
26
36
  export const STRUCTURED_ADDRESSES = Object.freeze([
@@ -77,12 +87,24 @@ export function addressPositions(fm, context = {}) {
77
87
  shape,
78
88
  type: field.ref,
79
89
  accepts: field.accepts,
90
+ ...(field.anchors ? { anchors: field.anchors } : {}),
80
91
  ...(ASSET_TYPE_NAMES.has(field.ref) || field.ref === "folder" ?
81
92
  { system: "none" }
82
93
  : {}),
83
94
  });
84
95
  }
85
96
  out.push(...STRUCTURED_ADDRESSES.filter((p) => !p.noteType || p.noteType === fm.type));
97
+ // An event's Addresses, on every type that carries events. Read leniently:
98
+ // a value that is no accepted Address stays as written, for the event
99
+ // check to refuse at its own key with the message its key earns, rather
100
+ // than taking the whole note out of the index.
101
+ if ((dataFields(fm.type) ?? []).some((field) => field.name === "events"))
102
+ for (const position of EVENT_ADDRESS_POSITIONS)
103
+ out.push({
104
+ ...position,
105
+ path: ["data", "events", "*", ...position.path],
106
+ lenient: true,
107
+ });
86
108
  for (const system of new Set([
87
109
  ...SHIPPED_SYSTEMS,
88
110
  ...Object.keys(context.systemBlocks ?? {}),
@@ -206,7 +228,27 @@ export function decodeNoteAddresses(fm, context) {
206
228
  };
207
229
  const read = (value, path) => {
208
230
  if (value == null || value === "") return value;
209
- const tuple = parseAddress(value, defaults, {
231
+ if (value instanceof AddressLink) {
232
+ if (!position.anchors)
233
+ fail(
234
+ value,
235
+ path,
236
+ "takes no anchor — it names a whole note, so write the Address without `#…`",
237
+ );
238
+ return value;
239
+ }
240
+ // An anchor is admitted only where the position declares the kinds
241
+ // it takes; the anchor's existence and kind are a resolution
242
+ // question, asked where the index is.
243
+ const anchored = hasAnchor(value);
244
+ if (anchored && !position.anchors)
245
+ fail(
246
+ value,
247
+ path,
248
+ "takes no anchor — it names a whole note, so write the Address without `#…`",
249
+ );
250
+ const { address: written, anchor } = anchored ? splitAnchor(value) : { address: value };
251
+ const tuple = parseAddress(written, defaults, {
210
252
  declared: true,
211
253
  legacyShortcodeCase: position.legacyShortcodeCase,
212
254
  });
@@ -217,7 +259,13 @@ export function decodeNoteAddresses(fm, context) {
217
259
  path,
218
260
  `must name a ${position.accepts.join(" or ")} Address, but names ${tuple.type}`,
219
261
  );
220
- return tuple;
262
+ return anchored ?
263
+ new AddressLink(
264
+ tuple,
265
+ anchor,
266
+ position.anchors.length === 1 ? position.anchors[0] : null,
267
+ )
268
+ : tuple;
221
269
  };
222
270
  const convert = (value, path) => {
223
271
  if (value == null || value === "") return value;
@@ -258,12 +306,24 @@ export function decodeNoteAddresses(fm, context) {
258
306
  }
259
307
  return read(value, path);
260
308
  };
309
+ // A lenient position keeps a value it cannot read, as written.
310
+ const settle = (value, path) => {
311
+ if (!position.lenient) return convert(value, path);
312
+ try {
313
+ return convert(value, path);
314
+ } catch {
315
+ return value;
316
+ }
317
+ };
261
318
  // Copy each traversed owner so YAML aliases retain their own read context.
262
319
  const visit = (owner, path, offset = 0, actual = []) => {
263
320
  if (owner == null || owner === "") return owner;
264
321
  const key = path[offset];
265
322
  if (key === "*") {
266
- if (!Array.isArray(owner)) fail(owner, actual, "must be a list of entries");
323
+ if (!Array.isArray(owner)) {
324
+ if (position.lenient) return owner;
325
+ fail(owner, actual, "must be a list of entries");
326
+ }
267
327
  return owner.map((value, i) => visit(value, path, offset + 1, [...actual, i]));
268
328
  }
269
329
  if (typeof owner !== "object") return owner;
@@ -271,7 +331,7 @@ export function decodeNoteAddresses(fm, context) {
271
331
  const copy = Array.isArray(owner) ? [...owner] : { ...owner };
272
332
  copy[key] =
273
333
  offset === path.length - 1 ?
274
- convert(owner[key], [...actual, key])
334
+ settle(owner[key], [...actual, key])
275
335
  : visit(owner[key], path, offset + 1, [...actual, key]);
276
336
  return copy;
277
337
  };
@@ -312,11 +372,18 @@ export function noteAddressContext(config) {
312
372
 
313
373
  /**
314
374
  * Decode Address properties contributed by an index record.
375
+ *
376
+ * A record read from a published index writes every Address as
377
+ * `{ address, anchor, anchorKind }`; each becomes a tuple, or an
378
+ * {@link AddressLink} where it carries an anchor, before the declared
379
+ * positions are read.
380
+ *
315
381
  * @param {object} record - The record.
316
382
  * @param {object} context - Builder defaults.
317
383
  * @returns {object} The typed record.
318
384
  */
319
385
  export function decodeIndexAddresses(record, context) {
386
+ Object.assign(record, readPublishedAddresses(record));
320
387
  decodeNoteAddresses(record, context);
321
388
  const canonical = (value, keyPath) => {
322
389
  const target = readCanonicalKey(value);
@@ -335,19 +402,8 @@ export function decodeIndexAddresses(record, context) {
335
402
  if (record.address?.canonical != null)
336
403
  record.address.canonical = canonical(record.address.canonical, ["address", "canonical"]);
337
404
  for (const anchor of record.anchors ?? []) {
338
- if (!anchor.link || anchor.link instanceof AddressLink) continue;
339
- const [written, section] = anchor.link.split("#");
340
- const target = parseAddress(
341
- written,
342
- {
343
- ...context,
344
- system: "note",
345
- types: new Set([...Object.keys(NOTE_VOCABULARY), ...ASSET_TYPE_NAMES]),
346
- },
347
- { declared: true },
348
- );
349
- if (target.reason) throw new Error(`Index anchor link is not an Address: ${anchor.link}`);
350
- anchor.link = new AddressLink(target, section);
405
+ if (anchor.link != null && !(anchor.link instanceof AddressLink))
406
+ throw new Error(`Index anchor link is not an Address: ${JSON.stringify(anchor.link)}`);
351
407
  }
352
408
  return record;
353
409
  }
@@ -955,11 +955,13 @@ function resolveEventEntry(entry, context) {
955
955
 
956
956
  /** Normalize a note's declared dates for indexes and generated pages. */
957
957
  export function resolvedDateFields(fm, context) {
958
- if (fm?.type === "lore") {
959
- const events = Array.isArray(fm.data?.events) ? fm.data.events : [];
960
- const resolved = events.map((entry) => resolveEventEntry(entry, context));
958
+ // A note carrying `data.events` — a lore, place or affiliation note — is
959
+ // dated by them.
960
+ if (Array.isArray(fm?.data?.events)) {
961
+ const resolved = fm.data.events.map((entry) => resolveEventEntry(entry, context));
961
962
  return resolved.length ? { events: resolved } : {};
962
963
  }
964
+ if (fm?.type === "lore") return {};
963
965
  const fields =
964
966
  fm?.type === "being" ?
965
967
  [