@heroiclands/package-build 22.14.4 → 22.15.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 (60) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/CONTENT.md +25 -1
  3. package/bin/content-build.mjs +3 -7
  4. package/bin/package-build.mjs +25 -0
  5. package/docs/api.md +61 -34
  6. package/docs/commands.md +65 -9
  7. package/docs/configuration.md +6 -3
  8. package/docs/content-format.md +165 -121
  9. package/docs/diagnostics.md +1 -1
  10. package/docs/getting-started.md +23 -0
  11. package/engine/base-compiler.mjs +3 -1
  12. package/engine/being-age.mjs +16 -24
  13. package/engine/calendar-artifacts.mjs +153 -0
  14. package/engine/calendar-notes.mjs +64 -16
  15. package/engine/calendars.mjs +129 -0
  16. package/engine/content-index.mjs +3 -1
  17. package/engine/exported-scene.mjs +230 -0
  18. package/engine/generate.mjs +1 -0
  19. package/engine/ids.mjs +1 -1
  20. package/engine/index.mjs +6 -0
  21. package/engine/infobox.mjs +4 -0
  22. package/engine/itinerary-scenes.mjs +170 -0
  23. package/engine/map-build.mjs +10 -45
  24. package/engine/map-dot.mjs +6 -3
  25. package/engine/map-graphviz.mjs +50 -131
  26. package/engine/map-notes.mjs +26 -3
  27. package/engine/map-places.mjs +12 -6
  28. package/engine/map-raster.mjs +49 -0
  29. package/engine/note-dates.mjs +125 -85
  30. package/engine/note-vocabulary.mjs +22 -6
  31. package/engine/pdf-build.mjs +57 -1
  32. package/engine/pdf-render.mjs +18 -0
  33. package/engine/reckoning-markers.mjs +207 -0
  34. package/engine/scenes.mjs +113 -26
  35. package/engine/site-build.mjs +11 -13
  36. package/engine/site-maps.mjs +5 -35
  37. package/engine/site-preview.mjs +6 -1
  38. package/engine/social-tie-terms.mjs +3 -0
  39. package/engine/social-ties.mjs +30 -19
  40. package/package.json +3 -1
  41. package/types/engine/being-age.d.mts +2 -2
  42. package/types/engine/calendar-artifacts.d.mts +16 -0
  43. package/types/engine/calendar-notes.d.mts +1 -1
  44. package/types/engine/calendars.d.mts +65 -0
  45. package/types/engine/exported-scene.d.mts +12 -0
  46. package/types/engine/index.d.mts +2 -0
  47. package/types/engine/itinerary-scenes.d.mts +29 -0
  48. package/types/engine/map-build.d.mts +4 -13
  49. package/types/engine/map-graphviz.d.mts +17 -59
  50. package/types/engine/map-places.d.mts +5 -1
  51. package/types/engine/map-raster.d.mts +8 -0
  52. package/types/engine/note-dates.d.mts +6 -0
  53. package/types/engine/note-vocabulary.d.mts +3 -3
  54. package/types/engine/pdf-render.d.mts +7 -1
  55. package/types/engine/reckoning-markers.d.mts +27 -0
  56. package/types/engine/scenes.d.mts +9 -2
  57. package/types/engine/site-build.d.mts +1 -5
  58. package/types/engine/site-maps.d.mts +2 -7
  59. package/types/engine/social-tie-terms.d.mts +2 -0
  60. package/types/engine/social-ties.d.mts +2 -1
