@heroiclands/package-build 22.6.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 CHANGED
@@ -1,5 +1,47 @@
1
1
  # @heroiclands/package-build
2
2
 
3
+ ## 22.8.0
4
+
5
+ ### Minor Changes
6
+
7
+ **A being's dates**
8
+
9
+ - A being says when it was born and when it died: `born:` takes over from `birthday:`, and `died:` sits beside it.
10
+ - Either date may read `unknown` where it was never recorded; an absent `born` says the being was never born, and an absent `died` says it is alive.
11
+ - `age:` is optional, and is an override — write it only where the number should win over the date, and write it `~34` where it is somebody's estimate.
12
+ - A note still writing `birthday:` compiles identically and is told once what to write instead.
13
+ - A profile reads its two dates ahead of the line describing the being's appearance.
14
+
15
+ ### Patch Changes
16
+
17
+ **Dates**
18
+
19
+ - A package can state how its year is divided — how many months, and how many
20
+ days to a month — and the date it treats as the present.
21
+ - A refused `BF`/`AF` date now states the replacement rule instead of a computed value, which was wrong for `AF`.
22
+
23
+ **The book**
24
+
25
+ - A numbered or bulleted point whose text runs to more than one paragraph now
26
+ sets every paragraph flush under the point, so it reads as one block hanging
27
+ off its number instead of a stray indented paragraph beneath it.
28
+
29
+ **A draft entry's notice**
30
+
31
+ - The sentence sets in italic, with `Draft.` in bold ahead of it, on the page, in a journal entry and on an actor's dossier alike: the entry speaking about its own state rather than about the world.
32
+ - In the book the notice's mark sits in a column of its own, so a sentence that runs to two lines sets both of them clear of it.
33
+ - The mark is a filled disc with the exclamation knocked out of it, in the book's accent.
34
+ - The sentence sets at the size, face and ink of the entry's prose; the rule down its edge and the mark are what set it apart.
35
+
36
+ ## 22.7.0
37
+
38
+ ### Minor Changes
39
+
40
+ **Draft notes say so.** A note tagged `draft` states that it is unfinished and
41
+ that nothing in it is settled, at the head of its compendium entry, on the
42
+ character sheet a referee opens, and on its page in the book. A finished note
43
+ carries nothing.
44
+
3
45
  ## 22.6.0
4
46
 
5
47
  ### Minor Changes
package/CONTENT.md CHANGED
@@ -1113,6 +1113,11 @@ page, no address and no link target. A note with a body and the `draft` tag is a
1113
1113
  **draft**, and one with a body and no tag is **full**.
1114
1114
  `docs/content-format.md` states the rule; this is what the lint does with it.
1115
1115
 
1116
+ A draft says so on every surface it reaches — its journal entry, the Actor a
1117
+ being compiles into, its page in the book — in one sentence at the head of the
1118
+ entry, above the infobox and the prose. A stub says nothing anywhere, because it
1119
+ publishes nothing to say it on.
1120
+
1116
1121
  **A document that would be empty is not created**, and the index then names no
1117
1122
  artefact — `uuid`, `path` and `address` are `null`. An unwritten note still
1118
1123
  compiles every document derived from `data:`, so an arcane talent is still its
@@ -76,6 +76,7 @@ import { EMPTY_ICON_REGISTRY, checkIconRegistry } from "./engine/content-icons.m
76
76
  import { MAP_TYPES, NO_PACK, PACK_BY_TYPE } from "./engine/ids.mjs";
77
77
  import { ACTOR_TYPES } from "./engine/subtype-registry.mjs";
78
78
  import { NOTE_VOCABULARY } from "./engine/note-vocabulary.mjs";
79
+ import { UNKNOWN_DATE, parseNoteDate } from "./engine/note-dates.mjs";
79
80
 
