@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 +42 -0
- package/CONTENT.md +5 -0
- package/content-config.mjs +78 -0
- package/docs/api.md +39 -0
- package/docs/configuration.md +57 -3
- package/docs/content-format.md +76 -15
- package/engine/calendars.mjs +132 -0
- package/engine/content-format-check.mjs +10 -1
- package/engine/draft-notice.mjs +207 -0
- package/engine/frontmatter-lint.mjs +26 -0
- package/engine/index.mjs +9 -0
- package/engine/infobox.mjs +7 -2
- package/engine/item-docs.mjs +9 -8
- package/engine/journals.mjs +23 -4
- package/engine/note-dates.mjs +344 -0
- package/engine/note-vocabulary.mjs +26 -2
- package/engine/pdf-build.mjs +8 -1
- package/engine/pdf-render.mjs +16 -0
- package/engine/retired-fields.mjs +58 -8
- package/hm3/actors.mjs +5 -1
- package/package.json +1 -1
- package/sohl/actors.mjs +6 -1
- package/types/content-config.d.mts +10 -0
- package/types/engine/calendars.d.mts +115 -0
- package/types/engine/draft-notice.d.mts +99 -0
- package/types/engine/index.d.mts +3 -0
- package/types/engine/item-docs.d.mts +9 -8
- package/types/engine/journals.d.mts +7 -1
- package/types/engine/note-dates.d.mts +70 -0
- package/types/engine/retired-fields.d.mts +23 -2
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
|
package/content-config.mjs
CHANGED
|
@@ -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.
|
package/docs/configuration.md
CHANGED
|
@@ -48,7 +48,7 @@ around its own evaluation.
|
|
|
48
48
|
|
|
49
49
|
### Quick reference
|
|
50
50
|
|
|
51
|
-
|
|
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
|
|
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
|
package/docs/content-format.md
CHANGED
|
@@ -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.**
|
|
324
|
-
link
|
|
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.
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
|
1681
|
-
| --------- | ------- | --------------- |
|
|
1682
|
-
| **stub** | empty | none | no
|
|
1683
|
-
| **draft** | written | `tags: [draft]` | yes,
|
|
1684
|
-
| **full** | written | none | 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
|
-
| `
|
|
2270
|
-
| `
|
|
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",
|