@heroiclands/package-build 22.7.0 → 22.9.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,350 @@
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` — a calendar's address plus `.<era shortcode>`, in the four address
31
+ * forms {@link ERA_QUALIFIER_PATTERN} admits. Omitted, the date sits on the
32
+ * 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, daysInMonth } 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
+ * A calendar note'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?: readonly object[]|null}} [options.calendar] - The calendar
186
+ * the value is written in, as its note declares it. Its ordered month list
187
+ * bounds a written month and a written day, the day against the length of
188
+ * the month it names. A calendar that declares no list bounds nothing.
189
+ * @param {string} [options.field] - The key that carried it, named in every
190
+ * message. Omitted, a message names the value alone.
191
+ * @param {boolean} [options.allowUnknown=true] - Whether `"unknown"` is a
192
+ * permitted value here. It is not, in a field whose absence already says the
193
+ * date is unrecorded.
194
+ * @param {string} [options.file] - The note, for the diagnostics.
195
+ * @param {string} [options.raw] - The note's full contents, to locate the value.
196
+ * @param {ReadonlyArray<string|number>} [options.keyPath] - Path to the key
197
+ * within the frontmatter, for the same.
198
+ * @returns {{date: object|null, findings: object[]}} The record, or `null` when
199
+ * the value is absent or refused, and every finding the value earned.
200
+ */
201
+ export function parseNoteDate(value, options) {
202
+ const { calendar, field, allowUnknown = true, file, raw, keyPath } = options ?? {};
203
+ const findings = [];
204
+
205
+ // Absence is a fact about the subject, not a gap — nothing to parse and
206
+ // nothing to say about it.
207
+ if (value === null || value === undefined) return { date: null, findings };
208
+
209
+ const at = () => ({
210
+ ...(file === undefined ? {} : { file }),
211
+ ...(raw !== undefined && keyPath !== undefined ?
212
+ positionOfFrontmatterPath(raw, keyPath)
213
+ : {}),
214
+ });
215
+ const authored = typeof value === "number" || typeof value === "string";
216
+ // A YAML scalar reaches here as a string or, for a bare year, as a number.
217
+ // Anything else is not a date, and the message shows what it was rather
218
+ // than an empty pair of backticks.
219
+ const text = authored ? String(value) : "";
220
+ const shown = authored ? text : JSON.stringify(value);
221
+ const subject = field ? `\`${field}: ${shown}\`` : `\`${shown}\``;
222
+ const refuse = (message) => {
223
+ findings.push({ ...at(), severity: "error", message });
224
+ return { date: null, findings };
225
+ };
226
+
227
+ if (text.trim() === UNKNOWN_DATE) {
228
+ if (allowUnknown) return { date: unknownRecord(), findings };
229
+ return refuse(
230
+ `${subject} — \`${UNKNOWN_DATE}\` records that something happened and ` +
231
+ `the date is not known, which only a field whose absence means it ` +
232
+ `never happened has any use for. \`${field ?? "this field"}\` is not ` +
233
+ `one, so leave it out instead`,
234
+ );
235
+ }
236
+
237
+ const trimmed = text.trim();
238
+ const match = NOTE_DATE_PATTERN.exec(trimmed);
239
+ if (!match) {
240
+ if (DAY_WITHOUT_MONTH_PATTERN.test(trimmed)) {
241
+ return refuse(
242
+ `${subject} writes a day with no month — a date is written to the ` +
243
+ `year, to the month or to the day, and each level needs the one ` +
244
+ `above it`,
245
+ );
246
+ }
247
+ if (MALFORMED_ERA_PATTERN.test(trimmed)) {
248
+ return refuse(
249
+ `${subject} — an era is written ` +
250
+ `\`<calendar shortcode>.<era shortcode>\`, lowercase letters ` +
251
+ `and digits only`,
252
+ );
253
+ }
254
+ if (DOTLESS_TOKEN_PATTERN.test(trimmed)) {
255
+ // The message an author who learned a retired spelling meets. No
256
+ // table says which direction that spelling counted, so it states
257
+ // the rule rather than guessing a concrete replacement.
258
+ return refuse(
259
+ `${subject} names no era — a reckoning is written ` +
260
+ `\`<calendar shortcode>.<era shortcode>\`: a year before ` +
261
+ `an era's epoch is written negative, and a year after it ` +
262
+ `simply drops the token`,
263
+ );
264
+ }
265
+ return refuse(
266
+ `${subject} is not a date — write ` +
267
+ `\`[~][-]YYYY[/MM[/DD]] [<calendar shortcode>.<era shortcode>]\`: ` +
268
+ `a year, negative where it falls before the epoch it is counted ` +
269
+ `from, optionally a month and a day, and an era where the date is a ` +
270
+ `reckoning somebody kept`,
271
+ );
272
+ }
273
+
274
+ const [, tilde, yearText, monthText, dayText, era] = match;
275
+ const approximate = tilde === "~";
276
+ const year = Number(yearText);
277
+ const month = monthText === undefined ? null : Number(monthText);
278
+ const day = dayText === undefined ? null : Number(dayText);
279
+
280
+ // No year zero, in any reckoning: the years either side of an epoch are -1
281
+ // and 1. `-0` is the same year written the other way and is refused with it.
282
+ if (year === 0) {
283
+ return refuse(
284
+ `${subject} writes year 0, and no reckoning has one — the years either ` +
285
+ `side of an epoch are -1 and 1`,
286
+ );
287
+ }
288
+
289
+ const { months } = calendarStructure(calendar);
290
+ if (month !== null && month < 1) {
291
+ return refuse(`${subject} writes month ${month}, and a month is numbered from 1`);
292
+ }
293
+ // An upper bound is checked only where the calendar states its months.
294
+ // Twelve Gregorian months assumed here would refuse `667/2/30`, which is a
295
+ // correct date in a year of twelve thirty-day months.
296
+ if (month !== null && months !== null && month > months.length) {
297
+ return refuse(
298
+ `${subject} writes month ${month}, and the calendar it is written in ` +
299
+ `keeps ${months.length} months`,
300
+ );
301
+ }
302
+ if (day !== null && day < 1) {
303
+ return refuse(`${subject} writes day ${day}, and a day is numbered from 1`);
304
+ }
305
+ // Per month, because the months differ: a five-day month sits in the list
306
+ // wherever its people put it, and a bound taken from the longest month
307
+ // would accept a date that calendar has no day for.
308
+ const length = month !== null ? daysInMonth(calendar, month) : null;
309
+ if (day !== null && length !== null && day > length) {
310
+ const named = months?.[month - 1]?.name;
311
+ return refuse(
312
+ `${subject} writes day ${day}, and ${named ? `${named}` : `month ${month}`} ` +
313
+ `is ${length} days long`,
314
+ );
315
+ }
316
+
317
+ const precision =
318
+ day !== null ? "day"
319
+ : month !== null ? "month"
320
+ : "year";
321
+ if (approximate && precision === "day") {
322
+ findings.push({
323
+ ...at(),
324
+ severity: "warning",
325
+ message:
326
+ `${subject} marks a day approximate, which is nearly always a slip ` +
327
+ `— drop the \`~\`, or write the month or the year the date is ` +
328
+ `approximate to`,
329
+ });
330
+ }
331
+
332
+ const date = {
333
+ text,
334
+ known: true,
335
+ era: era ?? null,
336
+ year,
337
+ month,
338
+ day,
339
+ approximate,
340
+ precision,
341
+ };
342
+ // A named era's epoch is a fact the corpus states, so the numbers derived
343
+ // from it are written by the pass that resolves it and are absent until
344
+ // then. A bare value is already on the axis.
345
+ if (era === undefined) {
346
+ date.canonicalYear = canonicalYear(year);
347
+ date.sort = dateSortKey(date.canonicalYear, month, day);
348
+ }
349
+ return { date, findings };
350
+ }
@@ -85,6 +85,12 @@ import { currentType } from "./ids.mjs";
85
85
  // What a place is next to and reachable from — the two relation lists and
