@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 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.
@@ -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. Unwritten beside `born: unknown` or an absent `born`, the age is
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
@@ -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
+ }
@@ -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
- const notes = fs.existsSync(contentBase) ? walkMarkdownTree(contentBase, walkOpts) : [];
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
 
@@ -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
  {
@@ -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.0",
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",
@@ -378,6 +378,7 @@ export class BasePackCompiler {
378
378
  linkIndex: any;
379
379
  contentDocs: any;
380
380
  sqlTables: any;
381
+ worldPresent: unknown;
381
382
  /**
382
383
  * The body {@link BasePackCompiler#buildEntry} receives.
383
384
  *
@@ -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[];
@@ -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";