@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.
@@ -14,20 +14,27 @@
14
14
  /**
15
15
  * Parse authored note dates into records used by content checks and renderers.
16
16
  *
17
- * A neutral day is `<year>.<day>[:HHMMSS]`, where `day` is an ordinal within
18
- * the world's year. A named date is `datefrom <calendar> <date>`; its calendar
19
- * and era resolve the value on the neutral timeline. Either form accepts a
20
- * leading `~` for approximation. `unknown` is unordered.
17
+ * A neutral date is `<year>[.<day>[:HHMMSS]]`, where `day` is an ordinal
18
+ * within the world's year. A bare year spans that year; a day spans that day;
19
+ * a clock time identifies a second. A named date uses the `datefrom` form;
20
+ * its calendar and era resolve the value on the neutral timeline.
21
+ * Either form accepts `~` for uncertainty beyond its stated interval.
22
+ * `unknown` is unordered.
21
23
  *
22
24
  * `text` keeps the authored spelling. `canonicalYear` and `sort` are set for
23
- * neutral and resolved named dates. Resolved dates also carry `canonicalDay`,
24
- * the ordinal day within the neutral year.
25
+ * neutral and resolved named dates. A neutral year retains `precision: "year"`
26
+ * and has no `canonicalDay`; day one is used only to calculate its bounds.
25
27
  *
26
28
  * @module
27
29
  */
28
30
 
29
31
  import { ADDRESS_SEGMENT_PATTERN } from "./address-charset.mjs";
30
32
  import { addressedCalendarDate } from "./calendar-human.mjs";
