@heroiclands/package-build 22.22.0 → 22.24.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 (140) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +1 -0
  3. package/bin/content-commands.mjs +51 -14
  4. package/ci/content-format-reference.mjs +1 -1
  5. package/content-config.mjs +80 -2
  6. package/docs/api.md +85 -72
  7. package/docs/authoring/assets.md +29 -0
  8. package/docs/authoring/dates-and-calendars.md +37 -1
  9. package/docs/authoring/links-and-markup.md +112 -31
  10. package/docs/commands.md +30 -29
  11. package/docs/configuration.md +92 -19
  12. package/docs/content-format.md +1 -1
  13. package/docs/guides/book.md +5 -4
  14. package/docs/guides/module-project.md +2 -2
  15. package/docs/guides/site.md +41 -0
  16. package/docs/project-setup.md +4 -4
  17. package/docs/reference/format-details.md +323 -60
  18. package/docs/reference/note-types.md +9 -9
  19. package/docs/reference/theme-pages.md +496 -0
  20. package/engine/anchors.mjs +16 -10
  21. package/engine/art-fields.mjs +118 -0
  22. package/engine/art-slots.mjs +9 -2
  23. package/engine/asset-index.mjs +160 -10
  24. package/engine/asset-replacement.mjs +93 -0
  25. package/engine/asset-types.mjs +240 -0
  26. package/engine/base-compiler.mjs +54 -2
  27. package/engine/bundles.mjs +13 -1
  28. package/engine/content-blocks.mjs +4 -4
  29. package/engine/content-embeds.mjs +19 -0
  30. package/engine/content-figures.mjs +468 -0
  31. package/engine/content-footnotes.mjs +10 -2
  32. package/engine/content-format.yaml +1 -1
  33. package/engine/content-images.mjs +126 -25
  34. package/engine/content-index.mjs +36 -10
  35. package/engine/field-reference.mjs +31 -10
  36. package/engine/field-spec.mjs +10 -8
  37. package/engine/foreign-catalog.mjs +145 -19
  38. package/engine/frontmatter-lint.mjs +73 -1
  39. package/engine/heading-attributes.mjs +31 -18
  40. package/engine/helpers.mjs +102 -33
  41. package/engine/infobox-registry.mjs +4 -1
  42. package/engine/infobox.mjs +47 -8
  43. package/engine/itinerary-scenes.mjs +4 -10
  44. package/engine/journals.mjs +264 -39
  45. package/engine/lore-events.mjs +197 -0
  46. package/engine/markdown-expressions.mjs +257 -7
  47. package/engine/note-dates.mjs +305 -12
  48. package/engine/note-vocabulary.mjs +11 -5
  49. package/engine/pathnames.mjs +36 -0
  50. package/engine/pdf-build.mjs +357 -34
  51. package/engine/pdf-images.mjs +64 -0
  52. package/engine/pdf-render.mjs +160 -36
  53. package/engine/scenes.mjs +16 -4
  54. package/engine/site-build.mjs +127 -13
  55. package/engine/site-config.mjs +19 -24
  56. package/engine/site-index.mjs +72 -1
  57. package/engine/site-preview.mjs +2 -0
  58. package/engine/system-block.mjs +44 -16
  59. package/hm3/actors.mjs +2 -2
  60. package/hugo-theme/data/banners.yaml +69 -0
  61. package/hugo-theme/layouts/404.html +85 -0
  62. package/hugo-theme/layouts/_default/baseof.html +64 -0
  63. package/hugo-theme/layouts/_default/list.html +139 -0
  64. package/hugo-theme/layouts/_default/single.html +4 -0
  65. package/hugo-theme/layouts/blog/list.html +54 -0
  66. package/hugo-theme/layouts/blog-post/single.html +36 -0
  67. package/hugo-theme/layouts/index.html +75 -0
  68. package/hugo-theme/layouts/partials/banner-url.html +117 -0
  69. package/hugo-theme/layouts/partials/cdn-url.html +36 -0
  70. package/hugo-theme/layouts/partials/draft-notice.html +41 -0
  71. package/hugo-theme/layouts/partials/draft-state.html +26 -0
  72. package/hugo-theme/layouts/partials/footer.html +29 -0
  73. package/hugo-theme/layouts/partials/header.html +35 -0
  74. package/hugo-theme/layouts/partials/hero-banner.html +86 -0
  75. package/hugo-theme/layouts/partials/holdings/entry.html +21 -0
  76. package/hugo-theme/layouts/partials/holdings/kind-groups.html +71 -0
  77. package/hugo-theme/layouts/partials/holdings.html +61 -0
  78. package/hugo-theme/layouts/partials/infobox/entry.html +15 -0
  79. package/hugo-theme/layouts/partials/infobox/items.html +26 -0
  80. package/hugo-theme/layouts/partials/infobox/section.html +56 -0
  81. package/hugo-theme/layouts/partials/infobox/value.html +43 -0
  82. package/hugo-theme/layouts/partials/infobox.html +57 -0
  83. package/hugo-theme/layouts/partials/page-body.html +154 -0
  84. package/hugo-theme/layouts/partials/place-map.html +9 -0
  85. package/hugo-theme/layouts/partials/related.html +121 -0
  86. package/hugo-theme/layouts/partials/search.html +84 -0
  87. package/hugo-theme/layouts/partials/site-url.html +32 -0
  88. package/hugo-theme/layouts/partials/tags.html +30 -0
  89. package/hugo-theme/layouts/partials/toc.html +28 -0
  90. package/hugo-theme/layouts/partials/warn-missing-banner.html +3 -0
  91. package/hugo-theme/layouts/shortcodes/cartouche.html +64 -0
  92. package/hugo-theme/layouts/shortcodes/exturl.html +12 -0
  93. package/hugo-theme/layouts/shortcodes/glyph.html +43 -0
  94. package/hugo-theme/layouts/shortcodes/pagelist.html +63 -0
  95. package/hugo-theme/static/css/sohl-icons.css +57 -0
  96. package/hugo-theme/static/css/style.css +2953 -0
  97. package/hugo-theme/static/fonts/game-icons-kb.woff2 +0 -0
  98. package/hugo-theme/static/img/banners/takheperu-hieroglyphs.svg +1 -0
  99. package/hugo-theme/static/img/cartouches/takheperu.svg +1 -0
  100. package/hugo-theme/theme.toml +11 -0
  101. package/manifest.mjs +30 -24
  102. package/package.json +2 -1
  103. package/packagebuild-metadata.jsonl +13 -13
  104. package/sohl/item-fields.mjs +4 -3
  105. package/types/content-config.d.mts +48 -1
  106. package/types/engine/anchors.d.mts +9 -4
  107. package/types/engine/art-fields.d.mts +64 -0
  108. package/types/engine/art-slots.d.mts +2 -1
  109. package/types/engine/asset-index.d.mts +43 -0
  110. package/types/engine/asset-replacement.d.mts +22 -0
  111. package/types/engine/asset-types.d.mts +18 -0
  112. package/types/engine/base-compiler.d.mts +0 -17
  113. package/types/engine/content-embeds.d.mts +16 -0
  114. package/types/engine/content-figures.d.mts +101 -0
  115. package/types/engine/content-footnotes.d.mts +7 -0
  116. package/types/engine/content-images.d.mts +78 -12
  117. package/types/engine/content-index.d.mts +8 -2
  118. package/types/engine/field-reference.d.mts +21 -0
  119. package/types/engine/field-spec.d.mts +20 -16
  120. package/types/engine/foreign-catalog.d.mts +39 -0
  121. package/types/engine/heading-attributes.d.mts +13 -8
  122. package/types/engine/helpers.d.mts +28 -10
  123. package/types/engine/infobox-registry.d.mts +4 -1
  124. package/types/engine/infobox.d.mts +6 -0
  125. package/types/engine/journals.d.mts +31 -9
  126. package/types/engine/lore-events.d.mts +14 -0
  127. package/types/engine/markdown-expressions.d.mts +23 -2
  128. package/types/engine/note-dates.d.mts +64 -1
  129. package/types/engine/pathnames.d.mts +26 -0
  130. package/types/engine/pdf-build.d.mts +28 -6
  131. package/types/engine/pdf-images.d.mts +52 -0
  132. package/types/engine/pdf-render.d.mts +12 -0
  133. package/types/engine/site-build.d.mts +14 -3
  134. package/types/engine/site-config.d.mts +9 -9
  135. package/types/engine/site-index.d.mts +54 -0
  136. package/types/engine/system-block.d.mts +26 -2
  137. package/types/hm3/actors.d.mts +18 -0
  138. package/types/manifest.d.mts +19 -23
  139. package/engine/content-captions.mjs +0 -230
  140. package/types/engine/content-captions.d.mts +0 -58
