@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
@@ -515,4 +515,4 @@ A row is a linked title and, when the page carries one, its description.
515
515
 
516
516
  ## Alerts
517
517
 
518
- The content renderer emits an `aside` with `alert` and `alert-note`, `alert-tip`, `alert-important`, `alert-warning` classes. The `alert-title` header contains a Font Awesome icon and the type's label. Shared styles supply a colored left rule, colored header and ordinary readable body; alerts do not use `details` and never start collapsed. Authored identifiers, additional classes and named attributes belong to the entire aside. Secret blocks retain their separate expandable presentation on the website.
518
+ The content renderer emits an `aside` with `alert` and `alert-note`, `alert-tip`, `alert-important`, `alert-warning` classes. The `alert-title` header contains a Font Awesome icon and the type's label. Shared styles supply a colored left rule, colored header, tinted background and readable body text. The website uses the dark palette; Foundry selects a light or dark palette from its color scheme. Alerts do not use `details` and never start collapsed. Authored identifiers, additional classes and named attributes belong to the entire aside. Secret blocks retain their separate expandable presentation on the website.
@@ -0,0 +1,172 @@
1
+ ---
2
+ shortcode: source-preview
3
+ name: { full: "Source preview and Gitea renderer" }
4
+ type: doc
5
+ subType: reference
6
+ ---
7
+
8
+ # Source preview and Gitea renderer
9
+
10
+ Preview renders only the supplied Markdown buffer. It works with ordinary
11
+ Markdown, optional YAML frontmatter, and local Heroic Lands extensions, without
12
+ configuration, a content tree, dependency caches, a content index or Pandoc.
13
+
14
+ ```sh
15
+ package-build-preview < note.md > preview.html
16
+ package-build-preview --dark < note.md > preview.html
17
+ package-build-preview --current-date 740.1 < note.md > preview.html
18
+ package-build-preview --css > preview.css
19
+ ```
20
+
21
+ The command writes an HTML fragment by default. Include `preview.css` in the
22
+ hosting page to apply the syntax colors, panels and tinted alerts. Light mode
23
+ is the default; `--dark` is explicit and independent of browser preferences.
24
+ `--css` emits both scoped palettes and does not read stdin. `--help` lists the
25
+ options. Unsupported arguments exit 1. Findings go to stderr; success exits 0,
26
+ and render errors exit 1. A partial preview may accompany error findings.
27
+
28
+ Wikilinks, image and portrait links retain their complete source: blue brackets,
29
+ purple addresses, bold foreground labels and grey attributes. Entire spans are bold, including their visible brackets and attributes.
30
+ Definition-list terms are bold and definitions use normal text. SQL and pagelist fences remain grey source;
31
+ they never execute. Calendar expressions, dependent expressions, references and
32
+ maps remain green source. Local expressions can evaluate against authored
33
+ frontmatter. Missing ages are calculated from neutral birth dates and the root
34
+ `currentDate` setting, defaulting to `"720.1"`, as for other package outputs.
35
+ Authored ages, including `0`, take precedence. Unknown or calendar-dependent
36
+ birth dates omit the age row; `{{data.age}}` is empty when unavailable. Generated sections,
37
+ related pages and See also are absent. Preview performs no publication or link
38
+ checks. Alerts render with their icons, titles, Markdown bodies and tinted
39
+ backgrounds, including nested alerts. Secret blocks are closed disclosure
40
+ spoilers. An authored `.secret` heading hides its section through the next
41
+ heading at the same or a higher level, while retaining its anchor. Secret spans
42
+ redact their label until hover or keyboard focus; explicit `.secret` wikilink
43
+ and image attributes similarly redact their inner source. The authored outer
44
+ brackets and attributes remain visible. This uses scoped CSS and a focusable
45
+ wrapper, with no preview JavaScript required.
46
+
47
+ Applications import `renderPreview` and `previewStyles` from
48
+ `@heroiclands/package-build/preview`. The HTML wrapper has `hl-preview` and
49
+ exactly one mode class: `hl-preview-light` or `hl-preview-dark`. See the
50
+ [API reference](api.md) for the function contract.
51
+
52
+ ## Current date
53
+
54
+ Declare the setting-wide date as a quoted root configuration value:
55
+
56
+ ```yaml
57
+ currentDate: "720.1"
58
+ ```
59
+
60
+ The standalone command discovers the nearest `package-build.config.yaml`,
61
+ `.yml` or `.mjs` from its working directory. YAML reads only `currentDate`; it
62
+ requires no other package fields, content or index. MJS executes its trusted
63
+ configuration module. Missing configuration or an absent setting uses `"720.1"`.
64
+ Invalid explicit values fail with a configuration diagnostic. Use
65
+ `--current-date DATE` to override discovery for one invocation. Dates must use
66
+ neutral `<year>[.<day>[:HHMMSS]]` syntax; named-calendar dates are refused.
67
+
68
+ The pure `renderPreview` API accepts `currentDate` and never reads configuration.
69
+ Hosts can opt into `await readPreviewCurrentDate(projectRoot)` to read the root
70
+ setting before rendering. Repeated calls observe configuration edits. This is
71
+ the same root setting used by compilation, website output and other toolchain
72
+ consumers; there is no separate preview date setting.
73
+
74
+ ## Gitea installation
75
+
76
+ Install Node 24 or newer and this package in the Gitea runtime, including the
77
+ container image when applicable. The examples assume installation at
78
+ `/opt/heroiclands/node_modules/@heroiclands/package-build` and a usable Node
79
+ executable on Gitea's PATH. Pin the package version in the installation's
80
+ `package.json` and lockfile.
81
+
82
+ Copy [the complete renderer configuration](../assets/preview/gitea.ini) into
83
+ `app.ini`. It replaces whole-file rendering for the listed Markdown extensions;
84
+ it does not replace Gitea's issue-comment or pull-request discussion renderer.
85
+ The renderer section must appear before its sanitizer sections. Its command
86
+ reads stdin and emits HTML; it needs no repository checkout. Change the absolute
87
+ command path to match the installation. Gitea normally uses the shared default
88
+ `"720.1"` because it receives no repository checkout. To select the deployment's
89
+ setting-wide date explicitly, use:
90
+
91
+ ```ini
92
+ RENDER_COMMAND = /opt/heroiclands/node_modules/.bin/package-build-preview --current-date 740.1
93
+ ```
94
+
95
+ Keep this override aligned with the setting's root configuration. Without an
96
+ override, run Gitea from a directory with no package-build configuration so
97
+ unrelated local configuration cannot select the date.
98
+
99
+ The supplied configuration keeps sanitization enabled and disables Gitea's
100
+ post-processing so preserved source and literal links remain intact. It permits
101
+ only the preview's class families on the elements that need them, plus the SVG
102
+ attributes needed for alert icons. Gitea's default SVG rules retain `viewBox`,
103
+ `width`, `height`, `aria-hidden`, path `d` and `fill-rule`; the extra rules retain
104
+ `fill="currentColor"` and circle coordinates. The wrapper mode class, syntax
105
+ span classes, fence classes and infobox wrapper must survive sanitization for
106
+ styling to work. Source is escaped text, so no additional source-data attributes
107
+ are required. The `secretFocus` rule retains only `tabindex="0"` on span
108
+ wrappers so keyboard users can reveal redacted source labels. Do not enable
109
+ arbitrary styles, scripts or data-URI images.
110
+
111
+ Install the stylesheet and optional theme adapter beneath the Gitea custom
112
+ path. Here `custom` means the actual instance's `CustomPath`:
113
+
114
+ ```sh
115
+ mkdir -p custom/public/assets/css custom/public/assets/js
116
+ /opt/heroiclands/node_modules/.bin/package-build-preview --css > custom/public/assets/css/heroiclands-preview.css
117
+ cp /opt/heroiclands/node_modules/@heroiclands/package-build/assets/preview/gitea-theme.js custom/public/assets/js/heroiclands-preview.js
118
+ ```
119
+
120
+ Add to `custom/templates/custom/header.tmpl`, preserving existing content:
121
+
122
+ ```html
123
+ <link rel="stylesheet" href="{{AppSubUrl}}/assets/css/heroiclands-preview.css" />
124
+ <script defer src="{{AppSubUrl}}/assets/js/heroiclands-preview.js"></script>
125
+ ```
126
+
127
+ Restart Gitea after configuration/template changes. Regenerate the CSS asset
128
+ when updating the package. These are administrator installation instructions;
129
+ package-build does not deploy or change the server configuration.
130
+
131
+ ## Gitea theme selection
132
+
133
+ The external renderer runs on the server and cannot see a reader's active
134
+ theme. The installed browser adapter reads Gitea's root CSS variable
135
+ `--is-dark-theme`, selecting dark for `true` and light for `false`. It follows
136
+ `data-theme` changes, stylesheet loads and newly inserted preview nodes. It
137
+ changes only `.markup.markdown .hl-preview` mode classes; the surrounding Gitea
138
+ interface keeps its own colors. A custom theme that omits the variable retains
139
+ the initial renderer mode.
140
+
141
+ Without the browser adapter, use the default command for fixed light rendering,
142
+ or append `--dark` to `RENDER_COMMAND` for fixed dark rendering. Explicit CLI
143
+ mode never changes itself. Theme adaptation happens only when the administrator
144
+ installs the separate browser script.
145
+
146
+ ## Sanitizer validation
147
+
148
+ [The validation harness](../assets/preview/check-sanitizer.go) applies the
149
+ shipped INI rules using the actual `bluemonday` sanitizer and the relevant
150
+ Gitea SVG defaults. It requires a Go toolchain and a temporary module with
151
+ `github.com/microcosm-cc/bluemonday` at the version used by the target Gitea
152
+ release. Run it against generated preview HTML:
153
+
154
+ ```sh
155
+ go run check-sanitizer.go /path/to/gitea.ini < preview.html > sanitized.html
156
+ ```
157
+
158
+ Check both light and dark fixtures containing all four alert types, a custom
159
+ title, nesting, wikilinks and attributes, SQL/pagelist fences, infoboxes, poetry
160
+ and footnotes. All wrapper/token classes and icon shapes should remain, while
161
+ injected scripts and event-handler attributes should disappear. This replay
162
+ checks the sanitizer primitives; also inspect the result in the actual Gitea
163
+ version before deployment, since defaults can vary between releases.
164
+
165
+ Configuration and installation follow Gitea's
166
+ [external renderer documentation](https://docs.gitea.com/administration/external-renderers/).
167
+ The relevant implementation contracts are its
168
+ [command adapter](https://github.com/go-gitea/gitea/blob/main/modules/markup/external/external.go),
169
+ [sanitizer defaults](https://github.com/go-gitea/gitea/blob/main/modules/markup/sanitizer_default.go),
170
+ [renderer settings](https://github.com/go-gitea/gitea/blob/main/modules/setting/markup.go),
171
+ [root theme attribute](https://github.com/go-gitea/gitea/blob/main/templates/base/head.tmpl),
172
+ and [theme scheme variable](https://github.com/go-gitea/gitea/blob/main/web_src/css/themes/theme-gitea-dark.css).
@@ -103,10 +103,11 @@ import { packForType } from "./ids.mjs";
103
103
  import { resolveNoteId } from "./note-ids.mjs";
104
104
  import { carriesSystemBlock } from "./system-block.mjs";
105
105
  import { checkAuthoredSystemData, checkEmittedSystemData } from "./schema-check.mjs";
106
- // A being's computed `age`, filled in against the package's own declared
107
- // present before this pass reads `data:` for anything else — see
106
+ // A being's computed `age`, filled in against the configured setting
107
+ // current date before this pass reads `data:` for anything else — see
108
108
  // `engine/being-age.mjs`.
109
- import { applyComputedBeingAge, presentAmongRecords } from "./being-age.mjs";
109
+ import { applyComputedBeingAge } from "./being-age.mjs";
110
+ import { normalizeCurrentDate } from "./current-date.mjs";
110
111
  import { reckoningContext } from "./reckoning-markers.mjs";
111
112
  import { loadPackConfig } from "./pack-config.mjs";
112
113
  import { annotateAssetReferences, assetReferenceLookup } from "./asset-references.mjs";
@@ -632,10 +633,8 @@ export class BasePackCompiler {
632
633
  if (this.constructor.convertsWikilinks) {
633
634
  this.sqlTables = this.corpus.sqlTables;
634
635
  }
635
- // The package's declared present, read once from the same corpus — so
636
- // a being's computed `age` reaches every compiled document, not only
637
- // the content index.
638
- this.worldPresent = presentAmongRecords(this.corpus.records);
636
+ // Use the compile corpus's setting date.
637
+ this.currentDate = normalizeCurrentDate(this.corpus.currentDate);
639
638
  this.unresolvedLinks = 0;
640
639
  }
641
640
 
@@ -1277,7 +1276,7 @@ export class BasePackCompiler {
1277
1276
  stats.skippedOther++;
1278
1277
  continue;
1279
1278
  }
1280
- applyComputedBeingAge(fm, this.worldPresent, dates);
1279
+ applyComputedBeingAge(fm, this.currentDate, dates);
1281
1280
  // `package:` is refused before `selects`, so a note is answered
1282
1281
  // whichever pass would have claimed it: a note's package is the
1283
1282
  // repository's configured one, so declaring it restates a
@@ -11,51 +11,9 @@
11
11
  * SPDX-License-Identifier: GPL-3.0-or-later
12
12
  */
13
13
 
14
- /**
15
- * **The middle state of `age`: absent beside a dated `born`, it follows from
16
- * the date.**
17
- *
18
- * `age` is a three-state override — authored it wins, absent beside a dated
19
- * `born` it is computed, absent beside `born: unknown` or an absent `born` it
20
- * stays unknown. This module builds the middle state, which nothing else in
21
- * the toolchain does: `born` and `age` are read straight through to every
22
- * compiled surface exactly as authored, unless a note's own package states the
23
- * day the setting stops.
24
- *
25
- * ## Where the present comes from
26
- *
27
- * A world's `present` is a fact about the world, not about a calendar or a
28
- * build configuration — it is `data.present` on the package's own `place` note
29
- * of `subType: world` (or `celestial`), read through
30
- * {@link module:engine/calendar-notes.worldInvariants}. A package with no such
31
- * note states no present, computes no age, and says nothing about it: the
32
- * absence is silent, exactly as a package that declares no calendar is asked
33
- * no calendar questions.
34
- *
35
- * ## The arithmetic is on two dates, not on two years
36
- *
37
- * A year subtracted from a year is wrong whenever the birthday has not yet
38
- * happened in the present's year — the common shape, not the exception. So
39
- * {@link computeAge} compares the two dates' canonical day ordinals where an
40
- * era marker supplies them. The birthday that still lies ahead in the current
41
- * year does not count as a completed year. An unmarked slash date has no
42
- * calendar to convert, so it uses its written month and day as its ordinal.
43
- *
44
- * ## `~` follows the convention {@link module:engine/note-dates} sets
45
- *
46
- * A date's `~` marks the value approximate without changing what it parses to,
47
- * so a renderer keeps the authored text and a comparison still has a number.
48
- * {@link parseAgeMagnitude} draws the same distinction for `age` — the tilde,
49
- * the magnitude — without routing through {@link module:engine/note-dates}
50
- * itself: `age` is a plain count of years, not a date, so it has no month, no
51
- * day, no era, and — unlike a date — no refusal of `0`. Reusing the date
52
- * grammar's month/day/era machinery here would accept an age nothing about the
53
- * field means, and refuse the one age an infant can have.
54
- *
55
- * @module
56
- */
14
+ /** Authored ages and setting-wide current-date age calculations. @module */
57
15
 
58
- import { worldInvariants } from "./calendar-notes.mjs";
16
+ import { DEFAULT_CURRENT_DATE } from "./current-date.mjs";
59
17
  import { positionOfFrontmatterPath } from "./diagnostics.mjs";
60
18
  import { parseNoteDate } from "./note-dates.mjs";
61
19
  import { reckoningContext } from "./reckoning-markers.mjs";
@@ -107,11 +65,9 @@ export function parseAgeMagnitude(raw) {
107
65
  *
108
66
  * @param {unknown} bornRaw - The being's authored `born`, or `undefined`/`null`
109
67
  * where it wrote none.
110
- * @param {unknown} presentRaw - The package's `data.present`, as
111
- * {@link module:engine/calendar-notes.worldInvariants} read it, or
112
- * `undefined`/`null` where the package states none.
68
+ * @param {unknown} presentRaw - The configured neutral current date.
113
69
  * @returns {number|null} The age, or `null` where `born` is `unknown` or
114
- * absent, where no present is declared, or where either date fails to parse.
70
+ * absent or future, or where either date fails to parse.
115
71
  */
116
72
  export function computeAge(bornRaw, presentRaw, dateContext = {}) {
117
73
  if (bornRaw === undefined || bornRaw === null) return null;
@@ -126,11 +82,15 @@ export function computeAge(bornRaw, presentRaw, dateContext = {}) {
126
82
  // A resolved marker supplies a canonical day. An unmarked slash date uses
127
83
  // its written month and day. Missing parts read as the first day that the
128
84
  // value can name.
129
- const bornOrdinal = born.canonicalDay ?? (born.month ?? 1) * 100 + (born.day ?? 1);
130
- const presentOrdinal = present.canonicalDay ?? (present.month ?? 1) * 100 + (present.day ?? 1);
85
+ const bornOrdinal =
86
+ born.canonicalDay ?? (born.month === null ? 1 : (born.month ?? 1) * 100 + (born.day ?? 1));
87
+ const presentOrdinal =
88
+ present.canonicalDay ??
89
+ (present.month === null ? 1 : (present.month ?? 1) * 100 + (present.day ?? 1));
131
90
  const notYetHappened = bornOrdinal > presentOrdinal;
132
91
 
133
- return present.canonicalYear - born.canonicalYear - (notYetHappened ? 1 : 0);
92
+ const age = present.canonicalYear - born.canonicalYear - (notYetHappened ? 1 : 0);
93
+ return age < 0 ? null : age;
134
94
  }
135
95
 
136
96
  /**
@@ -146,8 +106,7 @@ export function computeAge(bornRaw, presentRaw, dateContext = {}) {
146
106
  * itself untouched.
147
107
  *
148
108
  * @param {object} fm - A note's frontmatter, as freshly parsed.
149
- * @param {unknown} presentRaw - The package's declared present, or
150
- * `undefined`/`null` where it states none.
109
+ * @param {unknown} presentRaw - The configured neutral current date.
151
110
  * @returns {void}
152
111
  */
153
112
  export function applyComputedBeingAge(fm, presentRaw, dateContext = {}) {
@@ -171,63 +130,21 @@ export function applyComputedBeingAge(fm, presentRaw, dateContext = {}) {
171
130
  }
172
131
 
173
132
  /**
174
- * A being's `data.present`, read from every `place` note a batch of records or
175
- * frontmatter objects carries.
176
- *
177
- * Two shapes reach this by way of two thin adapters, because the callers that
178
- * need a present hold their notes two different ways: a flat index record
179
- * (`type` and `data` at the top level) and a raw walk's `{frontmatter}` pair.
180
- * Neither shape is the `{fm, type}` pair {@link worldInvariants} reads, so each
181
- * adapter states the one conversion its caller needs rather than asking every
182
- * caller to know it.
183
- *
184
- * @param {readonly object[]|undefined} records - Flat index records — a
185
- * content-index record, or anything else spreading a note's frontmatter to
186
- * its own top level.
187
- * @returns {unknown} The present, or `null` where no `place` note in the batch
188
- * declares one.
189
- */
190
- export function presentAmongRecords(records) {
191
- return worldInvariants({
192
- notes: (records ?? []).map((record) => ({ fm: record, type: record?.type })),
193
- }).present;
194
- }
195
-
196
- /**
197
- * The present read from a batch of raw frontmatter objects.
198
- *
199
- * @param {readonly (object|null|undefined)[]|undefined} frontmatters - One
200
- * entry per note walked, as parsed — never a record.
201
- * @returns {unknown} The present, or `null` where none is declared.
202
- */
203
- export function presentAmongFrontmatters(frontmatters) {
204
- return worldInvariants({
205
- notes: (frontmatters ?? []).map((fm) => ({ fm, type: fm?.type })),
206
- }).present;
207
- }
208
-
209
- /**
210
- * Check a being's authored `age` against what `born` and the package's present
211
- * compute.
133
+ * Check an authored age against the birth date and configured current date.
212
134
  *
213
135
  * Registered as `being`'s whole-note `check` in `note-vocabulary.mjs`, run the
214
136
  * way `checkPlace` and `checkCalendarNote` are — beside every other check the
215
137
  * closed `data:` container earns, with the same `index`.
216
138
  *
217
- * Two silences, both deliberate: beside `born: unknown` or an absent `born`
218
- * there is nothing to disagree with, and a package that declares no present
219
- * computes no age to disagree with either — both cases are read by
220
- * {@link computeAge} returning `null`, and neither says a word.
139
+ * An unknown or absent birth date supplies no computed age to compare.
221
140
  *
222
- * @param {object} note - The note, as the link index hands it over —
223
- * {@link module:engine/calendar-notes.worldInvariants}'s `index.notes` shape.
141
+ * @param {object} note - The note, as the link index hands it over.
224
142
  * @param {object} [opts]
225
- * @param {object} [opts.index] - The link index, holding the package's `place`
226
- * notes. Its absence skips the check, the way a reference check with no
227
- * index skips reporting every reference as dead.
143
+ * @param {object} [opts.index] - Calendar context for authored birth dates.
144
+ * @param {string} [opts.currentDate="720.1"] - The configured neutral current date.
228
145
  * @returns {object[]} Findings — at most one `warning`, naming both values.
229
146
  */
230
- export function checkBeingAge(note, { index } = {}) {
147
+ export function checkBeingAge(note, { index, currentDate = DEFAULT_CURRENT_DATE } = {}) {
231
148
  if (!isBeingType(note?.type ?? note?.fm?.type)) return [];
232
149
  const data = dataOf(note?.fm);
233
150
  if (!data) return [];
@@ -235,10 +152,7 @@ export function checkBeingAge(note, { index } = {}) {
235
152
  const bornRaw = data.born;
236
153
  const ageRaw = data.age;
237
154
  if (ageRaw === undefined || ageRaw === null || ageRaw === "") return [];
238
- if (!index) return [];
239
-
240
- const present = worldInvariants(index).present;
241
- const computed = computeAge(bornRaw, present, reckoningContext(index));
155
+ const computed = computeAge(bornRaw, currentDate, reckoningContext(index));
242
156
  if (computed === null) return [];
243
157
 
244
158
  const authored = parseAgeMagnitude(ageRaw);
@@ -20,7 +20,7 @@
20
20
  * A **world fact** is how long the year is, how the day divides, and how the
21
21
  * moon moves. Every people on the world lives under the same ones, so no
22
22
  * calendar may restate them: they are read from the package's `place` notes —
23
- * `data.year` and `data.present` from the world, `data.moon` from the moon —
23
+ * `data.year` from the world, `data.moon` from the moon —
24
24
  * and written by the emitters.
25
25
  *
26
26
  * A **calendar** is one people's division of those days: the months they name,
@@ -301,7 +301,7 @@ export const CALENDAR_FIELDS = Object.freeze([
301
301
  * The `data:` keys a world or a celestial body may write.
302
302
  *
303
303
  * Four groups, and which note carries which is a fact about the body rather
304
- * than a convention: a planet states its own size, tilt, year and present; a
304
+ * than a convention: a planet states its own size, tilt and year; a
305
305
  * moon states its own size, orbit and cycle.
306
306
  *
307
307
  * @type {readonly object[]}
@@ -349,12 +349,6 @@ export const INVARIANT_FIELDS = Object.freeze([
349
349
  kind: "number",
350
350
  describe: "How many seconds the minute divides into.",
351
351
  },
352
- {
353
- name: "present",
354
- shape: "a date",
355
- kind: "string",
356
- describe: "The day the setting stops and play begins — what an age is computed against.",
357
- },
358
352
  {
359
353
  name: "body.diameterKm",
360
354
  shape: "number",
@@ -438,14 +432,13 @@ function keysWritten(note, keys) {
438
432
  * note has no year, and every question that depends on one goes unasked.
439
433
  *
440
434
  * @param {object} [index] - The link index, holding every note in the package.
441
- * @returns {{year: object|null, present: unknown, moon: object|null,
435
+ * @returns {{year: object|null, moon: object|null,
442
436
  * body: object|null, moonName: string|null, yearNote: object|null,
443
437
  * moonNote: object|null}} What the package's notes state.
444
438
  */
445
439
  export function worldInvariants(index) {
446
440
  const empty = {
447
441
  year: null,
448
- present: null,
449
442
  moon: null,
450
443
  body: null,
451
444
  moonName: null,
@@ -459,7 +452,6 @@ export function worldInvariants(index) {
459
452
  const data = dataOf(note);
460
453
  if (!found.yearNote && data.year && typeof data.year === "object") {
461
454
  found.year = data.year;
462
- found.present = data.present ?? null;
463
455
  found.yearNote = note;
464
456
  }
465
457
  if (!found.moonNote && data.moon && typeof data.moon === "object") {
@@ -619,9 +611,6 @@ function checkCalendarDayNames(note, daysPerYear) {
619
611
  * - a moon with no year is refused, because a cycle measured in days has
620
612
  * nothing to be measured against.
621
613
  *
622
- * `data.present` is read through the parser a note's dates go through, with the
623
- * corpus in hand, so a present written in an era resolves where it is written.
624
- *
625
614
  * @param {object} note - The note, as the link index hands it over.
626
615
  * @param {object} [opts]
627
616
  * @param {object} [opts.index] - The link index, holding every note.
@@ -650,17 +639,6 @@ export function checkWorldFacts(note, { index } = {}) {
650
639
  return findings;
651
640
  }
652
641
 
653
- if (written.includes("present")) {
654
- const { findings: dated } = parseNoteDate(dataOf(note).present, {
655
- ...reckoningContext(index),
656
- field: "data.present",
657
- allowUnknown: false,
658
- file: note.file,
659
- raw: note.raw,
660
- keyPath: ["data", "present"],
661
- });
662
- findings.push(...dated);
663
- }
664
642
  if (written.includes("moon") && dataOf(note).moon?.newOn !== undefined) {
665
643
  const parsed = parseNoteDate(dataOf(note).moon.newOn, {
666
644
  ...reckoningContext(index),
@@ -61,7 +61,7 @@ import { prepareTreeSqlTables } from "./sql-tables.mjs";
61
61
  * the index refuses is a note the compile must still *report*, exactly as the
62
62
  * compile loop reported it when the loop was the first to see it.
63
63
  * @param {import("./content-session.mjs").ContentSession} [opts.session] - Captured context shared with other operations.
64
- * @returns {Promise<{records: object[], parsed: Map<string, object>, linkIndex: object, sqlTables: Map<string, object[]>|undefined, problems: object[], syntax?: import("./content-session.mjs").ContentSyntax}>} The corpus,
64
+ * @returns {Promise<{currentDate: string, records: object[], parsed: Map<string, object>, linkIndex: object, sqlTables: Map<string, object[]>|undefined, problems: object[], syntax?: import("./content-session.mjs").ContentSyntax}>} The corpus,
65
65
  * its indexes, and the notes it could not record.
66
66
  */
67
67
  export async function buildCompileCorpus({
@@ -99,6 +99,7 @@ export async function buildCompileCorpus({
99
99
  const sqlOptions = { ...corpusOptions, skipDirectories, parsed };
100
100
  const sqlTables = await prepareTreeSqlTables(contentBase, sqlOptions);
101
101
  return {
102
+ currentDate: resolved.currentDate,
102
103
  records,
103
104
  parsed,
104
105
  problems: collected,
@@ -333,7 +333,6 @@ types:
333
333
  - year.hoursPerDay
334
334
  - year.minutesPerHour
335
335
  - year.secondsPerMinute
336
- - present
337
336
  - body.diameterKm
338
337
  - body.orbitalRadiusKm
339
338
  - body.orbit
@@ -179,7 +179,8 @@ import { isDraftNote } from "./note-vocabulary.mjs";
179
179
  // A being's computed `age` — the middle state between an authored override and
180
180
  // an unknown one — filled in against the package's own declared present before
181
181
  // a record is built, so the index carries it without the note being written to.
182
- import { applyComputedBeingAge, presentAmongFrontmatters } from "./being-age.mjs";
182
+ import { applyComputedBeingAge } from "./being-age.mjs";
183
+ import { normalizeCurrentDate } from "./current-date.mjs";
183
184
  import { reckoningContext } from "./reckoning-markers.mjs";
184
185
  import { resolvedDateFields } from "./note-dates.mjs";
185
186
 
@@ -1097,6 +1098,7 @@ export function resolveCultures(
1097
1098
  * asserts that it does not exist. The same holds for a culture conflict: a
1098
1099
  * reader receives each as a finding, and the emitter throws once, listing
1099
1100
  * every one.
1101
+ * @param {string} [options.currentDate="720.1"] - Setting-wide neutral current date.
1100
1102
  * @param {(key: string) => ({culture?: unknown}|undefined)} [options.foreignRecord] -
1101
1103
  * Another package's entry for a canonical address, read for a parent that
1102
1104
  * package publishes; see {@link resolveCultures}.
@@ -1113,6 +1115,7 @@ export function collectContentIndex(
1113
1115
  contentBase,
1114
1116
  {
1115
1117
  contentPackage,
1118
+ currentDate,
1116
1119
  skipDirectories,
1117
1120
  assetsBase,
1118
1121
  manifest,
@@ -1134,10 +1137,7 @@ export function collectContentIndex(
1134
1137
  // tree to walk and the index is its asset records. The caller decides
1135
1138
  // whether an absent tree is a mistake; by the time the walk is reached it
1136
1139
  // is simply a package with nothing to compile.
1137
- // Materialised rather than consumed lazily: the present is read from this
1138
- // same walk before the loop below reads it a second time, and
1139
- // `walkMarkdownTree` is a generator — a single-use one, exhausted the
1140
- // moment anything else iterates it first.
1140
+ // Materialize parsed notes once for shared calendar and source context.
1141
1141
  const notes = fs.existsSync(contentBase) ? [...walkMarkdownTree(contentBase, walkOpts)] : [];
1142
1142
  const parsedNotes = [];
1143
1143
  for (const note of notes) {
@@ -1165,10 +1165,8 @@ export function collectContentIndex(
1165
1165
  problems.push(problem);
1166
1166
  }
1167
1167
 
1168
- // The package's declared present, read once from whichever `place` note in
1169
- // this same walk states one — the whole tree is already in memory, so no
1170
- // second read is needed to answer a question about all of it.
1171
- const present = presentAmongFrontmatters(parsedNotes.map((n) => n.frontmatter));
1168
+ // Every derived age and recurring event uses the same configured date.
1169
+ const present = normalizeCurrentDate(currentDate);
1172
1170
  // Carried on the same context `resolvedDateFields` reads: a recurring
1173
1171
  // event's `next` is computed against this present exactly as a being's
1174
1172
  // `age` already is, above.
@@ -1497,6 +1495,7 @@ export function indexRecordsFor({
1497
1495
  };
1498
1496
  return collectContentIndex(tree, {
1499
1497
  contentPackage: resolved.contentPackage,
1498
+ currentDate: resolved.currentDate,
1500
1499
  addressContext: {
1501
1500
  ...noteAddressContext(resolved),
1502
1501
  declaresAddress: dependencyAddresses(resolved),
@@ -1680,6 +1679,7 @@ export async function prepareContentIndex({ contentBase, config, session } = {})
1680
1679
  });
1681
1680
  const index = buildSiteIndex(pages, {
1682
1681
  package: resolved.contentPackage,
1682
+ currentDate: resolved.currentDate,
1683
1683
  records: visible,
1684
1684
  foreignIndex: foreign.index,
1685
1685
  syntax: session?.syntax,
@@ -22,17 +22,23 @@ import { inlineText } from "./name-markup.mjs";
22
22
  /** Add bracket spans after recognized Markdown links and footnotes.
23
23
  * @param {import("markdown-it")} md - Markdown parser to extend.
24
24
  * @param {object} [options] - Surface presentation.
25
- * @param {boolean} [options.spoilers] - Add visible static Secret labels (Foundry).
25
+ * @param {boolean} [options.source] - Emit opaque authored source tokens for buffer preview.
26
+ * @param {boolean} [options.spoilers] - Add focusable CSS spoiler wrappers (Foundry).
26
27
  */
27
- export function spanMarkdownPlugin(md, { spoilers = false } = {}) {
28
+ export function spanMarkdownPlugin(md, { spoilers = false, source = false } = {}) {
28
29
  if (spoilers) {
29
30
  md.renderer.rules.span_open = (tokens, index, options, env, renderer) => {
30
31
  const token = tokens[index];
31
32
  const secret = (token.attrGet("class") ?? "").split(/\s+/).includes("secret");
32
- return (
33
- renderer.renderToken(tokens, index, options) +
34
- (secret ? '<strong class="inline-secret-label">Secret:</strong> ' : "")
35
- );
33
+ if (secret) {
34
+ token.attrSet("data-inline-spoiler", "");
35
+ token.attrSet("tabindex", "0");
36
+ if (!token.attrGet("title"))
37
+ token.attrSet("title", "Spoiler: hover or focus to reveal");
38
+ if (!token.attrGet("aria-label"))
39
+ token.attrSet("aria-label", "Spoiler; focus to reveal");
40
+ }
41
+ return renderer.renderToken(tokens, index, options);
36
42
  };
37
43
  }
38
44
  md.inline.ruler.after("link", "bracket_span", (state, silent) => {
@@ -89,6 +95,22 @@ export function spanMarkdownPlugin(md, { spoilers = false } = {}) {
89
95
  state.pos = finish;
90
96
  return true;
91
97
  }
98
+ if (source) {
99
+ // An incomplete attribute stays opaque through the end of its line.
100
+ if (state.src[finish] === "{") {
101
+ const newline = state.src.indexOf("\n", finish);
102
+ finish = newline < 0 ? state.posMax : newline;
103
+ }
104
+ const token = state.push("span_source", "", 0);
105
+ token.content = state.src.slice(start, finish);
106
+ token.meta = {
107
+ text: state.src.slice(start + 1, end),
108
+ labelEnd: end - start,
109
+ secret: parsed.classes.includes("secret"),
110
+ };
111
+ state.pos = finish;
112
+ return true;
113
+ }
92
114
  const token = state.push("span_open", "span", 1);
93
115
  token.meta = {
94
116
  span: true,