@heroiclands/package-build 22.16.0 → 22.17.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.
@@ -64,7 +64,20 @@
64
64
  * @module
65
65
  */
66
66
 
67
- import { dayOfYear, daysInYear, monthDayOfYear } from "./calendars.mjs";
67
+ import {
68
+ canonicalDayOffset,
69
+ canonicalYear,
70
+ dayOfYear,
71
+ daysInYear,
72
+ monthDayOfYear,
73
+ parseCanonicalDate,
74
+ } from "./calendars.mjs";
75
+ import {
76
+ CALENDAR_FORMAT_TOKENS,
77
+ calendarFormatSyntaxError,
78
+ readableCalendarFormatError,
79
+ selectCalendarFormat,
80
+ } from "./calendar-format.mjs";
68
81
  import { positionInFrontmatter, positionOfFrontmatterPath } from "./diagnostics.mjs";
69
82
  import { parseNoteDate } from "./note-dates.mjs";
70
83
  import { reckoningContext } from "./reckoning-markers.mjs";
@@ -89,6 +102,7 @@ export const CALENDAR_DATE_FORMAT_KEYS = Object.freeze([
89
102
  "long",
90
103
  "full",
91
104
  "time",
105
+ "time12",
92
106
  "weekHeader",
93
107
  "yearHeader",
94
108
  "yearLabel",
@@ -96,13 +110,7 @@ export const CALENDAR_DATE_FORMAT_KEYS = Object.freeze([
96
110
  ]);
97
111
 
98
112
  /** Bare date tokens Calendaria substitutes inside authored format strings. */
99
- export const CALENDAR_FORMAT_TOKENS = Object.freeze(
100
- new Set(
101
- "YYYY YY Y MMMM MMM MM Mo M EEEEE EEEE EEE EE E dddd ddd dd Do DDD DD D d e GGGG GGG GG G QQQQ QQQ QQ Q zzzz z ww w W HH H hh h mm m ss s A a".split(
102
- " ",
103
- ),
104
- ),
105
- );
113
+ export { CALENDAR_FORMAT_TOKENS };
106
114
 
107
115
  /**
108
116
  * The `data:` keys a calendar note may write — the closed list, in authored
@@ -117,9 +125,9 @@ export const CALENDAR_FORMAT_TOKENS = Object.freeze(
117
125
  export const CALENDAR_FIELDS = Object.freeze([
118
126
  {
119
127
  name: "epoch",
120
- shape: "a date",
128
+ shape: "a canonical `<year>.<day>`",
121
129
  kind: "string",
122
- describe: "Which in-world day the world's clock reads zero on — `720/1/1`.",
130
+ describe: "Canonical day when calendar year 1, day 1 begins — `1.1`.",
123
131
  },
124
132
  {
125
133
  name: "months",
@@ -139,22 +147,28 @@ export const CALENDAR_FIELDS = Object.freeze([
139
147
  },
140
148
  {
141
149
  name: "seasons",
142
- shape: "list of `{ name, abbreviation?, monthStart?, monthEnd?, dayStart?, dayEnd? }`",
150
+ shape: "list of `{ name, abbreviation?, start }`",
151
+ kind: "list",
152
+ describe: "The seasons this calendar marks, starting on numbered days of the year.",
153
+ },
154
+ {
155
+ name: "namedDays",
156
+ shape: "list of `{ name, abbreviation?, day }`",
143
157
  kind: "list",
144
- describe: "The seasons this calendar marks, bounded by month or by day of year.",
158
+ describe: "Names assigned to particular days of the year.",
145
159
  },
146
160
  {
147
161
  name: "eras",
148
- shape: "list of `{ shortcode, name, marker?, abbreviation?, proclaimedBy?, start, end?, label? }`",
162
+ shape: "list of `{ shortcode, name, marker?, abbreviation?, proclaimedBy?, start, label? }`",
149
163
  kind: "list",
150
164
  describe:
151
165
  "The year-counts kept in this calendar. A marker names one era and uses these months.",
152
166
  },
153
167
  {
154
- name: "dateFormats",
155
- shape: "map of short, long, full, time, weekHeader, yearHeader, yearLabel, crossCalendar strings",
168
+ name: "formats",
169
+ shape: "map of named Calendaria format strings",
156
170
  kind: "map",
157
- describe: "How this calendar writes dates and time in each display context.",
171
+ describe: "Named patterns for reading and writing this calendar's dates.",
158
172
  },
159
173
  ]);
160
174
 
@@ -248,7 +262,7 @@ export const INVARIANT_FIELDS = Object.freeze([
248
262
  },
249
263
  {
250
264
  name: "moon.newOn",
251
- shape: "a date",
265
+ shape: "a day-precision date",
252
266
  kind: "string",
253
267
  describe: "A day the body was new, written in the reference calendar.",
254
268
  },
@@ -363,46 +377,132 @@ function atData(note, keyPath, severity, message) {
363
377
  };
364
378
  }
365
379
 
366
- /** Check each authored format slot and every unescaped letter run in it. */
380
+ /** Check each named pattern and the standard pattern's readable fields. */
367
381
  export function checkCalendarDateFormats(note) {
368
- const formats = dataOf(note).dateFormats;
369
- if (formats === undefined || formats === null) return [];
370
- if (typeof formats !== "object" || Array.isArray(formats)) return [];
382
+ const formats = dataOf(note).formats;
383
+ if (formats === undefined) return [];
384
+ if (typeof formats !== "object" || Array.isArray(formats) || !Object.keys(formats).length)
385
+ return [
386
+ atData(note, ["formats"], "error", "data.formats needs at least one named pattern"),
387
+ ];
371
388
  const findings = [];
372
389
  for (const [slot, value] of Object.entries(formats)) {
373
- if (!CALENDAR_DATE_FORMAT_KEYS.includes(slot)) {
390
+ if (!/^[A-Za-z][A-Za-z0-9]*$/.test(slot)) {
374
391
  findings.push(
375
392
  atData(
376
393
  note,
377
- ["dateFormats", slot],
394
+ ["formats", slot],
378
395
  "error",
379
- `data.dateFormats.${slot} is not a calendar display format; choose ${CALENDAR_DATE_FORMAT_KEYS.join(", ")}`,
396
+ `data.formats.${slot} needs a name made of letters and digits`,
380
397
  ),
381
398
  );
382
399
  continue;
383
400
  }
384
401
  if (typeof value !== "string") {
385
402
  findings.push(
386
- atData(
387
- note,
388
- ["dateFormats", slot],
389
- "error",
390
- `data.dateFormats.${slot} must be a string`,
391
- ),
403
+ atData(note, ["formats", slot], "error", `data.formats.${slot} must be a string`),
392
404
  );
393
405
  continue;
394
406
  }
395
- const bare = value.replace(/\[[^\]]*\]|\{[^}]*\}/g, " ");
396
- for (const run of bare.match(/\p{L}+/gu) ?? []) {
397
- if (CALENDAR_FORMAT_TOKENS.has(run)) continue;
398
- findings.push(
399
- atData(
400
- note,
401
- ["dateFormats", slot],
402
- "error",
403
- `data.dateFormats.${slot} has unescaped letter run ${JSON.stringify(run)}; write literal words in brackets, or use a Calendaria date token`,
404
- ),
405
- );
407
+ const syntaxError = calendarFormatSyntaxError(value);
408
+ if (syntaxError) findings.push(atData(note, ["formats", slot], "error", syntaxError));
409
+ }
410
+ const standard = Object.hasOwn(formats, "std") ? "std" : Object.keys(formats)[0];
411
+ if (typeof formats[standard] === "string") {
412
+ const error = readableCalendarFormatError(formats[standard]);
413
+ if (error) findings.push(atData(note, ["formats", standard], "error", error));
414
+ }
415
+ return findings;
416
+ }
417
+
418
+ /** Validate ordered, one-based season starts and named day positions. */
419
+ function checkCalendarDayNames(note, daysPerYear) {
420
+ const data = dataOf(note);
421
+ const findings = [];
422
+ const seasons = data.seasons;
423
+ if (seasons !== undefined && !Array.isArray(seasons))
424
+ findings.push(atData(note, ["seasons"], "error", "data.seasons must be a list"));
425
+ if (Array.isArray(seasons)) {
426
+ let prior = 0;
427
+ for (const [position, season] of seasons.entries()) {
428
+ const start = season?.start;
429
+ if (typeof season?.name !== "string" || !season.name.trim())
430
+ findings.push(
431
+ atData(note, ["seasons", position, "name"], "error", "a season needs a name"),
432
+ );
433
+ if (
434
+ !Number.isSafeInteger(start) ||
435
+ start <= prior ||
436
+ (Number.isSafeInteger(daysPerYear) && start > daysPerYear)
437
+ )
438
+ findings.push(
439
+ atData(
440
+ note,
441
+ ["seasons", position, "start"],
442
+ "error",
443
+ "season starts must increase within the year",
444
+ ),
445
+ );
446
+ if (
447
+ Object.keys(season ?? {}).some(
448
+ (key) => !["name", "abbreviation", "start"].includes(key),
449
+ )
450
+ )
451
+ findings.push(
452
+ atData(
453
+ note,
454
+ ["seasons", position],
455
+ "error",
456
+ "a season uses only name, abbreviation, and start",
457
+ ),
458
+ );
459
+ prior = start;
460
+ }
461
+ }
462
+ const namedDays = data.namedDays;
463
+ if (namedDays !== undefined && !Array.isArray(namedDays))
464
+ findings.push(atData(note, ["namedDays"], "error", "data.namedDays must be a list"));
465
+ if (Array.isArray(namedDays)) {
466
+ const seen = new Set();
467
+ for (const [position, namedDay] of namedDays.entries()) {
468
+ if (typeof namedDay?.name !== "string" || !namedDay.name.trim())
469
+ findings.push(
470
+ atData(
471
+ note,
472
+ ["namedDays", position, "name"],
473
+ "error",
474
+ "a named day needs a name",
475
+ ),
476
+ );
477
+ const day = namedDay?.day;
478
+ if (
479
+ !Number.isSafeInteger(day) ||
480
+ day < 1 ||
481
+ (Number.isSafeInteger(daysPerYear) && day > daysPerYear) ||
482
+ seen.has(day)
483
+ )
484
+ findings.push(
485
+ atData(
486
+ note,
487
+ ["namedDays", position, "day"],
488
+ "error",
489
+ "named days need distinct days within the year",
490
+ ),
491
+ );
492
+ if (
493
+ Object.keys(namedDay ?? {}).some(
494
+ (key) => !["name", "abbreviation", "day"].includes(key),
495
+ )
496
+ )
497
+ findings.push(
498
+ atData(
499
+ note,
500
+ ["namedDays", position],
501
+ "error",
502
+ "a named day uses only name, abbreviation, and day",
503
+ ),
504
+ );
505
+ seen.add(day);
406
506
  }
407
507
  }
408
508
  return findings;
@@ -466,16 +566,24 @@ export function checkWorldFacts(note, { index } = {}) {
466
566
  findings.push(...dated);
467
567
  }
468
568
  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
- );
569
+ const parsed = parseNoteDate(dataOf(note).moon.newOn, {
570
+ ...reckoningContext(index),
571
+ field: "data.moon.newOn",
572
+ allowUnknown: false,
573
+ file: note.file,
574
+ raw: note.raw,
575
+ keyPath: ["data", "moon", "newOn"],
576
+ });
577
+ findings.push(...parsed.findings);
578
+ if (parsed.date && parsed.date.precision !== "day")
579
+ findings.push(
580
+ atData(
581
+ note,
582
+ ["moon", "newOn"],
583
+ "error",
584
+ "`data.moon.newOn` needs a specific day; write `<year>.<day>` or a day in a named calendar",
585
+ ),
586
+ );
479
587
  }
480
588
 
481
589
  // The corpus questions, which need every note and so are asked only where
@@ -559,7 +667,7 @@ export function checkCalendarNote(note, { index } = {}) {
559
667
  return findings;
560
668
  }
561
669
 
562
- for (const required of ["months", "epoch"]) {
670
+ for (const required of ["months", "epoch", "eras"]) {
563
671
  if (written.includes(required)) continue;
564
672
  findings.push({
565
673
  file: note.file,
@@ -572,20 +680,53 @@ export function checkCalendarNote(note, { index } = {}) {
572
680
  }
573
681
 
574
682
  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
- );
683
+ const epochText = String(dataOf(note).epoch);
684
+ if (!/^-?\d+\.\d+$/.test(epochText) || /^-?0+\./.test(epochText))
685
+ findings.push(
686
+ atData(
687
+ note,
688
+ ["epoch"],
689
+ "error",
690
+ "data.epoch is a canonical <year>.<day> for calendar year 1, day 1",
691
+ ),
692
+ );
693
+ const parsed = parseNoteDate(dataOf(note).epoch, {
694
+ ...reckoningContext(index),
695
+ field: "data.epoch",
696
+ allowUnknown: false,
697
+ file: note.file,
698
+ raw: note.raw,
699
+ keyPath: ["data", "epoch"],
700
+ });
701
+ findings.push(...parsed.findings);
702
+ if (parsed.date && parsed.date.precision !== "day")
703
+ findings.push(
704
+ atData(
705
+ note,
706
+ ["epoch"],
707
+ "error",
708
+ "`data.epoch` needs a specific day; write `<year>.<day>` or a day in a named calendar",
709
+ ),
710
+ );
585
711
  }
586
712
 
587
713
  findings.push(...checkMonthSum(note, index));
714
+ findings.push(...checkCalendarDayNames(note, worldInvariants(index).year?.days));
588
715
  findings.push(...checkEraShortcodes(note));
716
+ for (const [position, day] of (Array.isArray(dataOf(note).weekdays) ?
717
+ dataOf(note).weekdays
718
+ : []
719
+ ).entries()) {
720
+ if (day?.ordinal !== undefined)
721
+ findings.push(
722
+ atData(
723
+ note,
724
+ ["weekdays", position, "ordinal"],
725
+ "error",
726
+ "weekday order is its array position, starting at zero; omit ordinal",
727
+ ),
728
+ );
729
+ }
589
730
  findings.push(
590
731
  ...reckoningContext(index).findings.filter((finding) => finding.file === note.file),
591
732
  );
@@ -749,7 +890,7 @@ function monthValues(months) {
749
890
  );
750
891
  }
751
892
 
752
- /** A weekday's name, abbreviation and position. An empty list stays empty. */
893
+ /** Calendaria stores a one-based ordinal; the authored order remains zero-based. */
753
894
  function weekdayValues(weekdays) {
754
895
  return (Array.isArray(weekdays) ? weekdays : []).map((day, position) =>
755
896
  written({
@@ -760,42 +901,47 @@ function weekdayValues(weekdays) {
760
901
  );
761
902
  }
762
903
 
763
- /** A season's name and whichever pair of bounds the note wrote. */
764
- function seasonValues(seasons) {
765
- return (Array.isArray(seasons) ? seasons : []).map((season) =>
904
+ /** A season's one-based start becomes an inclusive zero-based Calendaria range. */
905
+ function seasonValues(seasons, daysPerYear) {
906
+ return (Array.isArray(seasons) ? seasons : []).map((season, position, rows) =>
766
907
  written({
767
908
  name: String(season?.name ?? ""),
768
909
  abbreviation:
769
910
  season?.abbreviation === undefined ? undefined : String(season.abbreviation),
770
- monthStart: season?.monthStart,
771
- monthEnd: season?.monthEnd,
772
- dayStart: season?.dayStart,
773
- dayEnd: season?.dayEnd,
911
+ dayStart: season.start - 1,
912
+ dayEnd:
913
+ position + 1 < rows.length ? rows[position + 1].start - 2
914
+ : rows[0].start === 1 ? daysPerYear - 1
915
+ : rows[0].start - 2,
916
+ }),
917
+ );
918
+ }
919
+
920
+ /** A named day uses Calendaria's one-based year day number. */
921
+ function namedDayValues(namedDays) {
922
+ return (Array.isArray(namedDays) ? namedDays : []).map((day) =>
923
+ written({
924
+ name: day.name,
925
+ abbreviation: day.abbreviation,
926
+ dayNumber: day.day,
774
927
  }),
775
928
  );
776
929
  }
777
930
 
778
931
  /** The era rows, keyed by shortcode, with the addressing segments dropped. */
779
- function eraEntries(eras, dateContext = {}) {
932
+ function eraEntries(eras) {
780
933
  const entries = {};
781
- for (const era of Array.isArray(eras) ? eras : []) {
934
+ const dated = (Array.isArray(eras) ? eras : [])
935
+ .filter((era) => Number.isSafeInteger(era?.start) && era.start >= 1)
936
+ .sort((a, b) => a.start - b.start);
937
+ for (const [position, era] of dated.entries()) {
782
938
  const shortcode = String(era?.shortcode ?? "");
783
939
  if (!shortcode) continue;
784
- const start = parseNoteDate(era?.start, {
785
- ...dateContext,
786
- allowUnknown: false,
787
- ignoreEraBounds: true,
788
- }).date;
789
- const end = parseNoteDate(era?.end, {
790
- ...dateContext,
791
- allowUnknown: false,
792
- ignoreEraBounds: true,
793
- }).date;
794
940
  entries[shortcode] = written({
795
941
  name: String(era?.name ?? ""),
796
942
  abbreviation: era?.abbreviation === undefined ? undefined : String(era.abbreviation),
797
- start: era?.start === undefined ? undefined : String(start?.year ?? era.start),
798
- end: era?.end === undefined ? undefined : String(end?.year ?? era.end),
943
+ startYear: era.start,
944
+ endYear: dated[position + 1] ? dated[position + 1].start - 1 : null,
799
945
  });
800
946
  }
801
947
  return entries;
@@ -817,6 +963,8 @@ function eraEntries(eras, dateContext = {}) {
817
963
  function moonEntries(moon, body, name, months, dateContext = {}) {
818
964
  if (!moon || !name) return null;
819
965
  const date = parseNoteDate(moon.newOn, { ...dateContext, allowUnknown: false }).date;
966
+ if (date && date.precision !== "day")
967
+ throw new RangeError("data.moon.newOn needs a specific day");
820
968
  const calendarDay =
821
969
  date?.canonicalDay && months.length ? monthDayOfYear(months, date.canonicalDay) : null;
822
970
  return {
@@ -862,7 +1010,26 @@ export function compileCalendar({ note, invariants, contentPackage, dateContext
862
1010
  const data = dataOf(note);
863
1011
  const year = invariants?.year ?? {};
864
1012
  const months = Array.isArray(data.months) ? data.months : [];
865
- const epoch = parseNoteDate(data.epoch, { ...dateContext, allowUnknown: false }).date;
1013
+ const dayNameError = checkCalendarDayNames(note, year.days)[0];
1014
+ if (dayNameError) throw new RangeError(dayNameError.message);
1015
+ const starts = Array.isArray(data.eras) ? data.eras.map((era) => era?.start) : [];
1016
+ if (
1017
+ starts.filter((start) => start === null).length !== 1 ||
1018
+ starts.filter((start) => start === 1).length !== 1 ||
1019
+ starts.some((start) => start !== null && (!Number.isSafeInteger(start) || start < 1)) ||
1020
+ new Set(starts).size !== starts.length
1021
+ )
1022
+ throw new RangeError(
1023
+ "data.eras needs one null start, one year-one start, and distinct positive year starts",
1024
+ );
1025
+ const formatError = checkCalendarDateFormats(note).find(
1026
+ (finding) => finding.severity === "error",
1027
+ );
1028
+ if (formatError) throw new RangeError(formatError.message);
1029
+ const epoch = parseCanonicalDate(String(data.epoch ?? ""), year.days);
1030
+ if (!epoch || epoch.year === 0 || epoch.seconds !== undefined)
1031
+ throw new RangeError("data.epoch needs a canonical <year>.<day> specific day");
1032
+ const epochOffset = canonicalDayOffset(canonicalYear(epoch.year), epoch.day, 1, year.days);
866
1033
  const shortcode = String(note.fm?.shortcode ?? "");
867
1034
  const name = String(note.fm?.name?.full ?? "");
868
1035
  const description = String(note.fm?.description ?? "");
@@ -879,9 +1046,8 @@ export function compileCalendar({ note, invariants, contentPackage, dateContext
879
1046
  name,
880
1047
  description,
881
1048
  years: {
882
- // The year the epoch falls in is the year a reader is shown at the
883
- // world's own zero.
884
- yearZero: epoch?.year ?? 0,
1049
+ // Internal year zero displays as calendar year one at the epoch.
1050
+ yearZero: 1,
885
1051
  // Required, non-nullable and with no initial, so it must be
886
1052
  // written; with no weekdays it indexes nothing and 0 is the only
887
1053
  // defensible value.
@@ -893,19 +1059,34 @@ export function compileCalendar({ note, invariants, contentPackage, dateContext
893
1059
  months: { values: monthValues(months) },
894
1060
  days: written({
895
1061
  values: weekdayValues(data.weekdays),
1062
+ names: namedDayValues(data.namedDays),
896
1063
  daysPerYear: year.days,
897
1064
  hoursPerDay: year.hoursPerDay,
898
1065
  minutesPerHour: year.minutesPerHour,
899
1066
  secondsPerMinute: year.secondsPerMinute,
900
1067
  }),
901
- seasons: { values: seasonValues(data.seasons) },
902
- // How far into the year the world's zero falls. `1/1` is none.
903
- epochDayOffset:
904
- epoch && months.length ?
905
- (epoch.canonicalDay ?? dayOfYear(months, epoch.month ?? 1, epoch.day ?? 1)) - 1
906
- : 0,
907
- eras: eraEntries(data.eras, dateContext),
908
- dateFormats: data.dateFormats,
1068
+ seasons: { values: seasonValues(data.seasons, year.days) },
1069
+ // Shift world time so the authored epoch is calendar year one, day one.
1070
+ epochDayOffset: epochOffset === 0 ? 0 : -epochOffset,
1071
+ eras: eraEntries(data.eras),
1072
+ dateFormats:
1073
+ data.formats && Object.keys(data.formats).length ?
1074
+ written({
1075
+ short: selectCalendarFormat(data).pattern,
1076
+ long: data.formats.long ?? selectCalendarFormat(data).pattern,
1077
+ full:
1078
+ data.formats.full ??
1079
+ data.formats.long ??
1080
+ selectCalendarFormat(data).pattern,
1081
+ ...Object.fromEntries(
1082
+ CALENDAR_DATE_FORMAT_KEYS.filter(
1083
+ (key) => !["short", "long", "full"].includes(key),
1084
+ )
1085
+ .filter((key) => data.formats[key] !== undefined)
1086
+ .map((key) => [key, data.formats[key]]),
1087
+ ),
1088
+ })
1089
+ : undefined,
909
1090
  moons: moons ?? undefined,
910
1091
  metadata: written({
911
1092
  id: shortcode || undefined,
@@ -236,8 +236,9 @@ types:
236
236
  - months
237
237
  - weekdays
238
238
  - seasons
239
+ - namedDays
239
240
  - eras
240
- - dateFormats
241
+ - formats
241
242
  - event
242
243
  subTypes:
243
244
  - cosmology
@@ -1,6 +1,6 @@
1
1
  /* SPDX-License-Identifier: GPL-3.0-or-later */
2
2
 
3
- import { formatCanonicalDate, parseCanonicalDate } from "./calendars.mjs";
3
+ import { eraYear, formatCanonicalDate, parseCanonicalDate } from "./calendars.mjs";
4
4
  import { addressedCalendarDate } from "./calendar-human.mjs";
5
5
  import { calendarEras, eraCovering, formatNoteDate, parseNoteDate } from "./note-dates.mjs";
6
6
 
@@ -23,13 +23,13 @@ export function dateFromCalendar(reference, value, context) {
23
23
  if (eraCovering(date, eras, context.daysPerYear) !== context.eras.get(date.qualifier))
24
24
  throw new RangeError(`date falls outside calendar ${reference}`);
25
25
  return `${date.approximate ? "~" : ""}${formatCanonicalDate(
26
- { year: date.canonicalYear, day: date.canonicalDay, seconds: date.seconds },
26
+ { year: eraYear(date.canonicalYear), day: date.canonicalDay, seconds: date.seconds },
27
27
  context.daysPerYear,
28
28
  )}`;
29
29
  }
30
30
 
31
31
  /** Convert a canonical day to the date in the covering era of a calendar. */
32
- export function dateToCalendar(reference, value, context) {
32
+ export function dateToCalendar(reference, value, context, formatName) {
33
33
  const text = String(value ?? "");
34
34
  const canonical = parseCanonicalDate(
35
35
  text.startsWith("~") ? text.slice(1) : text,
@@ -40,7 +40,7 @@ export function dateToCalendar(reference, value, context) {
40
40
  const parsed = parseNoteDate(value, context).date;
41
41
  const era = eraCovering(parsed, calendarEras(reference, context), context.daysPerYear);
42
42
  if (!era) throw new RangeError(`canonical date falls outside calendar ${reference}`);
43
- const printed = formatNoteDate(parsed, era, context.daysPerYear);
43
+ const printed = formatNoteDate(parsed, era, context.daysPerYear, formatName);
44
44
  if (!printed || printed.day === null)
45
45
  throw new RangeError(`canonical date cannot be expressed in calendar ${reference}`);
46
46
  return printed.text;
@@ -80,7 +80,7 @@ export function renderMarkdownExpressions(
80
80
  if (result.error) throw new RangeError(`SQL expression failed: ${result.error}`);
81
81
  return result.value;
82
82
  });
83
- engine.registerHelper("dateformat", (calendar, authored) => {
83
+ engine.registerHelper("dateformat", (calendar, authored, formatOrOptions) => {
84
84
  if (
85
85
  !(typeof calendar === "string" || isAddressTuple(calendar)) ||
86
86
  !["string", "number"].includes(typeof authored)
@@ -93,6 +93,7 @@ export function renderMarkdownExpressions(
93
93
  parsed.date,
94
94
  isAddressTuple(calendar) ? renderAddress(calendar) : calendar,
95
95
  dates,
96
+ typeof formatOrOptions === "string" ? formatOrOptions : undefined,
96
97
  );
97
98
  if (!printable) throw new RangeError(`date cannot be printed in calendar ${calendar}`);
98
99
  return printable.prose ?? printable.text;