@heroiclands/package-build 22.8.0 → 22.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/content-config.mjs +0 -78
- package/docs/api.md +45 -9
- package/docs/configuration.md +1 -55
- package/docs/content-format.md +150 -14
- package/engine/base-compiler.mjs +9 -0
- package/engine/being-age.mjs +270 -0
- package/engine/calendar-notes.mjs +805 -0
- package/engine/calendars.mjs +115 -14
- package/engine/content-index.mjs +15 -1
- package/engine/index.mjs +6 -0
- package/engine/infobox.mjs +6 -0
- package/engine/note-dates.mjs +27 -21
- package/engine/note-vocabulary.mjs +57 -6
- package/engine/site-build.mjs +16 -0
- package/package.json +1 -1
- package/types/content-config.d.mts +0 -10
- package/types/engine/base-compiler.d.mts +1 -0
- package/types/engine/being-age.d.mts +92 -0
- package/types/engine/calendar-notes.d.mts +173 -0
- package/types/engine/calendars.d.mts +108 -17
- package/types/engine/index.d.mts +2 -0
- package/types/engine/note-dates.d.mts +6 -6
|
@@ -0,0 +1,805 @@
|
|
|
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
|
+
* **A calendar is a note, the world's facts are a note, and one object is
|
|
16
|
+
* emitted from both.**
|
|
17
|
+
*
|
|
18
|
+
* ## Two layers, and the whole arrangement is the distinction
|
|
19
|
+
*
|
|
20
|
+
* A **world fact** is how long the year is, how the day divides, and how the
|
|
21
|
+
* moon moves. Every people on the world lives under the same ones, so no
|
|
22
|
+
* calendar may restate them: they are read from the package's `place` notes —
|
|
23
|
+
* `data.year` and `data.present` from the world, `data.moon` from the moon —
|
|
24
|
+
* and written by the emitters.
|
|
25
|
+
*
|
|
26
|
+
* A **calendar** is one people's division of those days: the months they name,
|
|
27
|
+
* the weekdays if they keep any, the seasons they mark, and the eras they count
|
|
28
|
+
* years in. That is a `lore` note with `subType: calendar`, and its whole
|
|
29
|
+
* authorable surface is {@link CALENDAR_FIELDS}.
|
|
30
|
+
*
|
|
31
|
+
* Because the invariants are unauthorable, **the sum guard is the only
|
|
32
|
+
* arithmetic a calendar note is held to**: the months must add up to the year
|
|
33
|
+
* the world keeps. A list that does not is not a different calendar, it is a
|
|
34
|
+
* calendar that fails to describe the world — which is what the finding says.
|
|
35
|
+
*
|
|
36
|
+
* ## A package with no world note has no calendar mechanism
|
|
37
|
+
*
|
|
38
|
+
* `daysPerYear` comes from `data.year.days` on the package's own world note. A
|
|
39
|
+
* package that declares none — every Hârn package this toolchain builds —
|
|
40
|
+
* compiles no calendars and is asked no questions about one. A package that
|
|
41
|
+
* declares a moon and no year is refused: a cycle of thirty days has no year to
|
|
42
|
+
* be measured against.
|
|
43
|
+
*
|
|
44
|
+
* ## One definition, two readers
|
|
45
|
+
*
|
|
46
|
+
* The emitted definition is written in **array shape** — `months.values`,
|
|
47
|
+
* `days.values`, `seasons.values` as arrays, which is Foundry core's
|
|
48
|
+
* `ArrayField` shape.
|
|
49
|
+
*
|
|
50
|
+
* - **Core takes it and prunes what it does not know.** `SchemaField#clean`
|
|
51
|
+
* discards every key outside the schema before validation, by default and
|
|
52
|
+
* silently (`common/data/fields.mjs`, `DataModel.cleanData` passing
|
|
53
|
+
* `prune: true`), so `eras`, `moons`, `epochDayOffset` and `metadata` cost a
|
|
54
|
+
* core consumer nothing.
|
|
55
|
+
* - **Calendaria takes the same object and converts the arrays**, in
|
|
56
|
+
* `migrateData`, ahead of validation, on exactly those three collections.
|
|
57
|
+
*
|
|
58
|
+
* So one artefact serves both, and two artefacts drifting is a failure mode
|
|
59
|
+
* this pair does not have. What no test here can prove is schema conformance
|
|
60
|
+
* itself — this package's testing carries no Foundry stubs on purpose — so the
|
|
61
|
+
* consumer build and the Foundry container prove that, and the shapes below
|
|
62
|
+
* claim no more than the emitters produce.
|
|
63
|
+
*
|
|
64
|
+
* @module
|
|
65
|
+
*/
|
|
66
|
+
|
|
67
|
+
import { dayOfYear, daysInYear } from "./calendars.mjs";
|
|
68
|
+
import { positionInFrontmatter, positionOfFrontmatterPath } from "./diagnostics.mjs";
|
|
69
|
+
import { parseNoteDate } from "./note-dates.mjs";
|
|
70
|
+
|
|
71
|
+
/** The `lore` subType whose notes are calendars. */
|
|
72
|
+
export const CALENDAR_SUBTYPE = "calendar";
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The `place` subTypes a world fact may be written on.
|
|
76
|
+
*
|
|
77
|
+
* `world` is a body people stand on; `celestial` is one they only look at. A
|
|
78
|
+
* region, a settlement, a site, a structure or a feature is somewhere *in* a
|
|
79
|
+
* world and states nothing about the world's year.
|
|
80
|
+
*
|
|
81
|
+
* @type {readonly string[]}
|
|
82
|
+
*/
|
|
83
|
+
export const INVARIANT_SUBTYPES = Object.freeze(["world", "celestial"]);
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The `data:` keys a calendar note may write — the closed list, in authored
|
|
87
|
+
* order.
|
|
88
|
+
*
|
|
89
|
+
* Every key in either target schema that is not here is either a world fact,
|
|
90
|
+
* read from a `place` note and written by the emitters, or omitted because no
|
|
91
|
+
* calendar has declared one.
|
|
92
|
+
*
|
|
93
|
+
* @type {readonly object[]}
|
|
94
|
+
*/
|
|
95
|
+
export const CALENDAR_FIELDS = Object.freeze([
|
|
96
|
+
{
|
|
97
|
+
name: "epoch",
|
|
98
|
+
shape: "a date",
|
|
99
|
+
kind: "string",
|
|
100
|
+
describe: "Which in-world day the world's clock reads zero on — `720/1/1`.",
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
name: "months",
|
|
104
|
+
shape: "list of `{ name, abbreviation?, days }`",
|
|
105
|
+
kind: "list",
|
|
106
|
+
describe:
|
|
107
|
+
"The months this calendar keeps, in order — position in the list is " +
|
|
108
|
+
"position in the year, and the day counts sum to the world's year.",
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
name: "weekdays",
|
|
112
|
+
shape: "list of `{ name, abbreviation? }`",
|
|
113
|
+
kind: "list",
|
|
114
|
+
describe:
|
|
115
|
+
"The days of the week this calendar names, in order. A calendar with " +
|
|
116
|
+
"no week writes none.",
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
name: "seasons",
|
|
120
|
+
shape: "list of `{ name, abbreviation?, monthStart?, monthEnd?, dayStart?, dayEnd? }`",
|
|
121
|
+
kind: "list",
|
|
122
|
+
describe: "The seasons this calendar marks, bounded by month or by day of year.",
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
name: "eras",
|
|
126
|
+
shape: "list of `{ shortcode, name, abbreviation?, proclaimedBy?, start, end? }`",
|
|
127
|
+
kind: "list",
|
|
128
|
+
describe:
|
|
129
|
+
"The year-counts kept in this calendar. A date names one by writing " +
|
|
130
|
+
"`<calendar shortcode>.<era shortcode>`.",
|
|
131
|
+
},
|
|
132
|
+
]);
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The `data:` keys a world or a celestial body may write.
|
|
136
|
+
*
|
|
137
|
+
* Four groups, and which note carries which is a fact about the body rather
|
|
138
|
+
* than a convention: a planet states its own size, tilt, year and present; a
|
|
139
|
+
* moon states its own size, orbit and cycle.
|
|
140
|
+
*
|
|
141
|
+
* @type {readonly object[]}
|
|
142
|
+
*/
|
|
143
|
+
export const INVARIANT_FIELDS = Object.freeze([
|
|
144
|
+
{
|
|
145
|
+
name: "world.equatorialCircumferenceKm",
|
|
146
|
+
shape: "number",
|
|
147
|
+
kind: "number",
|
|
148
|
+
describe: "The distance round the body at its equator, in kilometres.",
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
name: "world.surfaceGravityG",
|
|
152
|
+
shape: "number",
|
|
153
|
+
kind: "number",
|
|
154
|
+
describe: "Surface gravity, as a multiple of Earth's.",
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
name: "world.axialTiltDegrees",
|
|
158
|
+
shape: "number",
|
|
159
|
+
kind: "number",
|
|
160
|
+
describe: "The tilt of the axis, in degrees — what makes the body have seasons.",
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
name: "year.days",
|
|
164
|
+
shape: "number",
|
|
165
|
+
kind: "number",
|
|
166
|
+
describe: "How many days the body's year holds. Every calendar's months sum to it.",
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
name: "year.hoursPerDay",
|
|
170
|
+
shape: "number",
|
|
171
|
+
kind: "number",
|
|
172
|
+
describe: "How many hours the day divides into.",
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
name: "year.minutesPerHour",
|
|
176
|
+
shape: "number",
|
|
177
|
+
kind: "number",
|
|
178
|
+
describe: "How many minutes the hour divides into.",
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
name: "year.secondsPerMinute",
|
|
182
|
+
shape: "number",
|
|
183
|
+
kind: "number",
|
|
184
|
+
describe: "How many seconds the minute divides into.",
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
name: "present",
|
|
188
|
+
shape: "a date",
|
|
189
|
+
kind: "string",
|
|
190
|
+
describe: "The day the setting stops and play begins — what an age is computed against.",
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
name: "body.diameterKm",
|
|
194
|
+
shape: "number",
|
|
195
|
+
kind: "number",
|
|
196
|
+
describe: "The body's diameter, in kilometres.",
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
name: "body.orbitalRadiusKm",
|
|
200
|
+
shape: "number",
|
|
201
|
+
kind: "number",
|
|
202
|
+
describe: "How far the body orbits from the one it circles, in kilometres.",
|
|
203
|
+
},
|
|
204
|
+
{
|
|
205
|
+
name: "body.orbit",
|
|
206
|
+
shape: "string",
|
|
207
|
+
kind: "string",
|
|
208
|
+
describe: "The orbit's shape — `circular` means the cycle never varies.",
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
name: "body.inclined",
|
|
212
|
+
shape: "boolean",
|
|
213
|
+
kind: "boolean",
|
|
214
|
+
describe: "Whether the orbit is inclined to the plane the world orbits in.",
|
|
215
|
+
},
|
|
216
|
+
{
|
|
217
|
+
name: "moon.cycle",
|
|
218
|
+
shape: "number",
|
|
219
|
+
kind: "number",
|
|
220
|
+
describe: "How many days the body takes to return to the same phase.",
|
|
221
|
+
},
|
|
222
|
+
{
|
|
223
|
+
name: "moon.newOn",
|
|
224
|
+
shape: "a date",
|
|
225
|
+
kind: "string",
|
|
226
|
+
describe: "A day the body was new, written in the reference calendar.",
|
|
227
|
+
},
|
|
228
|
+
{
|
|
229
|
+
name: "moon.eclipses",
|
|
230
|
+
shape: "string",
|
|
231
|
+
kind: "string",
|
|
232
|
+
describe:
|
|
233
|
+
"How often the body eclipses or is eclipsed — `never`, `rare`, " +
|
|
234
|
+
"`occasional` or `frequent`.",
|
|
235
|
+
},
|
|
236
|
+
]);
|
|
237
|
+
|
|
238
|
+
/** First segment of every calendar key, for the check that refuses them elsewhere. */
|
|
239
|
+
const CALENDAR_KEYS = Object.freeze(CALENDAR_FIELDS.map((field) => field.name));
|
|
240
|
+
|
|
241
|
+
/** First segment of every world-fact key, for the check that scopes them. */
|
|
242
|
+
const INVARIANT_KEYS = Object.freeze([
|
|
243
|
+
...new Set(INVARIANT_FIELDS.map((field) => field.name.split(".")[0])),
|
|
244
|
+
]);
|
|
245
|
+
|
|
246
|
+
/** The `data:` map of a note, or an empty one. */
|
|
247
|
+
function dataOf(note) {
|
|
248
|
+
const data = note?.fm?.data;
|
|
249
|
+
return data && typeof data === "object" && !Array.isArray(data) ? data : {};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** A note's type, lower-cased, whether the walk recorded it or only the frontmatter did. */
|
|
253
|
+
function typeOf(note) {
|
|
254
|
+
return String(note?.type ?? note?.fm?.type ?? "").toLowerCase();
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** A note's subType, lower-cased. */
|
|
258
|
+
function subTypeOf(note) {
|
|
259
|
+
return String(note?.fm?.subType ?? "").toLowerCase();
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Which of a key list a note actually writes. */
|
|
263
|
+
function keysWritten(note, keys) {
|
|
264
|
+
const data = dataOf(note);
|
|
265
|
+
return keys.filter((key) => data[key] !== undefined && data[key] !== null);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* The world facts a package declares, read from its own notes.
|
|
270
|
+
*
|
|
271
|
+
* Nothing here is configured and nothing is assumed: a package with no world
|
|
272
|
+
* note has no year, and every question that depends on one goes unasked.
|
|
273
|
+
*
|
|
274
|
+
* @param {object} [index] - The link index, holding every note in the package.
|
|
275
|
+
* @returns {{year: object|null, present: unknown, moon: object|null,
|
|
276
|
+
* body: object|null, moonName: string|null, yearNote: object|null,
|
|
277
|
+
* moonNote: object|null}} What the package's notes state.
|
|
278
|
+
*/
|
|
279
|
+
export function worldInvariants(index) {
|
|
280
|
+
const empty = {
|
|
281
|
+
year: null,
|
|
282
|
+
present: null,
|
|
283
|
+
moon: null,
|
|
284
|
+
body: null,
|
|
285
|
+
moonName: null,
|
|
286
|
+
yearNote: null,
|
|
287
|
+
moonNote: null,
|
|
288
|
+
};
|
|
289
|
+
if (!index) return empty;
|
|
290
|
+
const found = { ...empty };
|
|
291
|
+
for (const note of index.notes ?? []) {
|
|
292
|
+
if (typeOf(note) !== "place") continue;
|
|
293
|
+
const data = dataOf(note);
|
|
294
|
+
if (!found.yearNote && data.year && typeof data.year === "object") {
|
|
295
|
+
found.year = data.year;
|
|
296
|
+
found.present = data.present ?? null;
|
|
297
|
+
found.yearNote = note;
|
|
298
|
+
}
|
|
299
|
+
if (!found.moonNote && data.moon && typeof data.moon === "object") {
|
|
300
|
+
found.moon = data.moon;
|
|
301
|
+
found.body = data.body && typeof data.body === "object" ? data.body : null;
|
|
302
|
+
found.moonName = String(note.fm?.name?.full ?? "") || null;
|
|
303
|
+
found.moonNote = note;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return found;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Every `place` note in the package carrying one of the world-fact groups.
|
|
311
|
+
*
|
|
312
|
+
* Sorted by path so a package declaring two reports the same pair whichever
|
|
313
|
+
* order the corpus walk happened to take.
|
|
314
|
+
*
|
|
315
|
+
* @param {object} index - The link index.
|
|
316
|
+
* @param {string} key - The group — `year` or `moon`.
|
|
317
|
+
* @returns {object[]} The notes, by path.
|
|
318
|
+
*/
|
|
319
|
+
function notesCarrying(index, key) {
|
|
320
|
+
const carrying = [];
|
|
321
|
+
for (const note of index.notes ?? []) {
|
|
322
|
+
if (typeOf(note) !== "place") continue;
|
|
323
|
+
const held = dataOf(note)[key];
|
|
324
|
+
if (held && typeof held === "object") carrying.push(note);
|
|
325
|
+
}
|
|
326
|
+
return carrying.sort((a, b) => String(a.rel ?? a.file).localeCompare(String(b.rel ?? b.file)));
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** A finding positioned at a `data:` key of a note. */
|
|
330
|
+
function atData(note, keyPath, severity, message) {
|
|
331
|
+
return {
|
|
332
|
+
file: note.file,
|
|
333
|
+
...positionOfFrontmatterPath(note.raw ?? "", ["data", ...keyPath], { key: true }),
|
|
334
|
+
severity,
|
|
335
|
+
message,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Check a `place` note's world facts.
|
|
341
|
+
*
|
|
342
|
+
* Declared on `place` beside its tenure check, so the lint runs both with the
|
|
343
|
+
* same index. Four rules, and each is a fact about the setting rather than
|
|
344
|
+
* about the file:
|
|
345
|
+
*
|
|
346
|
+
* - a world fact is written on a body — a `world` or a `celestial` — and
|
|
347
|
+
* nowhere else;
|
|
348
|
+
* - a package states **one** year, because two years is two settings and
|
|
349
|
+
* nothing can choose between them;
|
|
350
|
+
* - a package states **one** moon, for the same reason;
|
|
351
|
+
* - a moon with no year is refused, because a cycle measured in days has
|
|
352
|
+
* nothing to be measured against.
|
|
353
|
+
*
|
|
354
|
+
* `data.present` is read through the parser a note's dates go through, with the
|
|
355
|
+
* corpus in hand, so a present written in an era resolves where it is written.
|
|
356
|
+
*
|
|
357
|
+
* @param {object} note - The note, as the link index hands it over.
|
|
358
|
+
* @param {object} [opts]
|
|
359
|
+
* @param {object} [opts.index] - The link index, holding every note.
|
|
360
|
+
* @returns {object[]} Findings.
|
|
361
|
+
*/
|
|
362
|
+
export function checkWorldFacts(note, { index } = {}) {
|
|
363
|
+
if (typeOf(note) !== "place") return [];
|
|
364
|
+
const findings = [];
|
|
365
|
+
const written = keysWritten(note, INVARIANT_KEYS);
|
|
366
|
+
if (written.length === 0) return findings;
|
|
367
|
+
|
|
368
|
+
const subType = subTypeOf(note);
|
|
369
|
+
if (!INVARIANT_SUBTYPES.includes(subType)) {
|
|
370
|
+
for (const key of written) {
|
|
371
|
+
findings.push(
|
|
372
|
+
atData(
|
|
373
|
+
note,
|
|
374
|
+
[key],
|
|
375
|
+
"error",
|
|
376
|
+
`\`data.${key}\` states a fact about a body, and this is a ` +
|
|
377
|
+
`${subType || "place"} within one — write it on the ` +
|
|
378
|
+
`\`world\` or \`celestial\` note whose subject the body is`,
|
|
379
|
+
),
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
return findings;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
if (written.includes("present")) {
|
|
386
|
+
const { findings: dated } = parseNoteDate(dataOf(note).present, {
|
|
387
|
+
field: "data.present",
|
|
388
|
+
allowUnknown: false,
|
|
389
|
+
file: note.file,
|
|
390
|
+
raw: note.raw,
|
|
391
|
+
keyPath: ["data", "present"],
|
|
392
|
+
});
|
|
393
|
+
findings.push(...dated);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// The corpus questions, which need every note and so are asked only where
|
|
397
|
+
// the caller handed one over.
|
|
398
|
+
if (!index) return findings;
|
|
399
|
+
|
|
400
|
+
for (const key of ["year", "moon"]) {
|
|
401
|
+
if (!written.includes(key)) continue;
|
|
402
|
+
const carrying = notesCarrying(index, key);
|
|
403
|
+
if (carrying.length < 2) continue;
|
|
404
|
+
const others = carrying
|
|
405
|
+
.filter((other) => other !== note)
|
|
406
|
+
.map((other) => other.rel ?? other.file);
|
|
407
|
+
findings.push(
|
|
408
|
+
atData(
|
|
409
|
+
note,
|
|
410
|
+
[key],
|
|
411
|
+
"error",
|
|
412
|
+
`a package states one ${key === "year" ? "year" : "moon"}, and ` +
|
|
413
|
+
`${others.join(", ")} states one too — two is two settings, and ` +
|
|
414
|
+
`nothing can say which one a calendar is written against`,
|
|
415
|
+
),
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
if (written.includes("moon") && notesCarrying(index, "year").length === 0) {
|
|
420
|
+
findings.push(
|
|
421
|
+
atData(
|
|
422
|
+
note,
|
|
423
|
+
["moon"],
|
|
424
|
+
"error",
|
|
425
|
+
"a cycle of days has no year to be measured against: no note in " +
|
|
426
|
+
"this package states `data.year`, so nothing says how long the " +
|
|
427
|
+
"year this moon runs against is",
|
|
428
|
+
),
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
return findings;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Check a `lore` note's calendar declaration.
|
|
437
|
+
*
|
|
438
|
+
* Both halves of one sentence, and the second is the half that keeps the family
|
|
439
|
+
* from leaking: a calendar note must carry its months and its epoch, and a lore
|
|
440
|
+
* note that is not a calendar must carry none of the family at all.
|
|
441
|
+
*
|
|
442
|
+
* The sum is the only arithmetic. It runs where the package states a year and
|
|
443
|
+
* the note states months, and nowhere else — neither fires on a package that
|
|
444
|
+
* declared nothing.
|
|
445
|
+
*
|
|
446
|
+
* A calendar note **cannot** restate a world fact, and it needs no rule of its
|
|
447
|
+
* own to stop it: `data.year`, `data.moon`, `data.world` and `data.body` are
|
|
448
|
+
* declared on `place` and not on `lore`, so the closed `data:` container
|
|
449
|
+
* already refuses them by the rule it refuses every undeclared key by.
|
|
450
|
+
*
|
|
451
|
+
* @param {object} note - The note, as the link index hands it over.
|
|
452
|
+
* @param {object} [opts]
|
|
453
|
+
* @param {object} [opts.index] - The link index, for the world's year.
|
|
454
|
+
* @returns {object[]} Findings.
|
|
455
|
+
*/
|
|
456
|
+
export function checkCalendarNote(note, { index } = {}) {
|
|
457
|
+
if (typeOf(note) !== "lore") return [];
|
|
458
|
+
const findings = [];
|
|
459
|
+
const written = keysWritten(note, CALENDAR_KEYS);
|
|
460
|
+
|
|
461
|
+
if (subTypeOf(note) !== CALENDAR_SUBTYPE) {
|
|
462
|
+
for (const key of written) {
|
|
463
|
+
findings.push(
|
|
464
|
+
atData(
|
|
465
|
+
note,
|
|
466
|
+
[key],
|
|
467
|
+
"error",
|
|
468
|
+
`\`data.${key}\` is a calendar's, and this note's \`subType\` is ` +
|
|
469
|
+
`not \`calendar\` — move it to the note about the calendar, ` +
|
|
470
|
+
`which every body keeping that calendar can then link to`,
|
|
471
|
+
),
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
return findings;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
for (const required of ["months", "epoch"]) {
|
|
478
|
+
if (written.includes(required)) continue;
|
|
479
|
+
findings.push({
|
|
480
|
+
file: note.file,
|
|
481
|
+
...positionInFrontmatter(note.raw ?? "", "subType", undefined, { topLevel: true }),
|
|
482
|
+
severity: "error",
|
|
483
|
+
message:
|
|
484
|
+
`a calendar divides the year and says where the count begins, so ` +
|
|
485
|
+
`this note must state \`data.${required}\``,
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
findings.push(...checkMonthSum(note, index));
|
|
490
|
+
findings.push(...checkEraShortcodes(note));
|
|
491
|
+
return findings;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* The sum-to-the-world's-year finding.
|
|
496
|
+
*
|
|
497
|
+
* Positioned on the list rather than on an entry: the pass holds a column of
|
|
498
|
+
* numbers and no way to know which one was mistyped, and a position pointed at
|
|
499
|
+
* the wrong entry is worse than a position on the list.
|
|
500
|
+
*
|
|
501
|
+
* The message states what is wrong with the world rather than with the
|
|
502
|
+
* addition, because the remedy is to decide which month is the wrong length and
|
|
503
|
+
* not to make the column balance.
|
|
504
|
+
*
|
|
505
|
+
* @param {object} note - The calendar note.
|
|
506
|
+
* @param {object} [index] - The link index.
|
|
507
|
+
* @returns {object[]} Findings.
|
|
508
|
+
*/
|
|
509
|
+
function checkMonthSum(note, index) {
|
|
510
|
+
const months = dataOf(note).months;
|
|
511
|
+
if (!Array.isArray(months) || months.length === 0) return [];
|
|
512
|
+
const days = worldInvariants(index).year?.days;
|
|
513
|
+
if (typeof days !== "number") return [];
|
|
514
|
+
const sum = daysInYear(months);
|
|
515
|
+
if (sum === days) return [];
|
|
516
|
+
return [
|
|
517
|
+
atData(
|
|
518
|
+
note,
|
|
519
|
+
["months"],
|
|
520
|
+
"error",
|
|
521
|
+
`\`data.months\` sums to ${sum}, and the year is ${days} days — a ` +
|
|
522
|
+
`calendar names the same days differently, it does not keep a ` +
|
|
523
|
+
`different number of them`,
|
|
524
|
+
),
|
|
525
|
+
];
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* One calendar must not count two eras by the same name.
|
|
530
|
+
*
|
|
531
|
+
* A shortcode is unique within its `(type, package)` by construction, so a
|
|
532
|
+
* qualified era name cannot collide across calendars and there is no
|
|
533
|
+
* corpus-wide sweep to run. What is left is local, and verifiable against this
|
|
534
|
+
* note alone.
|
|
535
|
+
*
|
|
536
|
+
* @param {object} note - The calendar note.
|
|
537
|
+
* @returns {object[]} Findings.
|
|
538
|
+
*/
|
|
539
|
+
function checkEraShortcodes(note) {
|
|
540
|
+
const eras = dataOf(note).eras;
|
|
541
|
+
if (!Array.isArray(eras)) return [];
|
|
542
|
+
const seen = new Set();
|
|
543
|
+
const findings = [];
|
|
544
|
+
eras.forEach((era, position) => {
|
|
545
|
+
const shortcode = String(era?.shortcode ?? "");
|
|
546
|
+
if (!shortcode) {
|
|
547
|
+
findings.push(
|
|
548
|
+
atData(
|
|
549
|
+
note,
|
|
550
|
+
["eras", position],
|
|
551
|
+
"error",
|
|
552
|
+
"an era is addressed `<calendar shortcode>.<era shortcode>`, so " +
|
|
553
|
+
"every row states a `shortcode` of its own",
|
|
554
|
+
),
|
|
555
|
+
);
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
if (seen.has(shortcode)) {
|
|
559
|
+
findings.push(
|
|
560
|
+
atData(
|
|
561
|
+
note,
|
|
562
|
+
["eras", position],
|
|
563
|
+
"error",
|
|
564
|
+
`this calendar already counts an era called "${shortcode}", and a ` +
|
|
565
|
+
`date naming it would name both`,
|
|
566
|
+
),
|
|
567
|
+
);
|
|
568
|
+
}
|
|
569
|
+
seen.add(shortcode);
|
|
570
|
+
});
|
|
571
|
+
return findings;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* The four days the year turns on, by day of year.
|
|
576
|
+
*
|
|
577
|
+
* The year opens on the first of the first month, and the year's length sets
|
|
578
|
+
* the spacing; nothing else is consulted. A body's tilt is what makes seasons
|
|
579
|
+
* exist at all, and it is read by nobody — a quarter day is where the calendar
|
|
580
|
+
* opens plus a quarter of the year, to the nearest whole day, because a day is
|
|
581
|
+
* the smallest thing a calendar can name.
|
|
582
|
+
*
|
|
583
|
+
* @param {number} daysPerYear - The world's year.
|
|
584
|
+
* @returns {number[]} Four day-of-year numbers, in order from the year's start.
|
|
585
|
+
*/
|
|
586
|
+
export function quarterDays(daysPerYear) {
|
|
587
|
+
return [0, 1, 2, 3].map((quarter) => Math.round(1 + (daysPerYear * quarter) / 4));
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/** Drop the keys whose value is `undefined`, so an omitted field is absent. */
|
|
591
|
+
function written(object) {
|
|
592
|
+
return Object.fromEntries(Object.entries(object).filter(([, value]) => value !== undefined));
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/** A month's name, abbreviation and length, plus its position and its kind. */
|
|
596
|
+
function monthValues(months) {
|
|
597
|
+
const lengths = months.map((month) => Number(month?.days) || 0);
|
|
598
|
+
// Calendaria's own bundled calendars mark a short month this way, and it is
|
|
599
|
+
// display metadata rather than arithmetic: a month shorter than the length
|
|
600
|
+
// most months share is the days a people keeps outside their months.
|
|
601
|
+
const modal = [...lengths]
|
|
602
|
+
.sort(
|
|
603
|
+
(a, b) => lengths.filter((l) => l === b).length - lengths.filter((l) => l === a).length,
|
|
604
|
+
)
|
|
605
|
+
.at(0);
|
|
606
|
+
return months.map((month, position) =>
|
|
607
|
+
written({
|
|
608
|
+
name: String(month?.name ?? ""),
|
|
609
|
+
abbreviation:
|
|
610
|
+
month?.abbreviation === undefined ? undefined : String(month.abbreviation),
|
|
611
|
+
ordinal: position + 1,
|
|
612
|
+
days: lengths[position],
|
|
613
|
+
// `leapDays` is never written. A world with no leap rule has no
|
|
614
|
+
// second length for a month, and a value that could disagree with
|
|
615
|
+
// `days` is one that eventually will.
|
|
616
|
+
type: lengths[position] < modal ? "intercalary" : undefined,
|
|
617
|
+
}),
|
|
618
|
+
);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/** A weekday's name, abbreviation and position. An empty list stays empty. */
|
|
622
|
+
function weekdayValues(weekdays) {
|
|
623
|
+
return (Array.isArray(weekdays) ? weekdays : []).map((day, position) =>
|
|
624
|
+
written({
|
|
625
|
+
name: String(day?.name ?? ""),
|
|
626
|
+
abbreviation: day?.abbreviation === undefined ? undefined : String(day.abbreviation),
|
|
627
|
+
ordinal: position + 1,
|
|
628
|
+
}),
|
|
629
|
+
);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/** A season's name and whichever pair of bounds the note wrote. */
|
|
633
|
+
function seasonValues(seasons) {
|
|
634
|
+
return (Array.isArray(seasons) ? seasons : []).map((season) =>
|
|
635
|
+
written({
|
|
636
|
+
name: String(season?.name ?? ""),
|
|
637
|
+
abbreviation:
|
|
638
|
+
season?.abbreviation === undefined ? undefined : String(season.abbreviation),
|
|
639
|
+
monthStart: season?.monthStart,
|
|
640
|
+
monthEnd: season?.monthEnd,
|
|
641
|
+
dayStart: season?.dayStart,
|
|
642
|
+
dayEnd: season?.dayEnd,
|
|
643
|
+
}),
|
|
644
|
+
);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/** The era rows, keyed by shortcode, with the addressing segments dropped. */
|
|
648
|
+
function eraEntries(eras) {
|
|
649
|
+
const entries = {};
|
|
650
|
+
for (const era of Array.isArray(eras) ? eras : []) {
|
|
651
|
+
const shortcode = String(era?.shortcode ?? "");
|
|
652
|
+
if (!shortcode) continue;
|
|
653
|
+
entries[shortcode] = written({
|
|
654
|
+
name: String(era?.name ?? ""),
|
|
655
|
+
abbreviation: era?.abbreviation === undefined ? undefined : String(era.abbreviation),
|
|
656
|
+
start: era?.start === undefined ? undefined : String(era.start),
|
|
657
|
+
end: era?.end === undefined ? undefined : String(era.end),
|
|
658
|
+
});
|
|
659
|
+
}
|
|
660
|
+
return entries;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* The moon entry, from the moon's own note and nothing else.
|
|
665
|
+
*
|
|
666
|
+
* Every value is the world fact stated in the target's vocabulary, and where
|
|
667
|
+
* the setting models nothing the target's own way of saying nothing is written:
|
|
668
|
+
* eclipses that nobody computes are `eclipseMode` with no `nodalPeriod`, and a
|
|
669
|
+
* circular orbit is a cycle that never varies.
|
|
670
|
+
*
|
|
671
|
+
* @param {object} moon - `data.moon`, from the moon note.
|
|
672
|
+
* @param {object|null} body - `data.body`, from the same note.
|
|
673
|
+
* @param {string} name - What the body is called.
|
|
674
|
+
* @returns {object|null} The entry, keyed by the body's name, or `null`.
|
|
675
|
+
*/
|
|
676
|
+
function moonEntries(moon, body, name) {
|
|
677
|
+
if (!moon || !name) return null;
|
|
678
|
+
const date = parseNoteDate(moon.newOn, { allowUnknown: false }).date;
|
|
679
|
+
return {
|
|
680
|
+
[name]: written({
|
|
681
|
+
name,
|
|
682
|
+
cycleLength: Number(moon.cycle),
|
|
683
|
+
referenceDate:
|
|
684
|
+
date ? written({ year: date.year, month: date.month, day: date.day }) : undefined,
|
|
685
|
+
// A circular orbit is a cycle of exactly its length, every time.
|
|
686
|
+
cycleVariance: String(body?.orbit ?? "") === "circular" ? 0 : undefined,
|
|
687
|
+
phaseMode: "fixed",
|
|
688
|
+
eclipseMode: moon.eclipses === undefined ? undefined : String(moon.eclipses),
|
|
689
|
+
// Stated rather than fabricated: an inclined orbit makes eclipses
|
|
690
|
+
// uncommon, and nothing in the setting says when one falls, so
|
|
691
|
+
// there is no period to write.
|
|
692
|
+
nodalPeriod: null,
|
|
693
|
+
}),
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* Compile one calendar note and the package's world facts into one definition.
|
|
699
|
+
*
|
|
700
|
+
* The object both consumers read: array-shaped for core's `ArrayField`
|
|
701
|
+
* collections, carrying the keys core prunes and Calendaria keeps.
|
|
702
|
+
*
|
|
703
|
+
* @param {object} opts
|
|
704
|
+
* @param {object} opts.note - The calendar note.
|
|
705
|
+
* @param {object} opts.invariants - What {@link worldInvariants} read.
|
|
706
|
+
* @param {string} [opts.contentPackage] - The package shipping the note, which
|
|
707
|
+
* is a fact about the tree rather than about the note: a note never declares
|
|
708
|
+
* its own package.
|
|
709
|
+
* @returns {object} The definition.
|
|
710
|
+
*/
|
|
711
|
+
export function compileCalendar({ note, invariants, contentPackage }) {
|
|
712
|
+
const data = dataOf(note);
|
|
713
|
+
const year = invariants?.year ?? {};
|
|
714
|
+
const months = Array.isArray(data.months) ? data.months : [];
|
|
715
|
+
const epoch = parseNoteDate(data.epoch, { allowUnknown: false }).date;
|
|
716
|
+
const shortcode = String(note.fm?.shortcode ?? "");
|
|
717
|
+
const name = String(note.fm?.name?.full ?? "");
|
|
718
|
+
const description = String(note.fm?.description ?? "");
|
|
719
|
+
const moons = moonEntries(invariants?.moon, invariants?.body ?? null, invariants?.moonName);
|
|
720
|
+
const author = contentPackage ? String(contentPackage) : undefined;
|
|
721
|
+
|
|
722
|
+
return written({
|
|
723
|
+
name,
|
|
724
|
+
description,
|
|
725
|
+
years: {
|
|
726
|
+
// The year the epoch falls in is the year a reader is shown at the
|
|
727
|
+
// world's own zero.
|
|
728
|
+
yearZero: epoch?.year ?? 0,
|
|
729
|
+
// Required, non-nullable and with no initial, so it must be
|
|
730
|
+
// written; with no weekdays it indexes nothing and 0 is the only
|
|
731
|
+
// defensible value.
|
|
732
|
+
firstWeekday: 0,
|
|
733
|
+
// A world with no leap rule states that it has none, rather than
|
|
734
|
+
// leaving a shape for one to be inferred into.
|
|
735
|
+
leapYear: null,
|
|
736
|
+
},
|
|
737
|
+
months: { values: monthValues(months) },
|
|
738
|
+
days: written({
|
|
739
|
+
values: weekdayValues(data.weekdays),
|
|
740
|
+
daysPerYear: year.days,
|
|
741
|
+
hoursPerDay: year.hoursPerDay,
|
|
742
|
+
minutesPerHour: year.minutesPerHour,
|
|
743
|
+
secondsPerMinute: year.secondsPerMinute,
|
|
744
|
+
}),
|
|
745
|
+
seasons: { values: seasonValues(data.seasons) },
|
|
746
|
+
// How far into the year the world's zero falls. `1/1` is none.
|
|
747
|
+
epochDayOffset:
|
|
748
|
+
epoch && months.length ? dayOfYear(months, epoch.month ?? 1, epoch.day ?? 1) - 1 : 0,
|
|
749
|
+
eras: eraEntries(data.eras),
|
|
750
|
+
moons: moons ?? undefined,
|
|
751
|
+
metadata: written({
|
|
752
|
+
id: shortcode || undefined,
|
|
753
|
+
description,
|
|
754
|
+
author,
|
|
755
|
+
}),
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* Wrap a definition in the envelope a Calendaria settings import reads.
|
|
761
|
+
*
|
|
762
|
+
* `calendarData` is the definition unchanged — the importer derives an id from
|
|
763
|
+
* its `name` and creates a custom calendar from it — so the envelope adds a
|
|
764
|
+
* header and nothing else. That is what makes the two artefacts assertable
|
|
765
|
+
* against each other rather than merely similar.
|
|
766
|
+
*
|
|
767
|
+
* @param {object} definition - What {@link compileCalendar} produced.
|
|
768
|
+
* @param {object} opts
|
|
769
|
+
* @param {string} opts.version - The Calendaria version this was generated
|
|
770
|
+
* against, which the importer records.
|
|
771
|
+
* @param {string} opts.exportedAt - When, as an ISO 8601 instant.
|
|
772
|
+
* @returns {object} The envelope.
|
|
773
|
+
*/
|
|
774
|
+
export function calendariaEnvelope(definition, { version, exportedAt }) {
|
|
775
|
+
return {
|
|
776
|
+
version,
|
|
777
|
+
exportedAt,
|
|
778
|
+
settings: {},
|
|
779
|
+
calendarData: definition,
|
|
780
|
+
};
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/**
|
|
784
|
+
* Every calendar a package declares, compiled.
|
|
785
|
+
*
|
|
786
|
+
* A package whose notes state no year declares no calendars and is asked
|
|
787
|
+
* nothing about one, which is the right answer for a package that builds a
|
|
788
|
+
* setting somebody else wrote down.
|
|
789
|
+
*
|
|
790
|
+
* @param {object} [index] - The link index, holding every note.
|
|
791
|
+
* @param {object} [opts]
|
|
792
|
+
* @param {string} [opts.contentPackage] - The package shipping the tree.
|
|
793
|
+
* @returns {{calendars: object[], invariants: object}} One compiled definition
|
|
794
|
+
* per calendar note, and the facts they were compiled against.
|
|
795
|
+
*/
|
|
796
|
+
export function compileCalendars(index, { contentPackage } = {}) {
|
|
797
|
+
const invariants = worldInvariants(index);
|
|
798
|
+
if (!invariants.year) return { calendars: [], invariants };
|
|
799
|
+
const calendars = [];
|
|
800
|
+
for (const note of index?.notes ?? []) {
|
|
801
|
+
if (typeOf(note) !== "lore" || subTypeOf(note) !== CALENDAR_SUBTYPE) continue;
|
|
802
|
+
calendars.push(compileCalendar({ note, invariants, contentPackage }));
|
|
803
|
+
}
|
|
804
|
+
return { calendars, invariants };
|
|
805
|
+
}
|