@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
package/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # @heroiclands/package-build
2
2
 
3
+ ## 23.12.0
4
+
5
+ ### Minor Changes
6
+
7
+ **Preview and layout**
8
+
9
+ - Preview Markdown buffers directly in light or dark themes, without building a content index; cross-note constructs remain highlighted source.
10
+ - Definition lists use bold terms and hanging definitions across websites, Foundry, preview and books, with a narrower definition column on small screens.
11
+
12
+ **Dates and spoilers**
13
+
14
+ - One setting-wide current date, defaulting to 720.1, drives age calculations; world-note dates are retired and authored ages are preserved.
15
+ - Secret spans in Foundry and preview redact until hover or keyboard focus; secret preview sections and fences open as spoilers.
16
+ - PDF inline secret spans print normally with their formatting; secret sections and fences retain labelled boxes.
17
+
18
+ ### Patch Changes
19
+
20
+ **Alerts**
21
+
22
+ - Note, Tip, Important and Warning alerts have tinted backgrounds with coordinated icons, headings and borders, adapting to each surface's color scheme.
23
+
3
24
  ## 23.11.0
4
25
 
5
26
  ### Minor Changes
package/README.md CHANGED
@@ -69,6 +69,7 @@ The documentation lives in the Markdown files in this repository. Start with [th
69
69
  | Localization and templates | [Localization](docs/guides/localization.md) |
70
70
  | Foundry integration testing | [End-to-end testing](docs/guides/e2e.md), including an optional S3 server |
71
71
  | Release and deployment | [Release and deploy](docs/guides/release-and-deploy.md) |
72
+ | Buffer-only preview and Gitea rendering | [Source preview](docs/source-preview.md) |
72
73
  | Every CLI command | [Commands](docs/commands.md) |
73
74
  | Every build setting | [Configuration](docs/configuration.md) |
74
75
  | Programmatic exports | [API](docs/api.md) |
@@ -0,0 +1,68 @@
1
+ // Copyright (c) 2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
2
+ // SPDX-License-Identifier: GPL-3.0-or-later
3
+
4
+ // Replay the preview INI rules with the real bluemonday UGC sanitizer.
5
+ // Gitea also adds default rules; include the defaults needed by preview icons.
6
+ // This validates the shared policy primitives, not a deployed Gitea instance.
7
+ package main
8
+
9
+ import (
10
+ "bufio"
11
+ "fmt"
12
+ "io"
13
+ "os"
14
+ "regexp"
15
+ "strings"
16
+
17
+ "github.com/microcosm-cc/bluemonday"
18
+ )
19
+
20
+ func main() {
21
+ if len(os.Args) != 2 {
22
+ panic("usage: check-sanitizer path/to/gitea.ini < preview.html")
23
+ }
24
+ file, err := os.Open(os.Args[1])
25
+ if err != nil {
26
+ panic(err)
27
+ }
28
+ defer file.Close()
29
+ policy := bluemonday.UGCPolicy()
30
+ // Gitea modules/markup/sanitizer_default.go permits these SVG attributes.
31
+ policy.AllowAttrs("viewBox", "width", "height", "aria-hidden", "data-attr-class").OnElements("svg")
32
+ policy.AllowAttrs("fill-rule", "d").OnElements("path")
33
+ policy.AllowAttrs("aria-hidden").OnElements("span", "div")
34
+ var section string
35
+ rule := map[string]string{}
36
+ flush := func() {
37
+ if !strings.HasPrefix(section, "markup.sanitizer.markdown.") {
38
+ return
39
+ }
40
+ element, attr, pattern := rule["ELEMENT"], rule["ALLOW_ATTR"], rule["REGEXP"]
41
+ if element == "" || attr == "" || pattern == "" {
42
+ panic("incomplete sanitizer rule: " + section)
43
+ }
44
+ policy.AllowAttrs(attr).Matching(regexp.MustCompile(pattern)).OnElements(element)
45
+ }
46
+ scanner := bufio.NewScanner(file)
47
+ for scanner.Scan() {
48
+ line := strings.TrimSpace(scanner.Text())
49
+ if strings.HasPrefix(line, "[") {
50
+ flush()
51
+ section = strings.Trim(line, "[]")
52
+ rule = map[string]string{}
53
+ continue
54
+ }
55
+ if key, value, ok := strings.Cut(line, "="); ok {
56
+ rule[strings.TrimSpace(key)] = strings.TrimSpace(value)
57
+ }
58
+ }
59
+ flush()
60
+ if err := scanner.Err(); err != nil {
61
+ panic(err)
62
+ }
63
+ input, err := io.ReadAll(os.Stdin)
64
+ if err != nil {
65
+ panic(err)
66
+ }
67
+ fmt.Print(policy.Sanitize(string(input)))
68
+ }
@@ -0,0 +1,36 @@
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
+ // Installed by the Gitea administrator, outside user-authored rendered markup.
15
+ (() => {
16
+ const root = document.documentElement;
17
+ function update() {
18
+ const scheme = getComputedStyle(root).getPropertyValue("--is-dark-theme").trim();
19
+ // An unknown custom theme keeps the renderer's explicit initial mode.
20
+ if (scheme !== "true" && scheme !== "false") return;
21
+ for (const preview of document.querySelectorAll(".markup.markdown .hl-preview")) {
22
+ preview.classList.toggle("hl-preview-dark", scheme === "true");
23
+ preview.classList.toggle("hl-preview-light", scheme === "false");
24
+ }
25
+ }
26
+ new MutationObserver(update).observe(root, {
27
+ attributes: true,
28
+ attributeFilter: ["data-theme"],
29
+ childList: true,
30
+ subtree: true,
31
+ });
32
+ // Theme stylesheets can finish loading after data-theme changes.
33
+ document.addEventListener("load", update, true);
34
+ document.addEventListener("DOMContentLoaded", update, { once: true });
35
+ update();
36
+ })();
@@ -0,0 +1,127 @@
1
+ [markup.markdown]
2
+ ENABLED = true
3
+ FILE_EXTENSIONS = .md,.markdown,.mdown,.mkd
4
+ RENDER_COMMAND = /opt/heroiclands/node_modules/.bin/package-build-preview
5
+ IS_INPUT_FILE = false
6
+ NEED_POST_PROCESS = false
7
+ RENDER_CONTENT_MODE = sanitized
8
+
9
+ [markup.sanitizer.markdown.previewDiv]
10
+ ELEMENT = div
11
+ ALLOW_ATTR = class
12
+ REGEXP = ^(hl-preview(?:-[a-z-]+)?|alert(?:-(?:note|tip|important|warning))?|content-figure(?:-[a-z-]+)?|poetry)( (hl-preview(?:-[a-z-]+)?|alert(?:-(?:note|tip|important|warning))?|content-figure(?:-[a-z-]+)?|poetry))*$
13
+
14
+ [markup.sanitizer.markdown.previewSpan]
15
+ ELEMENT = span
16
+ ALLOW_ATTR = class
17
+ REGEXP = ^(hl-preview-[a-z-]+|alert-(?:title|icon)|poetry-[a-z-]+|i[0-8])( (hl-preview-[a-z-]+|alert-(?:title|icon)|poetry-[a-z-]+|i[0-8]))*$
18
+
19
+ [markup.sanitizer.markdown.previewPre]
20
+ ELEMENT = pre
21
+ ALLOW_ATTR = class
22
+ REGEXP = ^hl-preview-(fence|dependent)$
23
+
24
+ [markup.sanitizer.markdown.previewCode]
25
+ ELEMENT = code
26
+ ALLOW_ATTR = class
27
+ REGEXP = ^(hl-preview-fence|language-[A-Za-z0-9_-]+)$
28
+
29
+ [markup.sanitizer.markdown.previewAside]
30
+ ELEMENT = aside
31
+ ALLOW_ATTR = class
32
+ REGEXP = ^hl-preview-infobox$
33
+
34
+ [markup.sanitizer.markdown.alertSvgFill]
35
+ ELEMENT = svg
36
+ ALLOW_ATTR = fill
37
+ REGEXP = ^currentColor$
38
+
39
+ [markup.sanitizer.markdown.alertCircleX]
40
+ ELEMENT = circle
41
+ ALLOW_ATTR = cx
42
+ REGEXP = ^[0-9.]+$
43
+
44
+ [markup.sanitizer.markdown.alertCircleY]
45
+ ELEMENT = circle
46
+ ALLOW_ATTR = cy
47
+ REGEXP = ^[0-9.]+$
48
+
49
+ [markup.sanitizer.markdown.alertCircleRadius]
50
+ ELEMENT = circle
51
+ ALLOW_ATTR = r
52
+ REGEXP = ^[0-9.]+$
53
+
54
+ [markup.sanitizer.markdown.previewMain]
55
+ ELEMENT = main
56
+ ALLOW_ATTR = class
57
+ REGEXP = ^hl-preview-main$
58
+
59
+ [markup.sanitizer.markdown.alertBox]
60
+ ELEMENT = blockquote
61
+ ALLOW_ATTR = class
62
+ REGEXP = ^alert alert-(note|tip|important|warning)$
63
+
64
+ [markup.sanitizer.markdown.previewParagraph]
65
+ ELEMENT = p
66
+ ALLOW_ATTR = class
67
+ REGEXP = ^(alert-title|content-figure-label)$
68
+
69
+ [markup.sanitizer.markdown.alertSvgClass]
70
+ ELEMENT = svg
71
+ ALLOW_ATTR = class
72
+ REGEXP = ^alert-icon$
73
+
74
+ [markup.sanitizer.markdown.previewDetails]
75
+ ELEMENT = details
76
+ ALLOW_ATTR = class
77
+ REGEXP = ^secret$
78
+
79
+ [markup.sanitizer.markdown.footnoteSection]
80
+ ELEMENT = section
81
+ ALLOW_ATTR = class
82
+ REGEXP = ^footnotes$
83
+
84
+ [markup.sanitizer.markdown.footnoteSeparator]
85
+ ELEMENT = hr
86
+ ALLOW_ATTR = class
87
+ REGEXP = ^footnotes-sep$
88
+
89
+ [markup.sanitizer.markdown.footnoteList]
90
+ ELEMENT = ol
91
+ ALLOW_ATTR = class
92
+ REGEXP = ^footnotes-list$
93
+
94
+ [markup.sanitizer.markdown.footnoteItem]
95
+ ELEMENT = li
96
+ ALLOW_ATTR = class
97
+ REGEXP = ^footnote-item$
98
+
99
+ [markup.sanitizer.markdown.footnoteLink]
100
+ ELEMENT = a
101
+ ALLOW_ATTR = class
102
+ REGEXP = ^footnote-(ref|backref)$
103
+
104
+ [markup.sanitizer.markdown.footnoteRef]
105
+ ELEMENT = sup
106
+ ALLOW_ATTR = class
107
+ REGEXP = ^footnote-ref$
108
+
109
+ [markup.sanitizer.markdown.tableHeaderAlign]
110
+ ELEMENT = th
111
+ ALLOW_ATTR = class
112
+ REGEXP = ^hl-preview-align-(left|center|right)$
113
+
114
+ [markup.sanitizer.markdown.tableCellAlign]
115
+ ELEMENT = td
116
+ ALLOW_ATTR = class
117
+ REGEXP = ^hl-preview-align-(left|center|right)$
118
+
119
+ [markup.sanitizer.markdown.dictionaryList]
120
+ ELEMENT = dl
121
+ ALLOW_ATTR = class
122
+ REGEXP = ^dictionary$
123
+
124
+ [markup.sanitizer.markdown.secretFocus]
125
+ ELEMENT = span
126
+ ALLOW_ATTR = tabindex
127
+ REGEXP = ^0$
@@ -31,7 +31,7 @@
31
31
  */
32
32
 
33
33
  /*
34
- * Definition lists, in two states of one style.
34
+ * Profile definition grids, in two states of one style.
35
35
  *
36
36
  * **Wide.** Two columns, the term right-aligned against a fixed gutter so every
37
37
  * term in the list shares one right edge, the definition in the remaining
@@ -58,7 +58,8 @@
58
58
  * The three tunable values are custom properties, so a package adjusts the term
59
59
  * column, the gutter or the stacking point without copying the rule.
60
60
  */
61
- :is(.journal-entry-page, .journal-entry-content, .editor-content, .prosemirror) dl {
61
+ :is(.journal-entry-page, .journal-entry-content, .editor-content, .prosemirror)
62
+ dl:not(.dictionary) {
62
63
  display: grid;
63
64
  /* `minmax(0, …)` keeps the track at its full width wherever there is room
64
65
  * and lets it shrink rather than overflow where there is not. */
@@ -68,7 +69,9 @@
68
69
  container-name: pb-deflist;
69
70
  }
70
71
 
71
- :is(.journal-entry-page, .journal-entry-content, .editor-content, .prosemirror) dl > dt {
72
+ :is(.journal-entry-page, .journal-entry-content, .editor-content, .prosemirror)
73
+ dl:not(.dictionary)
74
+ > dt {
72
75
  grid-column: 1;
73
76
  /* The one rule that holds on every surface, so an author writes the term
74
77
  * and nothing else rather than bolding it by hand. */
@@ -78,13 +81,17 @@
78
81
  margin: 0;
79
82
  }
80
83
 
81
- :is(.journal-entry-page, .journal-entry-content, .editor-content, .prosemirror) dl > dd {
84
+ :is(.journal-entry-page, .journal-entry-content, .editor-content, .prosemirror)
85
+ dl:not(.dictionary)
86
+ > dd {
82
87
  grid-column: 2;
83
88
  margin: 0 0 var(--pb-deflist-row-gap, 1.5rem);
84
89
  line-height: 1.6;
85
90
  }
86
91
 
87
- :is(.journal-entry-page, .journal-entry-content, .editor-content, .prosemirror) dl > dd:last-child {
92
+ :is(.journal-entry-page, .journal-entry-content, .editor-content, .prosemirror)
93
+ dl:not(.dictionary)
94
+ > dd:last-child {
88
95
  margin-bottom: 0;
89
96
  }
90
97
 
@@ -96,12 +103,16 @@
96
103
  * against whichever ancestor container happens to be nearest.
97
104
  */
98
105
  @container pb-deflist (max-width: 32rem) {
99
- dt {
106
+ :is(.journal-entry-page, .journal-entry-content, .editor-content, .prosemirror)
107
+ dl:not(.dictionary)
108
+ > dt {
100
109
  grid-column: 1 / -1;
101
110
  text-align: left;
102
111
  }
103
112
 
104
- dd {
113
+ :is(.journal-entry-page, .journal-entry-content, .editor-content, .prosemirror)
114
+ dl:not(.dictionary)
115
+ > dd {
105
116
  grid-column: 1 / -1;
106
117
  }
107
118
  }
@@ -205,20 +216,22 @@
205
216
  }
206
217
  }
207
218
 
208
- /* Alerts share ordinary body colors, with an accent on their edge and title. */
219
+ /* Alerts select a tinted palette from the host's color scheme. */
209
220
  :is(.journal-entry-page, .journal-entry-content, .editor-content, .prosemirror) .alert {
210
221
  border-inline-start: 0.25rem solid var(--pb-alert-color);
211
- padding: 0.5rem 1rem;
222
+ border-radius: 0.375rem;
223
+ padding: 1rem;
212
224
  margin: 1rem 0;
213
- color: inherit;
214
- background: transparent;
225
+ color: #24292f;
226
+ color: light-dark(#24292f, #e6edf3);
227
+ background: var(--pb-alert-background);
215
228
  }
216
229
  :is(.journal-entry-page, .journal-entry-content, .editor-content, .prosemirror) .alert-title {
217
230
  display: flex;
218
231
  align-items: center;
219
232
  gap: 0.5em;
220
233
  margin: 0 0 0.5rem;
221
- font-weight: 600;
234
+ font-weight: 700;
222
235
  color: var(--pb-alert-color);
223
236
  }
224
237
  :is(.journal-entry-page, .journal-entry-content, .editor-content, .prosemirror) .alert-icon {
@@ -234,17 +247,107 @@
234
247
  }
235
248
  :is(.journal-entry-page, .journal-entry-content, .editor-content, .prosemirror) .alert-note {
236
249
  --pb-alert-color: #0969da;
237
- --pb-alert-color: light-dark(#0969da, #4493f8);
250
+ --pb-alert-color: light-dark(#0969da, #58a6ff);
251
+ --pb-alert-background: #f6faff;
252
+ --pb-alert-background: light-dark(#f6faff, #101a28);
238
253
  }
239
254
  :is(.journal-entry-page, .journal-entry-content, .editor-content, .prosemirror) .alert-tip {
240
255
  --pb-alert-color: #1a7f37;
241
- --pb-alert-color: light-dark(#1a7f37, #3fb950);
256
+ --pb-alert-color: light-dark(#1a7f37, #7ee787);
257
+ --pb-alert-background: #f4fbf5;
258
+ --pb-alert-background: light-dark(#f4fbf5, #102018);
242
259
  }
243
260
  :is(.journal-entry-page, .journal-entry-content, .editor-content, .prosemirror) .alert-important {
244
- --pb-alert-color: #8250df;
245
- --pb-alert-color: light-dark(#8250df, #ab7df8);
261
+ --pb-alert-color: #6c3fd1;
262
+ --pb-alert-color: light-dark(#6c3fd1, #c4b0ff);
263
+ --pb-alert-background: #faf7ff;
264
+ --pb-alert-background: light-dark(#faf7ff, #1c172a);
246
265
  }
247
266
  :is(.journal-entry-page, .journal-entry-content, .editor-content, .prosemirror) .alert-warning {
248
267
  --pb-alert-color: #9a6700;
249
268
  --pb-alert-color: light-dark(#9a6700, #d29922);
269
+ --pb-alert-background: #fffaf0;
270
+ --pb-alert-background: light-dark(#fffaf0, #241d10);
271
+ }
272
+
273
+ /* Authored definition lists use dictionary rows; profile grids remain separate. */
274
+ :is(.journal-entry-page, .journal-entry-content, .editor-content, .prosemirror) .dictionary {
275
+ --indent: 150px;
276
+ --gap: 0.75em;
277
+ margin: 0;
278
+ display: block;
279
+ }
280
+ :is(.journal-entry-page, .journal-entry-content, .editor-content, .prosemirror) .dictionary > div {
281
+ padding-left: var(--indent);
282
+ text-indent: calc(-1 * var(--indent));
283
+ margin-bottom: 0.5em;
284
+ }
285
+ :is(.journal-entry-page, .journal-entry-content, .editor-content, .prosemirror)
286
+ .dictionary
287
+ > div
288
+ > dt {
289
+ display: inline-block;
290
+ min-width: calc(var(--indent) - var(--gap));
291
+ margin-right: var(--gap);
292
+ font-weight: bold;
293
+ white-space: nowrap;
294
+ text-indent: 0;
295
+ text-align: left;
296
+ }
297
+ :is(.journal-entry-page, .journal-entry-content, .editor-content, .prosemirror)
298
+ .dictionary
299
+ > div
300
+ > dd {
301
+ display: inline;
302
+ margin: 0;
303
+ font-weight: normal;
304
+ }
305
+ :is(.journal-entry-page, .journal-entry-content, .editor-content, .prosemirror)
306
+ .dictionary
307
+ > div
308
+ > dd
309
+ > :is(p, ul, ol, dl, blockquote, pre, table, div) {
310
+ text-indent: 0;
311
+ }
312
+ :is(.journal-entry-page, .journal-entry-content, .editor-content, .prosemirror)
313
+ .dictionary
314
+ > div
315
+ > dd
316
+ > p:first-child {
317
+ display: inline;
318
+ }
319
+ @media (max-width: 600px) {
320
+ :is(.journal-entry-page, .journal-entry-content, .editor-content, .prosemirror) .dictionary {
321
+ --indent: 100px;
322
+ }
323
+ }
324
+
325
+ /* Inline spoilers reveal on pointer hover or keyboard focus. Native secret
326
+ * sections retain Foundry's separate GM reveal control. */
327
+ :is(.journal-entry-page, .journal-entry-content, .editor-content, .prosemirror)
328
+ span.secret[data-inline-spoiler] {
329
+ cursor: help;
330
+ border-radius: 0.15em;
331
+ }
332
+ :is(.journal-entry-page, .journal-entry-content, .editor-content, .prosemirror)
333
+ span.secret[data-inline-spoiler]:not(:hover):not(:focus):not(:focus-within) {
334
+ color: transparent !important;
335
+ -webkit-text-fill-color: transparent !important;
336
+ background-color: var(--pb-redaction-fill, #24292f) !important;
337
+ text-shadow: none !important;
338
+ text-decoration-color: transparent !important;
339
+ }
340
+ :is(.journal-entry-page, .journal-entry-content, .editor-content, .prosemirror)
341
+ span.secret[data-inline-spoiler]:not(:hover):not(:focus):not(:focus-within)
342
+ * {
343
+ color: transparent !important;
344
+ -webkit-text-fill-color: transparent !important;
345
+ visibility: hidden !important;
346
+ text-shadow: none !important;
347
+ text-decoration-color: transparent !important;
348
+ }
349
+ :is(.journal-entry-page, .journal-entry-content, .editor-content, .prosemirror)
350
+ span.secret[data-inline-spoiler]:focus-visible {
351
+ outline: 2px solid currentColor;
352
+ outline-offset: 2px;
250
353
  }
@@ -144,7 +144,6 @@ import { GRAPHVIZ_ENGINES } from "../engine/map-graphviz.mjs";
144
144
  import { loadMapWorld } from "../engine/map-places.mjs";
145
145
  import { encodeAddresses } from "../engine/address-values.mjs";
146
146
  import YAML from "yaml";
147
- import { worldInvariants } from "../engine/calendar-notes.mjs";
148
147
  import { reckoningContext } from "../engine/reckoning-markers.mjs";
149
148
  import { BEING_LOOK_VOCABULARY } from "../engine/note-vocabulary.mjs";
150
149
  import { profileFindings } from "../engine/appearance-profile.mjs";
@@ -1054,6 +1053,7 @@ function lintCommand() {
1054
1053
  );
1055
1054
  }
1056
1055
  const frontmatter = lintFrontmatter(index, {
1056
+ currentDate: config.currentDate,
1057
1057
  schemas: { ...ENGINE_NOTE_SCHEMAS, ...NOTE_SCHEMAS },
1058
1058
  // The closed frontmatter regions. Passed in rather
1059
1059
  // than reached for, so the linter stays a checker of
@@ -2905,7 +2905,7 @@ function newNpc(config, argv) {
2905
2905
  names,
2906
2906
  language: languageKey,
2907
2907
  seed: argv.seed,
2908
- present: worldInvariants(index).present,
2908
+ currentDate: config.currentDate,
2909
2909
  dateContext: reckoningContext(index),
2910
2910
  defaults,
2911
2911
  taken,
@@ -0,0 +1,75 @@
1
+ #!/usr/bin/env node
2
+ /*
3
+ * This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
4
+ * Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
5
+ *
6
+ * This work is licensed under the GNU General Public License v3.0 (GPLv3).
7
+ * You may copy, modify, and distribute it under the terms of that license.
8
+ *
9
+ * For full terms, see the LICENSE.md file in the project root or visit:
10
+ * https://www.gnu.org/licenses/gpl-3.0.html
11
+ *
12
+ * SPDX-License-Identifier: GPL-3.0-or-later
13
+ */
14
+
15
+ /** Render stdin with optional date-only configuration and no content index. */
16
+ import { readFileSync } from "node:fs";
17
+ import { formatDiagnostic } from "../engine/diagnostics.mjs";
18
+
19
+ const HELP = `Usage: package-build-preview [--dark] [--current-date DATE] [--css]
20
+
21
+ Read Markdown from stdin and write an HTML fragment to stdout.
22
+ No package configuration, content tree or content index is required.
23
+
24
+ Options:
25
+ --dark Select the dark palette (default: light).
26
+ --current-date DATE Override the setting's neutral current date (default: 720.1).
27
+ --css Write shared scoped CSS instead of HTML; does not read stdin.
28
+ --help, -h Show this help.
29
+
30
+ Findings go to stderr. Exit status: 0 for success, 1 for errors.
31
+ `;
32
+
33
+ try {
34
+ let dark = false;
35
+ let css = false;
36
+ let help = false;
37
+ let currentDate;
38
+ const args = process.argv.slice(2);
39
+ for (let index = 0; index < args.length; index++) {
40
+ const arg = args[index];
41
+ if (arg === "--dark") dark = true;
42
+ else if (arg === "--current-date") {
43
+ currentDate = args[++index];
44
+ if (!currentDate || currentDate.startsWith("--"))
45
+ throw new Error("--current-date requires a neutral date");
46
+ } else if (arg === "--css") css = true;
47
+ else if (arg === "--help" || arg === "-h") help = true;
48
+ else
49
+ throw new Error(
50
+ `${arg.startsWith("-") ? "Unknown option" : "Unexpected argument"}: ${arg}`,
51
+ );
52
+ }
53
+ if (help) process.stdout.write(HELP);
54
+ else {
55
+ const { renderPreview, previewStyles, readPreviewCurrentDate } =
56
+ await import("../engine/site-preview.mjs");
57
+ if (css) process.stdout.write(`${previewStyles()}\n`);
58
+ else {
59
+ currentDate ??= await readPreviewCurrentDate(process.cwd());
60
+ const result = await renderPreview(readFileSync(0, "utf8"), {
61
+ dark,
62
+ file: "<stdin>",
63
+ currentDate,
64
+ });
65
+ for (const finding of result.findings) {
66
+ process.stderr.write(`${formatDiagnostic(finding)}\n`);
67
+ }
68
+ process.stdout.write(result.html);
69
+ if (!result.ok) process.exitCode = 1;
70
+ }
71
+ }
72
+ } catch (error) {
73
+ process.stderr.write(`package-build-preview: ${error.message}\n`);
74
+ process.exitCode = 1;
75
+ }
@@ -65,6 +65,7 @@ import fs from "node:fs";
65
65
  import path from "node:path";
66
66
  import { fileURLToPath } from "node:url";
67
67
  import YAML from "yaml";
68
+ import { normalizeCurrentDate } from "./engine/current-date.mjs";
68
69
 
69
70
  // Leaves with no local imports of their own, so naming them here cannot close
70
71
  // a cycle around a consumer's config file (see `engine/pack-config.mjs`).
@@ -569,6 +570,7 @@ export function publishesContentPages(config) {
569
570
  * repository — every configured path is
570
571
  * resolved against it, so the build never
571
572
  * depends on the working directory.
573
+ * @property {string} [currentDate] Setting-wide neutral current date; defaults to "720.1".
572
574
  * @property {string} contentPackage Content package name — the address
573
575
  * namespace every note in this
574
576
  * repository is published under.
@@ -643,6 +645,7 @@ export function publishesContentPages(config) {
643
645
  * The normalized, frozen configuration the toolchain reads.
644
646
  *
645
647
  * @typedef {object} ContentBuildConfig
648
+ * @property {string} currentDate Setting-wide neutral current date.
646
649
  * @property {string} rootDir
647
650
  * @property {string} contentPackage
648
651
  * @property {string|null} foundryPackage `null` for a `documentation`
@@ -726,6 +729,7 @@ export function publishesContentPages(config) {
726
729
  const CONFIG_KEYS = [
727
730
  "rootDir",
728
731
  "contentPackage",
732
+ "currentDate",
729
733
  "foundryPackage",
730
734
  "homepage",
731
735
  "authors",
@@ -2943,6 +2947,13 @@ export function defineConfig(config) {
2943
2947
 
2944
2948
  return Object.freeze({
2945
2949
  rootDir,
2950
+ currentDate: (() => {
2951
+ try {
2952
+ return normalizeCurrentDate(input.currentDate);
2953
+ } catch (error) {
2954
+ fail("currentDate", error.message);
2955
+ }
2956
+ })(),
2946
2957
  contentPackage: requireContentPackage(input.contentPackage, docEntryTypes, {
2947
2958
  rootDir,
2948
2959
  packageKind,