@@ -0,0 +1,153 @@
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
+ * SPDX-License-Identifier: GPL-3.0-or-later
6
+ */
7
+
8
+ /** Build the calendar files a content package ships to Foundry. */
9
+
10
+ import fs from "node:fs";
11
+ import path from "node:path";
12
+
13
+ import { calendariaEnvelope, compileCalendars } from "./calendar-notes.mjs";
14
+ import { formatDiagnostic, positionOfFrontmatterPath } from "./diagnostics.mjs";
15
+ import { authoredFrontmatter, isNoteRecord, noteFile } from "./index-records.mjs";
16
+ import { metadataFileName } from "./packages.mjs";
17
+
18
+ // The import envelope requires an ISO instant. A fixed value keeps an export
19
+ // reproducible from its source rather than stamping the build machine's clock.
20
+ const EXPORT_EPOCH = "1970-01-01T00:00:00.000Z";
21
+
22
+ /** @param {string} file @param {string} message @param {string} raw @param {string[]} keyPath */
23
+ function failAt(file, message, raw, keyPath) {
24
+ const position = positionOfFrontmatterPath(raw, keyPath, { key: true });
25
+ const error = new Error(formatDiagnostic({ file, ...position, severity: "error", message }));
26
+ error.located = true;
27
+ throw error;
28
+ }
29
+
30
+ /** @param {string} file */
31
+ function readIndex(file) {
32
+ const display = path.relative(process.cwd(), file) || file;
33
+ if (!fs.existsSync(file)) {
34
+ const error = new Error(
35
+ `${display}: error: content index is absent; run content-build content-index`,
36
+ );
37
+ error.located = true;
38
+ throw error;
39
+ }
40
+ const records = fs
41
+ .readFileSync(file, "utf8")
42
+ .split(/\r?\n/)
43
+ .filter(Boolean)
44
+ .map((line, index) => {
45
+ try {
46
+ return JSON.parse(line);
47
+ } catch (cause) {
48
+ const error = new Error(
49
+ `${display}:${index + 1}: error: invalid content index record`,
50
+ {
51
+ cause,
52
+ },
53
+ );
54
+ error.located = true;
55
+ throw error;
56
+ }
57
+ });
58
+ if (!records.some(isNoteRecord)) {
59
+ const error = new Error(`${display}: error: content index contains no notes`);
60
+ error.located = true;
61
+ throw error;
62
+ }
63
+ return records;
64
+ }
65
+
66
+ /**
67
+ * Emit calendar definitions and import envelopes from the package's index.
68
+ *
69
+ * @param {object} opts
70
+ * @param {object} opts.config - Resolved content configuration.
71
+ * @param {string} opts.calendariaVersion - Version whose import format is targeted.
72
+ * @returns {{directory: string, calendars: number, files: number}}
73
+ */
74
+ export function emitCalendarArtifacts({ config, calendariaVersion } = {}) {
75
+ if (!config) throw new Error("calendar emission requires a content configuration");
76
+ if (!/^\d+\.\d+\.\d+$/.test(calendariaVersion ?? "")) {
77
+ throw new Error("calendar emission requires a Calendaria version such as 1.4.2");
78
+ }
79
+
80
+ const root = config.rootDir;
81
+ const contentBase = path.resolve(root, config.paths.content);
82
+ const indexFile = path.resolve(
83
+ root,
84
+ config.paths.contentIndex,
85
+ metadataFileName(config.contentPackage),
86
+ );
87
+ const directory = path.resolve(root, "build/calendars");
88
+ const notes = readIndex(indexFile)
89
+ .filter(isNoteRecord)
90
+ .map((record) => ({
91
+ fm: authoredFrontmatter(record),
92
+ file: path.relative(root, noteFile(contentBase, record)),
93
+ rel: record.file.path,
94
+ }));
95
+ const calendars = notes.filter(
96
+ (note) =>
97
+ String(note.fm.type).toLowerCase() === "lore" &&
98
+ String(note.fm.subType).toLowerCase() === "calendar",
99
+ );
100
+ const names = new Map();
101
+ for (const note of calendars) {
102
+ const raw = fs.readFileSync(path.resolve(root, note.file), "utf8");
103
+ const shortcode = note.fm.shortcode;
104
+ if (typeof shortcode !== "string" || !/^[a-z0-9]+$/.test(shortcode)) {
105
+ failAt(
106
+ note.file,
107
+ "calendar shortcode must contain lowercase ASCII letters and digits",
108
+ raw,
109
+ ["shortcode"],
110
+ );
111
+ }
112
+ if (names.has(shortcode)) {
113
+ failAt(
114
+ note.file,
115
+ `calendar shortcode ${shortcode} also occurs in ${names.get(shortcode)}`,
116
+ raw,
117
+ ["shortcode"],
118
+ );
119
+ }
120
+ names.set(shortcode, note.file);
121
+ }
122
+
123
+ const compiled = compileCalendars({ notes }, { contentPackage: config.contentPackage });
124
+ if (compiled.calendars.length !== calendars.length) {
125
+ const note = calendars[0];
126
+ failAt(
127
+ note.file,
128
+ "calendar notes require a world note declaring data.year",
129
+ fs.readFileSync(path.resolve(root, note.file), "utf8"),
130
+ ["subType"],
131
+ );
132
+ }
133
+ const outputs = [];
134
+ for (let index = 0; index < calendars.length; index++) {
135
+ const shortcode = calendars[index].fm.shortcode;
136
+ const definition = compiled.calendars[index];
137
+ outputs.push([`${shortcode}.json`, definition]);
138
+ outputs.push([
139
+ `${shortcode}.calendaria.json`,
140
+ calendariaEnvelope(definition, {
141
+ version: calendariaVersion,
142
+ exportedAt: EXPORT_EPOCH,
143
+ }),
144
+ ]);
145
+ }
146
+
147
+ fs.rmSync(directory, { recursive: true, force: true });
148
+ fs.mkdirSync(directory, { recursive: true });
149
+ for (const [name, value] of outputs) {
150
+ fs.writeFileSync(path.join(directory, name), `${JSON.stringify(value, null, 2)}\n`);
151
+ }
152
+ return { directory, calendars: calendars.length, files: outputs.length };
153
+ }
@@ -64,9 +64,10 @@
64
64
  * @module
