@heroiclands/package-build 22.21.0 → 22.23.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 (152) hide show
  1. package/CHANGELOG.md +223 -0
  2. package/assets/styles/content-base.css +162 -0
  3. package/bin/content-commands.mjs +77 -17
  4. package/bin/package-build.mjs +15 -2
  5. package/ci/content-format-reference.mjs +68 -1
  6. package/config.mjs +26 -0
  7. package/content-config.mjs +153 -6
  8. package/docs/api.md +120 -89
  9. package/docs/authoring/assets.md +14 -0
  10. package/docs/authoring/dates-and-calendars.md +41 -3
  11. package/docs/authoring/frontmatter.md +2 -2
  12. package/docs/authoring/links-and-markup.md +325 -35
  13. package/docs/commands.md +12 -10
  14. package/docs/configuration.md +117 -14
  15. package/docs/content-format.md +4 -2
  16. package/docs/guides/book.md +5 -4
  17. package/docs/guides/reference-pages.md +15 -0
  18. package/docs/reference/format-details.md +545 -104
  19. package/docs/reference/note-types.md +30 -13
  20. package/engine/actor-compiler.mjs +23 -0
  21. package/engine/anchored-sections.mjs +35 -4
  22. package/engine/anchors.mjs +26 -30
  23. package/engine/art-fields.mjs +118 -0
  24. package/engine/art-slots.mjs +7 -1
  25. package/engine/asset-index.mjs +160 -10
  26. package/engine/asset-replacement.mjs +93 -0
  27. package/engine/asset-types.mjs +240 -0
  28. package/engine/base-compiler.mjs +139 -22
  29. package/engine/base-styles.mjs +149 -0
  30. package/engine/bundles.mjs +13 -2
  31. package/engine/compendiums.mjs +8 -1
  32. package/engine/content-blocks.mjs +410 -0
  33. package/engine/content-embeds.mjs +19 -0
  34. package/engine/content-figures.mjs +468 -0
  35. package/engine/content-footnotes.mjs +197 -0
  36. package/engine/content-format-check.mjs +15 -12
  37. package/engine/content-format.yaml +1 -1
  38. package/engine/content-images.mjs +126 -25
  39. package/engine/content-index.mjs +38 -12
  40. package/engine/content-links.mjs +14 -6
  41. package/engine/content-tables.mjs +45 -4
  42. package/engine/content-tasklists.mjs +189 -0
  43. package/engine/docs-checks.mjs +4 -4
  44. package/engine/extension-attributes.mjs +48 -2
  45. package/engine/field-reference.mjs +31 -10
  46. package/engine/field-spec.mjs +10 -8
  47. package/engine/foreign-catalog.mjs +143 -18
  48. package/engine/frontmatter-lint.mjs +120 -65
  49. package/engine/frontmatter.mjs +3 -3
  50. package/engine/heading-attributes.mjs +321 -0
  51. package/engine/helpers.mjs +109 -41
  52. package/engine/homepage.mjs +8 -8
  53. package/engine/index.mjs +3 -0
  54. package/engine/infobox-registry.mjs +4 -1
  55. package/engine/infobox-render.mjs +15 -3
  56. package/engine/infobox.mjs +129 -15
  57. package/engine/itinerary-scenes.mjs +4 -10
  58. package/engine/journals.mjs +294 -50
  59. package/engine/lore-events.mjs +197 -0
  60. package/engine/macros.mjs +1 -1
  61. package/engine/markdown-expressions.mjs +434 -59
  62. package/engine/note-dates.mjs +354 -12
  63. package/engine/note-frontmatter.mjs +7 -16
  64. package/engine/note-vocabulary.mjs +183 -42
  65. package/engine/page-lists.mjs +358 -0
  66. package/engine/pathnames.mjs +36 -0
  67. package/engine/pdf-build.mjs +431 -74
  68. package/engine/pdf-images.mjs +64 -0
  69. package/engine/pdf-render.mjs +563 -66
  70. package/engine/rank-ladder.mjs +198 -0
  71. package/engine/reckoning-markers.mjs +1 -1
  72. package/engine/retired-fields.mjs +8 -13
  73. package/engine/scenes.mjs +26 -7
  74. package/engine/site-build.mjs +166 -23
  75. package/engine/site-config.mjs +10 -1
  76. package/engine/site-index.mjs +90 -3
  77. package/engine/site-preview.mjs +20 -7
  78. package/engine/social-ties.mjs +52 -14
  79. package/engine/sql-tables.mjs +66 -9
  80. package/engine/standings.mjs +89 -6
  81. package/engine/system-block.mjs +56 -30
  82. package/engine/web-wikilinks.mjs +48 -1
  83. package/hm3/actors.mjs +8 -5
  84. package/manifest.mjs +43 -24
  85. package/package.json +1 -1
  86. package/packagebuild-metadata.jsonl +13 -13
  87. package/sohl/actors.mjs +22 -99
  88. package/sohl/item-fields.mjs +5 -5
  89. package/sohl/items.mjs +1 -1
  90. package/sohl/note-schemas.mjs +11 -21
  91. package/types/config.d.mts +9 -0
  92. package/types/content-config.d.mts +48 -1
  93. package/types/engine/actor-compiler.d.mts +11 -0
  94. package/types/engine/anchored-sections.d.mts +24 -0
  95. package/types/engine/anchors.d.mts +9 -4
  96. package/types/engine/art-fields.d.mts +64 -0
  97. package/types/engine/asset-index.d.mts +43 -0
  98. package/types/engine/asset-replacement.d.mts +22 -0
  99. package/types/engine/asset-types.d.mts +18 -0
  100. package/types/engine/base-compiler.d.mts +19 -17
  101. package/types/engine/base-styles.d.mts +93 -0
  102. package/types/engine/content-blocks.d.mts +87 -0
  103. package/types/engine/content-embeds.d.mts +16 -0
  104. package/types/engine/content-figures.d.mts +101 -0
  105. package/types/engine/content-footnotes.d.mts +80 -0
  106. package/types/engine/content-format-check.d.mts +8 -6
  107. package/types/engine/content-images.d.mts +78 -12
  108. package/types/engine/content-index.d.mts +8 -2
  109. package/types/engine/content-tables.d.mts +12 -4
  110. package/types/engine/content-tasklists.d.mts +72 -0
  111. package/types/engine/extension-attributes.d.mts +22 -1
  112. package/types/engine/field-reference.d.mts +21 -0
  113. package/types/engine/field-spec.d.mts +20 -16
  114. package/types/engine/foreign-catalog.d.mts +39 -0
  115. package/types/engine/frontmatter.d.mts +1 -1
  116. package/types/engine/heading-attributes.d.mts +179 -0
  117. package/types/engine/helpers.d.mts +29 -10
  118. package/types/engine/homepage.d.mts +7 -7
  119. package/types/engine/index.d.mts +1 -0
  120. package/types/engine/infobox-registry.d.mts +4 -1
  121. package/types/engine/infobox-render.d.mts +6 -1
  122. package/types/engine/infobox.d.mts +6 -0
  123. package/types/engine/journals.d.mts +41 -12
  124. package/types/engine/lore-events.d.mts +14 -0
  125. package/types/engine/markdown-expressions.d.mts +46 -2
  126. package/types/engine/note-dates.d.mts +64 -1
  127. package/types/engine/note-frontmatter.d.mts +1 -4
  128. package/types/engine/note-vocabulary.d.mts +82 -6
  129. package/types/engine/page-lists.d.mts +126 -0
  130. package/types/engine/pathnames.d.mts +26 -0
  131. package/types/engine/pdf-build.d.mts +28 -6
  132. package/types/engine/pdf-images.d.mts +52 -0
  133. package/types/engine/pdf-render.d.mts +84 -2
  134. package/types/engine/rank-ladder.d.mts +32 -0
  135. package/types/engine/retired-fields.d.mts +4 -6
  136. package/types/engine/site-build.d.mts +27 -4
  137. package/types/engine/site-config.d.mts +9 -0
  138. package/types/engine/site-index.d.mts +67 -1
  139. package/types/engine/site-preview.d.mts +1 -1
  140. package/types/engine/sql-tables.d.mts +31 -7
  141. package/types/engine/standings.d.mts +21 -3
  142. package/types/engine/system-block.d.mts +37 -14
  143. package/types/engine/web-wikilinks.d.mts +3 -1
  144. package/types/hm3/actors.d.mts +21 -3
  145. package/types/manifest.d.mts +19 -23
  146. package/types/sohl/actors.d.mts +6 -7
  147. package/engine/content-admonitions.mjs +0 -114
  148. package/engine/content-captions.mjs +0 -181
  149. package/engine/content-secrets.mjs +0 -112
  150. package/types/engine/content-admonitions.d.mts +0 -24
  151. package/types/engine/content-captions.d.mts +0 -45
  152. package/types/engine/content-secrets.d.mts +0 -17
