@heroiclands/package-build 22.7.0 → 22.8.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.
@@ -115,6 +115,7 @@
115
115
  import { getFrontmatter } from "./frontmatter.mjs";
116
116
  import { currentType } from "./ids.mjs";
117
117
  import { NOTE_VOCABULARY, dataFields } from "./note-vocabulary.mjs";
118
+ import { readAliasedField } from "./retired-fields.mjs";
118
119
  import { subtypeRow } from "./document-subtypes.mjs";
119
120
 
120
121
  /**
@@ -272,7 +273,6 @@ export const NOTE_FIELD_PRESENTATION = Object.freeze({
272
273
  banner: Object.freeze({ withheld: "an image, which the box never carries" }),
273
274
  overlay: Object.freeze({ withheld: "an image, which the box never carries" }),
274
275
 
275
- birthday: Object.freeze({ label: "Born" }),
276
276
  assocSkill: Object.freeze({ label: "Skill" }),
277
277
  assocAffiliation: Object.freeze({ label: "Affiliation" }),
278
278
  parentSkill: Object.freeze({ label: "Specialises" }),
@@ -637,7 +637,12 @@ function noteBox(
637
637
  for (const field of dataFields(fm?.type, vocabulary) ?? []) {
638
638
  const overlay = overlayFor(presentation, fm?.type, field.name);
639
639
  if (overlay.withheld) continue;
640
- const raw = getFrontmatter(data, field.name, undefined);
640
+ // The retired spelling underneath, so a note part-way through a rename
641
+ // still shows its value rather than losing the row with nothing said.
642
+ // The current name wins, which is the whole of the retirement window's
643
+ // behaviour and is `readAliasedField`'s answer, not a second one.
644
+ const own = getFrontmatter(data, field.name, undefined);
645
+ const raw = hasValue(own) ? own : readAliasedField(fm, field.name, { inData: true });
641
646
  if (!hasValue(raw)) continue;
642
647
 
643
648
  if (overlay.group) {
@@ -0,0 +1,344 @@
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
+ * For full terms, see the LICENSE.md file in the project root or visit:
9
+ * https://www.gnu.org/licenses/gpl-3.0.html
10
+ *
11
+ * SPDX-License-Identifier: GPL-3.0-or-later
12
+ */
13
+
14
+ /**
15
+ * **A date on a note: one authored string, one grammar, one record.**
16
+ *
17
+ * ```text
18
+ * [~] [-] YYYY [/ MM [/ DD]] [ ERA ]
19
+ *
20
+ * ERA : (?:[a-z0-9]+-){0,3}[a-z0-9]+ . [a-z0-9]+
21
+ * ```
22
+ *
23
+ * - `~` — approximate. It marks the value and does not change what it parses
24
+ * to, so a renderer can print `c. -2110` from the same number it sorts on.
25
+ * It precedes the sign: `~-2500`, never `-~2500`.
26
+ * - `-` — before the epoch the year is counted from. There is no year zero, so
27
+ * `-1` sits immediately before `1`.
28
+ * - `YYYY` — required.
29
+ * - `MM`, `DD` — optional, in that order.
30
+ * - `ERA` — an affiliation's address plus `.<era shortcode>`, in the four
31
+ * address forms {@link ERA_QUALIFIER_PATTERN} admits. Omitted, the date sits
32
+ * on the canonical axis and is attributed to nobody.
33
+ *
34
+ * **Precision falls out of how much is written.** `-984` is a year, `689/6` a
35
+ * month, `689/6/19` a day. There is no precision vocabulary, so there is
36
+ * nothing to fall out of step with the value it describes.
37
+ *
38
+ * ## A trailing token is only ever an era
39
+ *
40
+ * It has exactly one shape and it always contains exactly one dot, so **a
41
+ * trailing token at all means the value needs the corpus, and no token means
42
+ * it is already on the axis** — which is readable off the string, before
43
+ * anything is resolved.
44
+ *
45
+ * ## The rule to copy: print `text`, order on `sort`, do arithmetic on `canonicalYear`
46
+ *
47
+ * Every parsed date carries all three, because neither the string nor the
48
+ * number is cheap to derive from the other at read time:
49
+ *
50
+ * ```yaml
51
+ * text: "689/6/19" # authored, verbatim — what a page prints
52
+ * known: true # what a query branches on
53
+ * era: null # the reckoning named, or null for the canonical axis
54
+ * year: 689 # as authored, signed, within its own reckoning
55
+ * month: 6 # null where unwritten
56
+ * day: 19 # null where unwritten
57
+ * approximate: false # the `~`
58
+ * precision: day # derived: day | month | year
59
+ * canonicalYear: 689 # signed — arithmetic
60
+ * sort: 689.0619 # total order across mixed precision — sorting
61
+ * ```
62
+ *
63
+ * `era` is `null` for a bare value and there is no substitute for it: the
64
+ * canonical axis is not a reckoning anybody keeps and has no address, so
65
+ * filling the field would state an identification that is not true.
66
+ *
67
+ * ## A number is written only where there is one to write
68
+ *
69
+ * A date naming an era carries the qualifier and **no `canonicalYear` and no
70
+ * `sort` keys at all** until the era is resolved against the corpus. They are
71
+ * absent rather than null, because a record carrying `known: true` beside a
72
+ * null year and a null sort is indistinguishable from the `unknown` record
73
+ * below — which is the one shape whose confusion is invisible in the output.
74
+ *
75
+ * ## `unknown` is a value, and it is unordered
76
+ *
77
+ * `"unknown"` says the thing happened and the date is not recorded. It parses
78
+ * to the same record with the year half empty — `known: false`, and **a null
79
+ * `canonicalYear` and a null `sort`**.
80
+ *
81
+ * Both shortcuts past that are silently wrong. Normalising it to `0` sorts the
82
+ * ancient dead into the year immediately before the epoch; treating it as a
83
+ * null year and letting a comparison default it sorts them into the present,
84
+ * beside the living. So anything that orders or filters on a date leaves an
85
+ * unknown one out of the ordering and reports it as its own group, and a range
86
+ * filter never matches it, because the record does not claim a year and a
87
+ * filter must not claim one for it.
88
+ *
89
+ * ## Absence is not a finding, and neither is a bare value
90
+ *
91
+ * An absent value is a fact about the subject rather than a gap, so parsing
92
+ * nothing yields nothing and reports nothing. A bare value is the canonical
93
+ * axis, always, and it is silent: an unattributed date is the honest form for
94
+ * a date an author is placing rather than a record somebody in the setting
95
+ * kept.
96
+ *
97
+ * @module
98
+ */
99
+
100
+ import { ADDRESS_SEGMENT_PATTERN } from "./address-charset.mjs";
101
+ import { calendarStructure, canonicalYear, dateSortKey } from "./calendars.mjs";
102
+ import { positionOfFrontmatterPath } from "./diagnostics.mjs";
103
+
104
+ /**
105
+ * The literal that says a thing happened and the date is not recorded.
106
+ *
107
+ * @type {string}
108
+ */
109
+ export const UNKNOWN_DATE = "unknown";
110
+
111
+ /**
112
+ * One address segment, read out of the charset addresses are already held to
113
+ * rather than restated — a second copy is one more thing to drift.
114
+ */
115
+ const SEGMENT = ADDRESS_SEGMENT_PATTERN.source.replace(/^\^|\$$/g, "");
116
+
117
+ /** The era qualifier's body, without anchors, for composing into the grammar. */
118
+ const ERA = `(?:${SEGMENT}-){0,3}${SEGMENT}\\.${SEGMENT}`;
119
+
120
+ /**
121
+ * What a date's trailing token may look like.
122
+ *
123
+ * An affiliation's address followed by `.<era shortcode>`. The `{0,3}`
124
+ * repetition is the four address forms exactly — bare `shortcode`,
125
+ * `type-shortcode`, `system-type-shortcode` and
126
+ * `package-system-type-shortcode` — so the hyphen separates an address's
127
+ * segments, the dot separates the address from the era, and the split needs no
128
+ * lookahead.
129
+ *
130
+ * @type {RegExp}
131
+ */
132
+ export const ERA_QUALIFIER_PATTERN = new RegExp(`^${ERA}$`);
133
+
134
+ /**
135
+ * The grammar, as one expression.
136
+ *
137
+ * @type {RegExp}
138
+ */
139
+ export const NOTE_DATE_PATTERN = new RegExp(
140
+ `^(~)?\\s*(-?\\d+)(?:/(\\d+)(?:/(\\d+))?)?(?:\\s+(${ERA}))?$`,
141
+ );
142
+
143
+ /** A day written with no month — ungrammatical, and worth its own sentence. */
144
+ const DAY_WITHOUT_MONTH_PATTERN = /^(?:~)?\s*-?\d+\/\/\d+(?:\s+\S+)?$/;
145
+
146
+ /** A trailing token carrying a dot that the era grammar still refuses. */
147
+ const MALFORMED_ERA_PATTERN = /^(?:~)?\s*-?\d+(?:\/\d+(?:\/\d+)?)?\s+\S*\.\S*$/;
148
+
149
+ /** A trailing word with no dot, which names nothing now that an era is addressed. */
150
+ const DOTLESS_TOKEN_PATTERN = /^(?:~)?\s*(-?\d+)((?:\/\d+(?:\/\d+)?)?)\s+[A-Za-z][A-Za-z0-9]*$/;
151
+
152
+ /** The record an unknown date parses to — every field but `text` and `known` empty. */
153
+ function unknownRecord() {
154
+ return {
155
+ text: UNKNOWN_DATE,
156
+ known: false,
157
+ era: null,
158
+ year: null,
159
+ month: null,
160
+ day: null,
161
+ approximate: false,
162
+ precision: null,
163
+ // Never 0 and never a defaulted null year: see the module docs.
164
+ canonicalYear: null,
165
+ sort: null,
166
+ };
167
+ }
168
+
169
+ /**
170
+ * Parse one authored date.
171
+ *
172
+ * Every field that holds a date — a birth, a death, an event's start and its
173
+ * end — parses through here, so there is one grammar to write, test, document
174
+ * and teach.
175
+ *
176
+ * Findings are returned rather than thrown, because a corpus pass wants every
177
+ * bad value in one run rather than the first. Each carries the `file` it was
178
+ * given and, where `raw` and `keyPath` locate the value, its line and column;
179
+ * a position that cannot be established honestly is dropped rather than
180
+ * defaulted, which is what keeps `file:line:column:` parseable.
181
+ *
182
+ * @param {unknown} value - The authored value. A number is what YAML hands
183
+ * back for a bare year, so it is accepted and stringified.
184
+ * @param {object} [options]
185
+ * @param {{months?: number|null, monthDays?: number|null}} [options.calendar]
186
+ * The resolved `calendar:` block, whose `months` and `monthDays` bound a
187
+ * written month and day. An absent bound is not applied.
188
+ * @param {string} [options.field] - The key that carried it, named in every
189
+ * message. Omitted, a message names the value alone.
190
+ * @param {boolean} [options.allowUnknown=true] - Whether `"unknown"` is a
191
+ * permitted value here. It is not, in a field whose absence already says the
192
+ * date is unrecorded.
193
+ * @param {string} [options.file] - The note, for the diagnostics.
194
+ * @param {string} [options.raw] - The note's full contents, to locate the value.
195
+ * @param {ReadonlyArray<string|number>} [options.keyPath] - Path to the key
196
+ * within the frontmatter, for the same.
197
+ * @returns {{date: object|null, findings: object[]}} The record, or `null` when
198
+ * the value is absent or refused, and every finding the value earned.
199
+ */
200
+ export function parseNoteDate(value, options) {
201
+ const { calendar, field, allowUnknown = true, file, raw, keyPath } = options ?? {};
202
+ const findings = [];
203
+
204
+ // Absence is a fact about the subject, not a gap — nothing to parse and
205
+ // nothing to say about it.
206
+ if (value === null || value === undefined) return { date: null, findings };
207
+
208
+ const at = () => ({
209
+ ...(file === undefined ? {} : { file }),
210
+ ...(raw !== undefined && keyPath !== undefined ?
211
+ positionOfFrontmatterPath(raw, keyPath)
212
+ : {}),
213
+ });
214
+ const authored = typeof value === "number" || typeof value === "string";
215
+ // A YAML scalar reaches here as a string or, for a bare year, as a number.
216
+ // Anything else is not a date, and the message shows what it was rather
217
+ // than an empty pair of backticks.
218
+ const text = authored ? String(value) : "";
219
+ const shown = authored ? text : JSON.stringify(value);
220
+ const subject = field ? `\`${field}: ${shown}\`` : `\`${shown}\``;
221
+ const refuse = (message) => {
222
+ findings.push({ ...at(), severity: "error", message });
223
+ return { date: null, findings };
224
+ };
225
+
226
+ if (text.trim() === UNKNOWN_DATE) {
227
+ if (allowUnknown) return { date: unknownRecord(), findings };
228
+ return refuse(
229
+ `${subject} — \`${UNKNOWN_DATE}\` records that something happened and ` +
230
+ `the date is not known, which only a field whose absence means it ` +
231
+ `never happened has any use for. \`${field ?? "this field"}\` is not ` +
232
+ `one, so leave it out instead`,
233
+ );
234
+ }
235
+
236
+ const trimmed = text.trim();
237
+ const match = NOTE_DATE_PATTERN.exec(trimmed);
238
+ if (!match) {
239
+ if (DAY_WITHOUT_MONTH_PATTERN.test(trimmed)) {
240
+ return refuse(
241
+ `${subject} writes a day with no month — a date is written to the ` +
242
+ `year, to the month or to the day, and each level needs the one ` +
243
+ `above it`,
244
+ );
245
+ }
246
+ if (MALFORMED_ERA_PATTERN.test(trimmed)) {
247
+ return refuse(
248
+ `${subject} — an era is written ` +
249
+ `\`<affiliation shortcode>.<era shortcode>\`, lowercase letters ` +
250
+ `and digits only`,
251
+ );
252
+ }
253
+ if (DOTLESS_TOKEN_PATTERN.test(trimmed)) {
254
+ // The message an author who learned a retired spelling meets. No
255
+ // table says which direction that spelling counted, so it states
256
+ // the rule rather than guessing a concrete replacement.
257
+ return refuse(
258
+ `${subject} names no era — a reckoning is written ` +
259
+ `\`<affiliation shortcode>.<era shortcode>\`: a year before ` +
260
+ `an era's epoch is written negative, and a year after it ` +
261
+ `simply drops the token`,
262
+ );
263
+ }
264
+ return refuse(
265
+ `${subject} is not a date — write ` +
266
+ `\`[~][-]YYYY[/MM[/DD]] [<affiliation shortcode>.<era shortcode>]\`: ` +
267
+ `a year, negative where it falls before the epoch it is counted ` +
268
+ `from, optionally a month and a day, and an era where the date is a ` +
269
+ `reckoning somebody kept`,
270
+ );
271
+ }
272
+
273
+ const [, tilde, yearText, monthText, dayText, era] = match;
274
+ const approximate = tilde === "~";
275
+ const year = Number(yearText);
276
+ const month = monthText === undefined ? null : Number(monthText);
277
+ const day = dayText === undefined ? null : Number(dayText);
278
+
279
+ // No year zero, in any reckoning: the years either side of an epoch are -1
280
+ // and 1. `-0` is the same year written the other way and is refused with it.
281
+ if (year === 0) {
282
+ return refuse(
283
+ `${subject} writes year 0, and no reckoning has one — the years either ` +
284
+ `side of an epoch are -1 and 1`,
285
+ );
286
+ }
287
+
288
+ const { months, monthDays } = calendarStructure(calendar);
289
+ if (month !== null && month < 1) {
290
+ return refuse(`${subject} writes month ${month}, and a month is numbered from 1`);
291
+ }
292
+ // An upper bound is checked only where the package states one. Twelve
293
+ // Gregorian months assumed here would refuse `667/2/30`, which is a correct
294
+ // date in a year of twelve thirty-day months.
295
+ if (month !== null && months !== null && month > months) {
296
+ return refuse(
297
+ `${subject} writes month ${month}, and \`calendar.months\` declares a ` +
298
+ `year of ${months}`,
299
+ );
300
+ }
301
+ if (day !== null && day < 1) {
302
+ return refuse(`${subject} writes day ${day}, and a day is numbered from 1`);
303
+ }
304
+ if (day !== null && monthDays !== null && day > monthDays) {
305
+ return refuse(
306
+ `${subject} writes day ${day}, and \`calendar.monthDays\` declares a ` +
307
+ `month of ${monthDays}`,
308
+ );
309
+ }
310
+
311
+ const precision =
312
+ day !== null ? "day"
313
+ : month !== null ? "month"
314
+ : "year";
315
+ if (approximate && precision === "day") {
316
+ findings.push({
317
+ ...at(),
318
+ severity: "warning",
319
+ message:
320
+ `${subject} marks a day approximate, which is nearly always a slip ` +
321
+ `— drop the \`~\`, or write the month or the year the date is ` +
322
+ `approximate to`,
323
+ });
324
+ }
325
+
326
+ const date = {
327
+ text,
328
+ known: true,
329
+ era: era ?? null,
330
+ year,
331
+ month,
332
+ day,
333
+ approximate,
334
+ precision,
335
+ };
336
+ // A named era's epoch is a fact the corpus states, so the numbers derived
337
+ // from it are written by the pass that resolves it and are absent until
338
+ // then. A bare value is already on the axis.
339
+ if (era === undefined) {
340
+ date.canonicalYear = canonicalYear(year);
341
+ date.sort = dateSortKey(date.canonicalYear, month, day);
342
+ }
343
+ return { date, findings };
344
+ }
@@ -586,8 +586,32 @@ export const NOTE_VOCABULARY = Object.freeze({
586
586
  },
587
587
  { name: "gender", ...TEXT, describe: "`male`, `female` or `other`." },
588
588
  { name: "species", ...LINK, describe: "The being's species, as a lore note." },
589
- { name: "age", ...NUM, describe: "Age in years." },
590
- { name: "birthday", ...TEXT, describe: "Date of birth, `YYYY/MM/DD`." },
589
+ {
590
+ name: "born",
591
+ ...TEXT,
592
+ describe:
593
+ "When the being was born — a date, or `unknown` where the birth is " +
594
+ "unrecorded. Absent, the being was never born.",
595
+ },
596
+ {
597
+ name: "died",
598
+ ...TEXT,
599
+ describe:
600
+ "When the being died — a date, or `unknown` where the death is " +
601
+ "unrecorded. Absent, the being is alive.",
602
+ },
603
+ // Ahead of `height` and behind the dates, because it opens the
604
+ // appearance clause: a composed row stands where its group's first
605
+ // field is declared, so the two dates read before the clause
606
+ // whether or not a note states an age.
607
+ {
608
+ name: "age",
609
+ ...TEXT,
610
+ describe:
611
+ "Age in years, stated only to override what `born` says — `34`, or `~34` " +
612
+ "for an estimate. Unstated beside a dated `born` it is computed; unstated " +
613
+ "beside an unknown or absent `born` the age is unknown.",
614
+ },
591
615
  { name: "height", ...NUM, describe: "Height in metres." },
592
616
  { name: "weight", ...NUM, describe: "Weight in kilograms." },
593
617
  {
@@ -1089,6 +1089,16 @@ export function renderBook({
1089
1089
  `#set text(font: "${escapeTypstString(serif)}", size: 9.6pt, fill: book-ink, lang: "en")`,
1090
1090
  );
1091
1091
  out.push("#set par(justify: true, leading: 0.55em, first-line-indent: 1.2em)");
1092
+ // The first-line indent is what separates one paragraph of running prose
1093
+ // from the next, and inside a list item the marker already does that. An
1094
+ // item carrying more than one paragraph sets every one of them flush at the
1095
+ // item's own text column, so the item reads as a single block hanging off
1096
+ // its marker. Neither the marker column nor the gap after it is set here:
1097
+ // Typst measures the widest marker in the list and aligns every body
1098
+ // against it, which is what keeps a list of twelve aligned once `10.`
1099
+ // becomes wider than `1.`.
1100
+ out.push("#show list: set par(first-line-indent: 0em)");
1101
+ out.push("#show enum: set par(first-line-indent: 0em)");
1092
1102
  // The mono face is a separate claim from the book face: a fenced block is
1093
1103
  // the one place the corpus is allowed box-drawing characters, and the
1094
1104
  // serif that sets the prose is not the font that carries them.
@@ -57,7 +57,7 @@
57
57
  * by accident.
58
58
  *
59
59
  * **What `traits:` did.** It held a being's own description — gender,
60
- * species, age, birthday, height, weight, frame and `appearance.*` — at the
60
+ * species, age, birth date, height, weight, frame and `appearance.*` — at the
61
61
  * note's top level. The content format gives those a home: `data:`, the closed
62
62
  * container for a subject's type-specific facts, which `being` declares every
63
63
  * one of them in. Every note that carried one has been moved, across four
@@ -111,13 +111,21 @@
111
111
  * are needed, and separately: a note may have moved one and not the other, and
112
112
  * a single finding covering both would name the wrong line half the time.
113
113
  *
114
+ * **A `data:` field's retired spelling is written in `data:`**, and nowhere
115
+ * else. The container is closed, so a key in it is either one the type declares
116
+ * or one refused by name — which makes the retired spelling of a declared key
117
+ * an error the moment the replacement lands, unless the container check knows
118
+ * to read it as a rename. {@link declaresRetiredAlias} and
119
+ * {@link readAliasedField} therefore take `inData`, and search that one region
120
+ * rather than the block and the top level a system field is written in.
121
+ *
114
122
  * @module
115
123
  */
116
124
 
117
125
  import fs from "node:fs";
118
126
 
119
127
  import { positionInFrontmatter } from "./diagnostics.mjs";
120
- import { sohlField } from "./frontmatter.mjs";
128
+ import { getFrontmatter, sohlField } from "./frontmatter.mjs";
121
129
  import { retiredTopLevelKey } from "./system-block.mjs";
122
130
 
123
131
  /**
@@ -355,8 +363,9 @@ export function traitsRetiredMessage(file) {
355
363
  "content format declares, where `being` declares every one of its " +
356
364
  "keys; at the top level it was passed through to the page unchecked, " +
357
365
  "so a misspelling became a theme parameter rather than a finding. " +
358
- "`gender`, `species`, `age`, `birthday` and `appearance.*` move " +
359
- "unchanged; three reshape — `traits.height.m` becomes `data.height` " +
366
+ "`gender`, `species`, `age` and `appearance.*` move unchanged; " +
367
+ "`traits.birthday` becomes `data.born`; three reshape — " +
368
+ "`traits.height.m` becomes `data.height` " +
360
369
  "(metres), `traits.weight.kg` becomes `data.weight` (kilograms), and " +
361
370
  "`traits.build.frame` becomes `data.frame`"
362
371
  );
@@ -451,11 +460,19 @@ export function locateFrontmatterKey(absPath, key, value = undefined, { topLevel
451
460
  * read past. Only `affiliation` declares the field, so the alias is reported
452
461
  * there and the old spelling stays an ordinary unknown key everywhere else.
453
462
  *
463
+ * **`born`.** A birthday is a day that recurs; what a note records is the date
464
+ * somebody was born, once. The two read alike while every value belonged to a
465
+ * living person, and `born` is what the field has to be called beside `died`,
466
+ * which a being now also states. It is the first alias whose field lives in
467
+ * `data:` rather than in a system block, so the two readers below take
468
+ * `inData`.
469
+ *
454
470
  * @type {Readonly<Record<string, string>>}
455
471
  */
456
472
  export const RETIRED_FIELD_ALIASES = Object.freeze({
457
473
  templatePriority: "archetype",
458
474
  relations: "relation",
475
+ born: "birthday",
459
476
  });
460
477
 
461
478
  /**
@@ -558,11 +575,17 @@ export function retiredTopLevelMessage(field, file) {
558
575
  *
559
576
  * @param {object|null|undefined} fm - Parsed frontmatter.
560
577
  * @param {string} current - The field's current name.
578
+ * @param {object} [options] - Options.
579
+ * @param {boolean} [options.inData=false] - Search the `data:` container
580
+ * instead. A `data:` field has exactly one home, so its retired spelling has
581
+ * exactly one too, and searching the block or the top level for it would
582
+ * answer about a key the container check has already refused.
561
583
  * @returns {boolean} Whether the retired spelling is declared.
562
584
  */
563
- export function declaresRetiredAlias(fm, current) {
585
+ export function declaresRetiredAlias(fm, current, { inData = false } = {}) {
564
586
  const retired = RETIRED_FIELD_ALIASES[current];
565
587
  if (!retired || !fm || typeof fm !== "object") return false;
588
+ if (inData) return Object.hasOwn(dataContainer(fm), retired);
566
589
  const block = fm.sohl;
567
590
  const inBlock =
568
591
  block &&
@@ -572,6 +595,21 @@ export function declaresRetiredAlias(fm, current) {
572
595
  return Boolean(inBlock) || Object.hasOwn(fm, retired);
573
596
  }
574
597
 
598
+ /**
599
+ * A note's `data:` container, or an empty one where it authored none.
600
+ *
601
+ * The property editor writes an emptied map as `[]`, which means the same thing
602
+ * `{}` does — this note authors no entries — so both read as empty rather than
603
+ * as something to search.
604
+ *
605
+ * @param {object} fm - Parsed frontmatter.
606
+ * @returns {object} The container.
607
+ */
608
+ function dataContainer(fm) {
609
+ const data = fm.data;
610
+ return data && typeof data === "object" && !Array.isArray(data) ? data : {};
611
+ }
612
+
575
613
  /**
576
614
  * Read a field that has a retired spelling, the current name winning.
577
615
  *
@@ -587,11 +625,23 @@ export function declaresRetiredAlias(fm, current) {
587
625
  *
588
626
  * @param {object|null|undefined} fm - Parsed frontmatter.
589
627
  * @param {string} current - The field's current name.
628
+ * @param {object} [options] - Options.
629
+ * @param {boolean} [options.inData=false] - Read the `data:` container instead,
630
+ * which is where a `data:` field and its retired spelling are both written.
631
+ * Dotted names resolve inside it, so `appearance.eye_color` reads the way it
632
+ * is authored.
590
633
  * @returns {any} The value, or `undefined` when neither spelling carries one.
591
634
  */
592
- export function readAliasedField(fm, current) {
593
- const value = sohlField(fm, current, undefined);
635
+ export function readAliasedField(fm, current, { inData = false } = {}) {
636
+ const read =
637
+ inData ?
638
+ /** @param {string} key */ (key) =>
639
+ fm && typeof fm === "object" ?
640
+ getFrontmatter(dataContainer(fm), key, undefined)
641
+ : undefined
642
+ : /** @param {string} key */ (key) => sohlField(fm, key, undefined);
643
+ const value = read(current);
594
644
  if (value !== undefined && value !== null && value !== "") return value;
595
645
  const retired = RETIRED_FIELD_ALIASES[current];
596
- return retired ? sohlField(fm, retired, undefined) : undefined;
646
+ return retired ? read(retired) : undefined;
597
647
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heroiclands/package-build",
3
- "version": "22.7.0",
3
+ "version": "22.8.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",
@@ -844,6 +844,16 @@ export type ContentBuildConfig = {
844
844
  * each pack followed by its companions.
845
845
  */
846
846
  packDirectories: readonly string[];
847
+ /**
848
+ * How this package's year is divided, and
849
+ * the date it calls the present. Every
850
+ * value `null` when none is declared.
851
+ */
852
+ calendar: Readonly<{
853
+ months: number | null;
854
+ monthDays: number | null;
855
+ present: string | null;
856
+ }>;
847
857
  /**
848
858
  * Passed through
849
859
  * frozen, uninterpreted. `{}` when absent.