@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.
@@ -0,0 +1,441 @@
1
+ /* SPDX-License-Identifier: GPL-3.0-or-later */
2
+
3
+ import { dayOfYear } from "./calendars.mjs";
4
+
5
+ /** Calendaria's bare date, time, week, season, and zone tokens. */
6
+ export const CALENDAR_FORMAT_TOKENS = Object.freeze(
7
+ new Set(
8
+ "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(
9
+ " ",
10
+ ),
11
+ ),
12
+ );
13
+
14
+ /** Calendaria's named fields inside brackets or braces. */
15
+ export const CALENDAR_CUSTOM_TOKENS = Object.freeze(
16
+ new Set(
17
+ "yearName namedWeek namedWeekAbbr namedDay namedDayAbbr era eraAbbr yearInEra yearInEraOrdinal season seasonAbbr moon moonIcon ch chAbbr cycle cycleName cycleRoman cycleYear approxTime approxDate meridiemFull".split(
18
+ " ",
19
+ ),
20
+ ),
21
+ );
22
+
23
+ const TOKEN =
24
+ /\[([^\]]+)]|{([^}]+)}|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/g;
25
+
26
+ /** Brackets and braces must close; `[[]` is a literal left bracket. */
27
+ export function calendarFormatSyntaxError(pattern) {
28
+ for (let position = 0; position < pattern.length;) {
29
+ if (pattern.startsWith("[[]", position)) {
30
+ position += 3;
31
+ continue;
32
+ }
33
+ const opener = pattern[position];
34
+ if (opener === "[" || opener === "{") {
35
+ const closer = opener === "[" ? "]" : "}";
36
+ const end = pattern.indexOf(closer, position + 1);
37
+ if (end < 0) return `calendar format has an unclosed ${opener}`;
38
+ if (end === position + 1) return "calendar format has an empty bracket or brace";
39
+ position = end + 1;
40
+ continue;
41
+ }
42
+ if (opener === "]" || opener === "}") return `calendar format has an unmatched ${opener}`;
43
+ position++;
44
+ }
45
+ return null;
46
+ }
47
+
48
+ /** Split a Calendaria pattern into tokens and literal text. */
49
+ export function calendarFormatSegments(pattern) {
50
+ const segments = [];
51
+ let cursor = 0;
52
+ for (const match of String(pattern).matchAll(TOKEN)) {
53
+ if (match.index > cursor)
54
+ segments.push({ token: false, value: pattern.slice(cursor, match.index) });
55
+ const custom = match[1] ?? match[2];
56
+ if (custom !== undefined) {
57
+ const base = custom.split(/[=|]/, 1)[0];
58
+ if (CALENDAR_CUSTOM_TOKENS.has(base)) segments.push({ token: true, value: custom });
59
+ else segments.push({ token: false, value: custom });
60
+ } else segments.push({ token: true, value: match[0] });
61
+ cursor = match.index + match[0].length;
62
+ }
63
+ if (cursor < pattern.length) segments.push({ token: false, value: pattern.slice(cursor) });
64
+ return segments;
65
+ }
66
+
67
+ const READABLE_TOKENS = new Set([
68
+ "Y",
69
+ "YYYY",
70
+ "M",
71
+ "MM",
72
+ "Mo",
73
+ "MMM",
74
+ "MMMM",
75
+ "D",
76
+ "DD",
77
+ "Do",
78
+ "DDD",
79
+ "G",
80
+ "GG",
81
+ "GGG",
82
+ "GGGG",
83
+ "era",
84
+ "eraAbbr",
85
+ "yearInEra",
86
+ "yearInEraOrdinal",
87
+ "H",
88
+ "HH",
89
+ "h",
90
+ "hh",
91
+ "m",
92
+ "mm",
93
+ "s",
94
+ "ss",
95
+ "A",
96
+ "a",
97
+ "meridiemFull",
98
+ ]);
99
+ const CLOCK_TOKENS = new Set(["H", "HH", "h", "hh", "m", "mm", "s", "ss"]);
100
+
101
+ /** A standard format must identify one calendar day and be readable without context. */
102
+ export function readableCalendarFormatError(pattern) {
103
+ const segments = calendarFormatSegments(pattern);
104
+ const tokens = segments.filter((segment) => segment.token).map((segment) => segment.value);
105
+ const unsupported = tokens.find((token) => !READABLE_TOKENS.has(token));
106
+ if (unsupported) return `standard calendar format cannot read token ${unsupported}`;
107
+ const variableDigits = new Set(["Y", "M", "D", "H", "h", "m", "s", "yearInEra"]);
108
+ if (
109
+ segments.some(
110
+ (segment, index) =>
111
+ segment.token &&
112
+ variableDigits.has(segment.value) &&
113
+ segments[index + 1]?.token &&
114
+ variableDigits.has(segments[index + 1].value),
115
+ )
116
+ )
117
+ return "standard calendar format has ambiguous adjacent numeric tokens";
118
+ if (!tokens.some((token) => ["Y", "YYYY", "yearInEra", "yearInEraOrdinal"].includes(token)))
119
+ return "standard calendar format needs Y, YYYY, or [yearInEra]";
120
+ if (tokens.includes("yearInEra") || tokens.includes("yearInEraOrdinal")) {
121
+ if (!tokens.some((token) => ["G", "GG", "GGG", "GGGG", "era", "eraAbbr"].includes(token)))
122
+ return "standard calendar format needs an era label with [yearInEra]";
123
+ }
124
+ if (
125
+ !tokens.includes("DDD") &&
126
+ (!tokens.some((token) => ["D", "DD", "Do"].includes(token)) ||
127
+ !tokens.some((token) => ["M", "MM", "Mo", "MMM", "MMMM"].includes(token)))
128
+ )
129
+ return "standard calendar format needs DDD or both month and day";
130
+ return null;
131
+ }
132
+
133
+ /** Whether a selected pattern prints clock components. */
134
+ export function calendarFormatHasClock(pattern) {
135
+ return calendarFormatSegments(pattern).some(
136
+ (segment) => segment.token && CLOCK_TOKENS.has(segment.value),
137
+ );
138
+ }
139
+
140
+ /** Keep the fields named by an authored date's precision. */
141
+ export function calendarPatternSegmentsForPrecision(
142
+ pattern,
143
+ precision = "day",
144
+ includeClock = true,
145
+ ) {
146
+ const segments = calendarFormatSegments(pattern).map((segment) => ({ ...segment }));
147
+ const drop = new Set([
148
+ ...(!includeClock ?
149
+ ["H", "HH", "h", "hh", "m", "mm", "s", "ss", "A", "a", "meridiemFull"]
150
+ : []),
151
+ ...(precision === "year" || precision === "month" ?
152
+ [
153
+ "E",
154
+ "EE",
155
+ "EEE",
156
+ "EEEE",
157
+ "EEEEE",
158
+ "d",
159
+ "dd",
160
+ "ddd",
161
+ "dddd",
162
+ "e",
163
+ "w",
164
+ "ww",
165
+ "W",
166
+ "namedDay",
167
+ "namedDayAbbr",
168
+ "namedWeek",
169
+ "namedWeekAbbr",
170
+ "Q",
171
+ "QQ",
172
+ "QQQ",
173
+ "QQQQ",
174
+ "season",
175
+ "seasonAbbr",
176
+ ]
177
+ : []),
178
+ ...(precision === "year" || precision === "month" ? ["D", "DD", "Do", "DDD"] : []),
179
+ ...(precision === "year" ? ["M", "MM", "Mo", "MMM", "MMMM"] : []),
180
+ ]);
181
+ for (let index = 0; index < segments.length;) {
182
+ if (!segments[index].token || !drop.has(segments[index].value)) {
183
+ index++;
184
+ continue;
185
+ }
186
+ const before = segments[index - 1];
187
+ const after = segments[index + 1];
188
+ if (before && !before.token && before.value) before.value = "";
189
+ else if (after && !after.token) after.value = "";
190
+ segments.splice(index, 1);
191
+ }
192
+ return segments;
193
+ }
194
+
195
+ /** Choose an authored named format, with `std` as the preferred default. */
196
+ export function selectCalendarFormat(calendar, name) {
197
+ const formats = calendar?.formats;
198
+ const names =
199
+ formats && typeof formats === "object" && !Array.isArray(formats) ?
200
+ Object.keys(formats)
201
+ : [];
202
+ if (names.length === 0) {
203
+ if (name && name !== "std") throw new RangeError(`calendar format ${name} does not exist`);
204
+ return { name: "std", pattern: "D MMMM [yearInEra] G" };
205
+ }
206
+ const selected = name ?? (names.includes("std") ? "std" : names[0]);
207
+ if (!Object.hasOwn(formats, selected))
208
+ throw new RangeError(`calendar format ${selected} does not exist`);
209
+ return { name: selected, pattern: formats[selected] };
210
+ }
211
+
212
+ const pad = (value, width) =>
213
+ `${value < 0 ? "-" : ""}${String(Math.abs(value)).padStart(width, "0")}`;
214
+ const ordinal = (value) => {
215
+ const mod100 = value % 100;
216
+ const suffix =
217
+ mod100 >= 11 && mod100 <= 13 ? "th"
218
+ : value % 10 === 1 ? "st"
219
+ : value % 10 === 2 ? "nd"
220
+ : value % 10 === 3 ? "rd"
221
+ : "th";
222
+ return `${value}${suffix}`;
223
+ };
224
+
225
+ /** Format calendar date components with Calendaria's documented tokens. */
226
+ export function formatCalendarPattern(pattern, parts, precision = "day", includeClock = true) {
227
+ const segments = calendarPatternSegmentsForPrecision(pattern, precision, includeClock);
228
+ const unsupported = segments.find(
229
+ (segment) =>
230
+ (segment.token &&
231
+ [
232
+ "z",
233
+ "zzzz",
234
+ "moon",
235
+ "moonIcon",
236
+ "ch",
237
+ "chAbbr",
238
+ "cycle",
239
+ "cycleName",
240
+ "cycleRoman",
241
+ "cycleYear",
242
+ "approxTime",
243
+ "approxDate",
244
+ "yearName",
245
+ "namedWeek",
246
+ "namedWeekAbbr",
247
+ ].includes(segment.value.split(/[=|]/, 1)[0])) ||
248
+ (segment.token && segment.value.includes("=")),
249
+ );
250
+ if (unsupported)
251
+ throw new RangeError(
252
+ `calendar format token ${unsupported.value} needs Calendaria runtime data`,
253
+ );
254
+ const calendar = parts.calendar ?? {};
255
+ const month = calendar.months?.[parts.month - 1];
256
+ const weekdays = Array.isArray(calendar.weekdays) ? calendar.weekdays : [];
257
+ const dayOffset = parts.canonicalOffset - parts.calendarEpochOffset;
258
+ const weekday =
259
+ weekdays.length ? ((dayOffset % weekdays.length) + weekdays.length) % weekdays.length : 0;
260
+ const weekdayName = weekdays[weekday]?.name ?? "";
261
+ const weekdayAbbr = weekdays[weekday]?.abbreviation ?? weekdayName.slice(0, 3);
262
+ const seconds = parts.seconds ?? 0;
263
+ const hour = Math.floor(seconds / 3600);
264
+ const minute = Math.floor(seconds / 60) % 60;
265
+ const second = seconds % 60;
266
+ const hour12 = hour % 12 || 12;
267
+ const year = parts.era?.beforeEra ? parts.eraYear : parts.calendarYear;
268
+ const era = parts.era;
269
+ const eraName = era?.name ?? "";
270
+ const eraAbbr =
271
+ era?.abbreviation ??
272
+ era?.marker ??
273
+ (eraName ? eraName.slice(0, 2) : era?.era?.split(".").at(-1));
274
+ const seasons = Array.isArray(calendar.seasons) ? calendar.seasons : [];
275
+ const datedSeason = seasons.findLastIndex((item) => parts.dayOfYear >= item.start);
276
+ const seasonIndex = datedSeason < 0 && seasons.length ? seasons.length - 1 : datedSeason;
277
+ const season = seasons[seasonIndex];
278
+ const namedDay = (Array.isArray(calendar.namedDays) ? calendar.namedDays : []).find(
279
+ (item) => item.day === parts.dayOfYear,
280
+ );
281
+ const values = {
282
+ Y: year,
283
+ YY: String(Math.abs(year) % 100).padStart(2, "0"),
284
+ YYYY: pad(year, 4),
285
+ M: parts.month,
286
+ MM: pad(parts.month, 2),
287
+ MMM: month?.abbreviation ?? month?.name?.slice(0, 3) ?? "",
288
+ MMMM: month?.name ?? "",
289
+ Mo: ordinal(parts.month),
290
+ D: parts.day,
291
+ DD: pad(parts.day, 2),
292
+ Do: ordinal(parts.day),
293
+ DDD: pad(parts.dayOfYear, 3),
294
+ E: weekdayAbbr,
295
+ EE: weekdayAbbr,
296
+ EEE: weekdayAbbr,
297
+ EEEE: weekdayName,
298
+ EEEEE: weekdayName.slice(0, 1),
299
+ e: weekday,
300
+ d: weekday,
301
+ dd: weekdayAbbr.slice(0, 2),
302
+ ddd: weekdayAbbr,
303
+ dddd: weekdayName,
304
+ w: Math.ceil(parts.dayOfYear / (weekdays.length || 7)),
305
+ ww: pad(Math.ceil(parts.dayOfYear / (weekdays.length || 7)), 2),
306
+ W: Math.ceil(parts.day / (weekdays.length || 7)),
307
+ H: hour,
308
+ HH: pad(hour, 2),
309
+ h: hour12,
310
+ hh: pad(hour12, 2),
311
+ m: minute,
312
+ mm: pad(minute, 2),
313
+ s: second,
314
+ ss: pad(second, 2),
315
+ A: hour < 12 ? "AM" : "PM",
316
+ a: hour < 12 ? "am" : "pm",
317
+ G: eraAbbr,
318
+ GG: eraAbbr,
319
+ GGG: eraAbbr,
320
+ GGGG: eraName,
321
+ Q: seasonIndex < 0 ? "" : seasonIndex + 1,
322
+ QQ: seasonIndex < 0 ? "" : pad(seasonIndex + 1, 2),
323
+ QQQ: season?.abbreviation ?? "",
324
+ QQQQ: season?.name ?? "",
325
+ z: "",
326
+ zzzz: "",
327
+ era: eraName,
328
+ eraAbbr,
329
+ yearInEra: parts.eraYear,
330
+ yearInEraOrdinal: ordinal(parts.eraYear),
331
+ season: season?.name ?? "",
332
+ seasonAbbr: season?.abbreviation ?? "",
333
+ namedDay: namedDay?.name ?? "",
334
+ namedDayAbbr: namedDay?.abbreviation ?? namedDay?.name?.slice(0, 3) ?? "",
335
+ meridiemFull: hour < 12 ? "AM" : "PM",
336
+ };
337
+ return segments
338
+ .map((segment) => {
339
+ if (!segment.token) return segment.value;
340
+ const [key, fallback] = segment.value.split("|", 2);
341
+ if (fallback !== undefined) return String(values[key] || values[fallback] || fallback);
342
+ return String(values[key] ?? "");
343
+ })
344
+ .join("");
345
+ }
346
+
347
+ /** Read the invertible date fields of one Calendaria pattern. */
348
+ export function parseCalendarPattern(pattern, text, calendar) {
349
+ const error = readableCalendarFormatError(pattern);
350
+ if (error) throw new RangeError(error);
351
+ let captures = [];
352
+ const escape = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
353
+ const capture = (token) => {
354
+ if (["YYYY"].includes(token)) return "(-?\\d{4,})";
355
+ if (["Y", "yearInEra"].includes(token)) return "(-?\\d+)";
356
+ if (["MM", "DD", "HH", "hh", "mm", "ss", "YY"].includes(token)) return "(\\d{2})";
357
+ if (token === "DDD") return "(\\d{3})";
358
+ if (["M", "D", "H", "h", "m", "s"].includes(token)) return "(\\d{1,2})";
359
+ if (["Mo", "Do", "yearInEraOrdinal"].includes(token)) return "(\\d+(?:st|nd|rd|th))";
360
+ return "(.+?)";
361
+ };
362
+ let match = null;
363
+ for (const [precision, includeClock] of [
364
+ ["day", true],
365
+ ["day", false],
366
+ ["month", false],
367
+ ["year", false],
368
+ ]) {
369
+ captures = [];
370
+ const regex = calendarPatternSegmentsForPrecision(pattern, precision, includeClock)
371
+ .map((segment) => {
372
+ if (!segment.token) return escape(segment.value);
373
+ captures.push(segment.value);
374
+ return capture(segment.value);
375
+ })
376
+ .join("");
377
+ match = new RegExp(`^${regex}$`, "u").exec(text);
378
+ if (match) break;
379
+ }
380
+ if (!match) throw new RangeError(`date does not match calendar format ${pattern}`);
381
+ const fields = {};
382
+ const set = (key, value) => {
383
+ if (fields[key] !== undefined && fields[key] !== value)
384
+ throw new RangeError(`date gives conflicting ${key} values`);
385
+ fields[key] = value;
386
+ };
387
+ const namedMonth = (value) => {
388
+ const found = (calendar.months ?? [])
389
+ .map((item, index) => ({ item, number: index + 1 }))
390
+ .filter(({ item }) =>
391
+ [item.name, item.abbreviation].some(
392
+ (name) =>
393
+ typeof name === "string" && name.toLowerCase() === value.toLowerCase(),
394
+ ),
395
+ );
396
+ if (found.length !== 1)
397
+ throw new RangeError(`date names unknown or ambiguous month ${value}`);
398
+ return found[0].number;
399
+ };
400
+ captures.forEach((token, index) => {
401
+ const value = match[index + 1];
402
+ if (["Y", "YY", "YYYY"].includes(token)) set("calendarYear", Number(value));
403
+ else if (["yearInEra", "yearInEraOrdinal"].includes(token))
404
+ set("eraYear", Number.parseInt(value, 10));
405
+ else if (["M", "MM", "Mo"].includes(token)) set("month", Number.parseInt(value, 10));
406
+ else if (["MMM", "MMMM"].includes(token)) set("month", namedMonth(value));
407
+ else if (["D", "DD", "Do"].includes(token)) set("day", Number.parseInt(value, 10));
408
+ else if (token === "DDD") set("dayOfYear", Number(value));
409
+ else if (["G", "GG", "GGG", "GGGG", "era", "eraAbbr"].includes(token))
410
+ set("eraLabel", value);
411
+ else if (["H", "HH", "h", "hh"].includes(token)) set("hour", Number(value));
412
+ else if (["m", "mm"].includes(token)) set("minute", Number(value));
413
+ else if (["s", "ss"].includes(token)) set("second", Number(value));
414
+ else if (["A", "a", "meridiemFull"].includes(token)) set("meridiem", value);
415
+ });
416
+ if (!Number.isSafeInteger(fields.calendarYear) && !Number.isSafeInteger(fields.eraYear))
417
+ throw new RangeError("calendar format must provide Y, YY, YYYY, or [yearInEra]");
418
+ if (fields.day !== undefined && fields.month === undefined && fields.dayOfYear === undefined)
419
+ throw new RangeError("calendar date gives a day without a month");
420
+ if (fields.dayOfYear !== undefined) {
421
+ let remaining = fields.dayOfYear;
422
+ for (const [index, month] of (calendar.months ?? []).entries()) {
423
+ if (remaining <= month.days) {
424
+ set("month", index + 1);
425
+ set("day", remaining);
426
+ break;
427
+ }
428
+ remaining -= month.days;
429
+ }
430
+ }
431
+ if (fields.hour !== undefined) {
432
+ if (/^(pm)$/i.test(fields.meridiem ?? "") && fields.hour < 12) fields.hour += 12;
433
+ if (/^(am)$/i.test(fields.meridiem ?? "") && fields.hour === 12) fields.hour = 0;
434
+ }
435
+ return fields;
436
+ }
437
+
438
+ /** Convert a calendar month/day into its one-based day of year. */
439
+ export function calendarOrdinal(calendar, month, day) {
440
+ return dayOfYear(calendar.months ?? [], month, day);
441
+ }
@@ -1,16 +1,78 @@
1
1
  /* SPDX-License-Identifier: GPL-3.0-or-later */
