@heroiclands/package-build 22.8.0 → 22.9.1
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 +26 -0
- package/content-config.mjs +0 -78
- package/docs/api.md +45 -9
- package/docs/configuration.md +1 -55
- package/docs/content-format.md +150 -14
- package/engine/base-compiler.mjs +9 -0
- package/engine/being-age.mjs +270 -0
- package/engine/calendar-notes.mjs +805 -0
- package/engine/calendars.mjs +115 -14
- package/engine/content-index.mjs +15 -1
- package/engine/index.mjs +6 -0
- package/engine/infobox.mjs +6 -0
- package/engine/note-dates.mjs +27 -21
- package/engine/note-vocabulary.mjs +57 -6
- package/engine/site-build.mjs +16 -0
- package/package.json +1 -1
- package/types/content-config.d.mts +0 -10
- package/types/engine/base-compiler.d.mts +1 -0
- package/types/engine/being-age.d.mts +92 -0
- package/types/engine/calendar-notes.d.mts +173 -0
- package/types/engine/calendars.d.mts +108 -17
- package/types/engine/index.d.mts +2 -0
- package/types/engine/note-dates.d.mts +6 -6
|
@@ -0,0 +1,270 @@
|
|
|
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
|
+
* **The middle state of `age`: absent beside a dated `born`, it follows from
|
|
16
|
+
* the date.**
|
|
17
|
+
*
|
|
18
|
+
* `age` is a three-state override — authored it wins, absent beside a dated
|
|
19
|
+
* `born` it is computed, absent beside `born: unknown` or an absent `born` it
|
|
20
|
+
* stays unknown. This module builds the middle state, which nothing else in
|
|
21
|
+
* the toolchain does: `born` and `age` are read straight through to every
|
|
22
|
+
* compiled surface exactly as authored, unless a note's own package states the
|
|
23
|
+
* day the setting stops.
|
|
24
|
+
*
|
|
25
|
+
* ## Where the present comes from
|
|
26
|
+
*
|
|
27
|
+
* A world's `present` is a fact about the world, not about a calendar or a
|
|
28
|
+
* build configuration — it is `data.present` on the package's own `place` note
|
|
29
|
+
* of `subType: world` (or `celestial`), read through
|
|
30
|
+
* {@link module:engine/calendar-notes.worldInvariants}. A package with no such
|
|
31
|
+
* note states no present, computes no age, and says nothing about it: the
|
|
32
|
+
* absence is silent, exactly as a package that declares no calendar is asked
|
|
33
|
+
* no calendar questions.
|
|
34
|
+
*
|
|
35
|
+
* ## The arithmetic is on two dates, not on two years
|
|
36
|
+
*
|
|
37
|
+
* A year subtracted from a year is wrong whenever the birthday has not yet
|
|
38
|
+
* happened in the present's year — the common shape, not the exception. So
|
|
39
|
+
* {@link computeAge} compares `born`'s month and day against `present`'s,
|
|
40
|
+
* **ordinally**, and drops a year where born's has not yet arrived. That
|
|
41
|
+
* ordinal comparison needs no month-length table: two dates written in one
|
|
42
|
+
* calendar order the same way whether the comparison counts days-of-year
|
|
43
|
+
* through the calendar's month lengths or simply reads `(month, day)` as a
|
|
44
|
+
* pair, because a calendar's months keep the same lengths every year — this
|
|
45
|
+
* package's calendars carry no leap rule (`calendar-notes.mjs`'s
|
|
46
|
+
* `years.leapYear` is always `null`). So the comparison is calendar-agnostic
|
|
47
|
+
* by construction, and correct for a twelve-month calendar of thirty-day
|
|
48
|
+
* months, a ten-month calendar, or none at all — a bare `born` with no era
|
|
49
|
+
* names no calendar to consult in the first place.
|
|
50
|
+
*
|
|
51
|
+
* ## `~` follows the convention {@link module:engine/note-dates} sets
|
|
52
|
+
*
|
|
53
|
+
* A date's `~` marks the value approximate without changing what it parses to,
|
|
54
|
+
* so a renderer keeps the authored text and a comparison still has a number.
|
|
55
|
+
* {@link parseAgeMagnitude} draws the same distinction for `age` — the tilde,
|
|
56
|
+
* the magnitude — without routing through {@link module:engine/note-dates}
|
|
57
|
+
* itself: `age` is a plain count of years, not a date, so it has no month, no
|
|
58
|
+
* day, no era, and — unlike a date — no refusal of `0`. Reusing the date
|
|
59
|
+
* grammar's month/day/era machinery here would accept an age nothing about the
|
|
60
|
+
* field means, and refuse the one age an infant can have.
|
|
61
|
+
*
|
|
62
|
+
* @module
|
|
63
|
+
*/
|
|
64
|
+
|
|
65
|
+
import { worldInvariants } from "./calendar-notes.mjs";
|
|
66
|
+
import { positionOfFrontmatterPath } from "./diagnostics.mjs";
|
|
67
|
+
import { parseNoteDate } from "./note-dates.mjs";
|
|
68
|
+
// `born`'s retired spelling is still read during its retirement window — see
|
|
69
|
+
// `docs/content-format.md`'s "`birthday:` is the retired spelling of `born:`"
|
|
70
|
+
// — so a being on the old spelling still computes an age rather than reading
|
|
71
|
+
// as never born.
|
|
72
|
+
import { readAliasedField } from "./retired-fields.mjs";
|
|
73
|
+
|
|
74
|
+
/** The note type this module's derivation and check apply to. */
|
|
75
|
+
const BEING_TYPE = "being";
|
|
76
|
+
|
|
77
|
+
/** Whether a note's type — read either directly or through its frontmatter — is `being`. */
|
|
78
|
+
function isBeingType(type) {
|
|
79
|
+
return String(type ?? "").toLowerCase() === BEING_TYPE;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The `data:` map of a note's frontmatter, or `undefined` where there is none to read. */
|
|
83
|
+
function dataOf(fm) {
|
|
84
|
+
const data = fm?.data;
|
|
85
|
+
return data && typeof data === "object" && !Array.isArray(data) ? data : undefined;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* `~34`'s tilde and its magnitude — the same distinction a date's `~` draws,
|
|
90
|
+
* asked of a plain count of years rather than of a calendar date.
|
|
91
|
+
*
|
|
92
|
+
* A whole, non-negative number only: an age is never fractional and never
|
|
93
|
+
* before an epoch, so neither a date's sign nor its month/day/era grammar
|
|
94
|
+
* belongs here.
|
|
95
|
+
*
|
|
96
|
+
* @type {RegExp}
|
|
97
|
+
*/
|
|
98
|
+
const AGE_MAGNITUDE_PATTERN = /^(~)?\s*(\d+)\s*$/;
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Parse an authored `age` into its estimate mark and its magnitude.
|
|
102
|
+
*
|
|
103
|
+
* @param {unknown} raw - The authored value — `34`, `"34"` or `"~34"`.
|
|
104
|
+
* @returns {{approximate: boolean, value: number}|null} The parts, or `null`
|
|
105
|
+
* for a value that does not read as a plain count of years.
|
|
106
|
+
*/
|
|
107
|
+
export function parseAgeMagnitude(raw) {
|
|
108
|
+
if (typeof raw !== "number" && typeof raw !== "string") return null;
|
|
109
|
+
const match = AGE_MAGNITUDE_PATTERN.exec(String(raw).trim());
|
|
110
|
+
if (!match) return null;
|
|
111
|
+
const [, tilde, digits] = match;
|
|
112
|
+
return { approximate: tilde === "~", value: Number(digits) };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The age two dates imply, in whole years, computed from the dates themselves
|
|
117
|
+
* rather than from the years alone.
|
|
118
|
+
*
|
|
119
|
+
* @param {unknown} bornRaw - The being's authored `born`, or `undefined`/`null`
|
|
120
|
+
* where it wrote none.
|
|
121
|
+
* @param {unknown} presentRaw - The package's `data.present`, as
|
|
122
|
+
* {@link module:engine/calendar-notes.worldInvariants} read it, or
|
|
123
|
+
* `undefined`/`null` where the package states none.
|
|
124
|
+
* @returns {number|null} The age, or `null` where `born` is `unknown` or
|
|
125
|
+
* absent, where no present is declared, or where either date fails to parse.
|
|
126
|
+
*/
|
|
127
|
+
export function computeAge(bornRaw, presentRaw) {
|
|
128
|
+
if (bornRaw === undefined || bornRaw === null) return null;
|
|
129
|
+
if (presentRaw === undefined || presentRaw === null) return null;
|
|
130
|
+
|
|
131
|
+
const born = parseNoteDate(bornRaw, { allowUnknown: true }).date;
|
|
132
|
+
if (!born || !born.known || born.canonicalYear == null) return null;
|
|
133
|
+
|
|
134
|
+
const present = parseNoteDate(presentRaw, { allowUnknown: false }).date;
|
|
135
|
+
if (!present || !present.known || present.canonicalYear == null) return null;
|
|
136
|
+
|
|
137
|
+
// Ordinal position within the year, day of year or not: see the module
|
|
138
|
+
// doc for why `(month, day)` alone orders correctly here. Missing month or
|
|
139
|
+
// day reads as the year's first day — the earliest a birthday could fall —
|
|
140
|
+
// so a `born` written to less precision than `present` never counts as
|
|
141
|
+
// "not yet happened".
|
|
142
|
+
const bornOrdinal = (born.month ?? 1) * 100 + (born.day ?? 1);
|
|
143
|
+
const presentOrdinal = (present.month ?? 1) * 100 + (present.day ?? 1);
|
|
144
|
+
const notYetHappened = bornOrdinal > presentOrdinal;
|
|
145
|
+
|
|
146
|
+
return present.canonicalYear - born.canonicalYear - (notYetHappened ? 1 : 0);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Fill in a being's computed `age`, and the numeric companion an estimate
|
|
151
|
+
* carries beside it — mutating the frontmatter object handed in, never a file
|
|
152
|
+
* on disk.
|
|
153
|
+
*
|
|
154
|
+
* Every caller holds a frontmatter object that was parsed fresh from a note's
|
|
155
|
+
* text for its own pass — the content index, a compiled document, a rendered
|
|
156
|
+
* page — and none of those objects round-trips back to the `.md` file. So
|
|
157
|
+
* patching `data.age` in place here is exactly as safe as reading it, and
|
|
158
|
+
* reaches every surface that reads `data:` after this runs, with the note
|
|
159
|
+
* itself untouched.
|
|
160
|
+
*
|
|
161
|
+
* @param {object} fm - A note's frontmatter, as freshly parsed.
|
|
162
|
+
* @param {unknown} presentRaw - The package's declared present, or
|
|
163
|
+
* `undefined`/`null` where it states none.
|
|
164
|
+
* @returns {void}
|
|
165
|
+
*/
|
|
166
|
+
export function applyComputedBeingAge(fm, presentRaw) {
|
|
167
|
+
if (!isBeingType(fm?.type)) return;
|
|
168
|
+
const data = dataOf(fm);
|
|
169
|
+
if (!data) return;
|
|
170
|
+
|
|
171
|
+
const authored = data.age;
|
|
172
|
+
if (authored !== undefined && authored !== null && authored !== "") {
|
|
173
|
+
// An authored age always wins — the value itself is never touched.
|
|
174
|
+
// `~34` is kept exactly as written; its normalised number is added
|
|
175
|
+
// beside it, the way a date's `canonicalYear` sits beside its `text`.
|
|
176
|
+
const parsed = parseAgeMagnitude(authored);
|
|
177
|
+
if (parsed?.approximate) data.ageYears = parsed.value;
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const born = readAliasedField(fm, "born", { inData: true });
|
|
182
|
+
const computed = computeAge(born, presentRaw);
|
|
183
|
+
if (computed !== null) data.age = computed;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* A being's `data.present`, read from every `place` note a batch of records or
|
|
188
|
+
* frontmatter objects carries.
|
|
189
|
+
*
|
|
190
|
+
* Two shapes reach this by way of two thin adapters, because the callers that
|
|
191
|
+
* need a present hold their notes two different ways: a flat index record
|
|
192
|
+
* (`type` and `data` at the top level) and a raw walk's `{frontmatter}` pair.
|
|
193
|
+
* Neither shape is the `{fm, type}` pair {@link worldInvariants} reads, so each
|
|
194
|
+
* adapter states the one conversion its caller needs rather than asking every
|
|
195
|
+
* caller to know it.
|
|
196
|
+
*
|
|
197
|
+
* @param {readonly object[]|undefined} records - Flat index records — a
|
|
198
|
+
* content-index record, or anything else spreading a note's frontmatter to
|
|
199
|
+
* its own top level.
|
|
200
|
+
* @returns {unknown} The present, or `null` where no `place` note in the batch
|
|
201
|
+
* declares one.
|
|
202
|
+
*/
|
|
203
|
+
export function presentAmongRecords(records) {
|
|
204
|
+
return worldInvariants({
|
|
205
|
+
notes: (records ?? []).map((record) => ({ fm: record, type: record?.type })),
|
|
206
|
+
}).present;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* The present read from a batch of raw frontmatter objects.
|
|
211
|
+
*
|
|
212
|
+
* @param {readonly (object|null|undefined)[]|undefined} frontmatters - One
|
|
213
|
+
* entry per note walked, as parsed — never a record.
|
|
214
|
+
* @returns {unknown} The present, or `null` where none is declared.
|
|
215
|
+
*/
|
|
216
|
+
export function presentAmongFrontmatters(frontmatters) {
|
|
217
|
+
return worldInvariants({
|
|
218
|
+
notes: (frontmatters ?? []).map((fm) => ({ fm, type: fm?.type })),
|
|
219
|
+
}).present;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Check a being's authored `age` against what `born` and the package's present
|
|
224
|
+
* compute.
|
|
225
|
+
*
|
|
226
|
+
* Registered as `being`'s whole-note `check` in `note-vocabulary.mjs`, run the
|
|
227
|
+
* way `checkPlace` and `checkCalendarNote` are — beside every other check the
|
|
228
|
+
* closed `data:` container earns, with the same `index`.
|
|
229
|
+
*
|
|
230
|
+
* Two silences, both deliberate: beside `born: unknown` or an absent `born`
|
|
231
|
+
* there is nothing to disagree with, and a package that declares no present
|
|
232
|
+
* computes no age to disagree with either — both cases are read by
|
|
233
|
+
* {@link computeAge} returning `null`, and neither says a word.
|
|
234
|
+
*
|
|
235
|
+
* @param {object} note - The note, as the link index hands it over —
|
|
236
|
+
* {@link module:engine/calendar-notes.worldInvariants}'s `index.notes` shape.
|
|
237
|
+
* @param {object} [opts]
|
|
238
|
+
* @param {object} [opts.index] - The link index, holding the package's `place`
|
|
239
|
+
* notes. Its absence skips the check, the way a reference check with no
|
|
240
|
+
* index skips reporting every reference as dead.
|
|
241
|
+
* @returns {object[]} Findings — at most one `warning`, naming both values.
|
|
242
|
+
*/
|
|
243
|
+
export function checkBeingAge(note, { index } = {}) {
|
|
244
|
+
if (!isBeingType(note?.type ?? note?.fm?.type)) return [];
|
|
245
|
+
const data = dataOf(note?.fm);
|
|
246
|
+
if (!data) return [];
|
|
247
|
+
|
|
248
|
+
const bornRaw = readAliasedField(note?.fm, "born", { inData: true });
|
|
249
|
+
const ageRaw = data.age;
|
|
250
|
+
if (ageRaw === undefined || ageRaw === null || ageRaw === "") return [];
|
|
251
|
+
if (!index) return [];
|
|
252
|
+
|
|
253
|
+
const present = worldInvariants(index).present;
|
|
254
|
+
const computed = computeAge(bornRaw, present);
|
|
255
|
+
if (computed === null) return [];
|
|
256
|
+
|
|
257
|
+
const authored = parseAgeMagnitude(ageRaw);
|
|
258
|
+
if (!authored || authored.value === computed) return [];
|
|
259
|
+
|
|
260
|
+
return [
|
|
261
|
+
{
|
|
262
|
+
file: note.file,
|
|
263
|
+
...positionOfFrontmatterPath(note.raw ?? "", ["data", "age"], { key: true }),
|
|
264
|
+
severity: "warning",
|
|
265
|
+
message:
|
|
266
|
+
`\`data.age: ${JSON.stringify(ageRaw)}\` disagrees with ` +
|
|
267
|
+
`\`data.born: ${JSON.stringify(bornRaw)}\`, which computes to ${computed}`,
|
|
268
|
+
},
|
|
269
|
+
];
|
|
270
|
+
}
|