80
81
  /**
81
82
  * What kind of package this is.
@@ -725,6 +726,10 @@ export function publishesContentPages(config) {
725
726
  * @property {readonly string[]} packDirectories Derived: every pack directory
726
727
  * the build produces, in compile order —
727
728
  * each pack followed by its companions.
729
+ * @property {Readonly<{months: number|null, monthDays: number|null, present: string|null}>} calendar
730
+ * How this package's year is divided, and
731
+ * the date it calls the present. Every
732
+ * value `null` when none is declared.
728
733
  * @property {Readonly<PackageBuildSection>} packageBuild Passed through
729
734
  * frozen, uninterpreted. `{}` when absent.
730
735
  * @property {Readonly<DocsSpec>} docs Frozen; `{}` when absent.
@@ -754,11 +759,13 @@ const CONFIG_KEYS = [
754
759
  "relationships",
755
760
  "systems",
756
761
  "requiresSystem",
762
+ "calendar",
757
763
  "packageBuild",
758
764
  "publish",
759
765
  "changelog",
760
766
  ];
761
767
  const SYSTEM_KEYS = ["manifest", "compatibility"];
768
+ const CALENDAR_KEYS = ["months", "monthDays", "present"];
762
769
  const COMPATIBILITY_KEYS = ["minimum", "verified"];
763
770
  const DOCS_KEYS = ["itemFields"];
764
771
  const CHANGELOG_KEYS = ["labels"];
@@ -2070,6 +2077,76 @@ function normalizeRequiresSystem(value) {
2070
2077
  return requireNonEmptyString(value, "requiresSystem");
2071
2078
  }
2072
2079
 
2080
+ /**
2081
+ * How this package's year is divided, and the date it calls the present.
2082
+ *
2083
+ * **Nothing here is about which people count from what.** A reckoning is an
2084
+ * era, declared by the affiliation that proclaimed it, and its epoch is the
2085
+ * `start` written on that note — so the only things configuration states are
2086
+ * the ones no note could.
2087
+ *
2088
+ * `months` and `monthDays` bound a written month and day. Both are optional,
2089
+ * and an absent one is checked against nothing: a toolchain that assumed a
2090
+ * Gregorian year here would refuse `667/2/30`, an ordinary date in a year of
2091
+ * twelve thirty-day months. One subdivision to a package — an era moves where
2092
+ * year 1 sits, it does not divide the year differently.
2093
+ *
2094
+ * `present` is the date the package computes an age against, written in the
2095
+ * same grammar a note's dates use, so a package whose present is best said in
2096
+ * an era says it that way. A package that states none computes no age, which
2097
+ * is the right answer for a corpus that has no present.
2098
+ *
2099
+ * @param {unknown} value - The declared `calendar:` mapping.
2100
+ * @returns {Readonly<{months: number|null, monthDays: number|null, present: string|null}>}
2101
+ * Frozen; every value `null` when absent.
2102
+ */
2103
+ function normalizeCalendar(value) {
2104
+ if (value === undefined || value === null) {
2105
+ return Object.freeze({ months: null, monthDays: null, present: null });
2106
+ }
2107
+ if (!isPlainObject(value)) fail("calendar", "must be a mapping");
2108
+ const input = /** @type {Record<string, unknown>} */ (value);
2109
+ rejectUnknownKeys(input, CALENDAR_KEYS, "calendar.");
2110
+
2111
+ const bound = (key) => {
2112
+ const held = input[key];
2113
+ if (held === undefined || held === null) return null;
2114
+ if (!Number.isInteger(held) || /** @type {number} */ (held) < 1) {
2115
+ fail(`calendar.${key}`, "must be a positive integer");
2116
+ }
2117
+ return /** @type {number} */ (held);
2118
+ };
2119
+ const months = bound("months");
2120
+ const monthDays = bound("monthDays");
2121
+
2122
+ // Read through the parser the notes use rather than a second grammar, and
2123
+ // against this package's own bounds — a present outside the year it
2124
+ // declares is the one date nothing downstream would report.
2125
+ let present = null;
2126
+ if (input.present !== undefined && input.present !== null) {
2127
+ if (typeof input.present !== "string" && typeof input.present !== "number") {
2128
+ fail("calendar.present", "must be a date written as a string");
2129
+ }
2130
+ if (String(input.present).trim() === UNKNOWN_DATE) {
2131
+ fail(
2132
+ "calendar.present",
2133
+ `must be a date — a package that states no present computes no age, ` +
2134
+ `so leave the key out rather than writing \`${UNKNOWN_DATE}\``,
2135
+ );
2136
+ }
2137
+ const { date, findings } = parseNoteDate(input.present, {
2138
+ calendar: { months, monthDays },
2139
+ allowUnknown: false,
2140
+ });
2141
+ const refused = findings.find((finding) => finding.severity === "error");
2142
+ if (refused)
2143
+ fail("calendar.present", `is not a date this package can read — ${refused.message}`);
2144
+ present = /** @type {{text: string}} */ (date).text;
2145
+ }
2146
+
2147
+ return Object.freeze({ months, monthDays, present });
2148
+ }
2149
+
2073
2150
  /**
2074
2151
  * Validate the declared relationships.
2075
2152
  *
@@ -2801,6 +2878,7 @@ export function defineConfig(config) {
2801
2878
  relationships: normalizeRelationships(input.relationships),
2802
2879
  systems,
2803
2880
  requiresSystem,
2881
+ calendar: normalizeCalendar(input.calendar),
2804
2882
  packageBuild: normalizePackageBuild(input.packageBuild),
2805
2883
  publish: normalizePublish(input.publish),
2806
2884
  changelog: normalizeChangelog(input.changelog),
package/docs/api.md CHANGED
@@ -427,6 +427,23 @@ The three states of a note — **stub**, **draft**, **full** — and the one obs
427
427
  | `placeholderBody` | `placeholderBody(body)` | `Array<{phrase: string, line: number}>` | finding a body that reduces to nothing but placeholders, which is an abandoned draft rather than a stub |
428
428
  | `bodyWordCount` | `bodyWordCount(body)` | `number` | counting the prose a reader meets, with a wikilink counting as the one word it renders |
429
429
 
430
+ ### `engine.draftNotice`
431
+
432
+ What a draft note says about itself, in one sentence, on every surface it reaches. The words are stated once and each renderer composes its own markup around them, so a reader meeting the note on a page, in a compendium and in the book meets one statement. The mark is a notice rather than a hazard, and the book draws it rather than setting a glyph, because a face missing one is silent in Typst.
433
+
434
+ | Export | Signature | Returns | Use it when |
435
+ | ------------------------- | ------------------------------------ | -------- | ---------------------------------------------------------------------------------- |
436
+ | `DRAFT_NOTICE` | `const DRAFT_NOTICE` | `string` | reading the sentence itself, which is the floor every surface degrades to |
437
+ | `DRAFT_NOTICE_LABEL` | `const DRAFT_NOTICE_LABEL` | `string` | reading the label that opens the notice |
438
+ | `DRAFT_NOTICE_ICON` | `const DRAFT_NOTICE_ICON` | `string` | naming the Font Awesome mark the HTML surfaces draw |
439
+ | `DRAFT_NOTICE_CLASS` | `const DRAFT_NOTICE_CLASS` | `string` | styling the HTML notice from a package that wants to |
440
+ | `BOOK_DRAFT_NOTICE` | `const BOOK_DRAFT_NOTICE` | `string` | naming the Typst function the book's notice calls |
441
+ | `draftNoticeHtml` | `draftNoticeHtml()` | `string` | drawing the notice for a JournalEntry page or an Actor's prose field |
442
+ | `draftNoticeFor` | `draftNoticeFor(frontmatter)` | `string` | asking whether a note has earned a notice, which is the one place the tag is asked |
443
+ | `withDraftNotice` | `withDraftNotice(frontmatter, html)` | `string` | leading a system's own prose field with the notice where one is due |
444
+ | `draftNoticeTypst` | `draftNoticeTypst()` | `string` | drawing the notice for a leaf of the book |
445
+ | `bookDraftNoticePreamble` | `bookDraftNoticePreamble()` | `string` | emitting the Typst definition the book's notice is drawn by |
446
+
430
447
  ### `engine.stubLint`
431
448
 
432
449
  An empty body must be deliberate. Four rules hold the state honest — a stub states what it is, a stub is not tagged `draft`, a body that renders to nothing is an abandoned draft, and a short unmarked body is asked whether it forgot its marker — and two reports say what the rules cannot decide.
@@ -587,6 +604,28 @@ The closed registry of system ids, and the `none` that stands for no system at a
587
604
  | `assertSystemSegment` | `assertSystemSegment(value, where)` | `string` — the value, unchanged | refusing a value the `<system>` segment may not hold, validating inline |
588
605
  | `assertSystemCharset` | `assertSystemCharset(segments, where)` | throws | refusing a registry declaration whose id could not be an address segment |
589
606
 
607
+ ### `engine.calendars`
608
+
609
+ The axis every date normalises onto, and the one function that puts a reckoning on it. A reckoning is an **era** declared by the affiliation that proclaimed it, and its epoch is the `start` written on that note — nothing is registered in configuration. The conversion is piecewise on the sign, because the authored numbering has no year zero while the normalised value does: authored `-1` is `0`, and `-1` and `1` are adjacent.
610
+
611
+ | Export | Signature | Returns | Use it when |
612
+ | ------------------- | ------------------------------- | --------------------------------------------- | ------------------------------------------------------------------------------- |
613
+ | `CANONICAL_EPOCH` | `const CANONICAL_EPOCH` | — | naming where the canonical axis's own year 1 sits |
614
+ | `canonicalYear` | `canonicalYear(year, epoch?)` | `number` — the signed year on the axis | converting a year written in one reckoning to the number everything compares on |
615
+ | `dateSortKey` | `dateSortKey(year, month, day)` | `number` | ordering mixed-precision dates against a single number |
616
+ | `calendarStructure` | `calendarStructure(calendar)` | `{months, monthDays}` — `null` where unstated | reading the month bounds a package declares, before phrasing a finding |
617
+
618
+ ### `engine.noteDates`
619
+
620
+ A date on a note is one authored string — `[~][-]YYYY[/MM[/DD]] [<affiliation shortcode>.<era shortcode>]` — and every field holding one parses through a single grammar into a single record. Print `text`, order on `sort`, do arithmetic on `canonicalYear`. A bare value sits on the canonical axis and carries `era: null`; one naming an era carries the qualifier and no `canonicalYear` or `sort` keys at all until the era is resolved against the corpus. The literal `unknown` parses to the same record with the year half empty, carrying a null `sort` and a null `canonicalYear`, so nothing can order it to one end of a list.
621
+
622
+ | Export | Signature | Returns | Use it when |
623
+ | ----------------------- | ------------------------------- | -------------------------------------------------- | ------------------------------------------------------------- |
624
+ | `UNKNOWN_DATE` | `const UNKNOWN_DATE` | — | naming the literal that says a date is not recorded |
625
+ | `NOTE_DATE_PATTERN` | `const NOTE_DATE_PATTERN` | — | matching the date grammar directly |
626
+ | `ERA_QUALIFIER_PATTERN` | `const ERA_QUALIFIER_PATTERN` | — | checking that a string is an era qualifier a date could carry |
627
+ | `parseNoteDate` | `parseNoteDate(value, options)` | `{date, findings}` — `date` is `null` when refused | reading a note's authored date, with every finding it earned |
628
+
590
629
  ### `engine.contentAddress`
591
630
 
592
631
  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.
@@ -48,7 +48,7 @@ around its own evaluation.
48
48
 
49
49
  ### Quick reference
50
50
 
51
- 21 top-level keys. `rootDir` is not one of them — a data configuration never
51
+ 22 top-level keys. `rootDir` is not one of them — a data configuration never
52
52
  writes it — and is documented under [Derived values](#derived-values) instead,
53
53
  alongside `foundryPackage`, `homepage`, `author` and `itemBuilders`, whose
54
54
  data-configuration behaviour is also derivation rather than ordinary
@@ -74,13 +74,14 @@ authoring.
74
74
  | [`relationships`](#relationships) | object | no; refused in a `documentation` package | `{}` |
75
75
  | [`systems`](#systems) | object | no; refused in a `documentation` package | `{}` |
76
76
  | [`requiresSystem`](#requiressystem) | string | no; refused in a `documentation` package | `null` |
77
+ | [`calendar`](#calendar) | object | no | no bounds, no present |
77
78
  | [`packageBuild`](#the-packagebuild-section) | object | no | `{}` |
78
79
  | [`publish`](#publish) | object | no; **required**, with `site: content`, in a `documentation` package | `{site: "homepage", address: {prefix: ""}}` |
79
80
  | [`changelog`](#changelog) | object | no | `{labels: null}` |
80
81
 
81
82
  Any key outside this list is refused:
82
83
 
83
- > `` `<key>` is not a recognized option (expected one of: rootDir, contentPackage, foundryPackage, homepage, author, packageKind, stats, itemBuilders, paths, skipDirectories, icons, packs, docs, site, pdf, compatibility, relationships, systems, requiresSystem, packageBuild, publish, changelog). ``
84
+ > `` `<key>` is not a recognized option (expected one of: rootDir, contentPackage, foundryPackage, homepage, author, packageKind, stats, itemBuilders, paths, skipDirectories, icons, packs, docs, site, pdf, compatibility, relationships, systems, requiresSystem, calendar, packageBuild, publish, changelog). ``
84
85
 
85
86
  (`rootDir` appears in that list because it is a key `defineConfig` itself
86
87
  accepts — an `.mjs` configuration authors it directly. A YAML configuration
@@ -290,7 +291,7 @@ Unlike the first three, authoring `itemBuilders` is not an error — it is
290
291
 
291
292
  ---
292
293
 
293
- ## The 21 keys
294
+ ## The 22 keys
294
295
 
295
296
  ### `contentPackage`
296
297
 
@@ -1297,6 +1298,59 @@ With one, naming what it does declare instead:
1297
1298
 
1298
1299
  > ``package-build config: `requiresSystem` must be a non-empty string.``
1299
1300
 
1301
+ ### `calendar`
1302
+
1303
+ **Type:** object · **Optional** · default: no bounds and no present.
1304
+
1305
+ How this package's year is divided, and the date it calls the present:
1306
+
1307
+ ```yaml
1308
+ calendar:
1309
+ months: 12
1310
+ monthDays: 30
1311
+ present: 720/6/19
1312
+ ```
1313
+
1314
+ **Nothing here is about which people count from what.** A reckoning is an
1315
+ **era**, declared by the affiliation that proclaimed it, and its epoch is the
1316
+ `start` written on that note — so the only things this block states are the ones
1317
+ no note could.
1318
+
1319
+ | Key (under `calendar`) | Type | Required | Default |
1320
+ | ---------------------- | ---------------- | -------- | ------- |
1321
+ | `calendar.months` | positive integer | no | `null` |
1322
+ | `calendar.monthDays` | positive integer | no | `null` |
1323
+ | `calendar.present` | date string | no | `null` |
1324
+
1325
+ `months` and `monthDays` bound a written month and day. **Both are optional, and
1326
+ an absent one is checked against nothing** — a year of twelve thirty-day months
1327
+ is as ordinary here as a Gregorian one, so the bound is declared or it is not
1328
+ applied. One subdivision to a package: an era moves where year 1 sits, it does
1329
+ not divide the year differently.
1330
+
1331
+ `present` is the date an age is computed against. It is written in the same
1332
+ grammar a note's dates use — `[~][-]YYYY[/MM[/DD]] [<affiliation shortcode>.<era
1333
+ shortcode>]` — so a package whose present is best said in an era says it that
1334
+ way, and one that states no present computes no age at all.
1335
+
1336
+ An age is a function of two **dates**, not two years: subtracting years alone
1337
+ reds every note whose birthday falls later in the year than the present date, so
1338
+ the day is part of what is declared.
1339
+
1340
+ > ``package-build config: `calendar` must be a mapping.``
1341
+
1342
+ > ``package-build config: `calendar.months` must be a positive integer.``
1343
+
1344
+ > ``package-build config: `calendar.monthDays` must be a positive integer.``
1345
+
1346
+ > ``package-build config: `calendar.present` must be a date written as a string.``
1347
+
1348
+ > ``package-build config: `calendar.present` must be a date — a package that states no present computes no age, so leave the key out rather than writing `unknown`.``
1349
+
1350
+ > ``package-build config: `calendar.present` is not a date this package can read — `720/13/1` writes month 13, and `calendar.months` declares a year of 12.``
1351
+
1352
+ > ``package-build config: `calendar.<key>` is not a recognized option (expected one of: months, monthDays, present).``
1353
+
1300
1354
  ### `packageBuild`
1301
1355
 
1302
1356
  Reserved for `@heroiclands/package-build`'s own packaging half — see
@@ -320,16 +320,51 @@ is declared, so a near miss is a finding that names what you probably meant.
320
320
  | **being kind** | `being` | `character`, `creature` |
321
321
  | **state** | any | `draft` |
322
322
 
323
- **`draft` is the one tag either build reads.** A note tagged `draft` exists so a
324
- link into it is not dead, and a link into it renders marked — as
323
+ **`draft` is the one tag either build reads.** It does two things and nothing
324
+ else: a link _into_ a draft note renders marked, and the note itself states that
325
+ it is unfinished wherever a reader meets it.
326
+
327
+ A link into it renders as
325
328
  `<span class="sohl-draft-link" title="Draft — not yet written">…</span>` in a
326
329
  compiled journal and on the website alike, with the appearance supplied by the
327
- consuming system's stylesheet or the site theme. **Nothing else changes**: the
328
- note compiles, validates, publishes and resolves exactly as any other, and it
329
- stays in the packs, in the link manifest and on the site. That is what separates
330
- the tag from the retired `draft:` field, whose whole effect was to move a note
331
- from published to unresolvable without saying so — and which is refused, by name,
332
- if you write it.
330
+ consuming system's stylesheet or the site theme.
331
+
332
+ The note's own statement is the **draft notice**, and it reads the same on every
333
+ surface:
334
+
335
+ > **Draft.** This entry is unfinished. What it states may change, and nothing in
336
+ > it is settled.
337
+
338
+ It leads the entry — above the infobox and above the prose — because a reader
339
+ deciding whether to rely on an entry has to be told before they read it. Where
340
+ it lands:
341
+
342
+ | Surface | What carries it |
343
+ | ------------ | -------------------------------------------------------------------- |
344
+ | JournalEntry | a `blockquote.sohl-draft-notice` at the head of the first page |
345
+ | Actor | the dossier the sheet draws — SoHL's `dossier`, HM3's `biography` |
346
+ | The book | a drawn mark and a left rule between the entry's plate and its panel |
347
+ | The website | the theme's own notice, above the infobox rail and the prose |
348
+
349
+ **An Actor carries it once.** A being's prose reaches two fields, and a document
350
+ saying the same thing twice teaches a reader to skip it — a printed sheet draws
351
+ both in turn — so the dossier carries the notice and the appearance is
352
+ untouched. The two systems pair by the authored section each field reads rather
353
+ than by its name, so `dossier` and `biography` carry it while `appearance` and
354
+ `description` do not.
355
+
356
+ **The mark is a notice, not a hazard** — `fa-circle-exclamation`, which Foundry
357
+ bundles, so a journal page carries it with no module stylesheet loaded. The book
358
+ draws its own, because a consumer declaring no icon family is the ordinary case
359
+ and a glyph a face lacks is silent in Typst. Stripped of mark and rule the
360
+ sentence still states the whole thing, which is the floor every surface is built
361
+ to.
362
+
363
+ **Nothing else changes**: the note compiles, validates, publishes and resolves
364
+ exactly as any other, and it stays in the packs, in the link manifest and on the
365
+ site. That is what separates the tag from the retired `draft:` field, whose whole
366
+ effect was to move a note from published to unresolvable without saying so — and
367
+ which is refused, by name, if you write it.
333
368
 
334
369
  **The group's scope is what makes the check work.** A group names the types it
335
370
  applies to, and a place's kinds are only ever checked on a place. Without that
@@ -1677,11 +1712,11 @@ convention rather than declared in a field — see
1677
1712
  A note has three states, and two of them are nothing but conventions the format
1678
1713
  already carried.
1679
1714
 
1680
- | State | Body | Marker | Page | Document | Link target |
1681
- | --------- | ------- | --------------- | ----------- | --------------------------------- | ----------- |
1682
- | **stub** | empty | none | no | yes, where one would not be empty | no |
1683
- | **draft** | written | `tags: [draft]` | yes, marked | yes | yes |
1684
- | **full** | written | none | yes | yes | yes |
1715
+ | State | Body | Marker | Page | Document | Link target |
1716
+ | --------- | ------- | --------------- | ------------ | --------------------------------- | ----------- |
1717
+ | **stub** | empty | none | no | yes, where one would not be empty | no |
1718
+ | **draft** | written | `tags: [draft]` | yes, noticed | yes, noticed | yes |
1719
+ | **full** | written | none | yes | yes | yes |
1685
1720
 
1686
1721
  "Document" here is the document a note's frontmatter earns. A system's Actor or
1687
1722
  Item is earned by that system's block, in every state —
@@ -2266,8 +2301,9 @@ Generates a living (or undead, or spirit) being.
2266
2301
  | `affiliations` | `WikiLink[]` | Affilliations (e.g., arcane/divine traditions, polities, etc) |
2267
2302
  | `gender` | `male \| female \| other` | Gender of the character |
2268
2303
  | `species` | `WikiLink` | Being's species (lore) |
2269
- | `age` | `number` | Age of the character |
2270
- | `birthday` | `YYYY/MM/DD` | Date of birth of the character |
2304
+ | `born` | `YYYY/MM/DD \| unknown` | When the being was born; absent, it was never born |
2305
+ | `died` | `YYYY/MM/DD \| unknown` | When the being died; absent, it is alive |
2306
+ | `age` | `34 \| ~34` | Age in years, stated only to override what `born` says; `~` marks an estimate |
2271
2307
  | `height` | `number` | Height in meters |
2272
2308
  | `weight` | `number` | Weight in kilograms |
2273
2309
  | `frame` | `scant \| light \| medium \| large \| massive` | Relative frame size |
@@ -2277,6 +2313,31 @@ Generates a living (or undead, or spirit) being.
2277
2313
  | `appearance.complexion` | `string` | Complexion |
2278
2314
  | `appearance.extra_features` | `string[]` | Extra features |
2279
2315
 
2316
+ #### When a being was born, when it died, and how old it is
2317
+
2318
+ **Absence asserts a fact rather than recording a gap**, and that is what these
2319
+ three fields are built around.
2320
+
2321
+ - **`born` absent** says the being was **never born** — a construct, a spirit,
2322
+ an elemental, a thing that was made. **`born: unknown`** says it was born and
2323
+ the date is unrecorded, which is the commonest shape in a tree.
2324
+ - **`died` absent** says the being is **alive**, which is the common case and so
2325
+ takes the cheapest form. **`died: unknown`** says it is dead and the date is
2326
+ unrecorded. Both spellings of `died` mean dead.
2327
+ - **`age` is optional and is an override.** Written, it wins over anything the
2328
+ 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.
2331
+
2332
+ Most people in a setting do not know their own birth date, so an age beside
2333
+ `born: unknown` is an ordinary complete record and not a note working around the
2334
+ schema. Where the number is somebody's estimate rather than a fact, write it
2335
+ `~34` — the same `~` a date carries.
2336
+
2337
+ `birthday:` is the retired spelling of `born:`. Both are read and `born` wins,
2338
+ so a note compiles identically either way; the old spelling is reported until
2339
+ every tree has moved.
2340
+
2280
2341
  #### The lead image
2281
2342
 
2282
2343
  **A being's portrait is the first thing inside its `{#appearance}` section,
@@ -0,0 +1,132 @@
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 axis every date normalises onto, and the one function that puts a
16
+ * reckoning on it.**
17
+ *
18
+ * A setting keeps several counts of years at once, each proclaimed by a body
19
+ * that has a note — a founding, a first occasion, a reign. A date either names
20
+ * one of them or names none, in which case it sits on the **canonical axis**.
21
+ *
22
+ * ## The canonical axis is an authoring device, not a calendar
23
+ *
24
+ * The axis is the line of integers dates written by peoples who never met are
25
+ * compared on. Nobody in a setting keeps it, no body stands behind it, and no
26
+ * shortcode addresses it: a date is on the axis exactly when it names no era.
27
+ * A reckoning whose year 1 happens to sit where the axis's year 1 sits
28
+ * coincides with it numerically and is still that people's own count.
29
+ *
30
+ * ## There is no year zero, and one function converts
31
+ *
32
+ * The authored numbering skips zero — `-1` sits immediately before `1` — while
33
+ * the normalised value does not, so that arithmetic across an epoch is one
34
+ * subtraction rather than a special case somebody forgets.
35
+ *
36
+ * ```text
37
+ * authored … -3 -2 -1 1 2 3 …
38
+ * canonicalYear … -2 -1 0 1 2 3 …
39
+ * ```
40
+ *
41
+ * ```text
42
+ * canonicalYear(y, E) = E + (y > 0 ? y - 1 : y)
43
+ * ```
44
+ *
45
+ * `E` is where a reckoning's year 1 sits on the axis — the `canonicalYear` of
46
+ * an era's own `start`. The axis is the same function with `E` of
47
+ * {@link CANONICAL_EPOCH}, which holds by definition: the axis's year 1 is the
48
+ * axis's year 1.
49
+ *
50
+ * **The conversion is piecewise on the sign**, and that is the property worth
51
+ * guarding. Collapsed to a single branch it is correct on one side of an epoch
52
+ * and off by one on the other, which is invisible in any list that does not
53
+ * span it.
54
+ *
55
+ * ## The month structure is the package's, and an absent bound checks nothing
56
+ *
57
+ * An era moves where year 1 sits; it does not divide the year differently. So
58
+ * the subdivision is declared once for the package, in `calendar.months` and
59
+ * `calendar.monthDays`, and a package that declares neither is bounded below
60
+ * and not above — a toolchain that invented twelve Gregorian months would
61
+ * refuse `667/2/30`, which is an ordinary date in a year of twelve thirty-day
62
+ * months.
63
+ *
64
+ * @module
65
+ */
66
+
67
+ /**
68
+ * Where the canonical axis's own year 1 sits — on itself.
69
+ *
70
+ * It takes no configuration entry and no era row, because it is true by
71
+ * definition rather than by declaration.
72
+ *
73
+ * @type {number}
74
+ */
75
+ export const CANONICAL_EPOCH = 1;
76
+
77
+ /**
78
+ * The year on the canonical axis that a written year lands on.
79
+ *
80
+ * The whole of the conversion. A reckoning differs from its neighbours in
81
+ * where its year 1 sits and in nothing else, so an era states one number and
82
+ * this states what to do with it.
83
+ *
84
+ * The two branches are the year-zero hole: the authored numbering has no year
85
+ * 0, so a positive year is one place closer to the epoch than its magnitude
86
+ * suggests and a negative year is exactly its magnitude below it.
87
+ *
88
+ * @param {number} year - The year as authored, signed, within its own
89
+ * reckoning. Never 0 — the authored numbering has no such year.
90
+ * @param {number} [epoch] - Where that reckoning's year 1 sits on the axis.
91
+ * Omitted, the axis itself.
92
+ * @returns {number} The signed year on the axis — authored `-1` is `0`.
93
+ */
94
+ export function canonicalYear(year, epoch = CANONICAL_EPOCH) {
95
+ return epoch + (year > 0 ? year - 1 : year);
96
+ }
97
+
98
+ /**
99
+ * The total-order key a date sorts on.
100
+ *
101
+ * Month and day fold into the fraction, so a mixed-precision list sorts stably
102
+ * against a single number instead of a three-key comparator. The arithmetic is
103
+ * done in ten-thousandths and divided once, because `year + 6 / 100 + 19 /
104
+ * 10000` accumulates a rounding error and `689.0619` is a value a reader
105
+ * compares against by eye.
106
+ *
107
+ * A coarser value sorts **before** the finer values inside it: `689` precedes
108
+ * `689/1`, which is the ordering a reader expects of a year whose month nobody
109
+ * wrote.
110
+ *
111
+ * @param {number} year - The year on the canonical axis.
112
+ * @param {number|null} month - The month, or `null`.
113
+ * @param {number|null} day - The day, or `null`.
114
+ * @returns {number} The sort key.
115
+ */
116
+ export function dateSortKey(year, month, day) {
117
+ return (year * 10000 + (month ?? 0) * 100 + (day ?? 0)) / 10000;
118
+ }
119
+
120
+ /**
121
+ * The bounds a package states for a month, or `null` where it states none.
122
+ *
123
+ * Separate from the check that reads it so a caller can ask what a package
124
+ * declares without phrasing a finding about it.
125
+ *
126
+ * @param {{months?: number|null, monthDays?: number|null}} [calendar] - The
127
+ * resolved `calendar:` block.
128
+ * @returns {{months: number|null, monthDays: number|null}} What it declares.
129
+ */
130
+ export function calendarStructure(calendar) {
131
+ return { months: calendar?.months ?? null, monthDays: calendar?.monthDays ?? null };
132
+ }
@@ -102,6 +102,7 @@ import { currentType } from "./ids.mjs";
102
102
  import { SCHEMA_ARTIFACT_VERSION } from "./schema-check.mjs";
