@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.
Files changed (62) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/CONTENT.md +25 -1
  3. package/bin/content-build.mjs +3 -7
  4. package/bin/package-build.mjs +25 -0
  5. package/docs/api.md +64 -36
  6. package/docs/commands.md +65 -9
  7. package/docs/configuration.md +6 -3
  8. package/docs/content-format.md +187 -140
  9. package/docs/diagnostics.md +1 -1
  10. package/docs/getting-started.md +27 -3
  11. package/engine/base-compiler.mjs +3 -1
  12. package/engine/being-age.mjs +16 -24
  13. package/engine/calendar-artifacts.mjs +153 -0
  14. package/engine/calendar-notes.mjs +64 -16
  15. package/engine/calendars.mjs +129 -0
  16. package/engine/content-images.mjs +20 -10
  17. package/engine/content-index.mjs +3 -1
  18. package/engine/exported-scene.mjs +230 -0
  19. package/engine/generate.mjs +1 -0
  20. package/engine/ids.mjs +1 -1
  21. package/engine/index.mjs +6 -0
  22. package/engine/infobox.mjs +4 -0
  23. package/engine/itinerary-scenes.mjs +170 -0
  24. package/engine/map-build.mjs +10 -45
  25. package/engine/map-dot.mjs +6 -3
  26. package/engine/map-graphviz.mjs +50 -131
  27. package/engine/map-notes.mjs +26 -3
  28. package/engine/map-places.mjs +12 -6
  29. package/engine/map-raster.mjs +49 -0
  30. package/engine/note-dates.mjs +125 -85
  31. package/engine/note-vocabulary.mjs +22 -6
  32. package/engine/pdf-build.mjs +57 -1
  33. package/engine/pdf-render.mjs +48 -7
  34. package/engine/reckoning-markers.mjs +207 -0
  35. package/engine/scenes.mjs +113 -26
  36. package/engine/site-build.mjs +11 -13
  37. package/engine/site-maps.mjs +5 -35
  38. package/engine/site-preview.mjs +6 -1
  39. package/engine/social-tie-terms.mjs +3 -0
  40. package/engine/social-ties.mjs +30 -19
  41. package/package.json +3 -1
  42. package/types/engine/being-age.d.mts +2 -2
  43. package/types/engine/calendar-artifacts.d.mts +16 -0
  44. package/types/engine/calendar-notes.d.mts +1 -1
  45. package/types/engine/calendars.d.mts +65 -0
  46. package/types/engine/content-images.d.mts +7 -4
  47. package/types/engine/exported-scene.d.mts +12 -0
  48. package/types/engine/index.d.mts +2 -0
  49. package/types/engine/itinerary-scenes.d.mts +29 -0
  50. package/types/engine/map-build.d.mts +4 -13
  51. package/types/engine/map-graphviz.d.mts +17 -59
  52. package/types/engine/map-places.d.mts +5 -1
  53. package/types/engine/map-raster.d.mts +8 -0
  54. package/types/engine/note-dates.d.mts +6 -0
  55. package/types/engine/note-vocabulary.d.mts +3 -3
  56. package/types/engine/pdf-render.d.mts +16 -1
  57. package/types/engine/reckoning-markers.d.mts +27 -0
  58. package/types/engine/scenes.d.mts +9 -2
  59. package/types/engine/site-build.d.mts +1 -5
  60. package/types/engine/site-maps.d.mts +2 -7
  61. package/types/engine/social-tie-terms.d.mts +2 -0
  62. package/types/engine/social-ties.d.mts +2 -1
@@ -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 path from "node:path";
30
- import { spawnSync } from "node:child_process";
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
- * Find a GraphViz engine's binary.
15
+ * Render a DOT source file to SVG, plain positions, or a PNG background.
56
16
  *
57
- * `PATH` first, then the directories a package manager installs into, so a
58
- * shell whose profile does not export Homebrew's directory still finds it.
59
- * The result is a path to a file that exists; whether it runs is the render's
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} engine - One of {@link GRAPHVIZ_ENGINES}.
63
- * @param {object} [opts]
64
- * @param {Record<string, string|undefined>} [opts.env] - The environment to
65
- * read `PATH` from, defaulting to the process's.
66
- * @param {readonly string[]} [opts.fallbacks] - The directories tried after
67
- * `PATH`, defaulting to the package managers' ones.
68
- * @returns {string|undefined} The binary, or `undefined` when none is found.
69
- * @throws {Error} When `engine` is not a GraphViz engine this command uses.
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 findGraphviz(engine, { env = process.env, fallbacks = KNOWN_DIRECTORIES } = {}) {
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 onPath = String(env.PATH ?? "")
79
- .split(path.delimiter)
80
- .filter(Boolean);
81
- for (const dir of [...onPath, ...fallbacks]) {
82
- const candidate = path.join(dir, engine);
83
- try {
84
- if (fs.statSync(candidate).isFile()) return candidate;
85
- } catch {
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
  }
@@ -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 },
@@ -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 `subType: world` place.
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()].filter((p) => p.subType === "world");
416
- const world = worlds[0]?.shortcode;
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 === world || visiting.has(sc)) return undefined;
435
- if (place.subType === "region" && world !== undefined && place.parents.includes(world)) {
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 !== world) anomalies.noParent.push(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
+ }
@@ -12,93 +12,34 @@
12
12
  */
13
13
 
14
14
  /**
15
- * **A date on a note: one authored string, one grammar, one record.**
15
+ * Parse authored note dates into records used by content checks and renderers.
16
16
  *
17
- * ```text
18
- * [~] [-] YYYY [/ MM [/ DD]] [ ERA ]
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
- * ERA : (?:[a-z0-9]+-){0,3}[a-z0-9]+ . [a-z0-9]+
21
- * ```
22
- *
23
- * - `~` — approximate. It marks the value and does not change what it parses
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 { calendarStructure, canonicalYear, dateSortKey, daysInMonth } from "./calendars.mjs";
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 { calendar, field, allowUnknown = true, file, raw, keyPath } = options ?? {};
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
- `\`[~][-]YYYY[/MM[/DD]] [<calendar shortcode>.<era shortcode>]\`: ` +
268
- `a year, negative where it falls before the epoch it is counted ` +
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.