@heroiclands/package-build 22.9.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 +12 -0
- package/docs/api.md +13 -0
- package/docs/content-format.md +26 -3
- package/engine/base-compiler.mjs +9 -0
- package/engine/being-age.mjs +270 -0
- package/engine/content-index.mjs +15 -1
- package/engine/index.mjs +3 -0
- package/engine/infobox.mjs +6 -0
- package/engine/note-vocabulary.mjs +14 -0
- package/engine/site-build.mjs +16 -0
- package/package.json +1 -1
- package/types/engine/base-compiler.d.mts +1 -0
- package/types/engine/being-age.d.mts +92 -0
- package/types/engine/index.d.mts +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @heroiclands/package-build
|
|
2
2
|
|
|
3
|
+
## 22.9.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
**A being's age can be computed rather than written.** Beside a dated `born` and no authored `age`, the age follows from the birth date and the package's declared present — a birthday later in the year counts one less than plain subtraction would.
|
|
8
|
+
|
|
9
|
+
**The present is declared on the world's own `place` note**, in `data.present`. A package that states none computes no age and says nothing about it.
|
|
10
|
+
|
|
11
|
+
**`content-build lint` warns when an authored `age` disagrees** with what a dated `born` computes to, naming both values; it stays silent beside `born: unknown` or an absent `born`.
|
|
12
|
+
|
|
13
|
+
**An estimated age keeps its `~` in the compiled document**, with the plain number carried beside it as `ageYears`.
|
|
14
|
+
|
|
3
15
|
## 22.9.0
|
|
4
16
|
|
|
5
17
|
### Minor Changes
|
package/docs/api.md
CHANGED
|
@@ -649,6 +649,19 @@ A calendar is a `lore` note with `subType: calendar`, declaring the months it ke
|
|
|
649
649
|
| `compileCalendars` | `compileCalendars(index, opts)` | `{calendars, invariants}` | building every calendar a package declares, or none where it states no year |
|
|
650
650
|
| `calendariaEnvelope` | `calendariaEnvelope(definition, opts)` | `object` — the import envelope | wrapping a definition for a hand import through Calendaria's settings |
|
|
651
651
|
|
|
652
|
+
### `engine.beingAge`
|
|
653
|
+
|
|
654
|
+
The middle state of a being's `age`: absent beside a dated `born`, it is computed against the package's declared present — the two dates compared, not the two years subtracted, so a birthday later in the year than the present counts one less. `applyComputedBeingAge` fills it in on the frontmatter object a pass already holds, never on the note; `checkBeingAge` is `being`'s `NOTE_VOCABULARY` check, raising a warning where an authored `age` disagrees with what a dated `born` computes to. An estimate's `~` is read the way a date's is: the authored string stays verbatim, and `ageYears` carries the magnitude beside it.
|
|
655
|
+
|
|
656
|
+
| Export | Signature | Returns | Use it when |
|
|
657
|
+
| -------------------------- | ---------------------------------------- | ------------------------------ | ------------------------------------------------------------------------- |
|
|
658
|
+
| `computeAge` | `computeAge(bornRaw, presentRaw)` | `number \| null` | reading the age two dates imply, in whole years |
|
|
659
|
+
| `parseAgeMagnitude` | `parseAgeMagnitude(raw)` | `{approximate, value} \| null` | reading an authored `age`'s `~` mark and its magnitude |
|
|
660
|
+
| `applyComputedBeingAge` | `applyComputedBeingAge(fm, presentRaw)` | `void` — mutates `fm.data` | filling in a being's computed `age`, or the `ageYears` beside an estimate |
|
|
661
|
+
| `presentAmongRecords` | `presentAmongRecords(records)` | the present, or `null` | reading the declared present from a batch of flat index records |
|
|
662
|
+
| `presentAmongFrontmatters` | `presentAmongFrontmatters(frontmatters)` | the present, or `null` | reading the declared present from a batch of raw frontmatter objects |
|
|
663
|
+
| `checkBeingAge` | `checkBeingAge(note, opts)` | `object[]` — findings | holding a being's authored `age` to what `born` and the present compute |
|
|
664
|
+
|
|
652
665
|
### `engine.contentAddress`
|
|
653
666
|
|
|
654
667
|
Where a content note publishes on the web. One rule, in one place, because two builds need the same answer: the knowledgebase build renders the page, and the link manifest records the address other packages link to. Stating it twice is how a manifest comes to assert a URL that resolves at build time and 404s for the reader.
|
package/docs/content-format.md
CHANGED
|
@@ -2304,6 +2304,7 @@ Generates a living (or undead, or spirit) being.
|
|
|
2304
2304
|
| `born` | `YYYY/MM/DD \| unknown` | When the being was born; absent, it was never born |
|
|
2305
2305
|
| `died` | `YYYY/MM/DD \| unknown` | When the being died; absent, it is alive |
|
|
2306
2306
|
| `age` | `34 \| ~34` | Age in years, stated only to override what `born` says; `~` marks an estimate |
|
|
2307
|
+
| `ageYears` | `number` | Written by the compiler beside an `age` estimate — the `~` stripped; a note never authors this |
|
|
2307
2308
|
| `height` | `number` | Height in meters |
|
|
2308
2309
|
| `weight` | `number` | Weight in kilograms |
|
|
2309
2310
|
| `frame` | `scant \| light \| medium \| large \| massive` | Relative frame size |
|
|
@@ -2326,13 +2327,35 @@ three fields are built around.
|
|
|
2326
2327
|
unrecorded. Both spellings of `died` mean dead.
|
|
2327
2328
|
- **`age` is optional and is an override.** Written, it wins over anything the
|
|
2328
2329
|
dates say. Unwritten beside a dated `born`, it is the age that follows from
|
|
2329
|
-
the date
|
|
2330
|
-
unknown.
|
|
2330
|
+
the date and the package's declared present — see below. Unwritten beside
|
|
2331
|
+
`born: unknown` or an absent `born`, the age is unknown.
|
|
2331
2332
|
|
|
2332
2333
|
Most people in a setting do not know their own birth date, so an age beside
|
|
2333
2334
|
`born: unknown` is an ordinary complete record and not a note working around the
|
|
2334
2335
|
schema. Where the number is somebody's estimate rather than a fact, write it
|
|
2335
|
-
`~34` — the same `~` a date carries.
|
|
2336
|
+
`~34` — the same `~` a date carries. The estimate string is kept exactly as
|
|
2337
|
+
written; the compiler adds `ageYears` beside it, the plain number with the `~`
|
|
2338
|
+
stripped, for anything that needs to sort or filter on the age rather than
|
|
2339
|
+
read it.
|
|
2340
|
+
|
|
2341
|
+
#### The age a dated `born` computes to
|
|
2342
|
+
|
|
2343
|
+
A package states the day its setting stops on `data.present`, on its own
|
|
2344
|
+
`world` or `celestial` `place` note — see _What a body states about itself_
|
|
2345
|
+
under `type: place`. Against that date, a being with a dated `born` and no
|
|
2346
|
+
authored `age` compiles the age it implies: the two dates compared, not the
|
|
2347
|
+
two years subtracted, so a birthday that falls later in the year than the
|
|
2348
|
+
present counts one less than the years alone would say. A package that
|
|
2349
|
+
declares no present computes no age and says nothing about it — the same
|
|
2350
|
+
silence a package with no calendar mechanism meets.
|
|
2351
|
+
|
|
2352
|
+
**An authored `age` beside a dated `born` is expected to agree with what the
|
|
2353
|
+
date computes.** Where it does not, `content-build lint` raises a warning
|
|
2354
|
+
naming both values, so a stale age left behind after a `born` was corrected
|
|
2355
|
+
does not go unnoticed. The warning is silent beside `born: unknown` or an
|
|
2356
|
+
absent `born` — there is nothing there to disagree with — and silent wherever
|
|
2357
|
+
the package declares no present, for the same reason no age is computed at
|
|
2358
|
+
all.
|
|
2336
2359
|
|
|
2337
2360
|
`birthday:` is the retired spelling of `born:`. Both are read and `born` wins,
|
|
2338
2361
|
so a note compiles identically either way; the old spelling is reported until
|
package/engine/base-compiler.mjs
CHANGED
|
@@ -92,6 +92,10 @@ import { resolveNoteId } from "./note-ids.mjs";
|
|
|
92
92
|
import { carriesSystemBlock } from "./system-block.mjs";
|
|
93
93
|
import { checkAuthoredSystemData, checkEmittedSystemData } from "./schema-check.mjs";
|
|
94
94
|
import { locateFrontmatterKey } from "./retired-fields.mjs";
|
|
95
|
+
// A being's computed `age`, filled in against the package's own declared
|
|
96
|
+
// present before this pass reads `data:` for anything else — see
|
|
97
|
+
// `engine/being-age.mjs`.
|
|
98
|
+
import { applyComputedBeingAge, presentAmongRecords } from "./being-age.mjs";
|
|
95
99
|
|
|
96
100
|
/**
|
|
97
101
|
* The tallies one pass accumulates while walking the tree.
|
|
@@ -594,6 +598,10 @@ export class BasePackCompiler {
|
|
|
594
598
|
this.contentDocs = this.corpus.contentDocs;
|
|
595
599
|
this.sqlTables = this.corpus.sqlTables;
|
|
596
600
|
}
|
|
601
|
+
// The package's declared present, read once from the same corpus — so
|
|
602
|
+
// a being's computed `age` reaches every compiled document, not only
|
|
603
|
+
// the content index.
|
|
604
|
+
this.worldPresent = presentAmongRecords(this.corpus.records);
|
|
597
605
|
this.unresolvedLinks = 0;
|
|
598
606
|
}
|
|
599
607
|
|
|
@@ -1016,6 +1024,7 @@ export class BasePackCompiler {
|
|
|
1016
1024
|
stats.skippedOther++;
|
|
1017
1025
|
continue;
|
|
1018
1026
|
}
|
|
1027
|
+
applyComputedBeingAge(fm, this.worldPresent);
|
|
1019
1028
|
// The retired frontmatter fields, refused before `selects` so a
|
|
1020
1029
|
// note is answered whichever pass would have claimed it — and
|
|
1021
1030
|
// whatever the declared value says.
|
|
@@ -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
|
+
}
|
package/engine/content-index.mjs
CHANGED
|
@@ -137,6 +137,10 @@ import {
|
|
|
137
137
|
sortKeysDeep,
|
|
138
138
|
} from "./index-records.mjs";
|
|
139
139
|
import { isDraftNote } from "./note-vocabulary.mjs";
|
|
140
|
+
// A being's computed `age` — the middle state between an authored override and
|
|
141
|
+
// an unknown one — filled in against the package's own declared present before
|
|
142
|
+
// a record is built, so the index carries it without the note being written to.
|
|
143
|
+
import { applyComputedBeingAge, presentAmongFrontmatters } from "./being-age.mjs";
|
|
140
144
|
|
|
141
145
|
export {
|
|
142
146
|
authoredFrontmatter,
|
|
@@ -606,10 +610,20 @@ export function collectContentIndex(
|
|
|
606
610
|
// tree to walk and the index is its asset records. The caller decides
|
|
607
611
|
// whether an absent tree is a mistake; by the time the walk is reached it
|
|
608
612
|
// is simply a package with nothing to compile.
|
|
609
|
-
|
|
613
|
+
// Materialised rather than consumed lazily: the present is read from this
|
|
614
|
+
// same walk before the loop below reads it a second time, and
|
|
615
|
+
// `walkMarkdownTree` is a generator — a single-use one, exhausted the
|
|
616
|
+
// moment anything else iterates it first.
|
|
617
|
+
const notes = fs.existsSync(contentBase) ? [...walkMarkdownTree(contentBase, walkOpts)] : [];
|
|
618
|
+
|
|
619
|
+
// The package's declared present, read once from whichever `place` note in
|
|
620
|
+
// this same walk states one — the whole tree is already in memory, so no
|
|
621
|
+
// second read is needed to answer a question about all of it.
|
|
622
|
+
const present = presentAmongFrontmatters(notes.map((n) => n.frontmatter));
|
|
610
623
|
|
|
611
624
|
for (const { frontmatter, body, bodyLine, absPath } of notes) {
|
|
612
625
|
const fm = frontmatter ?? {};
|
|
626
|
+
applyComputedBeingAge(fm, present);
|
|
613
627
|
// The id the note's document is filed under, resolved before
|
|
614
628
|
// the record is built so the index publishes the address *and* the id
|
|
615
629
|
// that address derives.
|
package/engine/index.mjs
CHANGED
|
@@ -133,6 +133,9 @@ export * as noteDates from "./note-dates.mjs";
|
|
|
133
133
|
/** Calendars as notes: what one may declare, and the definition emitted from it. */
|
|
134
134
|
export * as calendarNotes from "./calendar-notes.mjs";
|
|
135
135
|
|
|
136
|
+
/** A being's `age`, absent beside a dated `born`: the age the date computes to. */
|
|
137
|
+
export * as beingAge from "./being-age.mjs";
|
|
138
|
+
|
|
136
139
|
/** The one normalisation this build makes: prose to a URL-safe token. */
|
|
137
140
|
export * as contentSlug from "./content-slug.mjs";
|
|
138
141
|
|
package/engine/infobox.mjs
CHANGED
|
@@ -291,6 +291,12 @@ export const NOTE_FIELD_PRESENTATION = Object.freeze({
|
|
|
291
291
|
// than as six rows of numbers, exactly as the book prototype sets them.
|
|
292
292
|
// Qualified by type, because `weight` is also what a coin weighs.
|
|
293
293
|
"being.age": Object.freeze({ group: "appearance", phrase: (v) => `Age ${v}` }),
|
|
294
|
+
// The `~` estimate's numeric companion — beside `age` in the index for a
|
|
295
|
+
// machine to sort or filter on, not a second row for a reader who already
|
|
296
|
+
// meets it inside the appearance clause.
|
|
297
|
+
"being.ageYears": Object.freeze({
|
|
298
|
+
withheld: "the `age` row's own machinery, not a second fact about the subject",
|
|
299
|
+
}),
|
|
294
300
|
"being.height": Object.freeze({ group: "appearance", phrase: heightPhrase }),
|
|
295
301
|
"being.weight": Object.freeze({ group: "appearance", phrase: weightPhrase }),
|
|
296
302
|
"being.frame": Object.freeze({ group: "appearance", phrase: (v) => `${v} frame` }),
|
|
@@ -91,6 +91,10 @@ import {
|
|
|
91
91
|
checkCalendarNote,
|
|
92
92
|
checkWorldFacts,
|
|
93
93
|
} from "./calendar-notes.mjs";
|
|
94
|
+
// Whether a being's authored `age` disagrees with what `born` and the
|
|
95
|
+
// package's declared present compute — the closed `data:` container's own
|
|
96
|
+
// question, asked the way `checkPlace` and `checkCalendarNote` are.
|
|
97
|
+
import { checkBeingAge } from "./being-age.mjs";
|
|
94
98
|
import { checkHeld } from "./holdings.mjs";
|
|
95
99
|
import { checkCitedPopulations, checkPopulation } from "./populations.mjs";
|
|
96
100
|
// What trade a settlement supports — the scale and the check that holds a
|
|
@@ -587,6 +591,9 @@ export const NOTE_VOCABULARY = Object.freeze({
|
|
|
587
591
|
// lands with. Declared open until it does, because inventing the
|
|
588
592
|
// values here would put a second, weaker answer beside the real one.
|
|
589
593
|
subTypes: null,
|
|
594
|
+
// Whether an authored `age` disagrees with what `born` and the
|
|
595
|
+
// package's declared present compute — see `engine/being-age.mjs`.
|
|
596
|
+
check: checkBeingAge,
|
|
590
597
|
data: Object.freeze([
|
|
591
598
|
TOKEN_ICON,
|
|
592
599
|
TEMPLATE_PRIORITY,
|
|
@@ -634,6 +641,13 @@ export const NOTE_VOCABULARY = Object.freeze({
|
|
|
634
641
|
"for an estimate. Unstated beside a dated `born` it is computed; unstated " +
|
|
635
642
|
"beside an unknown or absent `born` the age is unknown.",
|
|
636
643
|
},
|
|
644
|
+
{
|
|
645
|
+
name: "ageYears",
|
|
646
|
+
...NUM,
|
|
647
|
+
describe:
|
|
648
|
+
"Written by the compiler beside an `age` estimate — the `~` stripped, " +
|
|
649
|
+
"the magnitude alone. A note never authors this.",
|
|
650
|
+
},
|
|
637
651
|
{ name: "height", ...NUM, describe: "Height in metres." },
|
|
638
652
|
{ name: "weight", ...NUM, describe: "Weight in kilograms." },
|
|
639
653
|
{
|
package/engine/site-build.mjs
CHANGED
|
@@ -72,6 +72,9 @@ import { routerFor } from "./pack-router.mjs";
|
|
|
72
72
|
import { searchableFrontmatter } from "./note-package.mjs";
|
|
73
73
|
// The corpus, from the one pass that derives it.
|
|
74
74
|
import { indexRecordsFor } from "./content-index.mjs";
|
|
75
|
+
// A being's computed `age` — see `engine/being-age.mjs`. `pdf-build.mjs`
|
|
76
|
+
// reuses `collectContentPages`'s pages, so wiring it here reaches the book too.
|
|
77
|
+
import { applyComputedBeingAge, presentAmongRecords } from "./being-age.mjs";
|
|
75
78
|
import { isNoteRecord, noteFile } from "./index-records.mjs";
|
|
76
79
|
// The one statement of what an empty body means, shared with the index.
|
|
77
80
|
import { isStubNote } from "./note-state.mjs";
|
|
@@ -166,9 +169,22 @@ export function collectContentPages(contentBase, ctx) {
|
|
|
166
169
|
const addressFindings = [];
|
|
167
170
|
const fmLinkFindings = [];
|
|
168
171
|
|
|
172
|
+
// The package's declared present, read once from the same corpus the site
|
|
173
|
+
// and the PDF share — see `engine/being-age.mjs`. `pdf-build.mjs` reaches
|
|
174
|
+
// this through the pages this function returns, so one call covers both.
|
|
175
|
+
const present = presentAmongRecords(
|
|
176
|
+
ctx.records ??
|
|
177
|
+
indexRecordsFor({
|
|
178
|
+
contentBase,
|
|
179
|
+
config: ctx.config,
|
|
180
|
+
skipDirectories: ctx.skipDirectories,
|
|
181
|
+
}),
|
|
182
|
+
);
|
|
183
|
+
|
|
169
184
|
for (const file of siteCorpusFiles(contentBase, ctx)) {
|
|
170
185
|
const note = readNote(file);
|
|
171
186
|
if (!note) continue;
|
|
187
|
+
applyComputedBeingAge(note.fm, present);
|
|
172
188
|
const { fm, body } = note;
|
|
173
189
|
// The configuration's, never a note's: `package:` is retired, so every
|
|
174
190
|
// note in the tree belongs to the package this repository compiles.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@heroiclands/package-build",
|
|
3
|
-
"version": "22.9.
|
|
3
|
+
"version": "22.9.1",
|
|
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",
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse an authored `age` into its estimate mark and its magnitude.
|
|
3
|
+
*
|
|
4
|
+
* @param {unknown} raw - The authored value — `34`, `"34"` or `"~34"`.
|
|
5
|
+
* @returns {{approximate: boolean, value: number}|null} The parts, or `null`
|
|
6
|
+
* for a value that does not read as a plain count of years.
|
|
7
|
+
*/
|
|
8
|
+
export function parseAgeMagnitude(raw: unknown): {
|
|
9
|
+
approximate: boolean;
|
|
10
|
+
value: number;
|
|
11
|
+
} | null;
|
|
12
|
+
/**
|
|
13
|
+
* The age two dates imply, in whole years, computed from the dates themselves
|
|
14
|
+
* rather than from the years alone.
|
|
15
|
+
*
|
|
16
|
+
* @param {unknown} bornRaw - The being's authored `born`, or `undefined`/`null`
|
|
17
|
+
* where it wrote none.
|
|
18
|
+
* @param {unknown} presentRaw - The package's `data.present`, as
|
|
19
|
+
* {@link module:engine/calendar-notes.worldInvariants} read it, or
|
|
20
|
+
* `undefined`/`null` where the package states none.
|
|
21
|
+
* @returns {number|null} The age, or `null` where `born` is `unknown` or
|
|
22
|
+
* absent, where no present is declared, or where either date fails to parse.
|
|
23
|
+
*/
|
|
24
|
+
export function computeAge(bornRaw: unknown, presentRaw: unknown): number | null;
|
|
25
|
+
/**
|
|
26
|
+
* Fill in a being's computed `age`, and the numeric companion an estimate
|
|
27
|
+
* carries beside it — mutating the frontmatter object handed in, never a file
|
|
28
|
+
* on disk.
|
|
29
|
+
*
|
|
30
|
+
* Every caller holds a frontmatter object that was parsed fresh from a note's
|
|
31
|
+
* text for its own pass — the content index, a compiled document, a rendered
|
|
32
|
+
* page — and none of those objects round-trips back to the `.md` file. So
|
|
33
|
+
* patching `data.age` in place here is exactly as safe as reading it, and
|
|
34
|
+
* reaches every surface that reads `data:` after this runs, with the note
|
|
35
|
+
* itself untouched.
|
|
36
|
+
*
|
|
37
|
+
* @param {object} fm - A note's frontmatter, as freshly parsed.
|
|
38
|
+
* @param {unknown} presentRaw - The package's declared present, or
|
|
39
|
+
* `undefined`/`null` where it states none.
|
|
40
|
+
* @returns {void}
|
|
41
|
+
*/
|
|
42
|
+
export function applyComputedBeingAge(fm: object, presentRaw: unknown): void;
|
|
43
|
+
/**
|
|
44
|
+
* A being's `data.present`, read from every `place` note a batch of records or
|
|
45
|
+
* frontmatter objects carries.
|
|
46
|
+
*
|
|
47
|
+
* Two shapes reach this by way of two thin adapters, because the callers that
|
|
48
|
+
* need a present hold their notes two different ways: a flat index record
|
|
49
|
+
* (`type` and `data` at the top level) and a raw walk's `{frontmatter}` pair.
|
|
50
|
+
* Neither shape is the `{fm, type}` pair {@link worldInvariants} reads, so each
|
|
51
|
+
* adapter states the one conversion its caller needs rather than asking every
|
|
52
|
+
* caller to know it.
|
|
53
|
+
*
|
|
54
|
+
* @param {readonly object[]|undefined} records - Flat index records — a
|
|
55
|
+
* content-index record, or anything else spreading a note's frontmatter to
|
|
56
|
+
* its own top level.
|
|
57
|
+
* @returns {unknown} The present, or `null` where no `place` note in the batch
|
|
58
|
+
* declares one.
|
|
59
|
+
*/
|
|
60
|
+
export function presentAmongRecords(records: readonly object[] | undefined): unknown;
|
|
61
|
+
/**
|
|
62
|
+
* The present read from a batch of raw frontmatter objects.
|
|
63
|
+
*
|
|
64
|
+
* @param {readonly (object|null|undefined)[]|undefined} frontmatters - One
|
|
65
|
+
* entry per note walked, as parsed — never a record.
|
|
66
|
+
* @returns {unknown} The present, or `null` where none is declared.
|
|
67
|
+
*/
|
|
68
|
+
export function presentAmongFrontmatters(frontmatters: readonly (object | null | undefined)[] | undefined): unknown;
|
|
69
|
+
/**
|
|
70
|
+
* Check a being's authored `age` against what `born` and the package's present
|
|
71
|
+
* compute.
|
|
72
|
+
*
|
|
73
|
+
* Registered as `being`'s whole-note `check` in `note-vocabulary.mjs`, run the
|
|
74
|
+
* way `checkPlace` and `checkCalendarNote` are — beside every other check the
|
|
75
|
+
* closed `data:` container earns, with the same `index`.
|
|
76
|
+
*
|
|
77
|
+
* Two silences, both deliberate: beside `born: unknown` or an absent `born`
|
|
78
|
+
* there is nothing to disagree with, and a package that declares no present
|
|
79
|
+
* computes no age to disagree with either — both cases are read by
|
|
80
|
+
* {@link computeAge} returning `null`, and neither says a word.
|
|
81
|
+
*
|
|
82
|
+
* @param {object} note - The note, as the link index hands it over —
|
|
83
|
+
* {@link module:engine/calendar-notes.worldInvariants}'s `index.notes` shape.
|
|
84
|
+
* @param {object} [opts]
|
|
85
|
+
* @param {object} [opts.index] - The link index, holding the package's `place`
|
|
86
|
+
* notes. Its absence skips the check, the way a reference check with no
|
|
87
|
+
* index skips reporting every reference as dead.
|
|
88
|
+
* @returns {object[]} Findings — at most one `warning`, naming both values.
|
|
89
|
+
*/
|
|
90
|
+
export function checkBeingAge(note: object, { index }?: {
|
|
91
|
+
index?: object | undefined;
|
|
92
|
+
}): object[];
|
package/types/engine/index.d.mts
CHANGED
|
@@ -32,6 +32,7 @@ export * as systems from "./systems.mjs";
|
|
|
32
32
|
export * as calendars from "./calendars.mjs";
|
|
33
33
|
export * as noteDates from "./note-dates.mjs";
|
|
34
34
|
export * as calendarNotes from "./calendar-notes.mjs";
|
|
35
|
+
export * as beingAge from "./being-age.mjs";
|
|
35
36
|
export * as contentSlug from "./content-slug.mjs";
|
|
36
37
|
export * as contentAddress from "./content-address.mjs";
|
|
37
38
|
export * as subtypeRegistry from "./subtype-registry.mjs";
|