@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
package/engine/map-graphviz.mjs
CHANGED
|
@@ -1,147 +1,66 @@
|
|
|
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
|
-
*/
|
|
1
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
13
2
|
|
|
14
|
-
/**
|
|
15
|
-
* GraphViz, as `content-build map` reaches it.
|
|
16
|
-
*
|
|
17
|
-
* GraphViz is a build-time tool of the map command alone: nothing else in the
|
|
18
|
-
* toolchain draws, so nothing else depends on it, and a package that never
|
|
19
|
-
* runs `map` never needs it installed. The command finds the engine it needs
|
|
20
|
-
* once, names what to install when it is absent, and runs it over a `.dot`
|
|
21
|
-
* file it has already written — the `.dot` is always kept beside the `.svg`,
|
|
22
|
-
* so a drawing can be re-rendered, inspected or diffed without re-reading the
|
|
23
|
-
* content index.
|
|
24
|
-
*
|
|
25
|
-
* @module
|
|
26
|
-
*/
|
|
3
|
+
/** Render DOT maps with the Graphviz runtime installed by npm. */
|
|
27
4
|
|
|
28
5
|
import fs from "node:fs";
|
|
29
|
-
import
|
|
30
|
-
import {
|
|
6
|
+
import { instance } from "@viz-js/viz";
|
|
7
|
+
import { Resvg } from "@resvg/resvg-js";
|
|
31
8
|
|
|
32
|
-
/**
|
|
33
|
-
* The layout engines the map command may ask for. `dot` draws a tree by
|
|
34
|
-
* rank, `twopi` the same tree radially, `neato` a graph by its edge lengths —
|
|
35
|
-
* and, with every node pinned, the map from a place.
|
|
36
|
-
*
|
|
37
|
-
* @type {readonly string[]}
|
|
38
|
-
*/
|
|
9
|
+
/** The layout engines accepted by the map command. */
|
|
39
10
|
export const GRAPHVIZ_ENGINES = Object.freeze(["dot", "twopi", "neato"]);
|
|
40
11
|
|
|
41
|
-
|
|
42
|
-
* Where a package manager puts GraphViz when `PATH` does not say: Homebrew
|
|
43
|
-
* on Apple silicon, Homebrew on Intel, and the system directories.
|
|
44
|
-
*
|
|
45
|
-
* @type {readonly string[]}
|
|
46
|
-
*/
|
|
47
|
-
const KNOWN_DIRECTORIES = Object.freeze([
|
|
48
|
-
"/opt/homebrew/bin",
|
|
49
|
-
"/usr/local/bin",
|
|
50
|
-
"/usr/bin",
|
|
51
|
-
"/opt/local/bin",
|
|
52
|
-
]);
|
|
12
|
+
const graphviz = await instance();
|
|
53
13
|
|
|
54
14
|
/**
|
|
55
|
-
*
|
|
15
|
+
* Render a DOT source file to SVG, plain positions, or a PNG background.
|
|
56
16
|
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
* business.
|
|
17
|
+
* A PNG uses the same Graphviz SVG layout as the vector map. SVG dimensions
|
|
18
|
+
* are in points; resvg starts at 96 pixels per inch, so the zoom converts
|
|
19
|
+
* the requested DPI to the same physical size as Graphviz's PNG output.
|
|
61
20
|
*
|
|
62
|
-
* @param {string}
|
|
63
|
-
* @param {
|
|
64
|
-
* @param {
|
|
65
|
-
*
|
|
66
|
-
* @param {
|
|
67
|
-
*
|
|
68
|
-
* @
|
|
69
|
-
* @
|
|
21
|
+
* @param {string} dotPath - The saved DOT source.
|
|
22
|
+
* @param {string} outPath - The output file.
|
|
23
|
+
* @param {object} opts
|
|
24
|
+
* @param {"dot"|"twopi"|"neato"} opts.engine - Layout engine.
|
|
25
|
+
* @param {"svg"|"plain"|"png"} [opts.format="svg"] - Output format.
|
|
26
|
+
* @param {2} [opts.nop] - Preserve positioned nodes as `neato -n2` does.
|
|
27
|
+
* @param {number} [opts.dpi=96] - PNG resolution.
|
|
28
|
+
* @returns {{warnings: string}} Renderer warnings.
|
|
70
29
|
*/
|
|
71
|
-
export function
|
|
30
|
+
export function renderDot(dotPath, outPath, { engine, format = "svg", nop, dpi = 96 }) {
|
|
72
31
|
if (!GRAPHVIZ_ENGINES.includes(engine)) {
|
|
73
|
-
throw new Error(
|
|
74
|
-
`"${engine}" is not a GraphViz engine the map command uses; ` +
|
|
75
|
-
`choose ${GRAPHVIZ_ENGINES.join(", ")}`,
|
|
76
|
-
);
|
|
32
|
+
throw new Error(`Unsupported Graphviz engine "${engine}" for ${dotPath}`);
|
|
77
33
|
}
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
.
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
// Not here; the next directory may have it.
|
|
34
|
+
const dot = fs.readFileSync(dotPath, "utf8");
|
|
35
|
+
try {
|
|
36
|
+
const result = graphviz.render(dot, {
|
|
37
|
+
engine: nop === 2 ? "nop2" : engine,
|
|
38
|
+
format: format === "png" ? "svg" : format,
|
|
39
|
+
});
|
|
40
|
+
if (result.status !== "success" || !result.output) {
|
|
41
|
+
throw new Error(result.errors.map((error) => error.message).join("; ") || "no output");
|
|
87
42
|
}
|
|
43
|
+
if (format === "png") {
|
|
44
|
+
const image = new Resvg(result.output, {
|
|
45
|
+
fitTo: { mode: "zoom", value: dpi / 96 },
|
|
46
|
+
}).render();
|
|
47
|
+
fs.writeFileSync(outPath, image.asPng());
|
|
48
|
+
} else {
|
|
49
|
+
fs.writeFileSync(outPath, result.output);
|
|
50
|
+
}
|
|
51
|
+
// The embedded renderer uses Times metrics for the generated bold
|
|
52
|
+
// headings; its warning repeats for every map and needs no action.
|
|
53
|
+
return {
|
|
54
|
+
warnings: result.errors
|
|
55
|
+
.filter(
|
|
56
|
+
(error) =>
|
|
57
|
+
error.level === "warning" &&
|
|
58
|
+
!error.message.includes("no hard-coded metrics for 'Helvetica-Bold'"),
|
|
59
|
+
)
|
|
60
|
+
.map((error) => error.message)
|
|
61
|
+
.join("; "),
|
|
62
|
+
};
|
|
63
|
+
} catch (error) {
|
|
64
|
+
throw new Error(`Graphviz failed over ${dotPath}: ${error.message}`, { cause: error });
|
|
88
65
|
}
|
|
89
|
-
return undefined;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* What installs GraphViz on the two platforms a contributor is likely to be
|
|
94
|
-
* on, as the clause every "not installed" message ends with.
|
|
95
|
-
*
|
|
96
|
-
* @type {string}
|
|
97
|
-
*/
|
|
98
|
-
export const GRAPHVIZ_INSTALL =
|
|
99
|
-
"install GraphViz (`brew install graphviz` on macOS, " +
|
|
100
|
-
"`apt-get install graphviz` on Debian or Ubuntu)";
|
|
101
|
-
|
|
102
|
-
/**
|
|
103
|
-
* What to say when an engine is not installed: the name, and what installs
|
|
104
|
-
* it.
|
|
105
|
-
*
|
|
106
|
-
* @param {string} engine - The engine that was wanted.
|
|
107
|
-
* @returns {string} The message.
|
|
108
|
-
*/
|
|
109
|
-
export function graphvizMissingMessage(engine) {
|
|
110
|
-
return (
|
|
111
|
-
`GraphViz's \`${engine}\` is not installed, and \`content-build map\` draws with it; ` +
|
|
112
|
-
`${GRAPHVIZ_INSTALL} and run the command again`
|
|
113
|
-
);
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/**
|
|
117
|
-
* Render a `.dot` file with a GraphViz engine.
|
|
118
|
-
*
|
|
119
|
-
* The engine is run over the file rather than fed the text, so the file the
|
|
120
|
-
* command keeps is exactly what was drawn. Warnings GraphViz prints — an
|
|
121
|
-
* unknown font, an overlapping label — are returned rather than swallowed,
|
|
122
|
-
* and a non-zero exit is an error naming the file.
|
|
123
|
-
*
|
|
124
|
-
* @param {string} dotPath - The `.dot` file.
|
|
125
|
-
* @param {string} outPath - Where to write the rendering.
|
|
126
|
-
* @param {object} opts
|
|
127
|
-
* @param {string} opts.binary - The engine's binary, from {@link findGraphviz}.
|
|
128
|
-
* @param {string} [opts.format="svg"] - The `-T` format.
|
|
129
|
-
* @param {string[]} [opts.args] - Further arguments, before the file.
|
|
130
|
-
* @returns {{warnings: string}} What the engine printed on stderr.
|
|
131
|
-
* @throws {Error} When the engine exits non-zero or cannot be run.
|
|
132
|
-
*/
|
|
133
|
-
export function renderDot(dotPath, outPath, { binary, format = "svg", args = [] }) {
|
|
134
|
-
const result = spawnSync(binary, [`-T${format}`, ...args, dotPath, "-o", outPath], {
|
|
135
|
-
encoding: "utf8",
|
|
136
|
-
});
|
|
137
|
-
if (result.error) {
|
|
138
|
-
throw new Error(`${binary} could not be run over ${dotPath}: ${result.error.message}`);
|
|
139
|
-
}
|
|
140
|
-
if (result.status !== 0) {
|
|
141
|
-
throw new Error(
|
|
142
|
-
`${binary} failed over ${dotPath} (exit ${result.status}): ` +
|
|
143
|
-
`${String(result.stderr ?? "").trim()}`,
|
|
144
|
-
);
|
|
145
|
-
}
|
|
146
|
-
return { warnings: String(result.stderr ?? "").trim() };
|
|
147
66
|
}
|
package/engine/map-notes.mjs
CHANGED
|
@@ -113,6 +113,12 @@ export const MAP_SUBTYPE_PROFILES = Object.freeze({
|
|
|
113
113
|
fog: { mode: 0 /* DISABLED */ },
|
|
114
114
|
padding: 0,
|
|
115
115
|
}),
|
|
116
|
+
totm: Object.freeze({
|
|
117
|
+
grid: { type: 0 /* GRIDLESS */, distance: 1, units: "" },
|
|
118
|
+
tokenVision: false,
|
|
119
|
+
fog: { mode: 0 /* DISABLED */ },
|
|
120
|
+
padding: 0,
|
|
121
|
+
}),
|
|
116
122
|
});
|
|
117
123
|
|
|
118
124
|
/**
|
|
@@ -894,6 +900,23 @@ export function buildScene(fm, ctx) {
|
|
|
894
900
|
const sceneId = fm.id;
|
|
895
901
|
if (!sceneId) throw new Error("a map note needs an `id`");
|
|
896
902
|
const profile = mapProfile(fm.subType);
|
|
903
|
+
const scale = fm.data?.scale;
|
|
904
|
+
if (scale !== undefined) {
|
|
905
|
+
if (
|
|
906
|
+
fm.subType !== "regionalmap" ||
|
|
907
|
+
!scale ||
|
|
908
|
+
typeof scale !== "object" ||
|
|
909
|
+
Array.isArray(scale) ||
|
|
910
|
+
Object.keys(scale).some((key) => !["distance", "unit"].includes(key)) ||
|
|
911
|
+
!(Number.isFinite(scale.distance) && scale.distance > 0) ||
|
|
912
|
+
typeof scale.unit !== "string" ||
|
|
913
|
+
!scale.unit.trim()
|
|
914
|
+
) {
|
|
915
|
+
throw new Error(
|
|
916
|
+
"`data.scale` on a regionalmap is {distance: positive number, unit: nonempty string}",
|
|
917
|
+
);
|
|
918
|
+
}
|
|
919
|
+
}
|
|
897
920
|
|
|
898
921
|
const dimensions = sohl.dimensions;
|
|
899
922
|
if (
|
|
@@ -903,7 +926,7 @@ export function buildScene(fm, ctx) {
|
|
|
903
926
|
) {
|
|
904
927
|
throw new Error("`dimensions` is [width, height] in whole pixels — the map's own size");
|
|
905
928
|
}
|
|
906
|
-
const pxPerGrid = sohl.pxPerGrid;
|
|
929
|
+
const pxPerGrid = fm.subType === "totm" ? 100 : sohl.pxPerGrid;
|
|
907
930
|
if (!Number.isInteger(pxPerGrid) || pxPerGrid <= 0) {
|
|
908
931
|
throw new Error(
|
|
909
932
|
"`pxPerGrid` is the whole number of pixels per grid square, and must " +
|
|
@@ -933,8 +956,8 @@ export function buildScene(fm, ctx) {
|
|
|
933
956
|
grid: {
|
|
934
957
|
type: profile.grid.type,
|
|
935
958
|
size: pxPerGrid,
|
|
936
|
-
distance: profile.grid.distance,
|
|
937
|
-
units: profile.grid.units,
|
|
959
|
+
distance: scale?.distance ?? profile.grid.distance,
|
|
960
|
+
units: scale?.unit ?? profile.grid.units,
|
|
938
961
|
},
|
|
939
962
|
tokenVision: profile.tokenVision,
|
|
940
963
|
fog: { mode: profile.fog.mode },
|
package/engine/map-places.mjs
CHANGED
|
@@ -332,7 +332,8 @@ export function mapWorld({ records, foreignIndex, contentBase, base, config }) {
|
|
|
332
332
|
* What `parents` says about the world's shape.
|
|
333
333
|
*
|
|
334
334
|
* @typedef {object} Containment
|
|
335
|
-
* @property {string|undefined} world - The one
|
|
335
|
+
* @property {string|undefined} world - The world when exactly one is present.
|
|
336
|
+
* @property {string[]} worlds - Every `subType: world` place, sorted.
|
|
336
337
|
* @property {string[]} continents - Regions parented directly on the world,
|
|
337
338
|
* in name order.
|
|
338
339
|
* @property {Map<string, string>} continentOf - Each place's continent, for
|
|
@@ -412,8 +413,12 @@ function findCycles(places) {
|
|
|
412
413
|
* @returns {Containment} The analysis.
|
|
413
414
|
*/
|
|
414
415
|
export function analyzeContainment(places, polities = []) {
|
|
415
|
-
const worlds = [...places.values()]
|
|
416
|
-
|
|
416
|
+
const worlds = [...places.values()]
|
|
417
|
+
.filter((p) => p.subType === "world")
|
|
418
|
+
.map((p) => p.shortcode)
|
|
419
|
+
.sort();
|
|
420
|
+
const world = worlds.length === 1 ? worlds[0] : undefined;
|
|
421
|
+
const worldSet = new Set(worlds);
|
|
417
422
|
|
|
418
423
|
/** @type {Map<string, string[]>} */
|
|
419
424
|
const children = new Map();
|
|
@@ -431,8 +436,8 @@ export function analyzeContainment(places, polities = []) {
|
|
|
431
436
|
const resolve = (sc, visiting) => {
|
|
432
437
|
if (continentOf.has(sc)) return continentOf.get(sc);
|
|
433
438
|
const place = places.get(sc);
|
|
434
|
-
if (!place || sc
|
|
435
|
-
if (place.subType === "region" &&
|
|
439
|
+
if (!place || worldSet.has(sc) || visiting.has(sc)) return undefined;
|
|
440
|
+
if (place.subType === "region" && place.parents.some((parent) => worldSet.has(parent))) {
|
|
436
441
|
continentOf.set(sc, sc);
|
|
437
442
|
return sc;
|
|
438
443
|
}
|
|
@@ -476,7 +481,7 @@ export function analyzeContainment(places, polities = []) {
|
|
|
476
481
|
};
|
|
477
482
|
for (const sc of [...places.keys()].sort()) {
|
|
478
483
|
const place = /** @type {MapPlace} */ (places.get(sc));
|
|
479
|
-
if (place.parents.length === 0 && sc
|
|
484
|
+
if (place.parents.length === 0 && !worldSet.has(sc)) anomalies.noParent.push(sc);
|
|
480
485
|
if (place.parents.length > 1) anomalies.multiParent.push(sc);
|
|
481
486
|
if (place.local && place.subType === "region" && place.hasPackFolder === false) {
|
|
482
487
|
anomalies.regionNoPackFolder.push(sc);
|
|
@@ -492,6 +497,7 @@ export function analyzeContainment(places, polities = []) {
|
|
|
492
497
|
|
|
493
498
|
return {
|
|
494
499
|
world,
|
|
500
|
+
worlds,
|
|
495
501
|
continents,
|
|
496
502
|
continentOf,
|
|
497
503
|
children,
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { Resvg } from "@resvg/resvg-js";
|
|
8
|
+
|
|
9
|
+
/** Rasterize an authored SVG map into the staged Foundry package. */
|
|
10
|
+
export function rasterizeMapSvg({ foundryPath, width, height, sceneId, config }) {
|
|
11
|
+
if (!config) throw new Error("SVG map rasterization requires the package build configuration");
|
|
12
|
+
if (![width, height].every((value) => Number.isInteger(value) && value > 0)) {
|
|
13
|
+
throw new Error("an SVG map needs `sohl.dimensions: [width, height]` in whole pixels");
|
|
14
|
+
}
|
|
15
|
+
const marker = "/assets/";
|
|
16
|
+
const at = foundryPath.indexOf(marker);
|
|
17
|
+
if (at < 0 || !foundryPath.toLowerCase().endsWith(".svg")) {
|
|
18
|
+
throw new Error(`map art path "${foundryPath}" is not an SVG inside assets`);
|
|
19
|
+
}
|
|
20
|
+
const suffix = foundryPath.slice(at + marker.length);
|
|
21
|
+
const source = path.resolve(config.rootDir, "assets", suffix);
|
|
22
|
+
const assetRoot = path.resolve(config.rootDir, "assets");
|
|
23
|
+
if (!source.startsWith(`${assetRoot}${path.sep}`) || !fs.existsSync(source)) {
|
|
24
|
+
throw new Error(`map SVG "${foundryPath}" is not a local asset this package can rasterize`);
|
|
25
|
+
}
|
|
26
|
+
const staged = path.resolve(
|
|
27
|
+
config.rootDir,
|
|
28
|
+
config.packageBuild.stageDir,
|
|
29
|
+
"assets/maps/rasterized",
|
|
30
|
+
`${sceneId}.png`,
|
|
31
|
+
);
|
|
32
|
+
fs.mkdirSync(path.dirname(staged), { recursive: true });
|
|
33
|
+
try {
|
|
34
|
+
const image = new Resvg(fs.readFileSync(source), {
|
|
35
|
+
fitTo: { mode: "width", value: width },
|
|
36
|
+
}).render();
|
|
37
|
+
if (image.height !== height) {
|
|
38
|
+
throw new Error(
|
|
39
|
+
`the SVG is ${width}×${image.height} at the stated width, not ${width}×${height}`,
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
fs.writeFileSync(staged, image.asPng());
|
|
43
|
+
} catch (error) {
|
|
44
|
+
throw new Error(`could not rasterize map SVG "${foundryPath}": ${error.message}`, {
|
|
45
|
+
cause: error,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
return `${config.packageKind}/${config.foundryPackage.id}/assets/maps/rasterized/${sceneId}.png`;
|
|
49
|
+
}
|
package/engine/note-dates.mjs
CHANGED
|
@@ -12,93 +12,34 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
|
-
*
|
|
15
|
+
* Parse authored note dates into records used by content checks and renderers.
|
|
16
16
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
17
|
+
* A neutral day is `<year>.<day>[:HHMMSS]`, where `day` is an ordinal within
|
|
18
|
+
* the world's year. A marked date is `<marker>(<year>[/<month>[/<day>]])`.
|
|
19
|
+
* The marker selects one era and its calendar's ordered month list; the era's
|
|
20
|
+
* start places its year one on the neutral timeline. A bare year is year
|
|
21
|
+
* precision, and `unknown` is unordered. The parser also accepts unmarked
|
|
22
|
+
* slash dates and addressed era qualifiers.
|
|
19
23
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
* to, so a renderer can print `c. -2110` from the same number it sorts on.
|
|
25
|
-
* It precedes the sign: `~-2500`, never `-~2500`.
|
|
26
|
-
* - `-` — before the epoch the year is counted from. There is no year zero, so
|
|
27
|
-
* `-1` sits immediately before `1`.
|
|
28
|
-
* - `YYYY` — required.
|
|
29
|
-
* - `MM`, `DD` — optional, in that order.
|
|
30
|
-
* - `ERA` — a calendar's address plus `.<era shortcode>`, in the four address
|
|
31
|
-
* forms {@link ERA_QUALIFIER_PATTERN} admits. Omitted, the date sits on the
|
|
32
|
-
* canonical axis and is attributed to nobody.
|
|
33
|
-
*
|
|
34
|
-
* **Precision falls out of how much is written.** `-984` is a year, `689/6` a
|
|
35
|
-
* month, `689/6/19` a day. There is no precision vocabulary, so there is
|
|
36
|
-
* nothing to fall out of step with the value it describes.
|
|
37
|
-
*
|
|
38
|
-
* ## A trailing token is only ever an era
|
|
39
|
-
*
|
|
40
|
-
* It has exactly one shape and it always contains exactly one dot, so **a
|
|
41
|
-
* trailing token at all means the value needs the corpus, and no token means
|
|
42
|
-
* it is already on the axis** — which is readable off the string, before
|
|
43
|
-
* anything is resolved.
|
|
44
|
-
*
|
|
45
|
-
* ## The rule to copy: print `text`, order on `sort`, do arithmetic on `canonicalYear`
|
|
46
|
-
*
|
|
47
|
-
* Every parsed date carries all three, because neither the string nor the
|
|
48
|
-
* number is cheap to derive from the other at read time:
|
|
49
|
-
*
|
|
50
|
-
* ```yaml
|
|
51
|
-
* text: "689/6/19" # authored, verbatim — what a page prints
|
|
52
|
-
* known: true # what a query branches on
|
|
53
|
-
* era: null # the reckoning named, or null for the canonical axis
|
|
54
|
-
* year: 689 # as authored, signed, within its own reckoning
|
|
55
|
-
* month: 6 # null where unwritten
|
|
56
|
-
* day: 19 # null where unwritten
|
|
57
|
-
* approximate: false # the `~`
|
|
58
|
-
* precision: day # derived: day | month | year
|
|
59
|
-
* canonicalYear: 689 # signed — arithmetic
|
|
60
|
-
* sort: 689.0619 # total order across mixed precision — sorting
|
|
61
|
-
* ```
|
|
62
|
-
*
|
|
63
|
-
* `era` is `null` for a bare value and there is no substitute for it: the
|
|
64
|
-
* canonical axis is not a reckoning anybody keeps and has no address, so
|
|
65
|
-
* filling the field would state an identification that is not true.
|
|
66
|
-
*
|
|
67
|
-
* ## A number is written only where there is one to write
|
|
68
|
-
*
|
|
69
|
-
* A date naming an era carries the qualifier and **no `canonicalYear` and no
|
|
70
|
-
* `sort` keys at all** until the era is resolved against the corpus. They are
|
|
71
|
-
* absent rather than null, because a record carrying `known: true` beside a
|
|
72
|
-
* null year and a null sort is indistinguishable from the `unknown` record
|
|
73
|
-
* below — which is the one shape whose confusion is invisible in the output.
|
|
74
|
-
*
|
|
75
|
-
* ## `unknown` is a value, and it is unordered
|
|
76
|
-
*
|
|
77
|
-
* `"unknown"` says the thing happened and the date is not recorded. It parses
|
|
78
|
-
* to the same record with the year half empty — `known: false`, and **a null
|
|
79
|
-
* `canonicalYear` and a null `sort`**.
|
|
80
|
-
*
|
|
81
|
-
* Both shortcuts past that are silently wrong. Normalising it to `0` sorts the
|
|
82
|
-
* ancient dead into the year immediately before the epoch; treating it as a
|
|
83
|
-
* null year and letting a comparison default it sorts them into the present,
|
|
84
|
-
* beside the living. So anything that orders or filters on a date leaves an
|
|
85
|
-
* unknown one out of the ordering and reports it as its own group, and a range
|
|
86
|
-
* filter never matches it, because the record does not claim a year and a
|
|
87
|
-
* filter must not claim one for it.
|
|
88
|
-
*
|
|
89
|
-
* ## Absence is not a finding, and neither is a bare value
|
|
90
|
-
*
|
|
91
|
-
* An absent value is a fact about the subject rather than a gap, so parsing
|
|
92
|
-
* nothing yields nothing and reports nothing. A bare value is the canonical
|
|
93
|
-
* axis, always, and it is silent: an unattributed date is the honest form for
|
|
94
|
-
* a date an author is placing rather than a record somebody in the setting
|
|
95
|
-
* kept.
|
|
24
|
+
* `text` keeps the authored spelling. `canonicalYear` and `sort` are set for
|
|
25
|
+
* neutral and resolved marked dates. A marked date also carries
|
|
26
|
+
* `canonicalDay`, the ordinal day within its neutral year. An addressed era
|
|
27
|
+
* qualifier carries no resolved year until a caller supplies its era facts.
|
|
96
28
|
*
|
|
97
29
|
* @module
|
|
98
30
|
*/
|
|
99
31
|
|
|
100
32
|
import { ADDRESS_SEGMENT_PATTERN } from "./address-charset.mjs";
|
|
101
|
-
import {
|
|
33
|
+
import {
|
|
34
|
+
calendarStructure,
|
|
35
|
+
canonicalDateFromOffset,
|
|
36
|
+
canonicalDayOffset,
|
|
37
|
+
canonicalYear,
|
|
38
|
+
dateSortKey,
|
|
39
|
+
dayOfYear,
|
|
40
|
+
daysInMonth,
|
|
41
|
+
parseCanonicalDate,
|
|
42
|
+
} from "./calendars.mjs";
|
|
102
43
|
import { positionOfFrontmatterPath } from "./diagnostics.mjs";
|
|
103
44
|
|
|
104
45
|
/**
|
|
@@ -186,6 +127,10 @@ function unknownRecord() {
|
|
|
186
127
|
* the value is written in, as its note declares it. Its ordered month list
|
|
187
128
|
* bounds a written month and a written day, the day against the length of
|
|
188
129
|
* the month it names. A calendar that declares no list bounds nothing.
|
|
130
|
+
* @param {Map<string, object>} [options.markers] - Resolved era markers from
|
|
131
|
+
* the corpus, each carrying its calendar and canonical start.
|
|
132
|
+
* @param {number} [options.daysPerYear] - The world's year length, required for
|
|
133
|
+
* canonical days and marked dates.
|
|
189
134
|
* @param {string} [options.field] - The key that carried it, named in every
|
|
190
135
|
* message. Omitted, a message names the value alone.
|
|
191
136
|
* @param {boolean} [options.allowUnknown=true] - Whether `"unknown"` is a
|
|
@@ -199,7 +144,16 @@ function unknownRecord() {
|
|
|
199
144
|
* the value is absent or refused, and every finding the value earned.
|
|
200
145
|
*/
|
|
201
146
|
export function parseNoteDate(value, options) {
|
|
202
|
-
const {
|
|
147
|
+
const {
|
|
148
|
+
calendar,
|
|
149
|
+
markers,
|
|
150
|
+
daysPerYear,
|
|
151
|
+
field,
|
|
152
|
+
allowUnknown = true,
|
|
153
|
+
file,
|
|
154
|
+
raw,
|
|
155
|
+
keyPath,
|
|
156
|
+
} = options ?? {};
|
|
203
157
|
const findings = [];
|
|
204
158
|
|
|
205
159
|
// Absence is a fact about the subject, not a gap — nothing to parse and
|
|
@@ -235,6 +189,86 @@ export function parseNoteDate(value, options) {
|
|
|
235
189
|
}
|
|
236
190
|
|
|
237
191
|
const trimmed = text.trim();
|
|
192
|
+
const marked = /^([A-Z][A-Z0-9]*)\((.*)\)$/.exec(trimmed);
|
|
193
|
+
if (marked) {
|
|
194
|
+
const [, marker, payload] = marked;
|
|
195
|
+
const reckoning = markers?.get(marker);
|
|
196
|
+
if (!reckoning) return refuse(`${subject} names unknown reckoning marker ${marker}`);
|
|
197
|
+
if (!Number.isSafeInteger(daysPerYear) || daysPerYear < 1)
|
|
198
|
+
return refuse(`${subject} needs the world's year length to resolve ${marker}`);
|
|
199
|
+
const parsed = parseNoteDate(payload, {
|
|
200
|
+
calendar: reckoning.calendar,
|
|
201
|
+
field,
|
|
202
|
+
allowUnknown: false,
|
|
203
|
+
file,
|
|
204
|
+
raw,
|
|
205
|
+
keyPath,
|
|
206
|
+
});
|
|
207
|
+
if (parsed.findings.some((finding) => finding.severity === "error") || !parsed.date)
|
|
208
|
+
return { date: null, findings: parsed.findings };
|
|
209
|
+
if (!parsed.date.known)
|
|
210
|
+
return refuse(`${subject} wraps an unknown date; write \`unknown\` by itself`);
|
|
211
|
+
if (parsed.date.era !== null)
|
|
212
|
+
return refuse(
|
|
213
|
+
`${subject} names two reckonings; the payload of ${marker} is a calendar date`,
|
|
214
|
+
);
|
|
215
|
+
if (parsed.date.canonicalDay !== undefined)
|
|
216
|
+
return refuse(
|
|
217
|
+
`${subject} puts a canonical day inside ${marker}; write its calendar month and day`,
|
|
218
|
+
);
|
|
219
|
+
const { months } = calendarStructure(reckoning.calendar);
|
|
220
|
+
if (!months) return refuse(`${subject} names ${marker}, whose calendar declares no months`);
|
|
221
|
+
const ordinal = dayOfYear(months, parsed.date.month ?? 1, parsed.date.day ?? 1);
|
|
222
|
+
const start = canonicalDayOffset(reckoning.epochYear, reckoning.epochDay, 1, daysPerYear);
|
|
223
|
+
const yearOffset = parsed.date.year > 0 ? parsed.date.year - 1 : parsed.date.year;
|
|
224
|
+
const offset = start + yearOffset * daysPerYear + ordinal - 1;
|
|
225
|
+
if (!Number.isSafeInteger(offset))
|
|
226
|
+
return refuse(`${subject} lies outside the supported canonical day range`);
|
|
227
|
+
if (reckoning.endOffset !== undefined && offset > reckoning.endOffset)
|
|
228
|
+
return refuse(`${subject} lies after the end of reckoning marker ${marker}`);
|
|
229
|
+
const canonical = canonicalDateFromOffset(offset, 1, daysPerYear);
|
|
230
|
+
return {
|
|
231
|
+
date: {
|
|
232
|
+
...parsed.date,
|
|
233
|
+
text,
|
|
234
|
+
era: reckoning.era,
|
|
235
|
+
marker,
|
|
236
|
+
canonicalYear: canonical.year,
|
|
237
|
+
canonicalDay: canonical.day,
|
|
238
|
+
sort: canonical.year + (canonical.day - 1) / daysPerYear,
|
|
239
|
+
},
|
|
240
|
+
findings: parsed.findings,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (trimmed.includes(".")) {
|
|
245
|
+
const canonical = parseCanonicalDate(trimmed, daysPerYear);
|
|
246
|
+
if (canonical) {
|
|
247
|
+
return {
|
|
248
|
+
date: {
|
|
249
|
+
text,
|
|
250
|
+
known: true,
|
|
251
|
+
era: null,
|
|
252
|
+
year: canonical.year,
|
|
253
|
+
month: null,
|
|
254
|
+
day: canonical.day,
|
|
255
|
+
approximate: false,
|
|
256
|
+
precision: "day",
|
|
257
|
+
canonicalYear: canonical.year,
|
|
258
|
+
canonicalDay: canonical.day,
|
|
259
|
+
...(canonical.seconds === undefined ? {} : { seconds: canonical.seconds }),
|
|
260
|
+
sort:
|
|
261
|
+
canonical.year +
|
|
262
|
+
(canonical.day - 1 + (canonical.seconds ?? 0) / 86400) / daysPerYear,
|
|
263
|
+
},
|
|
264
|
+
findings,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
if (/^-?\d+\.\d+/.test(trimmed))
|
|
268
|
+
return refuse(
|
|
269
|
+
`${subject} is not a canonical date — write \`<year>.<day>[:HHMMSS]\` with a day inside the world's year`,
|
|
270
|
+
);
|
|
271
|
+
}
|
|
238
272
|
const match = NOTE_DATE_PATTERN.exec(trimmed);
|
|
239
273
|
if (!match) {
|
|
240
274
|
if (DAY_WITHOUT_MONTH_PATTERN.test(trimmed)) {
|
|
@@ -264,10 +298,8 @@ export function parseNoteDate(value, options) {
|
|
|
264
298
|
}
|
|
265
299
|
return refuse(
|
|
266
300
|
`${subject} is not a date — write ` +
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
`from, optionally a month and a day, and an era where the date is a ` +
|
|
270
|
-
`reckoning somebody kept`,
|
|
301
|
+
`\`<year>.<day>[:HHMMSS]\` or ` +
|
|
302
|
+
`\`<marker>(<year>[/<month>[/<day>]])\``,
|
|
271
303
|
);
|
|
272
304
|
}
|
|
273
305
|
|
|
@@ -276,6 +308,14 @@ export function parseNoteDate(value, options) {
|
|
|
276
308
|
const year = Number(yearText);
|
|
277
309
|
const month = monthText === undefined ? null : Number(monthText);
|
|
278
310
|
const day = dayText === undefined ? null : Number(dayText);
|
|
311
|
+
if (
|
|
312
|
+
!Number.isSafeInteger(year) ||
|
|
313
|
+
(month !== null && !Number.isSafeInteger(month)) ||
|
|
314
|
+
(day !== null && !Number.isSafeInteger(day))
|
|
315
|
+
)
|
|
316
|
+
return refuse(
|
|
317
|
+
`${subject} needs whole year, month, and day numbers inside the supported range`,
|
|
318
|
+
);
|
|
279
319
|
|
|
280
320
|
// No year zero, in any reckoning: the years either side of an epoch are -1
|
|
281
321
|
// and 1. `-0` is the same year written the other way and is refused with it.
|