@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.
- package/CHANGELOG.md +33 -0
- package/content-config.mjs +78 -0
- package/docs/api.md +22 -0
- package/docs/configuration.md +57 -3
- package/docs/content-format.md +28 -2
- package/engine/calendars.mjs +132 -0
- package/engine/content-format-check.mjs +10 -1
- package/engine/draft-notice.mjs +45 -13
- package/engine/frontmatter-lint.mjs +26 -0
- package/engine/index.mjs +6 -0
- package/engine/infobox.mjs +7 -2
- package/engine/note-dates.mjs +344 -0
- package/engine/note-vocabulary.mjs +26 -2
- package/engine/pdf-render.mjs +10 -0
- package/engine/retired-fields.mjs +58 -8
- package/package.json +1 -1
- package/types/content-config.d.mts +10 -0
- package/types/engine/calendars.d.mts +115 -0
- package/types/engine/draft-notice.d.mts +26 -4
- package/types/engine/index.d.mts +2 -0
- package/types/engine/note-dates.d.mts +70 -0
- package/types/engine/retired-fields.d.mts +23 -2
package/engine/infobox.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
{
|
|
590
|
-
|
|
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
|
{
|
package/engine/pdf-render.mjs
CHANGED
|
@@ -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,
|
|
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
|
|
359
|
-
"
|
|
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
|
|
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 ?
|
|
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.
|
|
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.
|