@heroiclands/package-build 22.14.3 → 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 (62) hide show
  1. package/CHANGELOG.md +32 -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 +64 -36
  6. package/docs/commands.md +65 -9
  7. package/docs/configuration.md +6 -3
  8. package/docs/content-format.md +187 -140
  9. package/docs/diagnostics.md +1 -1
  10. package/docs/getting-started.md +27 -3
  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-images.mjs +20 -10
  17. package/engine/content-index.mjs +3 -1
  18. package/engine/exported-scene.mjs +230 -0
  19. package/engine/generate.mjs +1 -0
  20. package/engine/ids.mjs +1 -1
  21. package/engine/index.mjs +6 -0
  22. package/engine/infobox.mjs +4 -0
  23. package/engine/itinerary-scenes.mjs +170 -0
  24. package/engine/map-build.mjs +10 -45
  25. package/engine/map-dot.mjs +6 -3
  26. package/engine/map-graphviz.mjs +50 -131
  27. package/engine/map-notes.mjs +26 -3
  28. package/engine/map-places.mjs +12 -6
  29. package/engine/map-raster.mjs +49 -0
  30. package/engine/note-dates.mjs +125 -85
  31. package/engine/note-vocabulary.mjs +22 -6
  32. package/engine/pdf-build.mjs +57 -1
  33. package/engine/pdf-render.mjs +48 -7
  34. package/engine/reckoning-markers.mjs +207 -0
  35. package/engine/scenes.mjs +113 -26
  36. package/engine/site-build.mjs +11 -13
  37. package/engine/site-maps.mjs +5 -35
  38. package/engine/site-preview.mjs +6 -1
  39. package/engine/social-tie-terms.mjs +3 -0
  40. package/engine/social-ties.mjs +30 -19
  41. package/package.json +3 -1
  42. package/types/engine/being-age.d.mts +2 -2
  43. package/types/engine/calendar-artifacts.d.mts +16 -0
  44. package/types/engine/calendar-notes.d.mts +1 -1
  45. package/types/engine/calendars.d.mts +65 -0
  46. package/types/engine/content-images.d.mts +7 -4
  47. package/types/engine/exported-scene.d.mts +12 -0
  48. package/types/engine/index.d.mts +2 -0
  49. package/types/engine/itinerary-scenes.d.mts +29 -0
  50. package/types/engine/map-build.d.mts +4 -13
  51. package/types/engine/map-graphviz.d.mts +17 -59
  52. package/types/engine/map-places.d.mts +5 -1
  53. package/types/engine/map-raster.d.mts +8 -0
  54. package/types/engine/note-dates.d.mts +6 -0
  55. package/types/engine/note-vocabulary.d.mts +3 -3
  56. package/types/engine/pdf-render.d.mts +16 -1
  57. package/types/engine/reckoning-markers.d.mts +27 -0
  58. package/types/engine/scenes.d.mts +9 -2
  59. package/types/engine/site-build.d.mts +1 -5
  60. package/types/engine/site-maps.d.mts +2 -7
  61. package/types/engine/social-tie-terms.d.mts +2 -0
  62. 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
  *
@@ -25,7 +25,8 @@
25
25
  * simple case needs no spelling. {@link IMAGE_CLASSES} holds the one class
26
26
  * there is. **Position is `float:`**, and {@link IMAGE_FLOATS} holds the five
27
27
  * values it takes. **Named size is `size:`**, and {@link IMAGE_SIZES} holds its
28
- * values. Size is parsed but does not change the figure's current presentation.
28
+ * values. The HTML surfaces receive a size class; the book receives a print
29
+ * measure.
29
30
  *
30
31
  * All three are closed, and an unrecognised value is **refused with a located
31
32
  * diagnostic** rather than ignored. Ignoring is the failure worth preventing:
@@ -105,7 +106,7 @@ export const IMAGE_CLASSES = Object.freeze({
105
106
  },
106
107
  });
107
108
 
