@heroiclands/package-build 22.14.3 → 22.15.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 +32 -0
- package/CONTENT.md +25 -1
- package/bin/content-build.mjs +3 -7
- package/bin/package-build.mjs +25 -0
- package/docs/api.md +64 -36
- package/docs/commands.md +65 -9
- package/docs/configuration.md +6 -3
- package/docs/content-format.md +187 -140
- package/docs/diagnostics.md +1 -1
- package/docs/getting-started.md +27 -3
- package/engine/base-compiler.mjs +3 -1
- package/engine/being-age.mjs +16 -24
- package/engine/calendar-artifacts.mjs +153 -0
- package/engine/calendar-notes.mjs +64 -16
- package/engine/calendars.mjs +129 -0
- package/engine/content-images.mjs +20 -10
- package/engine/content-index.mjs +3 -1
- package/engine/exported-scene.mjs +230 -0
- package/engine/generate.mjs +1 -0
- package/engine/ids.mjs +1 -1
- package/engine/index.mjs +6 -0
- package/engine/infobox.mjs +4 -0
- package/engine/itinerary-scenes.mjs +170 -0
- package/engine/map-build.mjs +10 -45
- package/engine/map-dot.mjs +6 -3
- package/engine/map-graphviz.mjs +50 -131
- package/engine/map-notes.mjs +26 -3
- package/engine/map-places.mjs +12 -6
- package/engine/map-raster.mjs +49 -0
- package/engine/note-dates.mjs +125 -85
- package/engine/note-vocabulary.mjs +22 -6
- package/engine/pdf-build.mjs +57 -1
- package/engine/pdf-render.mjs +48 -7
- package/engine/reckoning-markers.mjs +207 -0
- package/engine/scenes.mjs +113 -26
- package/engine/site-build.mjs +11 -13
- package/engine/site-maps.mjs +5 -35
- package/engine/site-preview.mjs +6 -1
- package/engine/social-tie-terms.mjs +3 -0
- package/engine/social-ties.mjs +30 -19
- package/package.json +3 -1
- package/types/engine/being-age.d.mts +2 -2
- package/types/engine/calendar-artifacts.d.mts +16 -0
- package/types/engine/calendar-notes.d.mts +1 -1
- package/types/engine/calendars.d.mts +65 -0
- package/types/engine/content-images.d.mts +7 -4
- package/types/engine/exported-scene.d.mts +12 -0
- package/types/engine/index.d.mts +2 -0
- package/types/engine/itinerary-scenes.d.mts +29 -0
- package/types/engine/map-build.d.mts +4 -13
- package/types/engine/map-graphviz.d.mts +17 -59
- package/types/engine/map-places.d.mts +5 -1
- package/types/engine/map-raster.d.mts +8 -0
- package/types/engine/note-dates.d.mts +6 -0
- package/types/engine/note-vocabulary.d.mts +3 -3
- package/types/engine/pdf-render.d.mts +16 -1
- package/types/engine/reckoning-markers.d.mts +27 -0
- package/types/engine/scenes.d.mts +9 -2
- package/types/engine/site-build.d.mts +1 -5
- package/types/engine/site-maps.d.mts +2 -7
- package/types/engine/social-tie-terms.d.mts +2 -0
- package/types/engine/social-ties.d.mts +2 -1
|
@@ -0,0 +1,153 @@
|
|
|
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
|
+
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Build the calendar files a content package ships to Foundry. */
|
|
9
|
+
|
|
10
|
+
import fs from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
|
|
13
|
+
import { calendariaEnvelope, compileCalendars } from "./calendar-notes.mjs";
|
|
14
|
+
import { formatDiagnostic, positionOfFrontmatterPath } from "./diagnostics.mjs";
|
|
15
|
+
import { authoredFrontmatter, isNoteRecord, noteFile } from "./index-records.mjs";
|
|
16
|
+
import { metadataFileName } from "./packages.mjs";
|
|
17
|
+
|
|
18
|
+
// The import envelope requires an ISO instant. A fixed value keeps an export
|
|
19
|
+
// reproducible from its source rather than stamping the build machine's clock.
|
|
20
|
+
const EXPORT_EPOCH = "1970-01-01T00:00:00.000Z";
|
|
21
|
+
|
|
22
|
+
/** @param {string} file @param {string} message @param {string} raw @param {string[]} keyPath */
|
|
23
|
+
function failAt(file, message, raw, keyPath) {
|
|
24
|
+
const position = positionOfFrontmatterPath(raw, keyPath, { key: true });
|
|
25
|
+
const error = new Error(formatDiagnostic({ file, ...position, severity: "error", message }));
|
|
26
|
+
error.located = true;
|
|
27
|
+
throw error;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** @param {string} file */
|
|
31
|
+
function readIndex(file) {
|
|
32
|
+
const display = path.relative(process.cwd(), file) || file;
|
|
33
|
+
if (!fs.existsSync(file)) {
|
|
34
|
+
const error = new Error(
|
|
35
|
+
`${display}: error: content index is absent; run content-build content-index`,
|
|
36
|
+
);
|
|
37
|
+
error.located = true;
|
|
38
|
+
throw error;
|
|
39
|
+
}
|
|
40
|
+
const records = fs
|
|
41
|
+
.readFileSync(file, "utf8")
|
|
42
|
+
.split(/\r?\n/)
|
|
43
|
+
.filter(Boolean)
|
|
44
|
+
.map((line, index) => {
|
|
45
|
+
try {
|
|
46
|
+
return JSON.parse(line);
|
|
47
|
+
} catch (cause) {
|
|
48
|
+
const error = new Error(
|
|
49
|
+
`${display}:${index + 1}: error: invalid content index record`,
|
|
50
|
+
{
|
|
51
|
+
cause,
|
|
52
|
+
},
|
|
53
|
+
);
|
|
54
|
+
error.located = true;
|
|
55
|
+
throw error;
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
if (!records.some(isNoteRecord)) {
|
|
59
|
+
const error = new Error(`${display}: error: content index contains no notes`);
|
|
60
|
+
error.located = true;
|
|
61
|
+
throw error;
|
|
62
|
+
}
|
|
63
|
+
return records;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Emit calendar definitions and import envelopes from the package's index.
|
|
68
|
+
*
|
|
69
|
+
* @param {object} opts
|
|
70
|
+
* @param {object} opts.config - Resolved content configuration.
|
|
71
|
+
* @param {string} opts.calendariaVersion - Version whose import format is targeted.
|
|
72
|
+
* @returns {{directory: string, calendars: number, files: number}}
|
|
73
|
+
*/
|
|
74
|
+
export function emitCalendarArtifacts({ config, calendariaVersion } = {}) {
|
|
75
|
+
if (!config) throw new Error("calendar emission requires a content configuration");
|
|
76
|
+
if (!/^\d+\.\d+\.\d+$/.test(calendariaVersion ?? "")) {
|
|
77
|
+
throw new Error("calendar emission requires a Calendaria version such as 1.4.2");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const root = config.rootDir;
|
|
81
|
+
const contentBase = path.resolve(root, config.paths.content);
|
|
82
|
+
const indexFile = path.resolve(
|
|
83
|
+
root,
|
|
84
|
+
config.paths.contentIndex,
|
|
85
|
+
metadataFileName(config.contentPackage),
|
|
86
|
+
);
|
|
87
|
+
const directory = path.resolve(root, "build/calendars");
|
|
88
|
+
const notes = readIndex(indexFile)
|
|
89
|
+
.filter(isNoteRecord)
|
|
90
|
+
.map((record) => ({
|
|
91
|
+
fm: authoredFrontmatter(record),
|
|
92
|
+
file: path.relative(root, noteFile(contentBase, record)),
|
|
93
|
+
rel: record.file.path,
|
|
94
|
+
}));
|
|
95
|
+
const calendars = notes.filter(
|
|
96
|
+
(note) =>
|
|
97
|
+
String(note.fm.type).toLowerCase() === "lore" &&
|
|
98
|
+
String(note.fm.subType).toLowerCase() === "calendar",
|
|
99
|
+
);
|
|
100
|
+
const names = new Map();
|
|
101
|
+
for (const note of calendars) {
|
|
102
|
+
const raw = fs.readFileSync(path.resolve(root, note.file), "utf8");
|
|
103
|
+
const shortcode = note.fm.shortcode;
|
|
104
|
+
if (typeof shortcode !== "string" || !/^[a-z0-9]+$/.test(shortcode)) {
|
|
105
|
+
failAt(
|
|
106
|
+
note.file,
|
|
107
|
+
"calendar shortcode must contain lowercase ASCII letters and digits",
|
|
108
|
+
raw,
|
|
109
|
+
["shortcode"],
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
if (names.has(shortcode)) {
|
|
113
|
+
failAt(
|
|
114
|
+
note.file,
|
|
115
|
+
`calendar shortcode ${shortcode} also occurs in ${names.get(shortcode)}`,
|
|
116
|
+
raw,
|
|
117
|
+
["shortcode"],
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
names.set(shortcode, note.file);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const compiled = compileCalendars({ notes }, { contentPackage: config.contentPackage });
|
|
124
|
+
if (compiled.calendars.length !== calendars.length) {
|
|
125
|
+
const note = calendars[0];
|
|
126
|
+
failAt(
|
|
127
|
+
note.file,
|
|
128
|
+
"calendar notes require a world note declaring data.year",
|
|
129
|
+
fs.readFileSync(path.resolve(root, note.file), "utf8"),
|
|
130
|
+
["subType"],
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
const outputs = [];
|
|
134
|
+
for (let index = 0; index < calendars.length; index++) {
|
|
135
|
+
const shortcode = calendars[index].fm.shortcode;
|
|
136
|
+
const definition = compiled.calendars[index];
|
|
137
|
+
outputs.push([`${shortcode}.json`, definition]);
|
|
138
|
+
outputs.push([
|
|
139
|
+
`${shortcode}.calendaria.json`,
|
|
140
|
+
calendariaEnvelope(definition, {
|
|
141
|
+
version: calendariaVersion,
|
|
142
|
+
exportedAt: EXPORT_EPOCH,
|
|
143
|
+
}),
|
|
144
|
+
]);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
fs.rmSync(directory, { recursive: true, force: true });
|
|
148
|
+
fs.mkdirSync(directory, { recursive: true });
|
|
149
|
+
for (const [name, value] of outputs) {
|
|
150
|
+
fs.writeFileSync(path.join(directory, name), `${JSON.stringify(value, null, 2)}\n`);
|
|
151
|
+
}
|
|
152
|
+
return { directory, calendars: calendars.length, files: outputs.length };
|
|
153
|
+
}
|
|
@@ -64,9 +64,10 @@
|
|
|
64
64
|
* @module
|
|
65
65
|
*/
|
|
66
66
|
|
|
67
|
-
import { dayOfYear, daysInYear } from "./calendars.mjs";
|
|
67
|
+
import { dayOfYear, daysInYear, monthDayOfYear } from "./calendars.mjs";
|
|
68
68
|
import { positionInFrontmatter, positionOfFrontmatterPath } from "./diagnostics.mjs";
|
|
69
69
|
import { parseNoteDate } from "./note-dates.mjs";
|
|
70
|
+
import { reckoningContext } from "./reckoning-markers.mjs";
|
|
70
71
|
|
|
71
72
|
/** The `lore` subType whose notes are calendars. */
|
|
72
73
|
export const CALENDAR_SUBTYPE = "calendar";
|
|
@@ -144,11 +145,10 @@ export const CALENDAR_FIELDS = Object.freeze([
|
|
|
144
145
|
},
|
|
145
146
|
{
|
|
146
147
|
name: "eras",
|
|
147
|
-
shape: "list of `{ shortcode, name, abbreviation?, proclaimedBy?, start, end? }`",
|
|
148
|
+
shape: "list of `{ shortcode, name, marker?, abbreviation?, proclaimedBy?, start, end? }`",
|
|
148
149
|
kind: "list",
|
|
149
150
|
describe:
|
|
150
|
-
"The year-counts kept in this calendar. A
|
|
151
|
-
"`<calendar shortcode>.<era shortcode>`.",
|
|
151
|
+
"The year-counts kept in this calendar. A marker names one era and uses these months.",
|
|
152
152
|
},
|
|
153
153
|
{
|
|
154
154
|
name: "dateFormats",
|
|
@@ -456,6 +456,7 @@ export function checkWorldFacts(note, { index } = {}) {
|
|
|
456
456
|
|
|
457
457
|
if (written.includes("present")) {
|
|
458
458
|
const { findings: dated } = parseNoteDate(dataOf(note).present, {
|
|
459
|
+
...reckoningContext(index),
|
|
459
460
|
field: "data.present",
|
|
460
461
|
allowUnknown: false,
|
|
461
462
|
file: note.file,
|
|
@@ -464,6 +465,18 @@ export function checkWorldFacts(note, { index } = {}) {
|
|
|
464
465
|
});
|
|
465
466
|
findings.push(...dated);
|
|
466
467
|
}
|
|
468
|
+
if (written.includes("moon") && dataOf(note).moon?.newOn !== undefined) {
|
|
469
|
+
findings.push(
|
|
470
|
+
...parseNoteDate(dataOf(note).moon.newOn, {
|
|
471
|
+
...reckoningContext(index),
|
|
472
|
+
field: "data.moon.newOn",
|
|
473
|
+
allowUnknown: false,
|
|
474
|
+
file: note.file,
|
|
475
|
+
raw: note.raw,
|
|
476
|
+
keyPath: ["data", "moon", "newOn"],
|
|
477
|
+
}).findings,
|
|
478
|
+
);
|
|
479
|
+
}
|
|
467
480
|
|
|
468
481
|
// The corpus questions, which need every note and so are asked only where
|
|
469
482
|
// the caller handed one over.
|
|
@@ -558,8 +571,24 @@ export function checkCalendarNote(note, { index } = {}) {
|
|
|
558
571
|
});
|
|
559
572
|
}
|
|
560
573
|
|
|
574
|
+
if (written.includes("epoch")) {
|
|
575
|
+
findings.push(
|
|
576
|
+
...parseNoteDate(dataOf(note).epoch, {
|
|
577
|
+
...reckoningContext(index),
|
|
578
|
+
field: "data.epoch",
|
|
579
|
+
allowUnknown: false,
|
|
580
|
+
file: note.file,
|
|
581
|
+
raw: note.raw,
|
|
582
|
+
keyPath: ["data", "epoch"],
|
|
583
|
+
}).findings,
|
|
584
|
+
);
|
|
585
|
+
}
|
|
586
|
+
|
|
561
587
|
findings.push(...checkMonthSum(note, index));
|
|
562
588
|
findings.push(...checkEraShortcodes(note));
|
|
589
|
+
findings.push(
|
|
590
|
+
...reckoningContext(index).findings.filter((finding) => finding.file === note.file),
|
|
591
|
+
);
|
|
563
592
|
findings.push(...checkCalendarDateFormats(note));
|
|
564
593
|
return findings;
|
|
565
594
|
}
|
|
@@ -718,16 +747,18 @@ function seasonValues(seasons) {
|
|
|
718
747
|
}
|
|
719
748
|
|
|
720
749
|
/** The era rows, keyed by shortcode, with the addressing segments dropped. */
|
|
721
|
-
function eraEntries(eras) {
|
|
750
|
+
function eraEntries(eras, dateContext = {}) {
|
|
722
751
|
const entries = {};
|
|
723
752
|
for (const era of Array.isArray(eras) ? eras : []) {
|
|
724
753
|
const shortcode = String(era?.shortcode ?? "");
|
|
725
754
|
if (!shortcode) continue;
|
|
755
|
+
const start = parseNoteDate(era?.start, { ...dateContext, allowUnknown: false }).date;
|
|
756
|
+
const end = parseNoteDate(era?.end, { ...dateContext, allowUnknown: false }).date;
|
|
726
757
|
entries[shortcode] = written({
|
|
727
758
|
name: String(era?.name ?? ""),
|
|
728
759
|
abbreviation: era?.abbreviation === undefined ? undefined : String(era.abbreviation),
|
|
729
|
-
start: era?.start === undefined ? undefined : String(era.start),
|
|
730
|
-
end: era?.end === undefined ? undefined : String(era.end),
|
|
760
|
+
start: era?.start === undefined ? undefined : String(start?.year ?? era.start),
|
|
761
|
+
end: era?.end === undefined ? undefined : String(end?.year ?? era.end),
|
|
731
762
|
});
|
|
732
763
|
}
|
|
733
764
|
return entries;
|
|
@@ -746,15 +777,23 @@ function eraEntries(eras) {
|
|
|
746
777
|
* @param {string} name - What the body is called.
|
|
747
778
|
* @returns {object|null} The entry, keyed by the body's name, or `null`.
|
|
748
779
|
*/
|
|
749
|
-
function moonEntries(moon, body, name) {
|
|
780
|
+
function moonEntries(moon, body, name, months, dateContext = {}) {
|
|
750
781
|
if (!moon || !name) return null;
|
|
751
|
-
const date = parseNoteDate(moon.newOn, { allowUnknown: false }).date;
|
|
782
|
+
const date = parseNoteDate(moon.newOn, { ...dateContext, allowUnknown: false }).date;
|
|
783
|
+
const calendarDay =
|
|
784
|
+
date?.canonicalDay && months.length ? monthDayOfYear(months, date.canonicalDay) : null;
|
|
752
785
|
return {
|
|
753
786
|
[name]: written({
|
|
754
787
|
name,
|
|
755
788
|
cycleLength: Number(moon.cycle),
|
|
756
789
|
referenceDate:
|
|
757
|
-
date ?
|
|
790
|
+
date ?
|
|
791
|
+
written({
|
|
792
|
+
year: date.canonicalYear ?? date.year,
|
|
793
|
+
month: calendarDay?.month ?? date.month,
|
|
794
|
+
day: calendarDay?.day ?? date.day,
|
|
795
|
+
})
|
|
796
|
+
: undefined,
|
|
758
797
|
// A circular orbit is a cycle of exactly its length, every time.
|
|
759
798
|
cycleVariance: String(body?.orbit ?? "") === "circular" ? 0 : undefined,
|
|
760
799
|
phaseMode: "fixed",
|
|
@@ -781,15 +820,21 @@ function moonEntries(moon, body, name) {
|
|
|
781
820
|
* its own package.
|
|
782
821
|
* @returns {object} The definition.
|
|
783
822
|
*/
|
|
784
|
-
export function compileCalendar({ note, invariants, contentPackage }) {
|
|
823
|
+
export function compileCalendar({ note, invariants, contentPackage, dateContext = {} }) {
|
|
785
824
|
const data = dataOf(note);
|
|
786
825
|
const year = invariants?.year ?? {};
|
|
787
826
|
const months = Array.isArray(data.months) ? data.months : [];
|
|
788
|
-
const epoch = parseNoteDate(data.epoch, { allowUnknown: false }).date;
|
|
827
|
+
const epoch = parseNoteDate(data.epoch, { ...dateContext, allowUnknown: false }).date;
|
|
789
828
|
const shortcode = String(note.fm?.shortcode ?? "");
|
|
790
829
|
const name = String(note.fm?.name?.full ?? "");
|
|
791
830
|
const description = String(note.fm?.description ?? "");
|
|
792
|
-
const moons = moonEntries(
|
|
831
|
+
const moons = moonEntries(
|
|
832
|
+
invariants?.moon,
|
|
833
|
+
invariants?.body ?? null,
|
|
834
|
+
invariants?.moonName,
|
|
835
|
+
months,
|
|
836
|
+
dateContext,
|
|
837
|
+
);
|
|
793
838
|
const author = contentPackage ? String(contentPackage) : undefined;
|
|
794
839
|
|
|
795
840
|
return written({
|
|
@@ -818,8 +863,10 @@ export function compileCalendar({ note, invariants, contentPackage }) {
|
|
|
818
863
|
seasons: { values: seasonValues(data.seasons) },
|
|
819
864
|
// How far into the year the world's zero falls. `1/1` is none.
|
|
820
865
|
epochDayOffset:
|
|
821
|
-
epoch && months.length ?
|
|
822
|
-
|
|
866
|
+
epoch && months.length ?
|
|
867
|
+
(epoch.canonicalDay ?? dayOfYear(months, epoch.month ?? 1, epoch.day ?? 1)) - 1
|
|
868
|
+
: 0,
|
|
869
|
+
eras: eraEntries(data.eras, dateContext),
|
|
823
870
|
dateFormats: data.dateFormats,
|
|
824
871
|
moons: moons ?? undefined,
|
|
825
872
|
metadata: written({
|
|
@@ -871,9 +918,10 @@ export function compileCalendars(index, { contentPackage } = {}) {
|
|
|
871
918
|
const invariants = worldInvariants(index);
|
|
872
919
|
if (!invariants.year) return { calendars: [], invariants };
|
|
873
920
|
const calendars = [];
|
|
921
|
+
const dateContext = reckoningContext(index);
|
|
874
922
|
for (const note of index?.notes ?? []) {
|
|
875
923
|
if (typeOf(note) !== "lore" || subTypeOf(note) !== CALENDAR_SUBTYPE) continue;
|
|
876
|
-
calendars.push(compileCalendar({ note, invariants, contentPackage }));
|
|
924
|
+
calendars.push(compileCalendar({ note, invariants, contentPackage, dateContext }));
|
|
877
925
|
}
|
|
878
926
|
return { calendars, invariants };
|
|
879
927
|
}
|
package/engine/calendars.mjs
CHANGED
|
@@ -126,6 +126,116 @@ export function dateSortKey(year, month, day) {
|
|
|
126
126
|
return (year * 10000 + (month ?? 0) * 100 + (day ?? 0)) / 10000;
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
+
/**
|
|
130
|
+
* Days from an arbitrary canonical origin to a canonical year and day.
|
|
131
|
+
*
|
|
132
|
+
* Canonical years form a continuous integer line. Day one opens each year;
|
|
133
|
+
* calendar month divisions play no part in this count.
|
|
134
|
+
*
|
|
135
|
+
* @param {number} year - Canonical year.
|
|
136
|
+
* @param {number} day - Day of that year, starting at one.
|
|
137
|
+
* @param {number} originYear - Canonical year whose first day is offset zero.
|
|
138
|
+
* @param {number} daysPerYear - The world's authored year length.
|
|
139
|
+
* @returns {number} Signed whole-day offset.
|
|
140
|
+
*/
|
|
141
|
+
export function canonicalDayOffset(year, day, originYear, daysPerYear) {
|
|
142
|
+
if (
|
|
143
|
+
!Number.isSafeInteger(year) ||
|
|
144
|
+
!Number.isSafeInteger(originYear) ||
|
|
145
|
+
!Number.isSafeInteger(daysPerYear) ||
|
|
146
|
+
daysPerYear < 1 ||
|
|
147
|
+
!Number.isSafeInteger(day) ||
|
|
148
|
+
day < 1 ||
|
|
149
|
+
day > daysPerYear
|
|
150
|
+
) {
|
|
151
|
+
throw new RangeError(
|
|
152
|
+
"a canonical date needs an integer year and a day in the world's year",
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
const offset = (year - originYear) * daysPerYear + day - 1;
|
|
156
|
+
if (!Number.isSafeInteger(offset))
|
|
157
|
+
throw new RangeError("canonical day offset exceeds integer range");
|
|
158
|
+
return offset;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The canonical year and day at a signed offset from an arbitrary origin.
|
|
163
|
+
* Flooring the division keeps the day within the year before the origin.
|
|
164
|
+
*
|
|
165
|
+
* @param {number} offset - Whole days from the origin.
|
|
166
|
+
* @param {number} originYear - Canonical year whose first day is offset zero.
|
|
167
|
+
* @param {number} daysPerYear - The world's authored year length.
|
|
168
|
+
* @returns {{year: number, day: number}} Canonical year and day.
|
|
169
|
+
*/
|
|
170
|
+
export function canonicalDateFromOffset(offset, originYear, daysPerYear) {
|
|
171
|
+
if (
|
|
172
|
+
!Number.isSafeInteger(offset) ||
|
|
173
|
+
!Number.isSafeInteger(originYear) ||
|
|
174
|
+
!Number.isSafeInteger(daysPerYear) ||
|
|
175
|
+
daysPerYear < 1
|
|
176
|
+
) {
|
|
177
|
+
throw new RangeError("a canonical offset needs integer days, origin year, and year length");
|
|
178
|
+
}
|
|
179
|
+
const years = Math.floor(offset / daysPerYear);
|
|
180
|
+
const year = originYear + years;
|
|
181
|
+
if (!Number.isSafeInteger(year)) throw new RangeError("canonical year exceeds integer range");
|
|
182
|
+
return { year, day: offset - years * daysPerYear + 1 };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Print a calendar-neutral date as `<year>.<day>[:HHMMSS]`.
|
|
187
|
+
*
|
|
188
|
+
* @param {{year: number, day: number, seconds?: number}} date - Canonical date.
|
|
189
|
+
* @param {number} daysPerYear - The world's authored year length.
|
|
190
|
+
* @returns {string} The canonical spelling.
|
|
191
|
+
*/
|
|
192
|
+
export function formatCanonicalDate(date, daysPerYear) {
|
|
193
|
+
canonicalDayOffset(date?.year, date?.day, date?.year, daysPerYear);
|
|
194
|
+
if (date.seconds === undefined) return `${date.year}.${date.day}`;
|
|
195
|
+
if (!Number.isSafeInteger(date.seconds) || date.seconds < 0 || date.seconds >= 86400) {
|
|
196
|
+
throw new RangeError("canonical time is a whole second within one day");
|
|
197
|
+
}
|
|
198
|
+
const hour = String(Math.floor(date.seconds / 3600)).padStart(2, "0");
|
|
199
|
+
const minute = String(Math.floor((date.seconds % 3600) / 60)).padStart(2, "0");
|
|
200
|
+
const second = String(date.seconds % 60).padStart(2, "0");
|
|
201
|
+
return `${date.year}.${date.day}:${hour}${minute}${second}`;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Read the calendar-neutral `<year>.<day>[:HHMMSS]` spelling.
|
|
206
|
+
*
|
|
207
|
+
* This is distinct from an authored month date such as `720/6`; no calendar
|
|
208
|
+
* month list is consulted to read a canonical day of year.
|
|
209
|
+
*
|
|
210
|
+
* @param {unknown} value - Canonical date text.
|
|
211
|
+
* @param {number} daysPerYear - The world's authored year length.
|
|
212
|
+
* @returns {{year: number, day: number, seconds?: number}|null} The date, or
|
|
213
|
+
* `null` when the spelling or a component is invalid.
|
|
214
|
+
*/
|
|
215
|
+
export function parseCanonicalDate(value, daysPerYear) {
|
|
216
|
+
if (typeof value !== "string") return null;
|
|
217
|
+
const match = /^(-?\d+)\.(\d+)(?::(\d{2})(\d{2})(\d{2}))?$/.exec(value);
|
|
218
|
+
if (!match) return null;
|
|
219
|
+
const year = Number(match[1]);
|
|
220
|
+
const day = Number(match[2]);
|
|
221
|
+
if (
|
|
222
|
+
!Number.isSafeInteger(year) ||
|
|
223
|
+
!Number.isSafeInteger(day) ||
|
|
224
|
+
!Number.isSafeInteger(daysPerYear) ||
|
|
225
|
+
daysPerYear < 1 ||
|
|
226
|
+
day < 1 ||
|
|
227
|
+
day > daysPerYear
|
|
228
|
+
) {
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
if (match[3] === undefined) return { year, day };
|
|
232
|
+
const hour = Number(match[3]);
|
|
233
|
+
const minute = Number(match[4]);
|
|
234
|
+
const second = Number(match[5]);
|
|
235
|
+
if (hour > 23 || minute > 59 || second > 59) return null;
|
|
236
|
+
return { year, day, seconds: hour * 3600 + minute * 60 + second };
|
|
237
|
+
}
|
|
238
|
+
|
|
129
239
|
/**
|
|
130
240
|
* One month of a calendar, as the note declares it.
|
|
131
241
|
*
|
|
@@ -213,6 +323,25 @@ export function dayOfYear(months, month, day) {
|
|
|
213
323
|
return monthStarts(months)[month - 1] + day - 1;
|
|
214
324
|
}
|
|
215
325
|
|
|
326
|
+
/**
|
|
327
|
+
* Recover a calendar's month and day from an ordinal day of its year.
|
|
328
|
+
*
|
|
329
|
+
* @param {readonly CalendarMonth[]} months - The ordered month list.
|
|
330
|
+
* @param {number} ordinal - Day of year, starting at one.
|
|
331
|
+
* @returns {{month: number, day: number}|null} The named day, or `null` outside the year.
|
|
332
|
+
*/
|
|
333
|
+
export function monthDayOfYear(months, ordinal) {
|
|
334
|
+
if (!Number.isSafeInteger(ordinal) || ordinal < 1) return null;
|
|
335
|
+
let remaining = ordinal;
|
|
336
|
+
for (let position = 0; position < months.length; position++) {
|
|
337
|
+
const length = Number(months[position]?.days);
|
|
338
|
+
if (!Number.isSafeInteger(length) || length < 1) return null;
|
|
339
|
+
if (remaining <= length) return { month: position + 1, day: remaining };
|
|
340
|
+
remaining -= length;
|
|
341
|
+
}
|
|
342
|
+
return null;
|
|
343
|
+
}
|
|
344
|
+
|
|
216
345
|
/**
|
|
217
346
|
* How far into its cycle a moon is on a given day.
|
|
218
347
|
*
|
|
@@ -25,7 +25,8 @@
|
|
|
25
25
|
* simple case needs no spelling. {@link IMAGE_CLASSES} holds the one class
|
|
26
26
|
* there is. **Position is `float:`**, and {@link IMAGE_FLOATS} holds the five
|
|
27
27
|
* values it takes. **Named size is `size:`**, and {@link IMAGE_SIZES} holds its
|
|
28
|
-
* values.
|
|
28
|
+
* values. The HTML surfaces receive a size class; the book receives a print
|
|
29
|
+
* measure.
|
|
29
30
|
*
|
|
30
31
|
* All three are closed, and an unrecognised value is **refused with a located
|
|
31
32
|
* diagnostic** rather than ignored. Ignoring is the failure worth preventing:
|
|
@@ -105,7 +106,7 @@ export const IMAGE_CLASSES = Object.freeze({
|
|
|
105
106
|
},
|
|
106
107
|
});
|
|
107
108
|
|
|
108
|
-
/** The named sizes an image directive accepts
|
|
109
|
+
/** The named sizes an image directive accepts. */
|
|
109
110
|
export const IMAGE_SIZES = Object.freeze([
|
|
110
111
|
"auto",
|
|
111
112
|
"small",
|
|
@@ -326,16 +327,17 @@ export function parseImageDirective(raw) {
|
|
|
326
327
|
/**
|
|
327
328
|
* The classes a figure carries, from a parsed directive.
|
|
328
329
|
*
|
|
329
|
-
* @param {{classes?: string[], float?: string}} [directive] - As parsed.
|
|
330
|
+
* @param {{classes?: string[], size?: string, float?: string}} [directive] - As parsed.
|
|
330
331
|
* @returns {string} A space-separated class list, always naming
|
|
331
332
|
* {@link IMAGE_FIGURE_CLASS} first.
|
|
332
333
|
*/
|
|
333
|
-
export function figureClasses({ classes = [], float = "" } = {}) {
|
|
334
|
+
export function figureClasses({ classes = [], size = "auto", float = "" } = {}) {
|
|
334
335
|
const names = [IMAGE_FIGURE_CLASS];
|
|
335
336
|
for (const name of classes) {
|
|
336
337
|
const spec = IMAGE_CLASSES[/** @type {keyof typeof IMAGE_CLASSES} */ (name)];
|
|
337
338
|
if (spec) names.push(spec.class);
|
|
338
339
|
}
|
|
340
|
+
if (size !== "auto" && IMAGE_SIZES.includes(size)) names.push(`note-image-size-${size}`);
|
|
339
341
|
const position = IMAGE_FLOATS[/** @type {keyof typeof IMAGE_FLOATS} */ (float)];
|
|
340
342
|
if (position) names.push(position.class);
|
|
341
343
|
return names.join(" ");
|
|
@@ -367,13 +369,14 @@ export function escapeHtml(text) {
|
|
|
367
369
|
* @param {string} image.src - The address, resolved for the surface.
|
|
368
370
|
* @param {string} [image.alt] - The alt text, which is also the caption.
|
|
369
371
|
* @param {string[]} [image.classes] - Width classes, from the directive.
|
|
372
|
+
* @param {string} [image.size] - Named display size, from the directive.
|
|
370
373
|
* @param {string} [image.float] - The float position, from the directive.
|
|
371
374
|
* @returns {string} The figure, as one HTML block.
|
|
372
375
|
*/
|
|
373
|
-
export function imageFigureHtml({ src, alt = "", classes = [], float = "" }) {
|
|
376
|
+
export function imageFigureHtml({ src, alt = "", classes = [], size = "auto", float = "" }) {
|
|
374
377
|
const caption = alt ? `\n<figcaption>${escapeHtml(alt)}</figcaption>` : "";
|
|
375
378
|
return (
|
|
376
|
-
`<figure class="${figureClasses({ classes, float })}">\n` +
|
|
379
|
+
`<figure class="${figureClasses({ classes, size, float })}">\n` +
|
|
377
380
|
`<img src="${escapeHtml(src)}" alt="${escapeHtml(alt)}">${caption}\n` +
|
|
378
381
|
`</figure>`
|
|
379
382
|
);
|
|
@@ -644,10 +647,16 @@ export function renderImageFigures(body, resolveSrc = (src) => src) {
|
|
|
644
647
|
let last = 0;
|
|
645
648
|
for (const image of imagesIn(text)) {
|
|
646
649
|
if (!image.block || imageSourceProblem(image.src) || image.title) continue;
|
|
647
|
-
const { classes, float, problems } = parseImageDirective(image.directive);
|
|
650
|
+
const { classes, size, float, problems } = parseImageDirective(image.directive);
|
|
648
651
|
if (problems.length) continue;
|
|
649
652
|
out += text.slice(last, image.index);
|
|
650
|
-
out += imageFigureHtml({
|
|
653
|
+
out += imageFigureHtml({
|
|
654
|
+
src: resolveSrc(image.src),
|
|
655
|
+
alt: image.alt,
|
|
656
|
+
classes,
|
|
657
|
+
size,
|
|
658
|
+
float,
|
|
659
|
+
});
|
|
651
660
|
last = image.index + image.length;
|
|
652
661
|
}
|
|
653
662
|
return out + text.slice(last);
|
|
@@ -696,6 +705,7 @@ export function imagePlugin(resolveSrc = (src) => src) {
|
|
|
696
705
|
src: resolveSrc(token.attrGet("src") ?? ""),
|
|
697
706
|
alt: token.content ?? "",
|
|
698
707
|
classes: token.meta.classes,
|
|
708
|
+
size: token.meta.size,
|
|
699
709
|
float: token.meta.float,
|
|
700
710
|
})}\n`;
|
|
701
711
|
};
|
|
@@ -733,12 +743,12 @@ function attachImageDirectives(tokens) {
|
|
|
733
743
|
directive = brace[0];
|
|
734
744
|
}
|
|
735
745
|
|
|
736
|
-
const { classes, float, problems } = parseImageDirective(directive);
|
|
746
|
+
const { classes, size, float, problems } = parseImageDirective(directive);
|
|
737
747
|
// Not ours to consume: leaving the braces in place is what makes an
|
|
738
748
|
// unrecognised value visible on the page instead of passing as ordinary.
|
|
739
749
|
if (problems.length) continue;
|
|
740
750
|
|
|
741
|
-
image.meta = { ...(image.meta ?? {}), block: true, classes, float };
|
|
751
|
+
image.meta = { ...(image.meta ?? {}), block: true, classes, size, float };
|
|
742
752
|
if (trailing) trailing.content = trailing.content.slice(directive.length);
|
|
743
753
|
// The figure is a block, so the paragraph that held it renders nothing.
|
|
744
754
|
tokens[i].hidden = true;
|
package/engine/content-index.mjs
CHANGED
|
@@ -151,6 +151,7 @@ import { isDraftNote } from "./note-vocabulary.mjs";
|
|
|
151
151
|
// an unknown one — filled in against the package's own declared present before
|
|
152
152
|
// a record is built, so the index carries it without the note being written to.
|
|
153
153
|
import { applyComputedBeingAge, presentAmongFrontmatters } from "./being-age.mjs";
|
|
154
|
+
import { reckoningContext } from "./reckoning-markers.mjs";
|
|
154
155
|
|
|
155
156
|
export {
|
|
156
157
|
authoredFrontmatter,
|
|
@@ -692,10 +693,11 @@ export function collectContentIndex(
|
|
|
692
693
|
// this same walk states one — the whole tree is already in memory, so no
|
|
693
694
|
// second read is needed to answer a question about all of it.
|
|
694
695
|
const present = presentAmongFrontmatters(notes.map((n) => n.frontmatter));
|
|
696
|
+
const dates = reckoningContext({ notes: notes.map((n) => n.frontmatter) });
|
|
695
697
|
|
|
696
698
|
for (const { frontmatter, body, bodyLine, absPath } of notes) {
|
|
697
699
|
const fm = frontmatter ?? {};
|
|
698
|
-
applyComputedBeingAge(fm, present);
|
|
700
|
+
applyComputedBeingAge(fm, present, dates);
|
|
699
701
|
const relPath = path.relative(contentBase, absPath);
|
|
700
702
|
try {
|
|
701
703
|
records.push(
|