103
103
  import { authoredFields } from "./field-spec.mjs";
104
104
  import { positionInFrontmatter } from "./diagnostics.mjs";
105
+ import { RETIRED_FIELD_ALIASES } from "./retired-fields.mjs";
105
106
 
106
107
  /**
107
108
  * Every field path any subtype of a published schema declares.
@@ -357,8 +358,16 @@ export function measureNote(note, format, { severity = "warning" } = {}) {
357
358
 
358
359
  // `data:` is closed: every key it carries must be one the type declares.
359
360
  const data = isBlock(fm.data) ? fm.data : undefined;
361
+ // The retired spelling of a key the format states is measured as the key it
362
+ // will be written as, exactly as a note still on a renamed *type* is
363
+ // measured against the section it will move to. The rename itself is the
364
+ // frontmatter lint's finding, and reporting it here as well would count one
365
+ // note twice in a migration whose counts are the point.
366
+ const renamedDataKeys = new Set(
367
+ [...spec.dataKeys].map((key) => RETIRED_FIELD_ALIASES[key]).filter(Boolean),
368
+ );
360
369
  for (const key of Object.keys(data ?? {})) {
361
- if (spec.dataKeys.has(key)) continue;
370
+ if (spec.dataKeys.has(key) || renamedDataKeys.has(key)) continue;
362
371
  const guess = nearest(key, spec.dataKeys);
363
372
  add(
364
373
  "unknown-data-key",