86
86
  // the checks that hold them to their closed sets and to each other.
87
87
  import { checkBorders, checkRoutes } from "./place-relations.mjs";
88
+ import {
89
+ CALENDAR_FIELDS,
90
+ INVARIANT_FIELDS,
91
+ checkCalendarNote,
92
+ checkWorldFacts,
93
+ } from "./calendar-notes.mjs";
88
94
  import { checkHeld } from "./holdings.mjs";
89
95
  import { checkCitedPopulations, checkPopulation } from "./populations.mjs";
90
96
  // What trade a settlement supports — the scale and the check that holds a
@@ -306,6 +312,22 @@ const CHARGES = Object.freeze([
306
312
  { name: "charges.max", ...NUM, describe: "Most charges it can hold; unset means no maximum." },
307
313
  ]);
308
314
 
315
+ /**
316
+ * Everything `place` asks of a whole note.
317
+ *
318
+ * Two questions about one subject, and they are independent: who holds this
319
+ * land, and — where the note is a body rather than somewhere within one — what
320
+ * the body states about itself. A type declares one whole-note check, so the
321
+ * two are composed here rather than either being folded into the other.
322
+ *
323
+ * @param {object} note - The note.
324
+ * @param {object} [opts] - The lint's options, passed through unchanged.
325
+ * @returns {object[]} Findings from both.
326
+ */
327
+ function checkPlace(note, opts) {
328
+ return [...checkHeld(note, opts), ...checkWorldFacts(note, opts)];
329
+ }
330
+
309
331
  /* --------------------------------------------------------------------- */