@@ -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
 
@@ -199,6 +207,10 @@ export function parseNoteDate(value, options) {
199
207
  daysPerYear,
200
208
  field,
201
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,
202
214
  file,
203
215
  raw,
204
216
  keyPath,
@@ -331,7 +343,14 @@ export function parseNoteDate(value, options) {
331
343
  return refuse(`${subject} cannot resolve its calendar date: ${err.message}`);
332
344
  }
333
345
  }
334
- 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
+ )
335
354
  return refuse(
336
355
  `${subject} writes year 0, and no reckoning has one — the years either ` +
337
356
  `side of an epoch are -1 and 1`,
@@ -343,6 +362,28 @@ export function parseNoteDate(value, options) {
343
362
  );
344
363
  if (unmarked.includes(".")) {
345
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
+ }
346
387
  if (canonical) {
347
388
  return {
348
389
  date: {
@@ -655,19 +696,276 @@ export function formatNoteDate(date, era, daysPerYear, formatName) {
655
696
  return { era, year, month, day, text, prose };
656
697
  }
657
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
+
658
956
  /** Normalize a note's declared dates for indexes and generated pages. */
659
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
+ }
660
963
  const fields =
661
964
  fm?.type === "being" ?
662
965
  [
663
966
  ["born", fm.data?.born],
664
967
  ["died", fm.data?.died],
665
968
  ]
666
- : fm?.type === "lore" ?
667
- [
668
- ["when", fm.data?.event?.when],
669
- ["until", fm.data?.event?.until],
670
- ]
671
969
  : [];
672
970
  const out = {};
673
971
  for (const [key, value] of fields) {
@@ -677,12 +975,7 @@ export function resolvedDateFields(fm, context) {
677
975
  allowUnknown: key === "born" || key === "died",
678
976
  });
679
977
  if (parsed.date && !parsed.findings.some((finding) => finding.severity === "error")) {
680
- const era =
681
- parsed.date.marker ? context?.markers?.get(parsed.date.marker)
682
- : parsed.date.qualifier ? context?.eras?.get(parsed.date.qualifier)
683
- : null;
684
- const printable = formatNoteDate(parsed.date, era, context?.daysPerYear);
685
- out[key] = { ...parsed.date, prose: printable?.prose ?? null };
978
+ out[key] = withMarkerProse(parsed.date, context);
686
979
  }
687
980
  }
688
981
  return out;
@@ -107,6 +107,7 @@ import { STANDING_BODY_TYPES } from "./standing-terms.mjs";
107
107
  import { checkDatedOffices } from "./office-holders.mjs";
108
108
  import { checkRankLadder } from "./rank-ladder.mjs";
109
109
  import { checkCalendarChoice } from "./calendar-choice.mjs";
110
+ import { checkLoreEvents } from "./lore-events.mjs";
110
111
  import { checkCultureChoice } from "./culture-choice.mjs";
111
112
  import { SOCIAL_TIES, SOCIAL_TIE_TARGET_TYPES } from "./social-tie-terms.mjs";
112
113
  import { parseNoteDate } from "./note-dates.mjs";
@@ -469,7 +470,9 @@ export const SHARED_DATA_FIELDS = Object.freeze([
469
470
  ...LINK,
470
471
  ref: "image",
471
472
  accepts: ART_SLOTS.find((slot) => slot.key === "banner").accepts,
472
- describe: "The page's hero image — an `image` address. Reaches no compiled document.",
473
+ describe:
474
+ "The page's hero image — an `image` address, cut to 1792×768. " +
475
+ "Reaches no compiled document.",
473
476
  }),
474
477
  ]);