@@ -25,6 +25,14 @@
25
25
  * neutral and resolved named dates. A neutral year retains `precision: "year"`
26
26
  * and has no `canonicalDay`; day one is used only to calculate its bounds.
27
27
  *
28
+ * **Year 0 means any year**, and is accepted only where a caller passes
29
+ * `allowZeroYear` — a `lore` event's `when`. `when: "0.5"` is the fifth day of
30
+ * the year, in every year: the value carries `year: 0` and a `canonicalDay`,
31
+ * but no `canonicalYear` and no `sort`, since it names no point on the axis
32
+ * and cannot date its own note. Every other field keeps the ordinary
33
+ * refusal, and a bare `0` with no day is refused everywhere — any year with
34
+ * no day names nothing.
35
+ *
28
36
  * @module
29
37
  */
30
38
 
@@ -117,6 +125,39 @@ function unknownRecord() {
117
125
  };
118
126
  }
119
127
 
128
+ /**
129
+ * Whether a number-valued date may have lost a trailing zero off its day.
130
+ *
131
+ * YAML reads an unquoted `<year>.<day>` as a float, and a float carries no
132
+ * trailing zero: `born: 667.130` and `born: 667.13` both arrive as `667.13`.
133
+ * The authored digits are unrecoverable by then, so the question a reader needs
134
+ * answered is whether a longer day was *possible* — and it was exactly when the
135
+ * value with one more zero on the end still names a day the world's year holds.
136
+ *
137
+ * One zero settles it. A second only makes the day ten times longer again, so a
138
+ * value whose single-zero expansion already falls outside the year has no
139
+ * reading but the one it was handed.
140
+ *
141
+ * **The world's year length is what makes the question answerable**, so a tree
142
+ * whose notes declare none gets no claim either way — the same rule the upper
143
+ * bound on a day follows. Refusing every float there would red a whole corpus
144
+ * over a test nothing could evaluate.
145
+ *
146
+ * @param {number} value - The authored value, as YAML parsed it.
147
+ * @param {number|undefined} daysPerYear - The world's year length.
148
+ * @returns {boolean} True when the authored day is ambiguous.
149
+ */
150
+ function dayCouldHaveLostAZero(value, daysPerYear) {
151
+ if (Number.isInteger(value)) return false;
152
+ const fraction = String(Math.abs(value)).split(".")[1] ?? "";
153
+ // An exponent-form string carries no day at all.
154
+ if (!/^\d+$/.test(fraction)) return false;
155
+ const tenfold = Number(`${fraction}0`);
156
+ if (!Number.isSafeInteger(tenfold)) return false;
157
+ if (!Number.isSafeInteger(daysPerYear) || daysPerYear < 1) return false;
158
+ return tenfold <= daysPerYear;
159
+ }
160
+
120
161
  /**
121
162
  * Parse one authored date.
122
163
  *
@@ -166,6 +207,10 @@ export function parseNoteDate(value, options) {
166
207
  daysPerYear,
167
208
  field,
168
209
  allowUnknown = true,
210
+ // Narrows the year-0 refusal for exactly one caller: an `events`
211
+ // entry's `when`, where year 0 means "this day, every year" rather
212
+ // than a point on the axis. Every other field keeps the refusal.
213
+ allowZeroYear = false,
169
214
  file,
170
215
  raw,
171
216
  keyPath,
@@ -194,6 +239,22 @@ export function parseNoteDate(value, options) {
194
239
  return { date: null, findings };
195
240
  };
196
241
 
242
+ // An unquoted `<year>.<day>` reaches here as a float, and YAML has already
243
+ // dropped a trailing zero off the day: `667.13` and `667.130` arrive as one
244
+ // value, so nothing downstream can tell day 13 from day 130. Refused where
245
+ // the lost zero would still name a day inside the world's year, and read as
246
+ // written where no such day exists — `675.281` cannot have been `675.2810`.
247
+ if (typeof value === "number" && dayCouldHaveLostAZero(value, daysPerYear)) {
248
+ const grown = `${text}0`;
249
+ return refuse(
250
+ `${subject} is a number, and a day's trailing zero is lost before a date ` +
251
+ `is read — \`${text}\` and \`${grown}\` reach this check as one value. ` +
252
+ `Quote a canonical date that states a day, \`"${text}"\` or ` +
253
+ `\`"${grown}"\`, so the digits the note states are the digits the ` +
254
+ `build reads`,
255
+ );
256
+ }
257
+
197
258
  const resolveEra = (parsed, reckoning, label) => {
198
259
  if (parsed.year < 1) return refuse(`${subject} needs a positive year in ${label}`);
199
260
  if (!Number.isSafeInteger(daysPerYear) || daysPerYear < 1)
@@ -282,7 +343,14 @@ export function parseNoteDate(value, options) {
282
343
  return refuse(`${subject} cannot resolve its calendar date: ${err.message}`);
283
344
  }
284
345
  }
285
- if (/^-?0+\.\d+(?::\d{6})?$/.test(unmarked))
346
+ // Negative zero (`-0.<day>`) is refused unconditionally — it is the same
347
+ // year as bare `0` written the other way, and `allowZeroYear` means only
348
+ // "any year", never "any year, backwards". A bare positive `0.<day>` is
349
+ // refused everywhere except the one caller that passes `allowZeroYear`.
350
+ if (
351
+ /^-0+\.\d+(?::\d{6})?$/.test(unmarked) ||
352
+ (!allowZeroYear && /^0+\.\d+(?::\d{6})?$/.test(unmarked))
353
+ )
286
354
  return refuse(
287
355
  `${subject} writes year 0, and no reckoning has one — the years either ` +
288
356
  `side of an epoch are -1 and 1`,
@@ -294,6 +362,28 @@ export function parseNoteDate(value, options) {
294
362
  );
295
363
  if (unmarked.includes(".")) {
296
364
  const canonical = parseCanonicalDate(unmarked, daysPerYear ?? Number.MAX_SAFE_INTEGER);
365
+ if (canonical && canonical.year === 0) {
366
+ // Year 0 means "this day, every year" — it names no point on the
367
+ // axis, so it carries no `canonicalYear` and no `sort`: nothing
368
+ // orders it and it cannot date its note, the same silence
369
+ // `unknown` keeps for the fields that carry no axis position.
370
+ return {
371
+ date: {
372
+ text,
373
+ known: true,
374
+ era: null,
375
+ year: 0,
376
+ month: null,
377
+ day: canonical.day,
378
+ approximate: approximateInput,
379
+ precision: "day",
380
+ canonicalDay: canonical.day,
381
+ spanDays: 1,
382
+ ...(canonical.seconds === undefined ? {} : { seconds: canonical.seconds }),
383
+ },
384
+ findings,
385
+ };
386
+ }
297
387
  if (canonical) {
298
388
  return {
299
389
  date: {
@@ -606,19 +696,276 @@ export function formatNoteDate(date, era, daysPerYear, formatName) {
606
696
  return { era, year, month, day, text, prose };
607
697
  }
608
698
 
699
+ /**
700
+ * The occurrences of one recurring series overlapping a bound.
701
+ *
702
+ * **No materialized occurrence list.** An annual series anchored centuries
703
+ * back has thousands of occurrences to the present, and the only questions a
704
+ * build asks are "is this year one of them" and "which is next" — so an
705
+ * omitted bound answers with a single occurrence rather than every one a live
706
+ * series could have:
707
+ *
708
+ * - `to` omitted: the first occurrence at or after `from` (default, the
709
+ * anchor's own year) — "next".
710
+ * - `from` omitted, `to` given: the last occurrence at or before `to` —
711
+ * "the greatest occurrence at or before `until`".
712
+ * - Both given: every occurrence between them, inclusive — a membership
713
+ * probe is `from === to`.
714
+ *
715
+ * **The lower bound is not optional.** A candidate year before the anchor's
716
+ * own year is never an occurrence, however a bare modulo would read it: `%`
717
+ * takes the sign of its dividend in both JavaScript and SQL, so a year
718
+ * exactly one period before the anchor would otherwise match. `k` is clamped
719
+ * at zero rather than left to run negative.
720
+ *
721
+ * **The period advances the canonical year, never the era-relative year.**
722
+ * `date.canonicalYear` is already on the continuous axis `canonicalYear`
723
+ * builds — era year `-1` sits immediately before era year `1`, but canonical
724
+ * year `0` is an ordinary integer between them — so stepping by whole
725
+ * multiples of `every` on `canonicalYear` crosses an epoch with no special
726
+ * case. Counting in era-relative years instead would need a correction at
727
+ * every crossing and silently loses one year each time it is forgotten.
728
+ *
729
+ * **Precision, approximation and the day of year carry through untouched.**
730
+ * Every occurrence is the anchor's own record with only `canonicalYear`
731
+ * advanced, so a day-precision anchor's `canonicalDay` — including one inside
732
+ * a short intercalary month — names the same day of every occurrence, and an
733
+ * approximate anchor's occurrences are approximate too.
734
+ *
735
+ * @param {object} date - The anchor's resolved record
736
+ * ({@link parseNoteDate}), carrying a real `canonicalYear`. A year-0 "any
737
+ * year" anchor carries none and has no series through this function — its
738
+ * `recurs` is refused, and its own next occurrence is computed directly
739
+ * from the day of year alone.
740
+ * @param {{every?: number, on?: object[]}} [recurs] - The declared
741
+ * recurrence: a whole-year period, or an enumeration of further resolved
742
+ * date records (each already carrying its own `canonicalYear`). Neither
743
+ * admits the other.
744
+ * @param {object} [bounds]
745
+ * @param {number} [bounds.from] - The lower bound, inclusive, as a canonical
746
+ * year.
747
+ * @param {number} [bounds.to] - The upper bound, inclusive, as a canonical
748
+ * year.
749
+ * @returns {object[]} Occurrence records, in increasing order.
750
+ */
751
+ export function occurrencesOf(date, recurs, { from, to } = {}) {
752
+ if (!date?.known || !Number.isSafeInteger(date.canonicalYear)) return [];
753
+ const anchor = date.canonicalYear;
754
+
755
+ if (Array.isArray(recurs?.on)) {
756
+ if (from === undefined && to === undefined) return [date];
757
+ const series = [date, ...recurs.on]
758
+ .filter((occ) => occ?.known && Number.isSafeInteger(occ.canonicalYear))
759
+ .sort((a, b) => a.canonicalYear - b.canonicalYear);
760
+ const inRange = series.filter(
761
+ (occ) =>
762
+ (from === undefined || occ.canonicalYear >= from) &&
763
+ (to === undefined || occ.canonicalYear <= to),
764
+ );
765
+ if (from === undefined) return inRange.slice(-1);
766
+ if (to === undefined) return inRange.slice(0, 1);
767
+ return inRange;
768
+ }
769
+
770
+ const every = recurs?.every;
771
+ if (!Number.isSafeInteger(every) || every < 1) {
772
+ const solo = (from === undefined || anchor >= from) && (to === undefined || anchor <= to);
773
+ return solo ? [date] : [];
774
+ }
775
+
776
+ const lowestK = from === undefined ? 0 : Math.max(0, Math.ceil((from - anchor) / every));
777
+ if (to === undefined) return [{ ...date, canonicalYear: anchor + lowestK * every }];
778
+
779
+ const highestK = Math.floor((to - anchor) / every);
780
+ if (highestK < lowestK) return [];
781
+ if (from === undefined) return [{ ...date, canonicalYear: anchor + highestK * every }];
782
+
783
+ const out = [];
784
+ for (let k = lowestK; k <= highestK; k += 1)
785
+ out.push({ ...date, canonicalYear: anchor + k * every });
786
+ return out;
787
+ }
788
+
789
+ /** A resolved date's printed form, in the era its own value selects. */
790
+ function withMarkerProse(date, context) {
791
+ const era =
792
+ date.marker ? context?.markers?.get(date.marker)
793
+ : date.qualifier ? context?.eras?.get(date.qualifier)
794
+ : null;
795
+ const printable = formatNoteDate(date, era, context?.daysPerYear);
796
+ return { ...date, prose: printable?.prose ?? null };
797
+ }
798
+
799
+ /**
800
+ * A resolved date's printed form, re-selecting the era that covers its own
801
+ * position rather than keeping whatever era its anchor was authored in.
802
+ *
803
+ * `when`/`until`/`on` are checked exactly as `born`/`died` are: an
804
+ * era-qualified value carries a `marker` or a `qualifier`, which names the
805
+ * calendar as well as the era, and a bare canonical value carries neither and
806
+ * prints bare. What is new here is that a *generated* occurrence keeps the
807
+ * anchor's marker or qualifier — advancing the year only — so that calendar's
808
+ * **whole** era list is swept through {@link eraCovering} for the
809
+ * occurrence's own position, rather than assuming it stays in the era the
810
+ * anchor was in. A later occurrence may therefore print in a different era
811
+ * than its anchor, or print bare where no count was proclaimed for that year;
812
+ * neither is a finding, since nobody authored the occurrence.
813
+ *
814
+ * @param {object} date - The resolved date, generated or authored.
815
+ * @param {object} [context] - As {@link resolvedDateFields} reads it.
816
+ * @returns {object} The date, carrying `prose`.
817
+ */
818
+ function withSweptEraProse(date, context, { generated = false } = {}) {
819
+ if (!date?.known) return { ...date, prose: null };
820
+ const anchorEra =
821
+ date.marker ? context?.markers?.get(date.marker)
822
+ : date.qualifier ? context?.eras?.get(date.qualifier)
823
+ : null;
824
+ let era = null;
825
+ if (anchorEra?.calendarShortcode) {
826
+ try {
827
+ const calendarRows = calendarEras(anchorEra.calendarShortcode, context);
828
+ era = eraCovering(date, calendarRows, context?.daysPerYear);
829
+ } catch {
830
+ // Ambiguous or overlapping eras are a corpus defect reported
831
+ // where the eras themselves are declared; an occurrence prints
832
+ // bare rather than failing a build over a note that authored
833
+ // nothing wrong.
834
+ era = null;
835
+ }
836
+ }
837
+ const printable = formatNoteDate(date, era, context?.daysPerYear);
838
+ return {
839
+ ...date,
840
+ prose: printable?.prose ?? null,
841
+ // `formatNoteDate`'s own bare `text` echoes `date.text` — the
842
+ // anchor's authored spelling, which a *generated* occurrence has
843
+ // outgrown. Only a generated occurrence with no era to print in
844
+ // needs a text of its own, built from its own canonical year.
845
+ ...(generated && !era ?
846
+ {
847
+ text:
848
+ (date.approximate ? "~" : "") +
849
+ (date.precision === "day" && Number.isSafeInteger(date.canonicalDay) ?
850
+ `${eraYear(date.canonicalYear)}.${date.canonicalDay}`
851
+ : String(eraYear(date.canonicalYear))),
852
+ }
853
+ : {}),
854
+ };
855
+ }
856
+
857
+ /** The package's declared present, resolved to a date record, or `null`. */
858
+ function resolvedPresent(context) {
859
+ if (context?.present === undefined || context?.present === null) return null;
860
+ const date = parseNoteDate(context.present, { ...context, allowUnknown: false }).date;
861
+ return date?.known ? date : null;
862
+ }
863
+
864
+ /**
865
+ * One `data.events` entry, resolved: its own `when`, `until`, `recurs` and
866
+ * `next`.
867
+ *
868
+ * `recurs.first` and `recurs.last` are canonical years rather than the
869
+ * fractional `sort` a day-precision date carries, because `every` counts
870
+ * whole years — a SQL fence testing membership with
871
+ * `(year - recurs.first) % recurs.every = 0 AND year >= recurs.first` reads
872
+ * them as the plain integers they are.
873
+ *
874
+ * @param {unknown} entry - The authored `{ when, until?, recurs? }`.
875
+ * @param {object} [context] - As {@link resolvedDateFields} reads it.
876
+ * @returns {object} The resolved entry; a key is present only where the note
877
+ * authored or the build computed something for it.
878
+ */
879
+ function resolveEventEntry(entry, context) {
880
+ const row = entry && typeof entry === "object" && !Array.isArray(entry) ? entry : {};
881
+ const out = {};
882
+
883
+ const when = parseNoteDate(row.when, {
884
+ ...context,
885
+ allowUnknown: true,
886
+ allowZeroYear: true,
887
+ }).date;
888
+ if (when?.known) out.when = withSweptEraProse(when, context);
889
+
890
+ const until = parseNoteDate(row.until, { ...context, allowUnknown: false }).date;
891
+ if (until?.known) out.until = withSweptEraProse(until, context);
892
+
893
+ if (!when?.known) return out;
894
+
895
+ // Year 0: an annual day-of-year marker, recurring by construction. Its
896
+ // own next occurrence is computed directly from the day of year rather
897
+ // than through `occurrencesOf`, which needs a real anchor year — and it
898
+ // carries no `recurs` record, since the entry is already annual.
899
+ if (when.year === 0) {
900
+ const present = resolvedPresent(context);
901
+ if (present && Number.isSafeInteger(when.canonicalDay)) {
902
+ const dueThisYear =
903
+ !Number.isSafeInteger(present.canonicalDay) ||
904
+ present.canonicalDay <= when.canonicalDay;
905
+ const year = dueThisYear ? present.canonicalYear : present.canonicalYear + 1;
906
+ if (!out.until || year <= out.until.canonicalYear) {
907
+ out.next = withSweptEraProse({ ...when, canonicalYear: year }, context, {
908
+ generated: true,
909
+ });
910
+ }
911
+ }
912
+ return out;
913
+ }
914
+
915
+ const recurs = row.recurs;
916
+ if (!recurs || typeof recurs !== "object" || Array.isArray(recurs)) return out;
917
+
918
+ const recursOut = {};
919
+ let series;
920
+ // An enumeration is bounded by construction — its own last entry — so
921
+ // `last` is never null for `on`; a period is live unless `until` bounds
922
+ // it, so `last` is null exactly where there is no `until` to bound it.
923
+ if (Number.isInteger(recurs.every) && recurs.every >= 1) {
924
+ recursOut.every = recurs.every;
925
+ series = { every: recurs.every };
926
+ recursOut.last =
927
+ out.until ?
928
+ (occurrencesOf(when, series, { to: out.until.canonicalYear }).at(-1)
929
+ ?.canonicalYear ?? null)
930
+ : null;
931
+ } else if (Array.isArray(recurs.on)) {
932
+ const onDates = recurs.on
933
+ .map((raw) => parseNoteDate(raw, { ...context, allowUnknown: false }).date)
934
+ .filter((parsedOn) => parsedOn?.known);
935
+ recursOut.on = onDates.map((onDate) => withSweptEraProse(onDate, context));
936
+ series = { on: onDates };
937
+ recursOut.last =
938
+ onDates.length ? onDates[onDates.length - 1].canonicalYear : when.canonicalYear;
939
+ } else {
940
+ return out;
941
+ }
942
+
943
+ recursOut.first = when.canonicalYear;
944
+ out.recurs = recursOut;
945
+
946
+ const present = resolvedPresent(context);
947
+ if (present?.known) {
948
+ const candidate = occurrencesOf(when, series, { from: present.canonicalYear })[0];
949
+ if (candidate && (!out.until || candidate.canonicalYear <= out.until.canonicalYear)) {
950
+ out.next = withSweptEraProse(candidate, context, { generated: true });
951
+ }
952
+ }
953
+ return out;
954
+ }
955
+
609
956
  /** Normalize a note's declared dates for indexes and generated pages. */