310
332
  /* The vocabulary */
311
333
  /* --------------------------------------------------------------------- */
@@ -586,8 +608,32 @@ export const NOTE_VOCABULARY = Object.freeze({
586
608
  },
587
609
  { name: "gender", ...TEXT, describe: "`male`, `female` or `other`." },
588
610
  { 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`." },
611
+ {
612
+ name: "born",
613
+ ...TEXT,
614
+ describe:
615
+ "When the being was born — a date, or `unknown` where the birth is " +
616
+ "unrecorded. Absent, the being was never born.",
617
+ },
618
+ {
619
+ name: "died",
620
+ ...TEXT,
621
+ describe:
622
+ "When the being died — a date, or `unknown` where the death is " +
623
+ "unrecorded. Absent, the being is alive.",
624
+ },
625
+ // Ahead of `height` and behind the dates, because it opens the
626
+ // appearance clause: a composed row stands where its group's first
627
+ // field is declared, so the two dates read before the clause
628
+ // whether or not a note states an age.
629
+ {
630
+ name: "age",
631
+ ...TEXT,
632
+ describe:
633
+ "Age in years, stated only to override what `born` says — `34`, or `~34` " +
634
+ "for an estimate. Unstated beside a dated `born` it is computed; unstated " +
635
+ "beside an unknown or absent `born` the age is unknown.",
636
+ },
591
637
  { name: "height", ...NUM, describe: "Height in metres." },
592
638
  { name: "weight", ...NUM, describe: "Weight in kilograms." },
593
639
  {
@@ -1020,16 +1066,27 @@ export const NOTE_VOCABULARY = Object.freeze({
1020
1066
  "bestiary",
1021
1067
  "gathering",
1022
1068
  ]),
1023
- // Nothing of its own: a lore note is prose, and what it *is* about is
1024
- // its subType. The specification declares an empty table for it, and
1025
- // the authored corpus writes no `data:` key on any of the 180.
1026
- data: Object.freeze([]),
1069
+ // A lore note is prose, and what it *is* about is its subType — with
1070
+ // one exception. A calendar is a division of the year, and a division
1071
+ // is data; the check scopes the family to the subType that means it,
1072
+ // because `DataFieldSpec` declares the keys a type accepts and not the
1073
+ // subType that may write them.
1074
+ check: checkCalendarNote,
1075
+ data: CALENDAR_FIELDS,
1027
1076
  }),
1028
1077
 
1029
1078
  place: Object.freeze({
1030
1079
  stubbable: true,
1031
- subTypes: Object.freeze(["world", "region", "settlement", "site", "structure", "feature"]),
1032
- check: checkHeld,
1080
+ subTypes: Object.freeze([
1081
+ "world",
1082
+ "region",
1083
+ "settlement",
1084
+ "site",
1085
+ "structure",
1086
+ "feature",
1087
+ "celestial",
1088
+ ]),
1089
+ check: checkPlace,
1033
1090
  data: Object.freeze([
1034
1091
  {
1035
1092
  name: "demonym",
@@ -1080,6 +1137,10 @@ export const NOTE_VOCABULARY = Object.freeze({
1080
1137
  "Journeys from this place's centre — where the destination lies, how " +
1081
1138
  "it is travelled, and about how many days it takes.",
1082
1139
  },
1140
+ // What a body states about itself: its size, its year, its moon.
1141
+ // Legal on a `world` or a `celestial` and nowhere else, which the
1142
+ // type's own check is what enforces.
1143
+ ...INVARIANT_FIELDS,
1083
1144
  ]),
1084
1145
  }),
1085
1146
 
@@ -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.9.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",