@heroiclands/package-build 23.11.0 → 23.12.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 (61) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +1 -0
  3. package/assets/preview/check-sanitizer.go +68 -0
  4. package/assets/preview/gitea-theme.js +36 -0
  5. package/assets/preview/gitea.ini +127 -0
  6. package/assets/styles/content-base.css +119 -16
  7. package/bin/content-commands.mjs +2 -2
  8. package/bin/package-build-preview.mjs +75 -0
  9. package/content-config.mjs +11 -0
  10. package/docs/api.md +105 -46
  11. package/docs/authoring/links-and-markup.md +46 -11
  12. package/docs/commands.md +46 -2
  13. package/docs/configuration.md +21 -4
  14. package/docs/design.md +4 -3
  15. package/docs/guides/book.md +10 -6
  16. package/docs/reference/format-details.md +33 -34
  17. package/docs/reference/note-types.md +0 -1
  18. package/docs/reference/theme-pages.md +1 -1
  19. package/docs/source-preview.md +172 -0
  20. package/engine/base-compiler.mjs +7 -8
  21. package/engine/being-age.mjs +19 -105
  22. package/engine/calendar-notes.mjs +3 -25
  23. package/engine/compile-corpus.mjs +2 -1
  24. package/engine/content-format.yaml +0 -1
  25. package/engine/content-index.mjs +9 -9
  26. package/engine/content-spans.mjs +28 -6
  27. package/engine/current-date.mjs +37 -0
  28. package/engine/definition-list.mjs +58 -0
  29. package/engine/frontmatter-lint.mjs +7 -2
  30. package/engine/helpers.mjs +2 -0
  31. package/engine/index.mjs +2 -0
  32. package/engine/infobox.mjs +1 -5
  33. package/engine/note-vocabulary.mjs +2 -2
  34. package/engine/npc-generate.mjs +7 -6
  35. package/engine/pdf-render.mjs +37 -4
  36. package/engine/preview-current-date.mjs +60 -0
  37. package/engine/site-build.mjs +5 -5
  38. package/engine/site-index.mjs +5 -2
  39. package/engine/site-preview.mjs +641 -325
  40. package/hugo-theme/layouts/_default/list.html +1 -1
  41. package/hugo-theme/layouts/blog/list.html +1 -1
  42. package/hugo-theme/layouts/blog-post/single.html +1 -1
  43. package/hugo-theme/layouts/partials/dictionary-lists.html +53 -0
  44. package/hugo-theme/layouts/partials/page-body.html +1 -1
  45. package/hugo-theme/static/css/style.css +22 -8
  46. package/package.json +8 -2
  47. package/types/content-config.d.mts +8 -0
  48. package/types/engine/base-compiler.d.mts +1 -1
  49. package/types/engine/being-age.d.mts +10 -44
  50. package/types/engine/calendar-notes.d.mts +2 -6
  51. package/types/engine/compile-corpus.d.mts +2 -1
  52. package/types/engine/content-index.d.mts +3 -1
  53. package/types/engine/content-spans.d.mts +4 -2
  54. package/types/engine/current-date.d.mts +9 -0
  55. package/types/engine/definition-list.d.mts +8 -0
  56. package/types/engine/frontmatter-lint.d.mts +6 -2
  57. package/types/engine/index.d.mts +2 -0
  58. package/types/engine/npc-generate.d.mts +2 -2
  59. package/types/engine/preview-current-date.d.mts +8 -0
  60. package/types/engine/site-index.d.mts +5 -1
  61. package/types/engine/site-preview.d.mts +40 -33