2
2
 
3
3
  import { calendarStructure } from "./calendars.mjs";
4
+ import {
5
+ calendarFormatHasClock,
6
+ parseCalendarPattern,
7
+ selectCalendarFormat,
8
+ } from "./calendar-format.mjs";
4
9
 
5
10
  /** The labels a human date may use to name one era. */
6
11
  export function eraNames(era) {
7
- return [era.abbreviation, era.name, era.marker, era.era.split(".").at(-1), era.era].filter(
8
- (value) => typeof value === "string" && value.trim(),
9
- );
12
+ return [
13
+ era.abbreviation,
14
+ era.name,
15
+ era.name?.slice(0, 2),
16
+ era.marker,
17
+ era.era.split(".").at(-1),
18
+ era.era,
19
+ ].filter((value) => typeof value === "string" && value.trim());
10
20
  }
11
21
 
12
22
  /** Translate a named date into the addressed form read by the date parser. */
13
23
  export function addressedCalendarDate(text, eras) {
24
+ if (eras[0]?.calendar?.formats && Object.keys(eras[0].calendar.formats).length) {
25
+ const calendar = eras[0].calendar;
26
+ const { pattern } = selectCalendarFormat(calendar);
27
+ const trailingClock =
28
+ calendarFormatHasClock(pattern) ? null : /^(.*) (\d{2}):(\d{2}):(\d{2})$/.exec(text);
29
+ const fields = parseCalendarPattern(
30
+ pattern,
31
+ trailingClock ? trailingClock[1] : text,
32
+ calendar,
33
+ );
34
+ if (trailingClock) {
35
+ fields.hour = Number(trailingClock[2]);
36
+ fields.minute = Number(trailingClock[3]);
37
+ fields.second = Number(trailingClock[4]);
38
+ }
39
+ const named =
40
+ fields.eraLabel === undefined ?
41
+ null
42
+ : eras.filter((era) =>
43
+ eraNames(era).some(
44
+ (name) => name.toLowerCase() === fields.eraLabel.toLowerCase(),
45
+ ),
46
+ );
47
+ if (named && named.length !== 1)
48
+ throw new RangeError(`date names unknown or ambiguous era ${fields.eraLabel}`);
49
+ const selected =
50
+ named?.[0] ??
51
+ (fields.calendarYear === undefined ?
52
+ null
53
+ : [...eras]
54
+ .sort((a, b) => (b.startYear ?? -Infinity) - (a.startYear ?? -Infinity))
55
+ .find((era) => era.startYear === null || fields.calendarYear >= era.startYear));
56
+ if (!selected) throw new RangeError("date needs an era in this calendar");
57
+ const year =
58
+ fields.eraYear ??
59
+ (selected.beforeEra ?
60
+ fields.calendarYear
61
+ : fields.calendarYear - selected.startYear + 1);
62
+ if (!Number.isSafeInteger(year) || year < 1)
63
+ throw new RangeError("date names a year outside its era");
64
+ if (
65
+ fields.calendarYear !== undefined &&
66
+ fields.eraYear !== undefined &&
67
+ (selected.beforeEra ? year : selected.startYear + year - 1) !== fields.calendarYear
68
+ )
69
+ throw new RangeError("calendar year and era year disagree");
70
+ const clock =
71
+ fields.hour === undefined ?
72
+ ""
73
+ : `:${String(fields.hour).padStart(2, "0")}${String(fields.minute ?? 0).padStart(2, "0")}${String(fields.second ?? 0).padStart(2, "0")}`;
74
+ return `${year}${fields.month === undefined ? "" : `/${fields.month}`}${fields.day === undefined ? "" : `/${fields.day}${clock}`} ${selected.qualifier}`;
75
+ }
14
76
  const first = /^(\d{1,2})\s+(.+)$/.exec(text);
15
77
  let day = null;
16
78
  let rest = text;