65
65
  */
66
66
 
67
- import { dayOfYear, daysInYear } from "./calendars.mjs";
67
+ import { dayOfYear, daysInYear, monthDayOfYear } from "./calendars.mjs";
68
68
  import { positionInFrontmatter, positionOfFrontmatterPath } from "./diagnostics.mjs";
69
69
  import { parseNoteDate } from "./note-dates.mjs";
70
+ import { reckoningContext } from "./reckoning-markers.mjs";
70
71
 
71
72
  /** The `lore` subType whose notes are calendars. */
72
73
  export const CALENDAR_SUBTYPE = "calendar";
@@ -144,11 +145,10 @@ export const CALENDAR_FIELDS = Object.freeze([
144
145
  },
145
146
  {
146
147
  name: "eras",
147
- shape: "list of `{ shortcode, name, abbreviation?, proclaimedBy?, start, end? }`",
148
+ shape: "list of `{ shortcode, name, marker?, abbreviation?, proclaimedBy?, start, end? }`",
148
149
  kind: "list",
149
150
  describe:
150
- "The year-counts kept in this calendar. A date names one by writing " +
151
- "`<calendar shortcode>.<era shortcode>`.",
151
+ "The year-counts kept in this calendar. A marker names one era and uses these months.",
152
152
  },
153
153
  {
154
154
  name: "dateFormats",
@@ -456,6 +456,7 @@ export function checkWorldFacts(note, { index } = {}) {
456
456
 
457
457
  if (written.includes("present")) {
458
458
  const { findings: dated } = parseNoteDate(dataOf(note).present, {
459
+ ...reckoningContext(index),
459
460
  field: "data.present",
460
461
  allowUnknown: false,
461
462
  file: note.file,
@@ -464,6 +465,18 @@ export function checkWorldFacts(note, { index } = {}) {
464
465
  });
465
466
  findings.push(...dated);
466
467
  }
468
+ if (written.includes("moon") && dataOf(note).moon?.newOn !== undefined) {
469
+ findings.push(
470
+ ...parseNoteDate(dataOf(note).moon.newOn, {
471
+ ...reckoningContext(index),
472
+ field: "data.moon.newOn",
473
+ allowUnknown: false,
474
+ file: note.file,
475
+ raw: note.raw,
476
+ keyPath: ["data", "moon", "newOn"],
477
+ }).findings,
478
+ );
479
+ }
467
480
 
468
481
  // The corpus questions, which need every note and so are asked only where
469
482
  // the caller handed one over.
@@ -558,8 +571,24 @@ export function checkCalendarNote(note, { index } = {}) {
558
571
  });
559
572
  }
560
573
 
574
+ if (written.includes("epoch")) {
575
+ findings.push(
576
+ ...parseNoteDate(dataOf(note).epoch, {
577
+ ...reckoningContext(index),
578
+ field: "data.epoch",
579
+ allowUnknown: false,
580
+ file: note.file,
581
+ raw: note.raw,
582
+ keyPath: ["data", "epoch"],
583
+ }).findings,
584
+ );
585
+ }
586
+
561
587
  findings.push(...checkMonthSum(note, index));
562
588
  findings.push(...checkEraShortcodes(note));
589
+ findings.push(
590
+ ...reckoningContext(index).findings.filter((finding) => finding.file === note.file),
591
+ );
563
592
  findings.push(...checkCalendarDateFormats(note));
564
593
  return findings;
565
594
  }
@@ -718,16 +747,18 @@ function seasonValues(seasons) {
718
747
  }
719
748
 
720
749
  /** The era rows, keyed by shortcode, with the addressing segments dropped. */
721
- function eraEntries(eras) {
750
+ function eraEntries(eras, dateContext = {}) {
722
751
  const entries = {};
723
752
  for (const era of Array.isArray(eras) ? eras : []) {
724
753
  const shortcode = String(era?.shortcode ?? "");
725
754
  if (!shortcode) continue;
755
+ const start = parseNoteDate(era?.start, { ...dateContext, allowUnknown: false }).date;
756
+ const end = parseNoteDate(era?.end, { ...dateContext, allowUnknown: false }).date;
726
757
  entries[shortcode] = written({
727
758
  name: String(era?.name ?? ""),
728
759
  abbreviation: era?.abbreviation === undefined ? undefined : String(era.abbreviation),
729
- start: era?.start === undefined ? undefined : String(era.start),
730
- end: era?.end === undefined ? undefined : String(era.end),
760
+ start: era?.start === undefined ? undefined : String(start?.year ?? era.start),
761
+ end: era?.end === undefined ? undefined : String(end?.year ?? era.end),
731
762
  });
732
763
  }
733
764
  return entries;
@@ -746,15 +777,23 @@ function eraEntries(eras) {
746
777
  * @param {string} name - What the body is called.
747
778
  * @returns {object|null} The entry, keyed by the body's name, or `null`.
748
779
  */
749
- function moonEntries(moon, body, name) {
780
+ function moonEntries(moon, body, name, months, dateContext = {}) {
750
781
  if (!moon || !name) return null;
751
- const date = parseNoteDate(moon.newOn, { allowUnknown: false }).date;
782
+ const date = parseNoteDate(moon.newOn, { ...dateContext, allowUnknown: false }).date;
783
+ const calendarDay =
784
+ date?.canonicalDay && months.length ? monthDayOfYear(months, date.canonicalDay) : null;
752
785
  return {
753
786
  [name]: written({
754
787
  name,
755
788
  cycleLength: Number(moon.cycle),
756
789
  referenceDate:
757
- date ? written({ year: date.year, month: date.month, day: date.day }) : undefined,
790
+ date ?
791
+ written({
792
+ year: date.canonicalYear ?? date.year,
793
+ month: calendarDay?.month ?? date.month,
794
+ day: calendarDay?.day ?? date.day,
795
+ })
796
+ : undefined,
758
797
  // A circular orbit is a cycle of exactly its length, every time.
759
798
  cycleVariance: String(body?.orbit ?? "") === "circular" ? 0 : undefined,
760
799
  phaseMode: "fixed",
@@ -781,15 +820,21 @@ function moonEntries(moon, body, name) {
781
820
  * its own package.
782
821
  * @returns {object} The definition.
783
822
  */
784
- export function compileCalendar({ note, invariants, contentPackage }) {
823
+ export function compileCalendar({ note, invariants, contentPackage, dateContext = {} }) {
785
824
  const data = dataOf(note);
786
825
  const year = invariants?.year ?? {};
787
826
  const months = Array.isArray(data.months) ? data.months : [];
788
- const epoch = parseNoteDate(data.epoch, { allowUnknown: false }).date;
827
+ const epoch = parseNoteDate(data.epoch, { ...dateContext, allowUnknown: false }).date;
789
828
  const shortcode = String(note.fm?.shortcode ?? "");
790
829
  const name = String(note.fm?.name?.full ?? "");
791
830
  const description = String(note.fm?.description ?? "");
792
- const moons = moonEntries(invariants?.moon, invariants?.body ?? null, invariants?.moonName);
831
+ const moons = moonEntries(
832
+ invariants?.moon,
833
+ invariants?.body ?? null,
834
+ invariants?.moonName,
835
+ months,
836
+ dateContext,
837
+ );
793
838
  const author = contentPackage ? String(contentPackage) : undefined;
794
839
 
795
840
  return written({
@@ -818,8 +863,10 @@ export function compileCalendar({ note, invariants, contentPackage }) {
818
863
  seasons: { values: seasonValues(data.seasons) },
819
864
  // How far into the year the world's zero falls. `1/1` is none.
820
865
  epochDayOffset:
821
- epoch && months.length ? dayOfYear(months, epoch.month ?? 1, epoch.day ?? 1) - 1 : 0,
822
- eras: eraEntries(data.eras),
866
+ epoch && months.length ?
867
+ (epoch.canonicalDay ?? dayOfYear(months, epoch.month ?? 1, epoch.day ?? 1)) - 1
868
+ : 0,
869
+ eras: eraEntries(data.eras, dateContext),
823
870
  dateFormats: data.dateFormats,
824
871
  moons: moons ?? undefined,
825
872
  metadata: written({
@@ -871,9 +918,10 @@ export function compileCalendars(index, { contentPackage } = {}) {
871
918
  const invariants = worldInvariants(index);
872
919
  if (!invariants.year) return { calendars: [], invariants };
873
920
  const calendars = [];
921
+ const dateContext = reckoningContext(index);
874
922
  for (const note of index?.notes ?? []) {
875
923
  if (typeOf(note) !== "lore" || subTypeOf(note) !== CALENDAR_SUBTYPE) continue;
876
- calendars.push(compileCalendar({ note, invariants, contentPackage }));
924
+ calendars.push(compileCalendar({ note, invariants, contentPackage, dateContext }));
877
925
  }
878
926
  return { calendars, invariants };
879
927
  }
@@ -126,6 +126,116 @@ export function dateSortKey(year, month, day) {
126
126
  return (year * 10000 + (month ?? 0) * 100 + (day ?? 0)) / 10000;
127
127
  }
128
128
 
129
+ /**
130
+ * Days from an arbitrary canonical origin to a canonical year and day.
131
+ *
132
+ * Canonical years form a continuous integer line. Day one opens each year;
133
+ * calendar month divisions play no part in this count.
134
+ *
135
+ * @param {number} year - Canonical year.
136
+ * @param {number} day - Day of that year, starting at one.
137
+ * @param {number} originYear - Canonical year whose first day is offset zero.
138
+ * @param {number} daysPerYear - The world's authored year length.
139
+ * @returns {number} Signed whole-day offset.
140
+ */
141
+ export function canonicalDayOffset(year, day, originYear, daysPerYear) {
142
+ if (
143
+ !Number.isSafeInteger(year) ||
144
+ !Number.isSafeInteger(originYear) ||
145
+ !Number.isSafeInteger(daysPerYear) ||
146
+ daysPerYear < 1 ||
147
+ !Number.isSafeInteger(day) ||
148
+ day < 1 ||
149
+ day > daysPerYear
150
+ ) {
151
+ throw new RangeError(
152
+ "a canonical date needs an integer year and a day in the world's year",
153
+ );
154
+ }
155
+ const offset = (year - originYear) * daysPerYear + day - 1;
156
+ if (!Number.isSafeInteger(offset))
157
+ throw new RangeError("canonical day offset exceeds integer range");
158
+ return offset;
159
+ }
160
+
161
+ /**
162
+ * The canonical year and day at a signed offset from an arbitrary origin.
163
+ * Flooring the division keeps the day within the year before the origin.
164
+ *
165
+ * @param {number} offset - Whole days from the origin.
166
+ * @param {number} originYear - Canonical year whose first day is offset zero.
167
+ * @param {number} daysPerYear - The world's authored year length.
168
+ * @returns {{year: number, day: number}} Canonical year and day.
169
+ */
170
+ export function canonicalDateFromOffset(offset, originYear, daysPerYear) {
171
+ if (
172
+ !Number.isSafeInteger(offset) ||
173
+ !Number.isSafeInteger(originYear) ||
174
+ !Number.isSafeInteger(daysPerYear) ||
175
+ daysPerYear < 1
176
+ ) {
177
+ throw new RangeError("a canonical offset needs integer days, origin year, and year length");
178
+ }
179
+ const years = Math.floor(offset / daysPerYear);
180
+ const year = originYear + years;
181
+ if (!Number.isSafeInteger(year)) throw new RangeError("canonical year exceeds integer range");
182
+ return { year, day: offset - years * daysPerYear + 1 };
183
+ }
184
+
185
+ /**
186
+ * Print a calendar-neutral date as `<year>.<day>[:HHMMSS]`.
187
+ *
188
+ * @param {{year: number, day: number, seconds?: number}} date - Canonical date.
189
+ * @param {number} daysPerYear - The world's authored year length.
190
+ * @returns {string} The canonical spelling.
191
+ */
192
+ export function formatCanonicalDate(date, daysPerYear) {
193
+ canonicalDayOffset(date?.year, date?.day, date?.year, daysPerYear);
194
+ if (date.seconds === undefined) return `${date.year}.${date.day}`;
195
+ if (!Number.isSafeInteger(date.seconds) || date.seconds < 0 || date.seconds >= 86400) {
196
+ throw new RangeError("canonical time is a whole second within one day");
197
+ }
198
+ const hour = String(Math.floor(date.seconds / 3600)).padStart(2, "0");
199
+ const minute = String(Math.floor((date.seconds % 3600) / 60)).padStart(2, "0");
200
+ const second = String(date.seconds % 60).padStart(2, "0");
201
+ return `${date.year}.${date.day}:${hour}${minute}${second}`;
202
+ }
203
+
204
+ /**
205
+ * Read the calendar-neutral `<year>.<day>[:HHMMSS]` spelling.
206
+ *
207
+ * This is distinct from an authored month date such as `720/6`; no calendar
208
+ * month list is consulted to read a canonical day of year.
209
+ *
210
+ * @param {unknown} value - Canonical date text.
211
+ * @param {number} daysPerYear - The world's authored year length.
212
+ * @returns {{year: number, day: number, seconds?: number}|null} The date, or
213
+ * `null` when the spelling or a component is invalid.
214
+ */
215
+ export function parseCanonicalDate(value, daysPerYear) {
216
+ if (typeof value !== "string") return null;
217
+ const match = /^(-?\d+)\.(\d+)(?::(\d{2})(\d{2})(\d{2}))?$/.exec(value);
218
+ if (!match) return null;
219
+ const year = Number(match[1]);
220
+ const day = Number(match[2]);
221
+ if (
222
+ !Number.isSafeInteger(year) ||
223
+ !Number.isSafeInteger(day) ||
224
+ !Number.isSafeInteger(daysPerYear) ||
225
+ daysPerYear < 1 ||
226
+ day < 1 ||
227
+ day > daysPerYear
228
+ ) {
229
+ return null;
230
+ }
231
+ if (match[3] === undefined) return { year, day };
232
+ const hour = Number(match[3]);
233
+ const minute = Number(match[4]);
234
+ const second = Number(match[5]);
235
+ if (hour > 23 || minute > 59 || second > 59) return null;
236
+ return { year, day, seconds: hour * 3600 + minute * 60 + second };
237
+ }
238
+
129
239
  /**
130
240
  * One month of a calendar, as the note declares it.
131
241
  *
@@ -213,6 +323,25 @@ export function dayOfYear(months, month, day) {
213
323
  return monthStarts(months)[month - 1] + day - 1;
214
324
  }
215
325
 
326
+ /**
327
+ * Recover a calendar's month and day from an ordinal day of its year.
328
+ *
329
+ * @param {readonly CalendarMonth[]} months - The ordered month list.
330
+ * @param {number} ordinal - Day of year, starting at one.
331
+ * @returns {{month: number, day: number}|null} The named day, or `null` outside the year.
332
+ */
333
+ export function monthDayOfYear(months, ordinal) {
334
+ if (!Number.isSafeInteger(ordinal) || ordinal < 1) return null;
335
+ let remaining = ordinal;
336
+ for (let position = 0; position < months.length; position++) {
337
+ const length = Number(months[position]?.days);
338
+ if (!Number.isSafeInteger(length) || length < 1) return null;
339
+ if (remaining <= length) return { month: position + 1, day: remaining };
340
+ remaining -= length;
341
+ }
342
+ return null;
343
+ }
344
+
216
345
  /**
217
346
  * How far into its cycle a moon is on a given day.
218
347
  *
@@ -151,6 +151,7 @@ import { isDraftNote } from "./note-vocabulary.mjs";
151
151
  // an unknown one — filled in against the package's own declared present before
152
152
  // a record is built, so the index carries it without the note being written to.
153
153
  import { applyComputedBeingAge, presentAmongFrontmatters } from "./being-age.mjs";
154
+ import { reckoningContext } from "./reckoning-markers.mjs";
154
155
 
155
156
  export {
156
157
  authoredFrontmatter,
@@ -692,10 +693,11 @@ export function collectContentIndex(
692
693
  // this same walk states one — the whole tree is already in memory, so no
693
694
  // second read is needed to answer a question about all of it.
694
695
  const present = presentAmongFrontmatters(notes.map((n) => n.frontmatter));
696
+ const dates = reckoningContext({ notes: notes.map((n) => n.frontmatter) });
695
697
 
696
698
  for (const { frontmatter, body, bodyLine, absPath } of notes) {
697
699
  const fm = frontmatter ?? {};
698
- applyComputedBeingAge(fm, present);
700
+ applyComputedBeingAge(fm, present, dates);
699
701
  const relPath = path.relative(contentBase, absPath);
700
702
  try {
701
703
  records.push(