@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
@@ -0,0 +1,823 @@
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 **event** schema: what one entry of `data.events` may say, and the check
16
+ * that holds every entry to it.
17
+ *
18
+ * An event is an entry in `data.events` on a `lore`, `place` or `affiliation`
19
+ * note. `note-vocabulary.mjs` declares the field once, as `EVENTS_FIELD`, and
20
+ * spreads that one object into all three types, so the schema cannot differ
21
+ * between them.
22
+ *
23
+ * **What an entry holds is declared once, in
24
+ * {@link module:engine/note-event-terms}**, as an inner-key declaration the
25
+ * field carries. The frontmatter lint's inner-key check reads it like every
26
+ * other `data:` field's: every key of an entry, and of each map nested in one,
27
+ * is closed, a required key is stated, and each value has its declared kind.
28
+ * This module walks the same declaration for what the values mean: every
29
+ * closed value is one of the lists the format reference documents as its
30
+ * vocabulary tables, text is not blank, and every Address resolves through the
31
+ * index the way a `data:` Address field does.
32
+ *
33
+ * **Identity.** An entry may carry `id`, an address segment, unique within its
34
+ * note and required when the note holds two or more events. The `id` is an
35
+ * anchor of kind `event` ({@link module:engine/anchors}), in the one namespace
36
+ * the note's headings and blocks share, so an event is addressed as
37
+ * `<note address>#<id>` — or as its note's address alone when the note holds
38
+ * one. `follows[].event` and `where.reach[].attributedTo` are the keys that
39
+ * accept an anchor, and only of kind `event`.
40
+ *
41
+ * **Consequence.** A `follows` edge is written on the later event and names an
42
+ * earlier one, so an edge pointing at a later `when` is a finding, and the
43
+ * graph of every edge in the corpus has no cycle. An edge touching a `when`
44
+ * that has no position on the axis — `unknown`, or a year-`0` date that recurs
45
+ * in every year — is not ordered. An edge into another package is resolved and
46
+ * ordered against that package's published index, which carries each note's
47
+ * anchors with their kinds and each event's resolved date.
48
+ *
49
+ * Every finding is positioned at the key or value that earned it, under
50
+ * `["data", "events", position, ...]`.
51
+ *
52
+ * @module
53
+ */
54
+
55
+ import {
56
+ acceptsType,
57
+ hasAnchor,
58
+ isAddressTuple,
59
+ parseAddress,
60
+ renderAddress,
61
+ splitAnchor,
62
+ } from "./address.mjs";
63
+ import { collectAnchors, eventAnchors, noteAnchorFindings } from "./anchors.mjs";
64
+ import { AddressLink } from "./address-values.mjs";
65
+ import { isAddressSegment } from "./address-charset.mjs";
66
+ import { frontmatterValueAt, positionOfFrontmatterPath } from "./diagnostics.mjs";
67
+ import { parseNoteDate } from "./note-dates.mjs";
68
+ import { reckoningContext } from "./reckoning-markers.mjs";
69
+
70
+ import {
71
+ ACCOUNT_AGREES,
72
+ EVENT_DEPTHS,
73
+ EVENT_ENTRY,
74
+ EVENT_ENTRY_KEYS,
75
+ EVENT_KINDS,
76
+ EVENT_STANDINGS,
77
+ EVENT_VOCABULARIES,
78
+ FOLLOWS_HOW,
79
+ PARTICIPANT_ROLES,
80
+ REACH_KNOWLEDGE,
81
+ } from "./note-event-terms.mjs";
82
+
83
+ export {
84
+ ACCOUNT_AGREES,
85
+ EVENT_DEPTHS,
86
+ EVENT_ENTRY,
87
+ EVENT_ENTRY_KEYS,
88
+ EVENT_KINDS,
89
+ EVENT_STANDINGS,
90
+ EVENT_VOCABULARIES,
91
+ FOLLOWS_HOW,
92
+ PARTICIPANT_ROLES,
93
+ REACH_KNOWLEDGE,
94
+ };
95
+
96
+ /** The declaration of one inner key of an entry, by its path of key names. */
97
+ function keyAt(...names) {
98
+ let spec = EVENT_ENTRY;
99
+ for (const name of names) {
100
+ const inner = spec.entries ?? spec;
101
+ spec = inner.fields.find((field) => field.name === name);
102
+ }
103
+ return spec;
104
+ }
105
+
106
+ /**
107
+ * Every Address position inside one event entry, derived from the schema
108
+ * above: its path from the entry (`*` for each list item), whether it holds one
109
+ * Address or a list of them, and its default type, accepted types and anchor
110
+ * kinds. The note boundary reads these Addresses into tuples, so the published
111
+ * index writes them as it writes every other Address.
112
+ *
113
+ * @type {ReadonlyArray<{path: readonly string[], shape: "value"|"list",
114
+ * type?: string, accepts?: readonly string[], anchors?: readonly string[]}>}
115
+ */
116
+ export const EVENT_ADDRESS_POSITIONS = Object.freeze(
117
+ (function positions(spec, at) {
118
+ if (spec.own) return [];
119
+ if (spec.kind === "address")
120
+ return [
121
+ Object.freeze({
122
+ path: Object.freeze(at),
123
+ shape: "value",
124
+ ...(spec.ref ? { type: spec.ref } : {}),
125
+ ...(spec.accepts ? { accepts: spec.accepts } : {}),
126
+ ...(spec.anchors ? { anchors: spec.anchors } : {}),
127
+ }),
128
+ ];
129
+ if (spec.kind === "list" && spec.entries) {
130
+ if (spec.entries.kind === "address")
131
+ return positions(spec.entries, at).map((one) =>
132
+ Object.freeze({ ...one, shape: "list" }),
133
+ );
134
+ return positions(spec.entries, [...at, "*"]);
135
+ }
136
+ if (spec.fields)
137
+ return spec.fields.flatMap((field) => positions(field, [...at, field.name]));
138
+ return [];
139
+ })(EVENT_ENTRY, []),
140
+ );
141
+
142
+ /**
143
+ * An Address as text: the note boundary reads an event's Addresses into tuples,
144
+ * and an anchored one into an {@link AddressLink}, while a value it could not
145
+ * read stays as written. Every one is checked in its written form.
146
+ *
147
+ * @param {unknown} value - The value at an Address key.
148
+ * @returns {unknown} The canonical Address, with `#<anchor>` where it has one,
149
+ * or the value unchanged.
150
+ */
151
+ function writtenOf(value) {
152
+ if (isAddressTuple(value)) return renderAddress(value);
153
+ if (value instanceof AddressLink) return `${renderAddress(value.target)}#${value.anchor}`;
154
+ return value;
155
+ }
156
+
157
+ /** Whether a value is a plain map — not `null`, not an array, not a parsed Address. */
158
+ function mapping(value) {
159
+ return (
160
+ value !== null &&
161
+ typeof value === "object" &&
162
+ !Array.isArray(value) &&
163
+ !isAddressTuple(value)
164
+ );
165
+ }
166
+
167
+ /** A path under the frontmatter as a message names it. */
168
+ const dotted = (path) => path.map(String).join(".");
169
+
170
+ /**
171
+ * Walk one value against its declaration for what it means, collecting
172
+ * findings and the Addresses to resolve.
173
+ *
174
+ * Shape is not asked here. An undeclared key, a required key left out and a
175
+ * value of the wrong kind are the inner-key check's findings, made from the
176
+ * same declaration, so a value of the wrong shape is passed over rather than
177
+ * reported twice. What is asked is what only the event schema can say: a
178
+ * closed value is one of its list, text holds something, and an Address is
179
+ * collected for resolution.
180
+ *
181
+ * @param {unknown} value - The authored value.
182
+ * @param {object} spec - Its declaration.
183
+ * @param {Array<string|number>} path - Its frontmatter path.
184
+ * @param {{at: Function, refs: object[]}} sink - Where findings and Addresses go.
185
+ * @returns {object[]} Findings.
186
+ */
187
+ function walk(value, spec, path, sink) {
188
+ const findings = [];
189
+ const name = dotted(path);
190
+ if (spec.own || value === undefined || value === null) return findings;
191
+ if (spec.oneOf) {
192
+ if (typeof value === "string" && !spec.oneOf.values.includes(value))
193
+ findings.push(
194
+ sink.at(
195
+ path,
196
+ `\`${name}\` reads ${JSON.stringify(value)}, which \`${spec.name}\` does ` +
197
+ `not admit — it takes ${spec.oneOf.values.join(", ")}`,
198
+ ),
199
+ );
200
+ return findings;
201
+ }
202
+ if (spec.kind === "string") {
203
+ // A required key left blank is the inner-key check's finding.
204
+ if (typeof value === "string" && !value.trim() && !spec.required)
205
+ findings.push(sink.at(path, `\`${name}\` is a string, but reads ""`));
206
+ return findings;
207
+ }
208
+ if (spec.kind === "address") {
209
+ const written = writtenOf(value);
210
+ if (typeof written === "string") sink.refs.push({ value: written, path, spec });
211
+ else if (typeof value !== "object")
212
+ findings.push(
213
+ sink.at(path, `\`${name}\` is an Address, but reads ${JSON.stringify(value)}`),
214
+ );
215
+ return findings;
216
+ }
217
+ if (Array.isArray(value)) {
218
+ if (spec.entries)
219
+ value.forEach((entry, position) =>
220
+ findings.push(...walk(entry, spec.entries, [...path, position], sink)),
221
+ );
222
+ return findings;
223
+ }
224
+ if (spec.fields && mapping(value)) {
225
+ for (const field of spec.fields)
226
+ findings.push(...walk(value[field.name], field, [...path, field.name], sink));
227
+ }
228
+ return findings;
229
+ }
230
+
231
+ /** Whether an index entry is a note of this tree, whose frontmatter is readable. */
232
+ function localNote(hit) {
233
+ return Boolean(hit && typeof hit === "object" && hit.fm && typeof hit.file === "string");
234
+ }
235
+
236
+ /** The authored `data.events` of a note, or an empty list. */
237
+ function eventsOf(note) {
238
+ const events = note?.fm?.data?.events;
239
+ return Array.isArray(events) ? events : [];
240
+ }
241
+
242
+ /** Each local note's anchors, read once. */
243
+ const noteAnchorCache = new WeakMap();
244
+
245
+ /**
246
+ * Every anchor the note an index entry names declares, each with its kind.
247
+ *
248
+ * A local note is read directly — its body's anchors and its events'. A note
249
+ * another package publishes is read from that package's index record.
250
+ *
251
+ * @param {object} hit - The index entry.
252
+ * @returns {Array<{slug: string, kind?: string}>} The anchors.
253
+ */
254
+ function anchorsOf(hit) {
255
+ if (!localNote(hit)) return Array.isArray(hit?.noteAnchors) ? hit.noteAnchors : [];
256
+ const cached = noteAnchorCache.get(hit);
257
+ if (cached) return cached;
258
+ const body =
259
+ typeof hit.body === "string" ?
260
+ hit.body
261
+ : String(hit.raw ?? "").replace(/^---\n[\s\S]*?\n---\n?/, "");
262
+ const anchors = [...collectAnchors(body), ...eventAnchors(hit.fm, hit.raw)];
263
+ noteAnchorCache.set(hit, anchors);
264
+ return anchors;
265
+ }
266
+
267
+ /**
268
+ * The events of the note an index entry names, each with its `id` and its
269
+ * place on the canonical axis.
270
+ *
271
+ * @param {object} hit - The index entry.
272
+ * @param {object} dates - The corpus's reckoning context, for a local note.
273
+ * @returns {Array<{id?: string, date: object|null}>} In entry order.
274
+ */
275
+ function datedEventsOf(hit, dates) {
276
+ if (localNote(hit))
277
+ return eventsOf(hit).map((entry) => ({
278
+ id: mapping(entry) && typeof entry.id === "string" ? entry.id : undefined,
279
+ date: mapping(entry) ? placeOnAxis(entry.when, dates) : null,
280
+ }));
281
+ return (Array.isArray(hit?.events) ? hit.events : []).map((event) => ({
282
+ id: event?.id ?? undefined,
283
+ date: Number.isFinite(event?.when?.canonicalYear) ? event.when : null,
284
+ }));
285
+ }
286
+
287
+ /**
288
+ * The types a key accepts, as a message names them: `a place note`, `a being,
289
+ * affiliation or lore note`.
290
+ *
291
+ * @param {readonly string[]} types - The accepted types.
292
+ * @returns {string} The phrase.
293
+ */
294
+ function typeList(types) {
295
+ const article = /^[aeiou]/.test(types[0]) ? "an" : "a";
296
+ const named =
297
+ types.length === 1 ? types[0] : `${types.slice(0, -1).join(", ")} or ${types.at(-1)}`;
298
+ return `${article} ${named} note`;
299
+ }
300
+
301
+ /** What a reference says of an anchor its key does not accept. */
302
+ const NO_ANCHOR = "takes no anchor — it names a whole note, so write the Address without `#…`";
303
+
304
+ /**
305
+ * Resolve one Address an event writes.
306
+ *
307
+ * @param {unknown} value - The authored value.
308
+ * @param {object} spec - Its schema node.
309
+ * @param {object} index - The link index.
310
+ * @param {object} dates - The corpus's reckoning context.
311
+ * @returns {{problem?: string, hit?: object, position?: number, target?: string,
312
+ * date?: object|null}} What it names: the index entry, and for an event the
313
+ * entry's position in its note and its date; or why it names nothing.
314
+ */
315
+ function resolveRef(value, spec, index, dates) {
316
+ const anchored = hasAnchor(value);
317
+ if (anchored && !spec.anchors) return { problem: NO_ANCHOR };
318
+ const { address: written, anchor } = anchored ? splitAnchor(value) : { address: value };
319
+ if (anchor !== undefined && !isAddressSegment(anchor))
320
+ return { problem: `names the anchor \`${anchor}\`, which is not an address segment` };
321
+ const tuple = parseAddress(
322
+ written,
323
+ {
324
+ package: index.contentPackage,
325
+ system: "note",
326
+ ...(spec.ref ? { type: spec.ref } : {}),
327
+ types: index.types,
328
+ packages: index.packages,
329
+ },
330
+ { declared: true },
331
+ );
332
+ if (tuple.reason)
333
+ return {
334
+ problem:
335
+ "should be an Address that states its type" +
336
+ (spec.ref ? ` or a ${spec.ref} shortcode` : ""),
337
+ };
338
+ const target = renderAddress(tuple);
339
+ if (spec.accepts && !acceptsType(tuple, spec.accepts))
340
+ return {
341
+ problem:
342
+ `names ${target}, which is a ${tuple.type} — it must name ` +
343
+ (spec.calendar ? "a calendar note" : typeList(spec.accepts)),
344
+ };
345
+ const hit = index.addressHit(target);
346
+ if (!hit)
347
+ return {
348
+ problem: `names ${target}, which does not resolve in this package or its declared dependencies`,
349
+ };
350
+ if (spec.calendar) {
351
+ const subType = (hit.fm ?? hit).subType;
352
+ if (subType !== "calendar")
353
+ return { problem: `names ${target}, a lore note whose subType is not calendar` };
354
+ }
355
+
356
+ if (anchor !== undefined) {
357
+ const found = anchorsOf(hit).find((one) => one?.slug === anchor);
358
+ if (!found)
359
+ return {
360
+ problem: `names ${target}#${anchor}, which does not resolve — that note declares no anchor ${anchor}`,
361
+ };
362
+ if (!spec.anchors.includes(found.kind))
363
+ return {
364
+ problem: `names ${target}#${anchor}, which is a ${found.kind ?? "no-kind"} anchor, not an ${spec.anchors.join(" or ")} anchor`,
365
+ };
366
+ const events = datedEventsOf(hit, dates);
367
+ const position = events.findIndex((event) => event.id === anchor);
368
+ return { hit, target, position, date: events[position]?.date ?? null };
369
+ }
370
+ if (spec.eventOrLore) {
371
+ if (tuple.type === "lore" || datedEventsOf(hit, dates).length === 1) return { hit, target };
372
+ return {
373
+ problem: `names ${target}, which must name an event or a lore note — it is a ${tuple.type} note holding no single event`,
374
+ };
375
+ }
376
+ if (!spec.single) return { hit, target };
377
+ const events = datedEventsOf(hit, dates);
378
+ if (events.length === 0) return { problem: `names ${target}, which holds no events` };
379
+ if (events.length > 1)
380
+ return {
381
+ problem:
382
+ `names ${target}, which holds ${events.length} events — name one as ` +
383
+ `${tuple.type}-${tuple.shortcode}#<id>`,
384
+ };
385
+ return { hit, target, position: 0, date: events[0].date };
386
+ }
387
+
388
+ /**
389
+ * Resolve an Address a field outside an event writes to name one — a gear
390
+ * note's `made` or a literature note's `subjects` entry — by the rules an
391
+ * event's own `follows` is resolved by.
392
+ *
393
+ * @param {unknown} value - The authored value: a string, a tuple, or an
394
+ * {@link AddressLink}.
395
+ * @param {{ref?: string, accepts?: readonly string[], anchors?: readonly string[],
396
+ * single?: boolean}} spec - The field's declaration: `ref` the default type of
397
+ * a bare shortcode, `accepts` the types the Address may name, `anchors` the
398
+ * anchor kinds `#<anchor>` may name, and `single` whether it must name exactly
399
+ * one event.
400
+ * @param {object} index - The link index.
401
+ * @returns {{problem?: string, hit?: object, position?: number, target?: string}}
402
+ * What it names, or why it names nothing.
403
+ */
404
+ export function resolveEventReference(value, spec, index) {
405
+ return resolveRef(
406
+ writtenOf(value),
407
+ { kind: "address", ...spec },
408
+ index,
409
+ reckoningContext(index),
410
+ );
411
+ }
412
+
413
+ /**
414
+ * Where an event sits on the canonical axis, or `null` when it has no
415
+ * position: `unknown`, a year-`0` date that recurs in every year, or a `when`
416
+ * that does not parse.
417
+ *
418
+ * @param {unknown} when - The authored `when`.
419
+ * @param {object} dates - The corpus's reckoning context.
420
+ * @returns {object|null} The parsed date.
421
+ */
422
+ function placeOnAxis(when, dates) {
423
+ const { date } = parseNoteDate(when, { ...dates, allowUnknown: true, allowZeroYear: true });
424
+ if (!date?.known || !Number.isFinite(date.canonicalYear)) return null;
425
+ return date;
426
+ }
427
+
428
+ /**
429
+ * Whether `earlier` falls after `later`. Two dates both stating a day compare
430
+ * by day; otherwise they compare by year, so a year and a day within it are
431
+ * never out of order.
432
+ *
433
+ * @param {object} earlier - The date the edge names.
434
+ * @param {object} later - The date of the event writing the edge.
435
+ * @returns {boolean} Whether the edge points forward in time.
436
+ */
437
+ function pointsForward(earlier, later) {
438
+ const byDay =
439
+ earlier.day != null &&
440
+ later.day != null &&
441
+ Number.isFinite(earlier.sort) &&
442
+ Number.isFinite(later.sort);
443
+ return byDay ? earlier.sort > later.sort : earlier.canonicalYear > later.canonicalYear;
444
+ }
445
+
446
+ /** One analysed `follows` graph per corpus index. */
447
+ const graphs = new WeakMap();
448
+
449
+ /**
450
+ * The `follows` edges of the whole corpus that lie on a cycle.
451
+ *
452
+ * Built once per index from every note it holds, resolving each edge the way
453
+ * the per-note check does, then split into strongly connected components: an
454
+ * edge whose two ends share a component — or that names its own event — is on
455
+ * a cycle.
456
+ *
457
+ * @param {object} index - The link index.
458
+ * @returns {Set<string>} `<file>#<event>#<edge>` for every edge on a cycle.
459
+ */
460
+ function cyclicEdges(index) {
461
+ const cached = graphs.get(index);
462
+ if (cached) return cached;
463
+
464
+ const nodeOf = (note, position) => `${note.file}#${position}`;
465
+ /** @type {Map<string, Array<{to: string, edge: string}>>} */
466
+ const out = new Map();
467
+ for (const note of index.notes ?? []) {
468
+ eventsOf(note).forEach((entry, position) => {
469
+ const from = nodeOf(note, position);
470
+ if (!out.has(from)) out.set(from, []);
471
+ const follows = mapping(entry) && Array.isArray(entry.follows) ? entry.follows : [];
472
+ follows.forEach((edge, k) => {
473
+ if (!mapping(edge)) return;
474
+ const resolved = resolveRef(
475
+ writtenOf(edge.event),
476
+ keyAt("follows", "event"),
477
+ index,
478
+ reckoningContext(index),
479
+ );
480
+ if (resolved.position === undefined || !localNote(resolved.hit)) return;
481
+ out.get(from).push({
482
+ to: nodeOf(resolved.hit, resolved.position),
483
+ edge: `${from}#${k}`,
484
+ });
485
+ });
486
+ });
487
+ }
488
+
489
+ // Tarjan's strongly connected components, iterative so a long chain
490
+ // cannot exhaust the stack.
491
+ const component = new Map();
492
+ const low = new Map();
493
+ const order = new Map();
494
+ const stack = [];
495
+ const onStack = new Set();
496
+ let counter = 0;
497
+ for (const root of out.keys()) {
498
+ if (order.has(root)) continue;
499
+ const work = [{ node: root, next: 0 }];
500
+ order.set(root, counter);
501
+ low.set(root, counter);
502
+ counter += 1;
503
+ stack.push(root);
504
+ onStack.add(root);
505
+ while (work.length) {
506
+ const frame = work[work.length - 1];
507
+ const edges = out.get(frame.node) ?? [];
508
+ if (frame.next < edges.length) {
509
+ const { to } = edges[frame.next];
510
+ frame.next += 1;
511
+ if (!order.has(to)) {
512
+ order.set(to, counter);
513
+ low.set(to, counter);
514
+ counter += 1;
515
+ stack.push(to);
516
+ onStack.add(to);
517
+ work.push({ node: to, next: 0 });
518
+ } else if (onStack.has(to)) {
519
+ low.set(frame.node, Math.min(low.get(frame.node), order.get(to)));
520
+ }
521
+ continue;
522
+ }
523
+ work.pop();
524
+ if (work.length) {
525
+ const parent = work[work.length - 1].node;
526
+ low.set(parent, Math.min(low.get(parent), low.get(frame.node)));
527
+ }
528
+ if (low.get(frame.node) === order.get(frame.node)) {
529
+ let member;
530
+ do {
531
+ member = stack.pop();
532
+ onStack.delete(member);
533
+ component.set(member, frame.node);
534
+ } while (member !== frame.node);
535
+ }
536
+ }
537
+ }
538
+
539
+ const cyclic = new Set();
540
+ for (const [from, edges] of out) {
541
+ for (const { to, edge } of edges) {
542
+ if (from === to || component.get(from) === component.get(to)) cyclic.add(edge);
543
+ }
544
+ }
545
+ graphs.set(index, cyclic);
546
+ return cyclic;
547
+ }
548
+
549
+ /**
550
+ * Validate a note's `data.events`, entry by entry.
551
+ *
552
+ * Registered as the `events` field's `check`, so it runs on every note whose
553
+ * type declares `events`, whatever its `subType`. Without an index the shape,
554
+ * the closed values, the identity and the dates are checked and no Address is
555
+ * resolved.
556
+ *
557
+ * @param {object} note - The note, as the link index hands it over.
558
+ * @param {{index?: object}} [options]
559
+ * @returns {object[]} Findings, each positioned at the key or value that
560
+ * earned it.
561
+ */
562
+ export function checkNoteEvents(note, { index } = {}) {
563
+ const events = note.fm?.data?.events;
564
+ if (!Array.isArray(events)) return [];
565
+
566
+ const findings = [];
567
+ const dates = reckoningContext(index);
568
+ const at = (path, message, options = {}) => ({
569
+ file: note.file,
570
+ ...positionOfFrontmatterPath(note.raw ?? "", path, options),
571
+ severity: "error",
572
+ message,
573
+ });
574
+ const resolving = Boolean(index?.addressHit);
575
+ const cyclic = resolving && Array.isArray(index.notes) ? cyclicEdges(index) : new Set();
576
+ const ids = new Map();
577
+ findings.push(...noteAnchorFindings(note));
578
+
579
+ events.forEach((entry, position) => {
580
+ const base = ["data", "events", position];
581
+ // An entry that is not a map is the inner-key check's finding.
582
+ if (!mapping(entry)) return;
583
+
584
+ const sink = { at, refs: [] };
585
+ findings.push(...walk(entry, EVENT_ENTRY, base, sink));
586
+
587
+ // Identity.
588
+ if (entry.id === undefined || entry.id === null) {
589
+ if (events.length > 1)
590
+ findings.push(
591
+ at(
592
+ base,
593
+ `\`${dotted(base)}\` needs an \`id\` — a note holding two or more ` +
594
+ "events names each one, so `follows` can say which it means",
595
+ ),
596
+ );
597
+ } else if (typeof entry.id !== "string" || !isAddressSegment(entry.id)) {
598
+ findings.push(
599
+ at(
600
+ [...base, "id"],
601
+ `\`${dotted([...base, "id"])}\`: an \`id\` is an address segment of lowercase ` +
602
+ `letters and digits, but reads ${JSON.stringify(entry.id)}`,
603
+ ),
604
+ );
605
+ } else if (ids.has(entry.id)) {
606
+ findings.push(
607
+ at(
608
+ [...base, "id"],
609
+ `\`${dotted([...base, "id"])}\`: \`id\` ${entry.id} is already used by ` +
610
+ `data.events.${ids.get(entry.id)} — an id is unique within its note`,
611
+ ),
612
+ );
613
+ } else {
614
+ ids.set(entry.id, position);
615
+ }
616
+
617
+ // `attributedTo` gives the cause a `misattributed` place names instead.
618
+ const reach =
619
+ mapping(entry.where) && Array.isArray(entry.where.reach) ? entry.where.reach : [];
620
+ reach.forEach((row, k) => {
621
+ if (!mapping(row) || row.attributedTo == null || row.knowledge === "misattributed")
622
+ return;
623
+ const path = [...base, "where", "reach", k, "attributedTo"];
624
+ findings.push(
625
+ at(
626
+ path,
627
+ `\`${dotted(path)}\` is written only beside \`knowledge: misattributed\` — ` +
628
+ "it names the cause that place gives instead",
629
+ { key: true },
630
+ ),
631
+ );
632
+ });
633
+
634
+ findings.push(...checkDates(entry, base, note, dates, at));
635
+
636
+ if (!resolving) return;
637
+ const own = placeOnAxis(entry.when, dates);
638
+ for (const ref of sink.refs) {
639
+ const resolved = resolveRef(ref.value, ref.spec, index, dates);
640
+ if (resolved.problem) {
641
+ findings.push(
642
+ at(
643
+ ref.path,
644
+ `\`${dotted(ref.path)}\` ${resolved.problem}, but reads ` +
645
+ JSON.stringify(frontmatterValueAt(note.raw, ref.path) ?? ref.value),
646
+ ),
647
+ );
648
+ continue;
649
+ }
650
+ if (!ref.spec.single || resolved.position === undefined) continue;
651
+ const k = ref.path[ref.path.length - 2];
652
+ if (cyclic.has(`${note.file}#${position}#${k}`)) {
653
+ findings.push(
654
+ at(
655
+ ref.path,
656
+ `\`${dotted(ref.path)}\` names ${ref.value}, which closes a cycle of ` +
657
+ "`follows` edges — an event cannot follow from itself",
658
+ ),
659
+ );
660
+ continue;
661
+ }
662
+ const named = resolved.date;
663
+ if (own && named && pointsForward(named, own)) {
664
+ findings.push(
665
+ at(
666
+ ref.path,
667
+ `\`${dotted(ref.path)}\` names ${ref.value}, dated ${named.text}, which is ` +
668
+ `later than this event's ${own.text} — \`follows\` names an earlier event`,
669
+ ),
670
+ );
671
+ }
672
+ }
673
+ });
674
+
675
+ return findings;
676
+ }
677
+
678
+ /**
679
+ * Check an entry's `when`, `until` and `recurs`.
680
+ *
681
+ * @param {object} row - The entry.
682
+ * @param {Array<string|number>} base - Its frontmatter path.
683
+ * @param {object} note - The note.
684
+ * @param {object} dates - The corpus's reckoning context.
685
+ * @param {Function} at - The finding builder.
686
+ * @returns {object[]} Findings.
687
+ */
688
+ function checkDates(row, base, note, dates, at) {
689
+ const findings = [];
690
+ const when = parseNoteDate(row.when, {
691
+ ...dates,
692
+ allowUnknown: true,
693
+ allowZeroYear: true,
694
+ field: "when",
695
+ file: note.file,
696
+ raw: note.raw,
697
+ keyPath: [...base, "when"],
698
+ });
699
+ findings.push(...when.findings);
700
+
701
+ const until = parseNoteDate(row.until, {
702
+ ...dates,
703
+ allowUnknown: false,
704
+ field: "until",
705
+ file: note.file,
706
+ raw: note.raw,
707
+ keyPath: [...base, "until"],
708
+ });
709
+ findings.push(...until.findings);
710
+
711
+ const recurs = row.recurs;
712
+ if (recurs === undefined || recurs === null) return findings;
713
+
714
+ if (row.when === undefined || row.when === null) {
715
+ findings.push(
716
+ at(
717
+ [...base, "recurs"],
718
+ "`recurs` needs a `when` of its own — a recurrence counts from the anchor it names",
719
+ ),
720
+ );
721
+ return findings;
722
+ }
723
+ if (when.date?.known && when.date.year === 0) {
724
+ findings.push(
725
+ at(
726
+ [...base, "recurs"],
727
+ "`recurs` is refused beside a `when` of year 0 — that date already recurs " +
728
+ "on that day every year, and `every` above 1 cannot be counted without " +
729
+ "an anchor year",
730
+ ),
731
+ );
732
+ return findings;
733
+ }
734
+ // A `recurs` that is not a map, and a key it does not declare, are the
735
+ // inner-key check's findings.
736
+ if (!mapping(recurs)) return findings;
737
+
738
+ const hasEvery = Object.hasOwn(recurs, "every");
739
+ const hasOn = Object.hasOwn(recurs, "on");
740
+ if (hasEvery && hasOn) {
741
+ findings.push(
742
+ at(
743
+ [...base, "recurs"],
744
+ "`recurs` declares exactly one of `every` or `on`, not both — a period " +
745
+ "with exceptions is written as an enumeration",
746
+ ),
747
+ );
748
+ return findings;
749
+ }
750
+ if (!hasEvery && !hasOn) {
751
+ findings.push(
752
+ at(
753
+ [...base, "recurs"],
754
+ "`recurs` needs `every` or `on` — its absence is what says the occasion " +
755
+ "happened once",
756
+ ),
757
+ );
758
+ return findings;
759
+ }
760
+
761
+ if (hasEvery) {
762
+ const { every } = recurs;
763
+ // Not a number at all is the inner-key check's finding.
764
+ if (typeof every === "number" && !(Number.isInteger(every) && every >= 1)) {
765
+ findings.push(
766
+ at(
767
+ [...base, "recurs", "every"],
768
+ `\`recurs.every\` needs a whole number of years, 1 or more, but reads ` +
769
+ `${JSON.stringify(every)}`,
770
+ ),
771
+ );
772
+ }
773
+ return findings;
774
+ }
775
+
776
+ if (row.until !== undefined && row.until !== null) {
777
+ findings.push(
778
+ at(
779
+ [...base, "until"],
780
+ "`until` is refused beside `recurs.on` — the enumeration states its own " +
781
+ "last entry",
782
+ ),
783
+ );
784
+ }
785
+
786
+ const onList = recurs.on;
787
+ // Not a list is the inner-key check's finding; an empty one is this one's.
788
+ if (!Array.isArray(onList)) return findings;
789
+ if (onList.length === 0) {
790
+ findings.push(at([...base, "recurs", "on"], "`recurs.on` needs a list of dates"));
791
+ return findings;
792
+ }
793
+
794
+ let previous = when.date?.known ? when.date : null;
795
+ onList.forEach((onRaw, onPosition) => {
796
+ const onPath = [...base, "recurs", "on", onPosition];
797
+ const onParsed = parseNoteDate(onRaw, {
798
+ ...dates,
799
+ allowUnknown: false,
800
+ field: "recurs.on",
801
+ file: note.file,
802
+ raw: note.raw,
803
+ keyPath: onPath,
804
+ });
805
+ findings.push(...onParsed.findings);
806
+ const onDate = onParsed.date;
807
+ if (onDate?.known && previous?.known) {
808
+ if (onDate.sort <= previous.sort) {
809
+ findings.push(
810
+ at(
811
+ onPath,
812
+ onPosition === 0 ?
813
+ "`recurs.on` entry is at or before `when`, so it is not a " +
814
+ "later occurrence"
815
+ : "`recurs.on` entries are not strictly increasing",
816
+ ),
817
+ );
818
+ }
819
+ }
820
+ if (onDate?.known) previous = onDate;
821
+ });
822
+ return findings;
823
+ }