33
+ import {
34
+ calendarFormatHasClock,
35
+ formatCalendarPattern,
36
+ selectCalendarFormat,
37
+ } from "./calendar-format.mjs";
31
38
  import {
32
39
  calendarStructure,
33
40
  canonicalDateFromOffset,
@@ -188,10 +195,7 @@ export function parseNoteDate(value, options) {
188
195
  };
189
196
 
190
197
  const resolveEra = (parsed, reckoning, label) => {
191
- if (!ignoreEraBounds && parsed.year < 0 && reckoning.firstEra === false)
192
- return refuse(
193
- `${subject} counts backward from ${label}, but only the first era of a calendar may do so`,
194
- );
198
+ if (parsed.year < 1) return refuse(`${subject} needs a positive year in ${label}`);
195
199
  if (!Number.isSafeInteger(daysPerYear) || daysPerYear < 1)
196
200
  return refuse(`${subject} needs the world's year length to resolve ${label}`);
197
201
  const { months } = calendarStructure(reckoning.calendar);
@@ -207,7 +211,7 @@ export function parseNoteDate(value, options) {
207
211
  );
208
212
  const ordinal = dayOfYear(months, parsed.month ?? 1, parsed.day ?? 1);
209
213
  const start = canonicalDayOffset(reckoning.epochYear, reckoning.epochDay, 1, daysPerYear);
210
- const yearOffset = parsed.year > 0 ? parsed.year - 1 : parsed.year;
214
+ const yearOffset = reckoning.beforeEra ? -parsed.year : parsed.year - 1;
211
215
  const offset = start + yearOffset * daysPerYear + ordinal - 1;
212
216
  if (!Number.isSafeInteger(offset))
213
217
  return refuse(`${subject} lies outside the supported canonical day range`);
@@ -283,10 +287,10 @@ export function parseNoteDate(value, options) {
283
287
  `${subject} writes year 0, and no reckoning has one — the years either ` +
284
288
  `side of an epoch are -1 and 1`,
285
289
  );
286
- if (field && !/^-?\d+\.\d+(?::\d{6})?$/.test(unmarked))
290
+ if (field && !/^-?\d+(?:\.\d+(?::\d{6})?)?$/.test(unmarked))
287
291
  return refuse(
288
292
  `${subject} is not a frontmatter date — write ` +
289
- "`<year>.<day>[:HHMMSS]` or `datefrom <calendar> <date>`",
293
+ "`<year>[.<day>[:HHMMSS]]` or `datefrom <calendar> <date>`",
290
294
  );
291
295
  if (unmarked.includes(".")) {
292
296
  const canonical = parseCanonicalDate(unmarked, daysPerYear ?? Number.MAX_SAFE_INTEGER);
@@ -301,13 +305,13 @@ export function parseNoteDate(value, options) {
301
305
  day: canonical.day,
302
306
  approximate: approximateInput,
303
307
  precision: "day",
304
- canonicalYear: canonical.year,
308
+ canonicalYear: canonicalYear(canonical.year),
305
309
  canonicalDay: canonical.day,
306
310
  spanDays: 1,
307
311
  ...(canonical.seconds === undefined ? {} : { seconds: canonical.seconds }),
308
312
  sort:
309
313
  Number.isSafeInteger(daysPerYear) ?
310
- canonical.year +
314
+ canonicalYear(canonical.year) +
311
315
  (canonical.day - 1 + (canonical.seconds ?? 0) / 86400) / daysPerYear
312
316
  : null,
313
317
  },
@@ -348,7 +352,7 @@ export function parseNoteDate(value, options) {
348
352
  }
349
353
  return refuse(
350
354
  `${subject} is not a date — write ` +
351
- `\`<year>.<day>[:HHMMSS]\` or ` +
355
+ `\`<year>[.<day>[:HHMMSS]]\` or ` +
352
356
  `\`datefrom <calendar> <date>\``,
353
357
  );
354
358
  }
@@ -515,17 +519,17 @@ export function calendarEras(reference, context) {
515
519
  }
516
520
 
517
521
  /** Print a resolved date using the era active in one addressed calendar. */
518
- export function formatDateInCalendar(date, reference, context) {
522
+ export function formatDateInCalendar(date, reference, context, formatName) {
519
523
  const eras = calendarEras(reference, context);
520
524
  const chosen = eraCovering(date, eras, context?.daysPerYear);
521
525
  const name = String(reference ?? "");
522
526
  if (context?.eras?.get(name)?.era && chosen === null)
523
527
  throw new RangeError(`date falls outside calendar era ${name}`);
524
- return formatNoteDate(date, chosen, context?.daysPerYear);
528
+ return formatNoteDate(date, chosen, context?.daysPerYear, formatName);
525
529
  }
526
530
 
527
531
  /** Print a resolved date in one era, retaining its authored precision. */
528
- export function formatNoteDate(date, era, daysPerYear) {
532
+ export function formatNoteDate(date, era, daysPerYear, formatName) {
529
533
  if (!date?.known || !Number.isSafeInteger(date.canonicalYear)) return null;
530
534
  if (!era) return { era: null, year: eraYear(date.canonicalYear), text: date.text, prose: null };
531
535
  if (!Number.isSafeInteger(daysPerYear) || !Number.isSafeInteger(date.spanDays)) return null;
@@ -538,7 +542,8 @@ export function formatNoteDate(date, era, daysPerYear) {
538
542
  canonicalDayOffset(target.epochYear, target.epochDay ?? 1, 1, daysPerYear);
539
543
  const delta = start - epoch;
540
544
  const cycle = Math.floor(delta / daysPerYear);
541
- const year = cycle >= 0 ? cycle + 1 : cycle;
545
+ const year = target.beforeEra ? -cycle : cycle + 1;
546
+ if (year < 1) return null;
542
547
  if (Math.floor((delta + date.spanDays - 1) / daysPerYear) !== cycle) return null;
543
548
 
544
549
  let month = null;
@@ -565,14 +570,37 @@ export function formatNoteDate(date, era, daysPerYear) {
565
570
  month === null ? null : calendarStructure(target.calendar).months?.[month - 1]?.name;
566
571
  const eraName =
567
572
  target.abbreviation || target.marker || target.name || target.era.split(".").at(-1);
568
- const text = `${date.approximate ? "~" : ""}${day === null ? "" : `${day} `}${monthName ? `${monthName} ` : ""}${year} ${eraName}${clock}`;
573
+ let text = `${date.approximate ? "~" : ""}${day === null ? "" : `${day} `}${monthName ? `${monthName} ` : ""}${year} ${eraName}${clock}`;
574
+ if (target.calendar?.formats) {
575
+ const { pattern } = selectCalendarFormat(target.calendar, formatName);
576
+ const hasClock = calendarFormatHasClock(pattern);
577
+ const rendered = formatCalendarPattern(
578
+ pattern,
579
+ {
580
+ calendar: target.calendar,
581
+ era: target,
582
+ eraYear: year,
583
+ calendarYear: target.beforeEra ? -year : target.startYear + year - 1,
584
+ month,
585
+ day,
586
+ dayOfYear: (((delta % daysPerYear) + daysPerYear) % daysPerYear) + 1,
587
+ seconds: date.seconds,
588
+ canonicalOffset: start,
589
+ calendarEpochOffset: target.calendarEpochOffset,
590
+ },
591
+ date.precision,
592
+ date.seconds !== undefined,
593
+ );
594
+ text = `${date.approximate ? "~" : ""}${rendered}${date.seconds !== undefined && !hasClock ? clock : ""}`;
595
+ }
569
596
  const digits = `${Math.abs(year)}${month === null ? "" : `/${month}`}${day === null ? "" : `/${day}${clock.replaceAll(":", "").replace(/^ /, ":")}`}`;
570
597
  const label =
571
598
  typeof target.label === "string" ?
572
599
  target.label
573
600
  : target.label?.[year < 0 ? "before" : "after"];
574
601
  const prose =
575
- typeof label === "string" && label.includes("{date}") ?
602
+ target.calendar?.formats ? null
603
+ : typeof label === "string" && label.includes("{date}") ?
576
604
  label.replace("{date}", `${date.approximate ? "~" : ""}${digits}`)
577
605
  : null;
578
606
  return { era, year, month, day, text, prose };
@@ -1,70 +1,64 @@
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
- * This work is licensed under the GNU General Public License v3.0 (GPLv3).
6
- * You may copy, modify, and distribute it under the terms of that license.
7
- *
8
- * SPDX-License-Identifier: GPL-3.0-or-later
9
- */
1
+ /* SPDX-License-Identifier: GPL-3.0-or-later */
10
2
 
11
3
  import { positionOfFrontmatterPath } from "./diagnostics.mjs";
12
- import { canonicalDayOffset, daysInMonth } from "./calendars.mjs";
13
- import { DATEFROM_PATTERN, parseNoteDate } from "./note-dates.mjs";
14
- import { eraNames } from "./calendar-human.mjs";
15
- import { addressSuffixes, renderAddress } from "./address-render.mjs";
4
+ import {
5
+ canonicalDateFromOffset,
6
+ canonicalDayOffset,
7
+ canonicalYear,
8
+ parseCanonicalDate,
9
+ } from "./calendars.mjs";
10
+ import { renderAddress } from "./address-render.mjs";
16
11
 
17
- /**
18
- * Resolve every declared reckoning marker against its calendar and era start.
19
- * The marker names one era, while the calendar supplies the month layout.
20
- *
21
- * @param {{notes?: readonly object[]}} index - The corpus link index.
22
- * @param {number} daysPerYear - The world's year length.
23
- * @returns {{eras: Map<string, object>, markers: Map<string, object>, findings: object[]}} Resolved eras, markers, and errors.
24
- */
12
+ /** Resolve calendar eras on the canonical day axis. */
25
13
  export function resolveReckoningMarkers(index, daysPerYear) {
26
- const declared = new Map();
27
- const qualifiers = new Map();
28
- const markerKeys = new Map();
29
14
  const eras = new Map();
30
15
  const markers = new Map();
31
16
  const findings = [];
32
- const failed = new Set();
33
- const visiting = [];
17
+ const qualifiers = new Map();
18
+ const markerOwners = new Map();
19
+ const calendarRows = [];
34
20
 
35
- const location = (row, key) => ({
36
- ...(row.note.file === undefined ? {} : { file: row.note.file }),
37
- ...(row.note.raw === undefined ?
21
+ const location = (note, position, key) => ({
22
+ ...(note.file === undefined ? {} : { file: note.file }),
23
+ ...(note.raw === undefined ?
38
24
  {}
39
- : positionOfFrontmatterPath(row.note.raw, ["data", "eras", row.position, key])),
25
+ : positionOfFrontmatterPath(note.raw, ["data", "eras", position, key])),
40
26
  });
41
- const finding = (row, key, message) =>
42
- findings.push({
43
- ...location(row, key),
44
- severity: "error",
45
- message,
46
- });
47
- const alias = (qualifier, key) => {
48
- if (qualifiers.has(qualifier) && qualifiers.get(qualifier) !== key)
49
- qualifiers.set(qualifier, null);
50
- else qualifiers.set(qualifier, key);
51
- };
27
+ const error = (note, position, key, message) =>
28
+ findings.push({ ...location(note, position, key), severity: "error", message });
52
29
 
53
30
  for (const note of index?.notes ?? []) {
54
31
  const fm = note?.fm ?? note?.frontmatter ?? note;
55
32
  if (fm?.type !== "lore" || fm?.subType !== "calendar") continue;
56
- const rows = fm.data?.eras;
33
+ const data = fm.data ?? {};
34
+ const rows = data.eras;
57
35
  if (!Array.isArray(rows)) continue;
58
- rows.forEach((era, position) => {
59
- if (
60
- !era ||
61
- typeof era.shortcode !== "string" ||
62
- !era.shortcode ||
63
- typeof fm.shortcode !== "string" ||
64
- !fm.shortcode
65
- )
66
- return;
67
- const pkg = fm.package ?? note.package ?? index?.contentPackage;
36
+ if (!Number.isSafeInteger(daysPerYear) || daysPerYear < 1) continue;
37
+ const epochText = String(data.epoch ?? "");
38
+ const epoch = parseCanonicalDate(epochText, daysPerYear);
39
+ if (!epoch || epoch.year === 0 || epoch.seconds !== undefined) {
40
+ findings.push({
41
+ ...(note.file === undefined ? {} : { file: note.file }),
42
+ ...(note.raw === undefined ?
43
+ {}
44
+ : positionOfFrontmatterPath(note.raw, ["data", "epoch"])),
45
+ severity: "error",
46
+ message:
47
+ "data.epoch must be a canonical <year>.<day> anchoring calendar year 1, day 1",
48
+ });
49
+ continue;
50
+ }
51
+ const epochOffset = canonicalDayOffset(
52
+ canonicalYear(epoch.year),
53
+ epoch.day,
54
+ 1,
55
+ daysPerYear,
56
+ );
57
+ const pkg = fm.package ?? note.package ?? index?.contentPackage;
58
+ const declared = [];
59
+ for (const [position, era] of rows.entries()) {
60
+ if (!era || typeof era.shortcode !== "string" || !era.shortcode || !fm.shortcode)
61
+ continue;
68
62
  const short = `${fm.shortcode}.${era.shortcode}`;
69
63
  const key =
70
64
  pkg ?
@@ -73,235 +67,139 @@ export function resolveReckoningMarkers(index, daysPerYear) {
73
67
  const row = {
74
68
  key,
75
69
  era: short,
76
- calendar: fm.data,
77
- marker: era.marker,
70
+ calendar: data,
78
71
  calendarShortcode: fm.shortcode,
72
+ note,
73
+ position,
74
+ start: era.start,
79
75
  name: era.name,
80
76
  abbreviation: era.abbreviation,
77
+ marker: era.marker,
81
78
  label: era.label,
82
- start: era.start,
83
- end: era.end,
84
- note,
85
- position,
86
79
  };
87
- if (declared.has(key)) {
88
- return;
80
+ declared.push(row);
81
+ const aliases = [short, `lore-${short}`, `note-lore-${short}`];
82
+ for (const alias of aliases) {
83
+ if (qualifiers.has(alias) && qualifiers.get(alias) !== key)
84
+ qualifiers.set(alias, null);
85
+ else qualifiers.set(alias, key);
89
86
  }
90
- declared.set(key, row);
91
- alias(short, key);
92
- if (pkg) {
93
- alias(
94
- renderAddress({ package: pkg, system: "note", type: "lore", shortcode: short }),
95
- key,
96
- );
97
- alias(`note-lore-${short}`, key);
98
- alias(`lore-${short}`, key);
87
+ if (era.marker !== undefined) {
88
+ if (typeof era.marker !== "string" || !/^[A-Z][A-Z0-9]*$/.test(era.marker))
89
+ error(
90
+ note,
91
+ position,
92
+ "marker",
93
+ "an era marker needs uppercase letters and digits",
94
+ );
95
+ else if (markerOwners.has(era.marker))
96
+ error(
97
+ note,
98
+ position,
99
+ "marker",
100
+ `reckoning marker ${era.marker} is declared more than once`,
101
+ );
102
+ else markerOwners.set(era.marker, key);
99
103
  }
100
- if (era.marker === undefined) return;
101
- if (typeof era.marker !== "string" || !/^[A-Z][A-Z0-9]*$/.test(era.marker)) {
102
- finding(
103
- row,
104
- "marker",
105
- "an era marker is uppercase letters and digits, starting with a letter",
104
+ if (era.end !== undefined)
105
+ error(
106
+ note,
107
+ position,
108
+ "end",
109
+ "an era ends when the next calendar year era begins; omit end",
106
110
  );
107
- return;
108
- }
109
- if (markerKeys.has(era.marker)) {
110
- finding(row, "marker", `reckoning marker ${era.marker} is declared more than once`);
111
- return;
112
- }
113
- markerKeys.set(era.marker, key);
114
- });
111
+ }
112
+ calendarRows.push({ note, declared, epochOffset });
115
113
  }
116
114
 
117
- for (const [qualifier, key] of qualifiers)
118
- if (key === null) eras.set(qualifier, { ambiguous: true });
119
-
120
- function resolve(key) {
121
- if (key === null || !declared.has(key) || failed.has(key)) return null;
122
- const row = declared.get(key);
123
- if (eras.has(key)) return eras.get(key);
124
- if (visiting.includes(key)) {
125
- const members = [...visiting.slice(visiting.indexOf(key)), key].map(
126
- (member) => declared.get(member).era,
115
+ for (const { note, declared, epochOffset } of calendarRows) {
116
+ const nullRows = declared.filter((row) => row.start === null);
117
+ const firstRows = declared.filter((row) => row.start === 1);
118
+ if (nullRows.length !== 1)
119
+ error(
120
+ note,
121
+ nullRows[0]?.position ?? 0,
122
+ "start",
123
+ "a calendar needs exactly one start: null era before year 1",
127
124
  );
128
- finding(row, "start", `calendar era cycle includes ${members.join(" → ")}`);
129
- for (const member of visiting.slice(visiting.indexOf(key))) failed.add(member);
130
- return null;
131
- }
132
- visiting.push(key);
133
- if (row.start === undefined || row.start === null) {
134
- finding(row, "start", `calendar era ${row.era} needs a start`);
135
- failed.add(key);
136
- }
137
- const source = typeof row.start === "string" ? row.start.trim() : "";
138
- const marker = /^([A-Z][A-Z0-9]*)\(/.exec(source)?.[1];
139
- const qualifier = /\s+([^\s]+\.[^\s]+)$/.exec(source)?.[1];
140
- const conversion = DATEFROM_PATTERN.exec(source);
141
- const convertedRef = conversion?.[1];
142
- const convertedDate = conversion?.[2];
143
- const convertedRows =
144
- convertedRef ?
145
- [...declared.values()].filter((candidate) => {
146
- const fm = candidate.note.fm ?? candidate.note.frontmatter ?? candidate.note;
147
- const pkg = fm.package ?? candidate.note.package ?? index?.contentPackage;
148
- if (!pkg) return convertedRef === candidate.calendarShortcode;
149
- return addressSuffixes({
150
- package: pkg,
151
- system: "note",
152
- type: "lore",
153
- shortcode: candidate.calendarShortcode,
154
- }).includes(convertedRef);
155
- })
156
- : [];
157
- const convertedTargets = convertedRows.filter((candidate) =>
158
- eraNames(candidate).some((name) =>
159
- convertedDate?.toLowerCase().endsWith(` ${name.toLowerCase()}`),
160
- ),
161
- );
162
- if (convertedTargets.length === 0 && convertedRows.length === 1)
163
- convertedTargets.push(convertedRows[0]);
164
- const dependency =
165
- marker ? markerKeys.get(marker)
166
- : qualifier ? qualifiers.get(qualifier)
167
- : convertedTargets.length === 1 ? convertedTargets[0].key
168
- : null;
169
- if (dependency && !failed.has(key)) resolve(dependency);
170
- if (!failed.has(key)) {
171
- const parsed = parseNoteDate(row.start, {
172
- markers,
173
- eras,
174
- daysPerYear,
175
- field: "data.eras.start",
176
- allowUnknown: false,
177
- ...location(row, "start"),
178
- raw: row.note.raw,
179
- keyPath: ["data", "eras", row.position, "start"],
180
- });
181
- findings.push(...parsed.findings);
182
- if (
183
- Number.isSafeInteger(parsed.date?.canonicalYear) &&
184
- parsed.findings.every((item) => item.severity !== "error")
185
- ) {
186
- const entry = {
187
- era: row.era,
188
- qualifier: row.key,
189
- marker: row.marker,
190
- firstEra: row.position === 0,
191
- calendar: row.calendar,
192
- calendarShortcode: row.calendarShortcode,
193
- name: row.name,
194
- abbreviation: row.abbreviation,
195
- label: row.label,
196
- epochYear: parsed.date.canonicalYear,
197
- epochDay: parsed.date.canonicalDay ?? 1,
198
- };
199
- if (Number.isSafeInteger(daysPerYear) && daysPerYear > 0)
200
- entry.startOffset = canonicalDayOffset(
201
- entry.epochYear,
202
- entry.epochDay,
203
- 1,
204
- daysPerYear,
205
- );
206
- eras.set(key, entry);
207
- for (const [name, owner] of qualifiers) if (owner === key) eras.set(name, entry);
208
- if (row.marker && markerKeys.get(row.marker) === key)
209
- markers.set(row.marker, entry);
210
- } else failed.add(key);
211
- }
212
- visiting.pop();
213
- return eras.get(key) ?? null;
214
- }
215
- for (const key of declared.keys()) resolve(key);
216
- for (const row of declared.values()) {
217
- const entry = eras.get(row.key);
218
- if (!entry || row.end === undefined || row.end === null) continue;
219
- const parsed = parseNoteDate(row.end, {
220
- markers,
221
- eras,
222
- daysPerYear,
223
- field: "data.eras.end",
224
- allowUnknown: false,
225
- file: row.note.file,
226
- raw: row.note.raw,
227
- keyPath: ["data", "eras", row.position, "end"],
228
- });
229
- findings.push(...parsed.findings);
230
- if (!Number.isSafeInteger(parsed.date?.canonicalYear)) continue;
231
- if (!Number.isSafeInteger(daysPerYear) || daysPerYear < 1) {
232
- finding(
233
- row,
234
- "end",
235
- `calendar era ${row.era} needs the world's year length to bound its end`,
125
+ if (firstRows.length !== 1)
126
+ error(
127
+ note,
128
+ firstRows[0]?.position ?? 0,
129
+ "start",
130
+ "a calendar needs exactly one era with start: 1",
236
131
  );
237
- continue;
238
- }
239
- const firstEndDay = canonicalDayOffset(
240
- parsed.date.canonicalYear,
241
- parsed.date.canonicalDay ?? 1,
242
- 1,
243
- daysPerYear,
244
- );
245
- const extraDays =
246
- parsed.date.precision === "year" ? daysPerYear - 1
247
- : parsed.date.precision === "month" ?
248
- (daysInMonth(
249
- markers.get(parsed.date.marker)?.calendar ??
250
- eras.get(parsed.date.qualifier)?.calendar,
251
- parsed.date.month,
252
- ) ?? 1) - 1
253
- : 0;
254
- const endOffset = firstEndDay + extraDays;
255
- const startOffset = canonicalDayOffset(entry.epochYear, entry.epochDay, 1, daysPerYear);
256
- if (endOffset < startOffset) {
257
- finding(row, "end", `calendar era ${row.era} ends before its start`);
258
- continue;
259
- }
260
- entry.endOffset = endOffset;
261
- }
262
- const calendars = new Map();
263
- for (const row of declared.values()) {
264
- const rows = calendars.get(row.note) ?? [];
265
- rows.push(row);
266
- calendars.set(row.note, rows);
267
- }
268
- for (const rows of calendars.values()) {
269
- for (let position = 0; position < rows.length; position++) {
270
- const row = rows[position];
271
- const entry = eras.get(row.key);
272
- if (!entry || !Number.isSafeInteger(entry.startOffset)) continue;
273
- entry.firstEra = position === 0;
274
- const next = rows[position + 1];
275
- if (!next) continue;
276
- const following = eras.get(next.key);
277
- if (!following || !Number.isSafeInteger(following.startOffset)) continue;
278
- if (following.startOffset <= entry.startOffset) {
279
- finding(
280
- next,
132
+ const byStart = new Map();
133
+ for (const row of declared) {
134
+ if (row.start !== null && (!Number.isSafeInteger(row.start) || row.start < 1)) {
135
+ error(
136
+ note,
137
+ row.position,
281
138
  "start",
282
- `calendar eras ${row.era} and ${next.era} must begin in increasing order`,
139
+ "an era start is null or a positive integer calendar year",
283
140
  );
284
141
  continue;
285
142
  }
286
- if (entry.endOffset !== undefined && entry.endOffset >= following.startOffset) {
287
- finding(next, "start", `calendar eras ${row.era} and ${next.era} overlap`);
143
+ if (byStart.has(row.start)) {
144
+ error(note, row.position, "start", `calendar eras cannot share start ${row.start}`);
145
+ continue;
146
+ }
147
+ byStart.set(row.start, row);
148
+ }
149
+ const ordered = [...byStart.values()].sort((a, b) =>
150
+ a.start === null ? -1
151
+ : b.start === null ? 1
152
+ : a.start - b.start,
153
+ );
154
+ for (const [position, row] of ordered.entries()) {
155
+ const startOffset =
156
+ row.start === null ? epochOffset : epochOffset + (row.start - 1) * daysPerYear;
157
+ if (!Number.isSafeInteger(startOffset)) {
158
+ error(
159
+ note,
160
+ row.position,
161
+ "start",
162
+ "era start lies outside the supported day range",
163
+ );
288
164
  continue;
289
165
  }
290
- if (entry.endOffset === undefined) entry.endOffset = following.startOffset - 1;
166
+ const startDate = canonicalDateFromOffset(startOffset, 1, daysPerYear);
167
+ const next = ordered[position + 1];
168
+ const endOffset = next ? epochOffset + (next.start - 1) * daysPerYear - 1 : undefined;
169
+ const entry = {
170
+ era: row.era,
171
+ qualifier: row.key,
172
+ marker: row.marker,
173
+ firstEra: row.start === null,
174
+ beforeEra: row.start === null,
175
+ startYear: row.start,
176
+ calendar: row.calendar,
177
+ calendarShortcode: row.calendarShortcode,
178
+ name: row.name,
179
+ abbreviation: row.abbreviation,
180
+ label: row.label,
181
+ epochYear: startDate.year,
182
+ epochDay: startDate.day,
183
+ calendarEpochOffset: epochOffset,
184
+ startOffset,
185
+ ...(endOffset === undefined ? {} : { endOffset }),
186
+ };
187
+ eras.set(row.key, entry);
188
+ if (row.marker && markerOwners.get(row.marker) === row.key)
189
+ markers.set(row.marker, entry);
291
190
  }
292
191
  }
192
+ for (const [alias, key] of qualifiers) {
193
+ if (key === null) eras.set(alias, { ambiguous: true });
194
+ else if (eras.has(key)) eras.set(alias, eras.get(key));
195
+ }
293
196
  return { eras, markers, findings };
294
197
  }
295
198
 
296
- /** One resolved date context per corpus index, shared by its note checks. */
199
+ /** One resolved date context per corpus index. */
297
200
  const contexts = new WeakMap();
298
201
 
299
- /**
300
- * Read the world's year length and its era markers from one corpus index.
301
- *
302
- * @param {{notes?: readonly object[]}|undefined} index - The corpus index.
303
- * @returns {{eras: Map<string, object>, markers: Map<string, object>, daysPerYear: number|undefined, findings: object[]}}
304
- */
202
+ /** Read the world's year length and resolve every calendar era in one corpus. */
305
203
  export function reckoningContext(index) {
306
204
  if (!index || typeof index !== "object")
307
205
  return { eras: new Map(), markers: new Map(), daysPerYear: undefined, findings: [] };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heroiclands/package-build",
3
- "version": "22.16.0",
3
+ "version": "22.17.0",
4
4
  "description": "Shared toolchain for building and shipping a HeroicLands Foundry VTT package — content compilation, manifest, localization, staging, bundle, release and deployment.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "type": "module",