108
- /** The named sizes an image directive accepts without applying a display size. */
109
+ /** The named sizes an image directive accepts. */
109
110
  export const IMAGE_SIZES = Object.freeze([
110
111
  "auto",
111
112
  "small",
@@ -326,16 +327,17 @@ export function parseImageDirective(raw) {
326
327
  /**
327
328
  * The classes a figure carries, from a parsed directive.
328
329
  *
329
- * @param {{classes?: string[], float?: string}} [directive] - As parsed.
330
+ * @param {{classes?: string[], size?: string, float?: string}} [directive] - As parsed.
330
331
  * @returns {string} A space-separated class list, always naming
331
332
  * {@link IMAGE_FIGURE_CLASS} first.
332
333
  */
333
- export function figureClasses({ classes = [], float = "" } = {}) {
334
+ export function figureClasses({ classes = [], size = "auto", float = "" } = {}) {
334
335
  const names = [IMAGE_FIGURE_CLASS];
335
336
  for (const name of classes) {
336
337
  const spec = IMAGE_CLASSES[/** @type {keyof typeof IMAGE_CLASSES} */ (name)];
337
338
  if (spec) names.push(spec.class);
338
339
  }
340
+ if (size !== "auto" && IMAGE_SIZES.includes(size)) names.push(`note-image-size-${size}`);
339
341
  const position = IMAGE_FLOATS[/** @type {keyof typeof IMAGE_FLOATS} */ (float)];
340
342
  if (position) names.push(position.class);
341
343
  return names.join(" ");
@@ -367,13 +369,14 @@ export function escapeHtml(text) {
367
369
  * @param {string} image.src - The address, resolved for the surface.
368
370
  * @param {string} [image.alt] - The alt text, which is also the caption.
369
371
  * @param {string[]} [image.classes] - Width classes, from the directive.
372
+ * @param {string} [image.size] - Named display size, from the directive.
370
373
  * @param {string} [image.float] - The float position, from the directive.
371
374
  * @returns {string} The figure, as one HTML block.
372
375
  */
373
- export function imageFigureHtml({ src, alt = "", classes = [], float = "" }) {
376
+ export function imageFigureHtml({ src, alt = "", classes = [], size = "auto", float = "" }) {
374
377
  const caption = alt ? `\n<figcaption>${escapeHtml(alt)}</figcaption>` : "";
375
378
  return (
376
- `<figure class="${figureClasses({ classes, float })}">\n` +
379
+ `<figure class="${figureClasses({ classes, size, float })}">\n` +
377
380
  `<img src="${escapeHtml(src)}" alt="${escapeHtml(alt)}">${caption}\n` +
378
381
  `</figure>`
379
382
  );
@@ -644,10 +647,16 @@ export function renderImageFigures(body, resolveSrc = (src) => src) {
644
647
  let last = 0;
645
648
  for (const image of imagesIn(text)) {
646
649
  if (!image.block || imageSourceProblem(image.src) || image.title) continue;
647
- const { classes, float, problems } = parseImageDirective(image.directive);
650
+ const { classes, size, float, problems } = parseImageDirective(image.directive);
648
651
  if (problems.length) continue;
649
652
  out += text.slice(last, image.index);
650
- out += imageFigureHtml({ src: resolveSrc(image.src), alt: image.alt, classes, float });
653
+ out += imageFigureHtml({
654
+ src: resolveSrc(image.src),
655
+ alt: image.alt,
656
+ classes,
657
+ size,
658
+ float,
659
+ });
651
660
  last = image.index + image.length;
652
661
  }
653
662
  return out + text.slice(last);
@@ -696,6 +705,7 @@ export function imagePlugin(resolveSrc = (src) => src) {
696
705
  src: resolveSrc(token.attrGet("src") ?? ""),
697
706
  alt: token.content ?? "",
698
707
  classes: token.meta.classes,
708
+ size: token.meta.size,
699
709
  float: token.meta.float,
700
710
  })}\n`;
701
711
  };
@@ -733,12 +743,12 @@ function attachImageDirectives(tokens) {
733
743
  directive = brace[0];
734
744
  }
735
745
 
736
- const { classes, float, problems } = parseImageDirective(directive);
746
+ const { classes, size, float, problems } = parseImageDirective(directive);
737
747
  // Not ours to consume: leaving the braces in place is what makes an
738
748
  // unrecognised value visible on the page instead of passing as ordinary.
739
749
  if (problems.length) continue;
740
750
 
741
- image.meta = { ...(image.meta ?? {}), block: true, classes, float };
751
+ image.meta = { ...(image.meta ?? {}), block: true, classes, size, float };
742
752
  if (trailing) trailing.content = trailing.content.slice(directive.length);
743
753
  // The figure is a block, so the paragraph that held it renders nothing.
744
754
  tokens[i].hidden = true;
@@ -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(