@heroiclands/package-build 22.7.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 +33 -0
- package/content-config.mjs +78 -0
- package/docs/api.md +22 -0
- package/docs/configuration.md +57 -3
- package/docs/content-format.md +28 -2
- package/engine/calendars.mjs +132 -0
- package/engine/content-format-check.mjs +10 -1
- package/engine/draft-notice.mjs +45 -13
- package/engine/frontmatter-lint.mjs +26 -0
- package/engine/index.mjs +6 -0
- package/engine/infobox.mjs +7 -2
- package/engine/note-dates.mjs +344 -0
- package/engine/note-vocabulary.mjs +26 -2
- package/engine/pdf-render.mjs +10 -0
- package/engine/retired-fields.mjs +58 -8
- package/package.json +1 -1
- package/types/content-config.d.mts +10 -0
- package/types/engine/calendars.d.mts +115 -0
- package/types/engine/draft-notice.d.mts +26 -4
- package/types/engine/index.d.mts +2 -0
- 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,38 @@
|
|
|
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
|
+
|
|
3
36
|
## 22.7.0
|
|
4
37
|
|
|
5
38
|
### Minor Changes
|
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
|
@@ -604,6 +604,28 @@ The closed registry of system ids, and the `none` that stands for no system at a
|
|
|
604
604
|
| `assertSystemSegment` | `assertSystemSegment(value, where)` | `string` — the value, unchanged | refusing a value the `<system>` segment may not hold, validating inline |
|
|
605
605
|
| `assertSystemCharset` | `assertSystemCharset(segments, where)` | throws | refusing a registry declaration whose id could not be an address segment |
|
|
606
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
|
+
|
|
607
629
|
### `engine.contentAddress`
|
|
608
630
|
|
|
609
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
|
@@ -2301,8 +2301,9 @@ Generates a living (or undead, or spirit) being.
|
|
|
2301
2301
|
| `affiliations` | `WikiLink[]` | Affilliations (e.g., arcane/divine traditions, polities, etc) |
|
|
2302
2302
|
| `gender` | `male \| female \| other` | Gender of the character |
|
|
2303
2303
|
| `species` | `WikiLink` | Being's species (lore) |
|
|
2304
|
-
| `
|
|
2305
|
-
| `
|
|
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 |
|
|
2306
2307
|
| `height` | `number` | Height in meters |
|
|
2307
2308
|
| `weight` | `number` | Weight in kilograms |
|
|
2308
2309
|
| `frame` | `scant \| light \| medium \| large \| massive` | Relative frame size |
|
|
@@ -2312,6 +2313,31 @@ Generates a living (or undead, or spirit) being.
|
|
|
2312
2313
|
| `appearance.complexion` | `string` | Complexion |
|
|
2313
2314
|
| `appearance.extra_features` | `string[]` | Extra features |
|
|
2314
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
|
+
|
|
2315
2341
|
#### The lead image
|
|
2316
2342
|
|
|
2317
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",
|
package/engine/draft-notice.mjs
CHANGED
|
@@ -33,6 +33,14 @@
|
|
|
33
33
|
* is the floor each surface is built to, because each of them can lose
|
|
34
34
|
* everything around it.
|
|
35
35
|
*
|
|
36
|
+
* **The label is bold roman and the sentence is italic**, on every surface. The
|
|
37
|
+
* words around it are the world describing itself; this one sentence is the
|
|
38
|
+
* text speaking about its own state, and italic is how a reader is told the
|
|
39
|
+
* voice has changed. It is the one typographic mark the sentence carries:
|
|
40
|
+
* smaller or lighter type would say the reader may skip it, which is the
|
|
41
|
+
* opposite of what it is for, and a bold italic beside the mark would be one
|
|
42
|
+
* emphasis too many.
|
|
43
|
+
*
|
|
36
44
|
* ## What each surface can rely on
|
|
37
45
|
*
|
|
38
46
|
* - **Foundry** renders a JournalEntry's own HTML inside a sheet, so the floor
|
|
@@ -87,7 +95,8 @@ export const BOOK_DRAFT_NOTICE = "book-draft-notice";
|
|
|
87
95
|
* `body.game .app blockquote` carries a left rule and an indent — so the
|
|
88
96
|
* treatment survives with no stylesheet of this package's own. The mark is a
|
|
89
97
|
* Font Awesome element, which Foundry bundles; `aria-hidden` keeps it out of a
|
|
90
|
-
* screen reader, where the label reads it.
|
|
98
|
+
* screen reader, where the label reads it. The sentence is an `em`, which every
|
|
99
|
+
* renderer of HTML sets in italic with no stylesheet to ask.
|
|
91
100
|
*
|
|
92
101
|
* @returns {string} The notice's HTML.
|
|
93
102
|
*/
|
|
@@ -95,7 +104,7 @@ export function draftNoticeHtml() {
|
|
|
95
104
|
return (
|
|
96
105
|
`<blockquote class="${DRAFT_NOTICE_CLASS}">` +
|
|
97
106
|
`<p><i class="fa-solid ${DRAFT_NOTICE_ICON}" aria-hidden="true"></i> ` +
|
|
98
|
-
`<strong>${DRAFT_NOTICE_LABEL}</strong>
|
|
107
|
+
`<strong>${DRAFT_NOTICE_LABEL}</strong> <em>${DRAFT_NOTICE}</em></p>` +
|
|
99
108
|
`</blockquote>`
|
|
100
109
|
);
|
|
101
110
|
}
|
|
@@ -139,9 +148,30 @@ export function withDraftNotice(fm, html) {
|
|
|
139
148
|
/**
|
|
140
149
|
* The `#let` the book's notice is drawn by.
|
|
141
150
|
*
|
|
142
|
-
* Emitted with the rest of the book's preamble. The mark is a
|
|
143
|
-
* exclamation
|
|
144
|
-
* page, so there is no font to resolve and nothing that can come out as a
|
|
151
|
+
* Emitted with the rest of the book's preamble. The mark is a disc with an
|
|
152
|
+
* exclamation knocked out of it: two primitives and the face already setting
|
|
153
|
+
* the page, so there is no font to resolve and nothing that can come out as a
|
|
154
|
+
* box. It is filled in the accent the book rules its headings with and the
|
|
155
|
+
* glyph is the colour of the paper, so the mark carries against the running
|
|
156
|
+
* face rather than reading as a character of it.
|
|
157
|
+
*
|
|
158
|
+
* **The mark is a column, not a word.** A two-column grid puts it beside the
|
|
159
|
+
* sentence rather than inside it, so a notice that takes two lines sets its
|
|
160
|
+
* second line against the first and never under the disc; the grid centres the
|
|
161
|
+
* mark's cell on the block, so it holds the middle of one line or two. The rule
|
|
162
|
+
* is the block's own left stroke and spans whatever height the sentence takes.
|
|
163
|
+
*
|
|
164
|
+
* **The sentence sets in the book's body face, size and ink**, because it is
|
|
165
|
+
* the one thing in the entry a reader has to read before trusting the rest. The
|
|
166
|
+
* rule and the mark set the block apart; nothing else has to, and type set
|
|
167
|
+
* smaller or lighter than the prose around it reads as apparatus a reader may
|
|
168
|
+
* skip. The mark scales with that text, so a consumer setting the book larger
|
|
169
|
+
* or smaller carries the disc with it.
|
|
170
|
+
*
|
|
171
|
+
* **One number sets the mark.** The glyph is a multiple of the disc's radius
|
|
172
|
+
* rather than a size of its own, so the exclamation keeps its share of the disc
|
|
173
|
+
* however large the disc is drawn and resizing the mark is one edit. Both are
|
|
174
|
+
* `em`, so the mark still follows the body text.
|
|
145
175
|
*
|
|
146
176
|
* @returns {string} The Typst definition.
|
|
147
177
|
*/
|
|
@@ -152,14 +182,16 @@ export function bookDraftNoticePreamble() {
|
|
|
152
182
|
" inset: (left: 0.7em, top: 0.35em, bottom: 0.35em),\n" +
|
|
153
183
|
" stroke: (left: 1.6pt + book-faint))[\n" +
|
|
154
184
|
" #set par(justify: false, first-line-indent: 0em)\n" +
|
|
155
|
-
" #
|
|
156
|
-
" #
|
|
157
|
-
|
|
158
|
-
"
|
|
159
|
-
"
|
|
160
|
-
'
|
|
161
|
-
"
|
|
162
|
-
"
|
|
185
|
+
" #let radius = 0.69em\n" +
|
|
186
|
+
" #grid(columns: (auto, 1fr), column-gutter: 0.5em,\n" +
|
|
187
|
+
" align: (center + horizon, horizon),\n" +
|
|
188
|
+
" circle(radius: radius, fill: book-accent)[\n" +
|
|
189
|
+
" #align(center + horizon)[\n" +
|
|
190
|
+
' #text(size: 1.6 * radius, weight: "bold", fill: book-paper)[!]\n' +
|
|
191
|
+
" ]\n" +
|
|
192
|
+
" ],\n" +
|
|
193
|
+
' [#text(weight: "bold")[#label]#h(0.25em)#emph[#body]],\n' +
|
|
194
|
+
" )\n" +
|
|
163
195
|
"]"
|
|
164
196
|
);
|
|
165
197
|
}
|
|
@@ -455,8 +455,34 @@ function checkDataContainer(note, { type, fields, packs }) {
|
|
|
455
455
|
/** First segment of each declared name — `charges.value` is authored as `charges`. */
|
|
456
456
|
const declared = new Set(fields.map((f) => f.name.split(".")[0]));
|
|
457
457
|
|
|
458
|
+
// The retired spelling of a key this type declares → what to write now.
|
|
459
|
+
// Built from the type's own declaration, so a renamed `data:` key is
|
|
460
|
+
// retired exactly where its replacement exists and stays an unrecognised
|
|
461
|
+
// key on every other type.
|
|
462
|
+
const renamed = new Map();
|
|
463
|
+
for (const name of declared) {
|
|
464
|
+
const retired = RETIRED_FIELD_ALIASES[name];
|
|
465
|
+
if (retired) renamed.set(retired, name);
|
|
466
|
+
}
|
|
467
|
+
|
|
458
468
|
for (const key of Object.keys(entries)) {
|
|
459
469
|
if (declared.has(key)) continue;
|
|
470
|
+
const current = renamed.get(key);
|
|
471
|
+
if (current) {
|
|
472
|
+
findings.push({
|
|
473
|
+
file: note.file,
|
|
474
|
+
...positionOfFrontmatterPath(raw, ["data", key], { key: true }),
|
|
475
|
+
// A warning, as every retired spelling is: the note compiles to
|
|
476
|
+
// the correct document, so failing a build over it would red a
|
|
477
|
+
// tree that has done nothing wrong yet. The refusal comes once
|
|
478
|
+
// no tree writes the key, and needs no code — with the alias
|
|
479
|
+
// gone the spelling is an unrecognised key again, which the
|
|
480
|
+
// loop below already refuses.
|
|
481
|
+
severity: "warning",
|
|
482
|
+
message: retiredAliasMessage(key, current),
|
|
483
|
+
});
|
|
484
|
+
continue;
|
|
485
|
+
}
|
|
460
486
|
const guess = nearest(key, declared);
|
|
461
487
|
findings.push({
|
|
462
488
|
file: note.file,
|
package/engine/index.mjs
CHANGED
|
@@ -124,6 +124,12 @@ export * as siteMaps from "./site-maps.mjs";
|
|
|
124
124
|
/** The closed registry of system ids, and the `none` that stands for no system. */
|
|
125
125
|
export * as systems from "./systems.mjs";
|
|
126
126
|
|
|
127
|
+
/** The reckonings a package dates by, and the arithmetic that puts them on one line. */
|
|
128
|
+
export * as calendars from "./calendars.mjs";
|
|
129
|
+
|
|
130
|
+
/** A note's dates: one authored string, one grammar, one record shape. */
|
|
131
|
+
export * as noteDates from "./note-dates.mjs";
|
|
132
|
+
|
|
127
133
|
/** The one normalisation this build makes: prose to a URL-safe token. */
|
|
128
134
|
export * as contentSlug from "./content-slug.mjs";
|
|
129
135
|
|