475
478
 
@@ -1493,10 +1496,13 @@ export const NOTE_VOCABULARY = Object.freeze({
1493
1496
  data: Object.freeze([
1494
1497
  ...CALENDAR_FIELDS,
1495
1498
  {
1496
- name: "event",
1497
- kind: "map",
1498
- shape: "event metadata map",
1499
- describe: "A dated occurrence and its relationships to other events and places.",
1499
+ name: "events",
1500
+ kind: "list",
1501
+ shape: "list of `{ when, until?, recurs? }`",
1502
+ check: checkLoreEvents,
1503
+ describe:
1504
+ "This note's dated occurrences — a founding once, an anniversary " +
1505
+ "that recurs, or a list of recorded happenings.",
1500
1506
  },
1501
1507
  ]),
1502
1508
  }),
@@ -443,3 +443,39 @@ export function resolvePathname(raw, config) {
443
443
  pdf: `${ASSETS_SEGMENT}/${suffix}`,
444
444
  };
445
445
  }
446
+
447
+ /**
448
+ * The pathname one authored image address keys an asset's role and pixel
449
+ * size under — `<package>/assets/<suffix>`, the same key
450
+ * {@link module:engine/art-fields.assetImageInfoByPathname} builds from the
451
+ * asset record itself.
452
+ *
453
+ * **Why this exists apart from `resolvePathname`'s own forms.** An embed
454
+ * rewrites to that exact pathname — see
455
+ * {@link module:engine/content-embeds.resolveEmbeds} — but an author's own
456
+ * body image is free to write the bare, own-package form instead
457
+ * (`images/m.webp`), which names the identical file without naming the
458
+ * package. The two forms have to key the same lookup, and `resolvePathname`
459
+ * already answers which package a bare form belongs to; this just restates
460
+ * that answer in the one shape a renderer looks a picture's role up by.
461
+ *
462
+ * **Never throws.** A lookup is a best-effort read, not a validation — the
463
+ * pathname lint is what refuses a Foundry-spelled address — so a value
464
+ * {@link resolvePathname} refuses outright answers `null` here as well, the
465
+ * same as a blank or an external one.
466
+ *
467
+ * @param {string} src - The address, as authored or as an embed resolved it.
468
+ * @param {object} config - The resolved build configuration.
469
+ * @returns {string|null} The pathname, or `null` for an address naming no
470
+ * asset record to look up.
471
+ */
472
+ export function assetPathnameKey(src, config) {
473
+ let forms;
474
+ try {
475
+ forms = resolvePathname(src, config);
476
+ } catch {
477
+ return null;
478
+ }
479
+ if (!forms || forms.state !== "package" || !forms.package) return null;
480
+ return `${forms.package}/${ASSETS_SEGMENT}/${forms.suffix}`;
481
+ }