610
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));
961
+ return resolved.length ? { events: resolved } : {};
962
+ }
611
963
  const fields =
612
964
  fm?.type === "being" ?
613
965
  [
614
966
  ["born", fm.data?.born],
615
967
  ["died", fm.data?.died],
616
968
  ]
617
- : fm?.type === "lore" ?
618
- [
619
- ["when", fm.data?.event?.when],
620
- ["until", fm.data?.event?.until],
621
- ]
622
969
  : [];
623
970
  const out = {};
624
971
  for (const [key, value] of fields) {
@@ -628,12 +975,7 @@ export function resolvedDateFields(fm, context) {
628
975
  allowUnknown: key === "born" || key === "died",
629
976
  });
630
977
  if (parsed.date && !parsed.findings.some((finding) => finding.severity === "error")) {
631
- const era =
632
- parsed.date.marker ? context?.markers?.get(parsed.date.marker)
633
- : parsed.date.qualifier ? context?.eras?.get(parsed.date.qualifier)
634
- : null;
635
- const printable = formatNoteDate(parsed.date, era, context?.daysPerYear);
636
- out[key] = { ...parsed.date, prose: printable?.prose ?? null };
978
+ out[key] = withMarkerProse(parsed.date, context);
637
979
  }
638
980
  }
639
981
  return out;
@@ -7,22 +7,13 @@
7
7
 
8
8
  import YAML from "yaml";
9
9
 
10
- /** The complete ordered top-level vocabulary for addressed content notes. */
11
- export const NOTE_TOP_LEVEL_KEYS = Object.freeze([
12
- "shortcode",
13
- "name",
14
- "type",
15
- "subType",
16
- "description",
17
- "tags",
18
- "data",
19
- "hm3",
20
- "sohl",
21
- "dnd5e",
22
- ]);
23
-
24
- /** Membership test for the top-level vocabulary. */
25
- export const NOTE_TOP_LEVEL_KEY_SET = new Set(NOTE_TOP_LEVEL_KEYS);
10
+ // The top-level vocabulary itself is declared beside the other two closed
11
+ // regions, so the lint, the formatter and the reference read one list.
12
+ export {
13
+ NOTE_TOP_LEVEL_FIELDS,
14
+ NOTE_TOP_LEVEL_KEYS,
15
+ NOTE_TOP_LEVEL_KEY_SET,
16
+ } from "./note-vocabulary.mjs";
26
17
 
27
18
  /** The name properties every note accepts, in display order. */
28
19
  export const NOTE_NAME_KEYS = Object.freeze(["full", "aliases"]);