@@ -0,0 +1,37 @@
1
+ /*
2
+ * This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
3
+ * Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
4
+ *
5
+ * This work is licensed under the GNU General Public License v3.0 (GPLv3).
6
+ * You may copy, modify, and distribute it under the terms of that license.
7
+ *
8
+ * For full terms, see the LICENSE.md file in the project root or visit:
9
+ * https://www.gnu.org/licenses/gpl-3.0.html
10
+ *
11
+ * SPDX-License-Identifier: GPL-3.0-or-later
12
+ */
13
+
14
+ /** Setting-wide neutral current date, independent of configuration loading. */
15
+ import { parseNoteDate } from "./note-dates.mjs";
16
+
17
+ /** Default day on the neutral setting timeline. */
18
+ export const DEFAULT_CURRENT_DATE = "720.1";
19
+
20
+ /**
21
+ * Validate a quoted neutral date and preserve its authored day digits.
22
+ * @param {unknown} [value] - Explicit configuration or adapter override.
23
+ * @returns {string} Validated neutral date; undefined selects the default.
24
+ * @throws {TypeError} An explicit value is not a valid neutral date string.
25
+ */
26
+ export function normalizeCurrentDate(value = undefined) {
27
+ if (value === undefined) return DEFAULT_CURRENT_DATE;
28
+ if (typeof value !== "string" || !/^-?\d+(?:\.\d+)?(?::\d{6})?$/.test(value.trim()))
29
+ throw new TypeError('currentDate must be a quoted neutral date: "<year>[.<day>[:HHMMSS]]"');
30
+ const text = value.trim();
31
+ const parsed = parseNoteDate(text, { allowUnknown: false });
32
+ if (!parsed.date || parsed.findings.some((finding) => finding.severity === "error"))
33
+ throw new TypeError(
34
+ `currentDate: ${parsed.findings[0]?.message ?? "invalid neutral date"}`,
35
+ );
36
+ return text;
37
+ }
@@ -0,0 +1,58 @@
1
+ /*
2
+ * This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
3
+ * Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
4
+ *
5
+ * This work is licensed under the GNU General Public License v3.0 (GPLv3).
6
+ * You may copy, modify, and distribute it under the terms of that license.
7
+ *
8
+ * For full terms, see the LICENSE.md file in the project root or visit:
9
+ * https://www.gnu.org/licenses/gpl-3.0.html
10
+ *
11
+ * SPDX-License-Identifier: GPL-3.0-or-later
12
+ */
13
+
14
+ /** Semantic dictionary rows for HTML definition lists. @module */
15
+
16
+ /**
17
+ * Group each term and its definitions without reparsing their Markdown tokens.
18
+ * Register after markdown-it-deflist; nested blocks and footnotes retain their
19
+ * document context.
20
+ * @param {import("markdown-it")} md - An HTML Markdown parser with definition lists.
21
+ */
22
+ export function dictionaryListPlugin(md) {
23
+ md.core.ruler.push("dictionary_list_rows", (state) => {
24
+ const output = [],
25
+ lists = [];
26
+ const row = (nesting) => {
27
+ const token = new state.Token(
28
+ nesting === 1 ? "dictionary_row_open" : "dictionary_row_close",
29
+ "div",
30
+ nesting,
31
+ );
32
+ token.block = true;
33
+ output.push(token);
34
+ };
35
+ for (const token of state.tokens) {
36
+ if (token.type === "dl_open") {
37
+ if (!(token.attrGet("class") ?? "").split(/\s+/).includes("dictionary"))
38
+ token.attrJoin("class", "dictionary");
39
+ lists.push({ row: false });
40
+ } else if (token.type === "dt_open" && lists.length) {
41
+ const list = lists.at(-1);
42
+ if (list.row) row(-1);
43
+ row(1);
44
+ list.row = true;
45
+ } else if (token.type === "dd_open" && lists.length) {
46
+ // A loose definition begins with an inline first paragraph.
47
+ token.block = false;
48
+ } else if (token.type === "dt_close" && lists.length) {
49
+ // Inter-tag whitespace would add a word space to the fixed gap.
50
+ token.block = false;
51
+ } else if (token.type === "dl_close" && lists.length) {
52
+ if (lists.pop().row) row(-1);
53
+ }
54
+ output.push(token);
55
+ }
56
+ state.tokens = output;
57
+ });
58
+ }
@@ -1143,6 +1143,7 @@ function checkEmbeddedShortcodes(note, blockName) {
1143
1143
  * @param {object} note - A note from the link index (`{fm, file, raw, type}`).
1144
1144
  * @param {object} opts
1145
1145
  * @param {Record<string, readonly object[]>} opts.schemas - Type → declaration.
1146
+ * @param {string} [opts.currentDate="720.1"] - Setting-wide neutral date for age checks.
1146
1147
  * @param {object} [opts.index] - The link index, for the reference check, which
1147
1148
  * runs through its `shortcodeHit`. Its absence skips that check rather than
1148
1149
  * reporting every reference as dead.
@@ -1175,6 +1176,7 @@ export function lintNote(
1175
1176
  {
1176
1177
  schemas,
1177
1178
  index,
1179
+ currentDate,
1178
1180
  vocabulary,
1179
1181
  packs,
1180
1182
  emittedArt,
@@ -1544,7 +1546,8 @@ export function lintNote(
1544
1546
  findings.push(...field.check(note, { index, addressContext: referenceContext }));
1545
1547
  }
1546
1548
  // A check the type declares of the whole note, with the same index.
1547
- if (typeof entry.check === "function") findings.push(...entry.check(note, { index }));
1549
+ if (typeof entry.check === "function")
1550
+ findings.push(...entry.check(note, { index, currentDate }));
1548
1551
  }
1549
1552
 
1550
1553
  const fields = authoredFields(schema);
@@ -1717,6 +1720,7 @@ export function lintNote(
1717
1720
  * @param {object} index - From `buildLinkIndex`.
1718
1721
  * @param {object} opts
1719
1722
  * @param {Record<string, readonly object[]>} opts.schemas - Type → declaration.
1723
+ * @param {string} [opts.currentDate="720.1"] - Setting-wide neutral date for age checks.
1720
1724
  * @param {Record<string, object>} [opts.vocabulary] - Type → the closed regions
1721
1725
  * it declares; see {@link lintNote}.
1722
1726
  * @param {boolean} [opts.references=true] - Whether to check references.
@@ -1732,7 +1736,7 @@ export function lintNote(
1732
1736
  */
1733
1737
  export function lintFrontmatter(
1734
1738
  index,
1735
- { schemas, vocabulary, packs, emittedArt, references = true, systems },
1739
+ { schemas, vocabulary, packs, emittedArt, references = true, systems, currentDate },
1736
1740
  ) {
1737
1741
  const findings = [];
1738
1742
  const notes = [...index.notes].sort((a, b) =>
@@ -1745,6 +1749,7 @@ export function lintFrontmatter(
1745
1749
  ...lintNote(note, {
1746
1750
  schemas,
1747
1751
  vocabulary,
1752
+ currentDate,
1748
1753
  packs,
1749
1754
  emittedArt,
1750
1755
  index: references ? index : undefined,
@@ -41,6 +41,7 @@ import unidecode from "unidecode";
41
41
  import markdownit from "markdown-it";
42
42
  import footnotePlugin from "markdown-it-footnote";
43
43
  import deflistPlugin from "markdown-it-deflist";
44
+ import { dictionaryListPlugin } from "./definition-list.mjs";
44
45
  import { iconPlugin } from "./content-icons.mjs";
45
46
  import { imagePlugin, imagesIn } from "./content-images.mjs";
46
47
  import { embedRole, resolveEmbeds } from "./content-embeds.mjs";
@@ -108,6 +109,7 @@ let localAssetImageInfoCache;
108
109
  export const md = markdownit({ html: true })
109
110
  .use(footnotePlugin)
110
111
  .use(deflistPlugin)
112
+ .use(dictionaryListPlugin)
111
113
  .use(spanMarkdownPlugin, { spoilers: true })
112
114
  .use(dictionaryEntryPlugin)
113
115
  // A heading's attribute block, written onto the heading element rather than
package/engine/index.mjs CHANGED
@@ -155,6 +155,8 @@ export * as calendarNotes from "./calendar-notes.mjs";
155
155
  export * as calendarArtifacts from "./calendar-artifacts.mjs";
156
156
 
157
157
  /** A being's `age`, absent beside a dated `born`: the age the date computes to. */
158
+ export * as definitionList from "./definition-list.mjs";
159
+ export * as currentDate from "./current-date.mjs";
158
160
  export * as beingAge from "./being-age.mjs";
159
161
 
160
162
  /** The one normalisation this build makes: prose to a URL-safe token. */
@@ -1038,11 +1038,7 @@ function noteBox(
1038
1038
  // A culture note resolves to itself, and a row linking it to itself
1039
1039
  // says nothing.
1040
1040
  if (field.name === "culture" && cultureNamesItself(fm, raw, { contentPackage })) continue;
1041
- if (
1042
- ((fm?.type === "being" && ["born", "died"].includes(field.name)) ||
1043
- (fm?.type === "place" && field.name === "present")) &&
1044
- dates
1045
- ) {
1041
+ if (fm?.type === "being" && ["born", "died"].includes(field.name) && dates) {
1046
1042
  const parsed = parseNoteDate(raw, dates).date;
1047
1043
  const era =
1048
1044
  parsed?.marker ? dates.markers?.get(parsed.marker)
@@ -92,7 +92,7 @@ import {
92
92
  checkWorldFacts,
93
93
  } from "./calendar-notes.mjs";
94
94
  // Whether a being's authored `age` disagrees with what `born` and the
95
- // package's declared present compute — the closed `data:` container's own
95
+ // configured current date compute — the closed `data:` container's own
96
96
  // question, asked the way `checkPlace` and `checkCalendarNote` are.
97
97
  import { checkBeingAge } from "./being-age.mjs";
98
98
  import { checkSocialTies } from "./social-ties.mjs";
@@ -1522,7 +1522,7 @@ export const NOTE_VOCABULARY = Object.freeze({
1522
1522
  stubbable: true,
1523
1523
  subTypes: Object.freeze(["npc", "character", "creature"]),
1524
1524
  // Whether an authored `age` disagrees with what `born` and the
1525
- // package's declared present compute — see `engine/being-age.mjs`.
1525
+ // configured current date compute — see `engine/being-age.mjs`.
1526
1526
  check: checkBeingAge,
1527
1527
  data: Object.freeze([
1528
1528
  TOKEN_ICON,
@@ -44,9 +44,8 @@
44
44
  * `complexion` is a list of the one word drawn.
45
45
  * - `height` — from the profile's range for the gender, in feet and inches.
46
46
  * - `weight` — from the height and the frame's body-mass band, in pounds.
47
- * - `born` — an adult age of {@link ADULT_AGE} years before the package's
48
- * present, as a neutral `<year>.<day>` date. A package that states no present
49
- * gets `born: unknown`.
47
+ * - `born` — an adult age of {@link ADULT_AGE} years before the configured
48
+ * current date, as a neutral `<year>.<day>` date. The default is `720.1`.
50
49
  * - `archetypes: [commoner]`, the archetype for a person who fits no more
51
50
  * specific one.
52
51
  * - `icon`, `species` and `packFolder` — what the caller derives from the
@@ -55,7 +54,7 @@
55
54
  * are `[]`; `affiliations` is `{}`; `appearance.extra_features` is `[]`.
56
55
  *
57
56
  * Plain ESM with no filesystem access; the CLI resolves the culture, its
58
- * language's name lists and the present and hands them in.
57
+ * language's name lists and the configured current date and hands them in.
59
58
  *
60
59
  * @module
61
60
  */
@@ -65,6 +64,7 @@ import YAML from "yaml";
65
64
  import { BEING_LOOK_VOCABULARY } from "./note-vocabulary.mjs";
66
65
  import { FRAME_MASS_INDEX, profileLookKeys } from "./appearance-profile.mjs";
67
66
  import { parseBeingHeight } from "./being-measurements.mjs";
67
+ import { normalizeCurrentDate } from "./current-date.mjs";
68
68
  import { computeAge, parseAgeMagnitude } from "./being-age.mjs";
69
69
  import { parseNoteDate } from "./note-dates.mjs";
70
70
  import { eraYear } from "./calendars.mjs";
@@ -435,7 +435,7 @@ function frontmatterYaml(frontmatter) {
435
435
  * about a name that does not fit.
436
436
  * @param {string} [context.seed] - Text joined to the spec's own seed, to draw a
437
437
  * different person from the same spec.
438
- * @param {unknown} [context.present] - The package's present, or `null`.
438
+ * @param {unknown} [context.currentDate] - The setting's neutral current date, defaulting to "720.1".
439
439
  * @param {object} [context.dateContext] - The reckoning context the present
440
440
  * and `born` are read in, with `daysPerYear`.
441
441
  * @param {{icon?: unknown, species?: unknown, packFolder?: unknown}} [context.defaults] -
@@ -449,7 +449,8 @@ function frontmatterYaml(frontmatter) {
449
449
  export function generateNpc(spec, context) {
450
450
  const problems = npcSpecProblems(spec);
451
451
  if (problems.length) throw new Error(problems.join("; "));
452
- const { profile, names, present = null, dateContext = {}, defaults = {} } = context;
452
+ const { profile, names, currentDate, dateContext = {}, defaults = {} } = context;
453
+ const present = normalizeCurrentDate(currentDate);
453
454
  if (!isPlainMap(names)) throw new Error("`npc new` needs the language's `data.names`");
454
455
  const taken = context.taken ?? new Set();
455
456
  const seed = canonicalSpec(spec) + (given(context.seed) ? `\u0000${context.seed}` : "");
@@ -822,9 +822,9 @@ function renderBlock(tokens, i, out, ctx) {
822
822
  definitions.push(renderTokens(tokens.slice(at + 1, definitionEnd), ctx));
823
823
  at = definitionEnd + 1;
824
824
  }
825
- entries.push(`terms.item([${term}],[${definitions.join("\n\n")}])`);
825
+ entries.push(`book-definition([${term}],[${definitions.join("\n\n")}])`);
826
826
  }
827
- out.push(`\n#terms(${entries.join(", ")})\n\n`);
827
+ out.push(`\n${renderDefinitionList(entries)}\n\n`);
828
828
  return end - i + 1;
829
829
  }
830
830
  case "table_open": {
@@ -840,6 +840,36 @@ function renderBlock(tokens, i, out, ctx) {
840
840
  }
841
841
  }
842
842
 
843
+ /**
844
+ * Self-contained dictionary rows with the website's 150 CSS-pixel hanging indent.
845
+ *
846
+ * Each row remains a breakable block; only the bold term is an inline box. The
847
+ * negative first-line space lets its label start at the original margin while
848
+ * subsequent paragraphs, lists and blocks retain the definition's inset. A long
849
+ * term enlarges its slot without changing the continuation margin.
850
+ *
851
+ * Scope the helper to this list so exported Markdown fragments also work without
852
+ * a book preamble, including nested definition lists.
853
+ * @param {string[]} entries - Calls containing already-rendered terms and bodies.
854
+ * @returns {string} Typst markup.
855
+ */
856
+ function renderDefinitionList(entries) {
857
+ return `#context {
858
+ let book-definition(term, body) = {
859
+ let indent = 112.5pt
860
+ let gap = (0.75em).to-absolute()
861
+ let label = text(weight: "bold", term)
862
+ let slot = calc.max(indent - gap, measure(label).width)
863
+ block(width: 100%, breakable: true, inset: (left: indent), above: 0pt, below: 0.5em)[
864
+ #set par(first-line-indent: 0pt, hanging-indent: 0pt)
865
+ #set text(weight: "regular")
866
+ #h(-indent)#box(width: slot, label)#h(gap)#body
867
+ ]
868
+ }
869
+ ${entries.join("\n ")}
870
+ }`;
871
+ }
872
+
843
873
  /**
844
874
  * A withheld section, set in the box a `secret` block is set in.
845
875
  *
@@ -1210,7 +1240,10 @@ function renderInline(token, ctx) {
1210
1240
  out.push(inlineRaw(child.content));
1211
1241
  break;
1212
1242
  case "span_open": {
1213
- const classes = (child.attrGet?.("class") ?? "").split(/\s+/).filter(Boolean);
1243
+ // Inline secrecy has no print presentation; other classes and ids survive.
1244
+ const classes = (child.attrGet?.("class") ?? "")
1245
+ .split(/\s+/)
1246
+ .filter((value) => value && value !== "secret");
1214
1247
  const values = classes
1215
1248
  .map((value) => '"' + escapeTypstString(value) + '"')
1216
1249
  .join(", ");
@@ -1630,7 +1663,7 @@ export function bookTypstPreamble() {
1630
1663
  // weight so it remains bold inside an ordinary strong phrase.
1631
1664
  "#let book-span(body, classes: ()) = {\n" +
1632
1665
  ' let styled = if classes.contains("name") { text(weight: "bold", body) } else { body }\n' +
1633
- ' if classes.contains("secret") { box(fill: rgb("#f2eefb"), inset: (x: 3pt, y: 1pt), text(fill: rgb("#5c4b8a"))[#strong[Secret:] #styled]) } else { box(styled) }\n' +
1666
+ " box(styled)\n" +
1634
1667
  "}",
1635
1668
  // Use the file's natural measure for auto. Every requested width is
1636
1669
  // bounded by the available measure and by the height of a page.
@@ -0,0 +1,60 @@
1
+ /*
2
+ * This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
3
+ * Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
4
+ *
5
+ * This work is licensed under the GNU General Public License v3.0 (GPLv3).
6
+ * You may copy, modify, and distribute it under the terms of that license.
7
+ *
8
+ * For full terms, see the LICENSE.md file in the project root or visit:
9
+ * https://www.gnu.org/licenses/gpl-3.0.html
10
+ *
11
+ * SPDX-License-Identifier: GPL-3.0-or-later
12
+ */
13
+
14
+ /** Opt-in date configuration for preview hosts; no content or dependency reads. @module */
15
+ import fs from "node:fs/promises";
16
+ import { pathToFileURL } from "node:url";
17
+ import YAML from "yaml";
18
+ import { findConfigFile } from "./pack-config.mjs";
19
+ import { formatDiagnostic, positionOfYamlPath } from "./diagnostics.mjs";
20
+ import { normalizeCurrentDate } from "./current-date.mjs";
21
+
22
+ /**
23
+ * Read only currentDate from the nearest package configuration.
24
+ * YAML does not load the full build configuration. An MJS configuration executes
25
+ * its own module, as any caller opting into a code configuration expects.
26
+ * @param {string} root - Directory at which configuration discovery starts.
27
+ * @returns {Promise<string>} Validated neutral current date.
28
+ */
29
+ export async function readPreviewCurrentDate(root) {
30
+ const file = findConfigFile(root);
31
+ if (!file) return normalizeCurrentDate();
32
+ let source = "";
33
+ try {
34
+ let input;
35
+ if (file.endsWith(".mjs")) {
36
+ const version = (await fs.stat(file)).mtimeMs;
37
+ const module = await import(`${pathToFileURL(file).href}?previewDate=${version}`);
38
+ input = module.default ?? module;
39
+ } else {
40
+ source = await fs.readFile(file, "utf8");
41
+ const document = YAML.parseDocument(source);
42
+ if (document.errors.length) throw document.errors[0];
43
+ input = document.toJS();
44
+ }
45
+ if (!input || typeof input !== "object" || Array.isArray(input)) {
46
+ throw new TypeError("package configuration must be a mapping");
47
+ }
48
+ return normalizeCurrentDate(input.currentDate);
49
+ } catch (error) {
50
+ throw new Error(
51
+ formatDiagnostic({
52
+ file,
53
+ ...(source ? positionOfYamlPath(source, ["currentDate"]) : {}),
54
+ severity: "error",
55
+ message: error.message,
56
+ }),
57
+ { cause: error },
58
+ );
59
+ }
60
+ }
@@ -91,7 +91,8 @@ import { searchableFrontmatter } from "./note-package.mjs";
91
91
  import { indexRecordsFor } from "./content-index.mjs";
92
92
  // A being's computed `age` — see `engine/being-age.mjs`. `pdf-build.mjs`
93
93
  // reuses `collectContentPages`'s pages, so wiring it here reaches the book too.
94
- import { applyComputedBeingAge, presentAmongRecords } from "./being-age.mjs";
94
+ import { applyComputedBeingAge } from "./being-age.mjs";
95
+ import { normalizeCurrentDate } from "./current-date.mjs";
95
96
  import { reckoningContext } from "./reckoning-markers.mjs";
96
97
  import { resolvedDateFields } from "./note-dates.mjs";
97
98
  import { isNoteRecord, noteFile } from "./index-records.mjs";
@@ -213,9 +214,7 @@ export function collectContentPages(contentBase, ctx) {
213
214
  const addressFindings = [];
214
215
  const fmLinkFindings = [];
215
216
 
216
- // The package's declared present, read once from the same corpus the site
217
- // and the PDF share — see `engine/being-age.mjs`. `pdf-build.mjs` reaches
218
- // this through the pages this function returns, so one call covers both.
217
+ // Site and PDF pages use the setting date alongside shared calendar context.
219
218
  const records =
220
219
  ctx.records ??
221
220
  indexRecordsFor({
@@ -223,7 +222,7 @@ export function collectContentPages(contentBase, ctx) {
223
222
  config: ctx.config,
224
223
  skipDirectories: ctx.skipDirectories,
225
224
  });
226
- const present = presentAmongRecords(records);
225
+ const present = normalizeCurrentDate(ctx.config?.currentDate);
227
226
  const dates = reckoningContext({ notes: records });
228
227
 
229
228
  for (const file of siteCorpusFiles(contentBase, ctx)) {
@@ -479,6 +478,7 @@ export function siteGates(pages, findings, { config, records, sqlTables }) {
479
478
  : pages;
480
479
  out.index = buildSiteIndex(expanded, {
481
480
  package: config.contentPackage,
481
+ currentDate: config.currentDate,
482
482
  foreignIndex: foreign.index,
483
483
  foreignReferences: foreign.references,
484
484
  records,
@@ -68,7 +68,7 @@ import {
68
68
  import { NOTE_SYSTEM } from "./systems.mjs";
69
69
  import { contentPackage } from "./content-package.mjs";
70
70
  import { reckoningContext } from "./reckoning-markers.mjs";
71
- import { presentAmongRecords } from "./being-age.mjs";
71
+ import { normalizeCurrentDate } from "./current-date.mjs";
72
72
  // The declared tag vocabulary, which is where `draft` is stated.
73
73
  import { isGmNote, isDraftNote } from "./note-vocabulary.mjs";
74
74
 
@@ -165,6 +165,8 @@ function mergeForeign(index, foreignIndex) {
165
165
  * @param {readonly SiteEntry[]} entries - Every page the site will publish,
166
166
  * each already knowing its own `url`.
167
167
  * @param {object} [options] - Cross-package inputs.
168
+ * @param {string} [options.package] - The builder's own content package.
169
+ * @param {string} [options.currentDate="720.1"] - Setting-wide neutral date for recurring events.
168
170
  * @param {Map<string, {package: string, type?: string}>} [options.foreignIndex]
169
171
  * The merged index from `loadForeignIndexes`. Omit when the build publishes
170
172
  * no cross-package links.
@@ -183,6 +185,7 @@ export function buildSiteIndex(
183
185
  foreignIndex = new Map(),
184
186
  noIndexPackages = new Set(),
185
187
  package: builderPackage,
188
+ currentDate,
186
189
  records = [],
187
190
  foreignReferences = new Map(),
188
191
  syntax,
@@ -303,7 +306,7 @@ export function buildSiteIndex(
303
306
  // event's `next` is computed against this present.
304
307
  dateContext: {
305
308
  ...reckoningContext({ notes: records }),
306
- present: presentAmongRecords(records),
309
+ present: normalizeCurrentDate(currentDate),
307
310
  },
308
311
  referenceTargets: buildReferenceTargets(records, new Map([...foreignReferences, ...index])),
309
312
  index,