@heroiclands/package-build 23.10.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 (74) hide show
  1. package/CHANGELOG.md +28 -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 +131 -45
  11. package/docs/authoring/first-note.md +10 -0
  12. package/docs/authoring/frontmatter.md +1 -1
  13. package/docs/authoring/links-and-markup.md +111 -6
  14. package/docs/commands.md +46 -2
  15. package/docs/configuration.md +21 -4
  16. package/docs/content-format.md +4 -2
  17. package/docs/design.md +19 -0
  18. package/docs/guides/book.md +11 -0
  19. package/docs/guides/site.md +7 -0
  20. package/docs/reference/format-details.md +82 -27
  21. package/docs/reference/note-types.md +0 -1
  22. package/docs/reference/theme-pages.md +1 -1
  23. package/docs/source-preview.md +172 -0
  24. package/engine/anchors.mjs +36 -4
  25. package/engine/base-compiler.mjs +7 -8
  26. package/engine/being-age.mjs +19 -105
  27. package/engine/calendar-notes.mjs +3 -25
  28. package/engine/compile-corpus.mjs +2 -1
  29. package/engine/content-format.yaml +0 -1
  30. package/engine/content-index.mjs +9 -9
  31. package/engine/content-links.mjs +41 -18
  32. package/engine/content-spans.mjs +54 -7
  33. package/engine/current-date.mjs +37 -0
  34. package/engine/definition-list.mjs +58 -0
  35. package/engine/frontmatter-lint.mjs +7 -2
  36. package/engine/helpers.mjs +6 -2
  37. package/engine/index.mjs +2 -0
  38. package/engine/infobox.mjs +1 -5
  39. package/engine/note-vocabulary.mjs +2 -2
  40. package/engine/npc-generate.mjs +7 -6
  41. package/engine/pdf-render.mjs +36 -3
  42. package/engine/preview-current-date.mjs +60 -0
  43. package/engine/site-build.mjs +5 -5
  44. package/engine/site-index.mjs +28 -6
  45. package/engine/site-preview.mjs +641 -325
  46. package/engine/web-wikilinks.mjs +33 -12
  47. package/engine/wikilink-syntax.mjs +65 -8
  48. package/engine/wikilinks.mjs +29 -11
  49. package/hugo-theme/layouts/_default/list.html +1 -1
  50. package/hugo-theme/layouts/blog/list.html +1 -1
  51. package/hugo-theme/layouts/blog-post/single.html +1 -1
  52. package/hugo-theme/layouts/partials/dictionary-lists.html +53 -0
  53. package/hugo-theme/layouts/partials/footer.html +2 -0
  54. package/hugo-theme/layouts/partials/page-body.html +1 -1
  55. package/hugo-theme/static/css/style.css +39 -9
  56. package/hugo-theme/static/js/inline-spoilers.js +23 -0
  57. package/package.json +8 -2
  58. package/types/content-config.d.mts +8 -0
  59. package/types/engine/anchors.d.mts +2 -1
  60. package/types/engine/base-compiler.d.mts +1 -1
  61. package/types/engine/being-age.d.mts +10 -44
  62. package/types/engine/calendar-notes.d.mts +2 -6
  63. package/types/engine/compile-corpus.d.mts +2 -1
  64. package/types/engine/content-index.d.mts +3 -1
  65. package/types/engine/content-spans.d.mts +7 -1
  66. package/types/engine/current-date.d.mts +9 -0
  67. package/types/engine/definition-list.d.mts +8 -0
  68. package/types/engine/frontmatter-lint.d.mts +6 -2
  69. package/types/engine/index.d.mts +2 -0
  70. package/types/engine/npc-generate.d.mts +2 -2
  71. package/types/engine/preview-current-date.d.mts +8 -0
  72. package/types/engine/site-index.d.mts +5 -1
  73. package/types/engine/site-preview.d.mts +40 -33
  74. package/types/engine/wikilink-syntax.d.mts +40 -3
@@ -41,7 +41,8 @@
41
41
  // cannot drift apart on it: they disagreed once over the unlabelled hyphen
42
42
  // form, which the packs showed as a raw shortcode and the knowledgebase as a
43
43
  // name.
44
- import { readWikilink } from "./wikilink-syntax.mjs";
44
+ import { collectAnchors } from "./anchors.mjs";
45
+ import { presentWikilink, readWikilink } from "./wikilink-syntax.mjs";
45
46
  import { readQualifier } from "./wikilinks.mjs";
46
47
  import { replaceOutsideCode } from "./code-fences.mjs";
47
48
  // The canonical `package-system-type-shortcode` key, so a package-qualified
@@ -354,6 +355,7 @@ export function resolveWebWikilinks(body, ctx) {
354
355
  // same counting the checker does, and what turns a finding into a
355
356
  // `file:line:column:` diagnostic rather than a note-wide one.
356
357
  const seen = new Map();
358
+ let ownAnchors = ctx.noteAnchors;
357
359
  /**
358
360
  * Records a finding against one authored literal.
359
361
  *
@@ -404,18 +406,28 @@ export function resolveWebWikilinks(body, ctx) {
404
406
  // Code is verbatim: a `[[…]]` inside a code fence, an indented block or an
405
407
  // inline span is source text, not a link.
406
408
  return replaceOutsideCode(embedded.markdown, WIKILINK, (all, rawInner) => {
407
- const parsed = readWikilink(rawInner, {
408
- package: ctx.contentPackage,
409
- system: "note",
410
- types: ctx.contentTypes ?? new Set(),
411
- packages: ctx.packages,
412
- noIndexPackages: ctx.noIndexPackages,
413
- });
409
+ const parsed = readWikilink(
410
+ rawInner,
411
+ {
412
+ package: ctx.contentPackage,
413
+ system: "note",
414
+ types: ctx.contentTypes ?? new Set(),
415
+ packages: ctx.packages,
416
+ noIndexPackages: ctx.noIndexPackages,
417
+ },
418
+ all,
419
+ );
414
420
  const { rawTarget: target, anchor, display } = parsed;
415
421
  // An empty label is not a label: `[[x|]]` addresses the target and
416
422
  // shows its name, so `""` falls through to the same place `null` does.
417
423
  // One reading, from {@link authoredLabel}.
418
424
  const label = authoredLabel({ display });
425
+ if (parsed.attributes?.problems.length)
426
+ return report(
427
+ all,
428
+ { target, reason: "invalid-attributes", problems: parsed.attributes.problems },
429
+ label ?? target,
430
+ );
419
431
 
420
432
  // **Every link carries a label**. Without one there is nothing
421
433
  // to resolve against, and nothing to show either, a shortcode being an
@@ -445,7 +457,11 @@ export function resolveWebWikilinks(body, ctx) {
445
457
  label ?? anchor,
446
458
  );
447
459
  }
448
- return `[${label ?? ctx.captionLabels?.get(anchor) ?? anchor}](#${slugify(anchor)})`;
460
+ return presentWikilink(
461
+ `[${label ?? ctx.captionLabels?.get(anchor) ?? anchor}](#${slugify(anchor)})`,
462
+ parsed,
463
+ { noteAnchors: (ownAnchors ??= collectAnchors(body)) },
464
+ );
449
465
  }
450
466
 
451
467
  // The canonical separator has to be resolved, not merely
@@ -480,7 +496,7 @@ export function resolveWebWikilinks(body, ctx) {
480
496
  label ?? hit.name ?? target,
481
497
  );
482
498
  }
483
- if (anchor && hit.url && !hasAnchor(hit.noteAnchors ?? hit.anchors, anchor)) {
499
+ if (anchor && !hasAnchor(hit.noteAnchors ?? hit.anchors, anchor)) {
484
500
  return report(
485
501
  all,
486
502
  { target, reason: "unknown-anchor", anchor, addressed: true },
@@ -506,8 +522,13 @@ export function resolveWebWikilinks(body, ctx) {
506
522
  // Presentation only — the href above is unchanged, and a
507
523
  // `[[#anchor]]` self-link is not marked because the reader is
508
524
  // already on the page it would be telling them about.
509
- if (!hit.draft) return link;
510
- return ctx.output === "book" ? `${link} (draft)` : draftLink(link);
525
+ return presentWikilink(
526
+ !hit.draft ? link
527
+ : ctx.output === "book" ? `${link} (draft)`
528
+ : draftLink(link),
529
+ parsed,
530
+ hit,
531
+ );
511
532
  }
512
533
 
513
534
  // **An address resolving nowhere is a failure, unconditionally**.
@@ -11,6 +11,9 @@
11
11
  * SPDX-License-Identifier: GPL-3.0-or-later
12
12
  */
13
13
 
14
+ import { parseExtensionAttributes, refusedAttributes } from "./extension-attributes.mjs";
15
+ import { isGmNote } from "./note-vocabulary.mjs";
16
+
14
17
  import { parseAddress, isAddressTuple, splitAnchor } from "./address.mjs";
15
18
 
16
19
  /**
@@ -67,7 +70,7 @@ import { parseAddress, isAddressTuple, splitAnchor } from "./address.mjs";
67
70
  * copies of the exclusion is three places for it to be forgotten. An embed's
68
71
  * own grammar is {@link module:engine/content-embeds}.
69
72
  */
70
- export const WIKILINK = /(?<!!)\[\[([^\]\n]+)\]\]/g;
73
+ export const WIKILINK = /(?<!!)\[\[([^\]\n]+)\]\](?:\{(?:[^{}"\n]|"(?:\\.|[^"\\\n])*")*\})?/g;
71
74
 
72
75
  /**
73
76
  * The parts of a wikilink's interior.
@@ -80,6 +83,7 @@ export const WIKILINK = /(?<!!)\[\[([^\]\n]+)\]\]/g;
80
83
  * @property {string} anchor - The `#section` slug, `""` when there is none.
81
84
  * @property {string|null} display - The text after `|`, or `null` when the link
82
85
  * is unlabelled. `null` and `""` differ: an author may write `[[x|]]`.
86
+ * @property {ReturnType<typeof parseExtensionAttributes>} [attributes] - Trailing extension attributes.
83
87
  * @property {boolean} labelled - Whether a `|` was present at all. A link
84
88
  * without one addresses nothing and is a finding — see
85
89
  * {@link unlabelledLinkMessage} — so every reader has to be able to ask.
@@ -92,9 +96,10 @@ export const WIKILINK = /(?<!!)\[\[([^\]\n]+)\]\]/g;
92
96
  * not the whole link, so a caller that has already matched does not re-match.
93
97
  *
94
98
  * @param {string} rawInner - The text between `[[` and `]]`.
99
+ * @param {string} [literal] - Whole matched link, including trailing attributes.
95
100
  * @returns {ParsedWikilink} The parts, each trimmed.
96
101
  */
97
- export function parseWikilink(rawInner) {
102
+ export function parseWikilink(rawInner, literal = "") {
98
103
  // A pipe inside a markdown table cell is escaped as `\|`; undo that before
99
104
  // looking for the label separator, or the cell's own escape reads as one.
100
105
  const inner = String(rawInner ?? "").replace(/\\\|/g, "|");
@@ -108,7 +113,18 @@ export function parseWikilink(rawInner) {
108
113
  // link and a field cannot disagree about where the anchor starts.
109
114
  const { address: target, anchor = "" } = splitAnchor(linkPart);
110
115
 
111
- return { inner: inner.trim(), target, anchor, display, labelled };
116
+ const suffix = literal.slice(literal.indexOf("]]") + 2);
117
+ const attributes =
118
+ suffix.startsWith("{") ? parseExtensionAttributes(suffix.slice(1, -1)) : null;
119
+ if (attributes) attributes.problems.push(...refusedAttributes(attributes.values));
120
+ return {
121
+ inner: inner.trim(),
122
+ target,
123
+ anchor,
124
+ display,
125
+ labelled,
126
+ ...(attributes ? { attributes } : {}),
127
+ };
112
128
  }
113
129
 
114
130
  /**
@@ -193,6 +209,7 @@ export function unlabelledLinkMessage(target) {
193
209
  */
194
210
  export const LINK_FINDING_REASONS = Object.freeze(
195
211
  new Set([
212
+ "invalid-attributes",
196
213
  "unlabelled",
197
214
  "not-an-address",
198
215
  "not-lowercase",
@@ -201,7 +218,6 @@ export const LINK_FINDING_REASONS = Object.freeze(
201
218
  "not-an-asset",
202
219
  "unresolved",
203
220
  "stub",
204
- "gm",
205
221
  "ambiguous",
206
222
  "unknown-anchor",
207
223
  "event-anchor",
@@ -323,6 +339,7 @@ export function ambiguousAddressMessage(target, packages = []) {
323
339
  * @param {string} [finding.stub] - For `stub`, where the stub is.
324
340
  * @param {string} [finding.shortcode] - For `shortcode-not-lowercase`, the
325
341
  * shortcode as written.
342
+ * @param {string[]} [finding.problems] - Invalid attribute diagnostics.
326
343
  * @param {string} [finding.declared] - For `shortcode-not-lowercase`, the
327
344
  * lowercase spelling, when it is a declared shortcode.
328
345
  * @returns {string} The message.
@@ -338,8 +355,11 @@ export function linkFindingMessage({
338
355
  stub,
339
356
  shortcode,
340
357
  declared,
358
+ problems,
341
359
  }) {
342
360
  switch (reason) {
361
+ case "invalid-attributes":
362
+ return `wikilink [[${target}]] has invalid attributes: ${(problems ?? []).join("; ")}`;
343
363
  case "unlabelled":
344
364
  return unlabelledLinkMessage(target);
345
365
  case "not-an-address":
@@ -386,8 +406,6 @@ export function linkFindingMessage({
386
406
  return unresolvedAddressMessage(target);
387
407
  case "stub":
388
408
  return stubAddressMessage(target, stub);
389
- case "gm":
390
- return `address [[${target}]] names a GM-only note from a player-facing note`;
391
409
  case "no-content-index":
392
410
  return (
393
411
  `address [[${target}]] names a package declared \`contentIndex: false\` — ` +
@@ -419,10 +437,11 @@ export function isSamePage({ target, anchor }) {
419
437
  * Read an operational link target in its enclosing Address context.
420
438
  * @param {string} inner - Lexical link contents.
421
439
  * @param {object} context - Builder defaults and type vocabulary.
440
+ * @param {string} [literal] - Whole matched link, including trailing attributes.
422
441
  * @returns {object} Typed target and diagnostic source spelling.
423
442
  */
424
- export function readWikilink(inner, context) {
425
- const parsed = parseWikilink(inner);
443
+ export function readWikilink(inner, context, literal = "") {
444
+ const parsed = parseWikilink(inner, literal);
426
445
  const rawTarget = parsed.target;
427
446
  if (!rawTarget) return { ...parsed, rawTarget, target: { kind: "same-page" } };
428
447
  const target = parseAddress(rawTarget, context);
@@ -433,3 +452,41 @@ export function readWikilink(inner, context) {
433
452
  { ...parsed, rawTarget, target: { kind: "literal", text: rawTarget }, problem: target }
434
453
  : { ...parsed, rawTarget, target };
435
454
  }
455
+
456
+ /** Whether the actual referenced destination needs spoiler presentation.
457
+ * @param {object} destination - Resolved note metadata.
458
+ * @param {string} [anchor] - Referenced prose anchor.
459
+ * @returns {boolean} Whole-note or section audience.
460
+ */
461
+ export function secretDestination(destination, anchor = "") {
462
+ return Boolean(
463
+ destination?.gm ||
464
+ isGmNote(destination?.fm ?? destination) ||
465
+ (anchor && destination?.noteAnchors?.some((one) => one.slug === anchor && one.secret)),
466
+ );
467
+ }
468
+
469
+ /** Apply destination-derived presentation without changing its address.
470
+ * @param {string} reference - Rendered reference Markdown.
471
+ * @param {object} parsed - Shared parsed wikilink.
472
+ * @param {object} destination - Resolved target metadata.
473
+ * @returns {string} Inline spoiler or visible reference.
474
+ */
475
+ export function presentWikilink(reference, parsed, destination) {
476
+ const attrs = parsed.attributes;
477
+ const classes = (attrs?.classes ?? []).filter((value) => value !== "show");
478
+ if (
479
+ !attrs?.classes.includes("show") &&
480
+ secretDestination(destination, parsed.anchor) &&
481
+ !classes.includes("secret")
482
+ )
483
+ classes.push("secret");
484
+ const fields = [
485
+ attrs?.id ? `#${attrs.id}` : "",
486
+ ...classes.map((value) => `.${value}`),
487
+ ...Object.entries(attrs?.values ?? {}).map(
488
+ ([key, value]) => `${key}="${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`,
489
+ ),
490
+ ].filter(Boolean);
491
+ return fields.length ? `[${reference}]{${fields.join(" ")}}` : reference;
492
+ }
@@ -27,7 +27,7 @@
27
27
 
28
28
  import { resolveShortcodeReference } from "./shortcode-references.mjs";
29
29
 
30
- import { readWikilink } from "./wikilink-syntax.mjs";
30
+ import { presentWikilink, readWikilink } from "./wikilink-syntax.mjs";
31
31
  import crypto from "crypto";
32
32
 
33
33
  import { compendiumUuid, ITEM_PACK, packForType, pageUuid, PACK_BY_TYPE } from "./ids.mjs";
@@ -407,15 +407,29 @@ export function convertWikilinks(markdown, { type, id, pack, docPack, index, cap
407
407
  // one capture group. It is what makes two identical unresolved links on
408
408
  // one note tellable apart, and a position reportable at all.
409
409
  const out = replaceOutsideCode(markdown, WIKILINK, (all, rawInner, offset) => {
410
- const parsed = readWikilink(rawInner, {
411
- package: index.contentPackage,
412
- system: "note",
413
- types: index.types,
414
- packages: index.packages,
415
- noIndexPackages: index.noIndexPackages,
416
- });
410
+ const parsed = readWikilink(
411
+ rawInner,
412
+ {
413
+ package: index.contentPackage,
414
+ system: "note",
415
+ types: index.types,
416
+ packages: index.packages,
417
+ noIndexPackages: index.noIndexPackages,
418
+ },
419
+ all,
420
+ );
417
421
  const target = parsed.rawTarget;
418
422
  const slug = parsed.anchor || null;
423
+ if (parsed.attributes?.problems.length) {
424
+ unresolved.push({
425
+ link: all,
426
+ target,
427
+ offset,
428
+ reason: "invalid-attributes",
429
+ problems: parsed.attributes.problems,
430
+ });
431
+ return unresolvedLink(authoredLabel(parsed) ?? target, target);
432
+ }
419
433
 
420
434
  // **Every link carries a label**. Without one there is nothing
421
435
  // to resolve against: a shortcode is an address rather than prose, so
@@ -512,7 +526,11 @@ export function convertWikilinks(markdown, { type, id, pack, docPack, index, cap
512
526
  if (hit) {
513
527
  const uuid = slug ? hit.anchors?.[slug] : hit.uuid;
514
528
  if (uuid) {
515
- return `@UUID[${uuid}]{${text || hit.name || target}}`;
529
+ return presentWikilink(
530
+ `@UUID[${uuid}]{${text || hit.name || target}}`,
531
+ parsed,
532
+ hit,
533
+ );
516
534
  }
517
535
  unresolved.push({
518
536
  link: all,
@@ -559,7 +577,7 @@ export function convertWikilinks(markdown, { type, id, pack, docPack, index, cap
559
577
  const entryUuid = itemDoc ? addresses.docUuid : addresses.uuid;
560
578
  // The target has no single Foundry document. Its address is real, but
561
579
  // a compendium has nothing to open, so the reader gets the label.
562
- if (entryUuid === undefined) return text;
580
+ if (entryUuid === undefined) return presentWikilink(text, parsed, doc);
563
581
  const entryId = itemDoc ? itemDocEntryId(doc.id) : doc.id;
564
582
  const isJournal = itemDoc || packForType(doc.type).docType === "JournalEntry";
565
583
  // A JournalEntry link opens a journal — at its first page, or at the
@@ -614,7 +632,7 @@ export function convertWikilinks(markdown, { type, id, pack, docPack, index, cap
614
632
  // Presentation only — the UUID above is unchanged, and a
615
633
  // `[[#slug]]` self-link is not marked because the reader is already in
616
634
  // the note it would be telling them about.
617
- return doc.draft ? draftLink(link) : link;
635
+ return presentWikilink(doc.draft ? draftLink(link) : link, parsed, doc);
618
636
  });
619
637
 
620
638
  return { markdown: out, unresolved };
@@ -19,7 +19,7 @@
19
19
  <div class="single-content">
20
20
  {{ if .Content }}
21
21
  <div class="single-body" data-pagefind-body>
22
- {{ .Content }}
22
+ {{ partial "dictionary-lists.html" .Content }}
23
23
  </div>
24
24
  {{ end }}
25
25
 
@@ -4,7 +4,7 @@
4
4
  <div class="single-content">
5
5
  {{ if .Content }}
6
6
  <div class="single-body">
7
- {{ .Content }}
7
+ {{ partial "dictionary-lists.html" .Content }}
8
8
  </div>
9
9
  {{ end }}
10
10
 
@@ -22,7 +22,7 @@
22
22
  </div>
23
23
 
24
24
  <div class="single-body" data-pagefind-body>
25
- {{ .Content }}
25
+ {{ partial "dictionary-lists.html" .Content }}
26
26
  </div>
27
27
 
28
28
  {{ partial "related.html" . }}
@@ -0,0 +1,53 @@
1
+ {{- /* Group the final Goldmark HTML. Quote-aware tokens preserve attributes,
2
+ literal examples, comments and raw blocks; a stack gives each nested list
3
+ its own rows. Existing grouped lists keep their authored divs. */ -}}
4
+ {{- $source := string . -}}
5
+ {{- if not (findRE `(?i)<dl(?:\s|>)` $source) -}}{{- return ($source | safeHTML) -}}{{- end -}}
6
+ {{- $pattern := `(?is)<!--.*?-->|<script\b(?:[^"'<>]|"[^"]*"|'[^']*')*>.*?</script\s*>|<style\b(?:[^"'<>]|"[^"]*"|'[^']*')*>.*?</style\s*>|<pre\b(?:[^"'<>]|"[^"]*"|'[^']*')*>.*?</pre\s*>|<textarea\b(?:[^"'<>]|"[^"]*"|'[^']*')*>.*?</textarea\s*>|<![^>]*>|</?[a-z][a-z0-9:-]*(?:[^"'<>]|"[^"]*"|'[^']*')*>|[^<]+|<` -}}
7
+ {{- $parts := slice -}}
8
+ {{- $lists := slice -}}
9
+ {{- $elements := slice -}}
10
+ {{- $tokens := findRE $pattern $source -}}
11
+ {{- range $i, $token := $tokens -}}
12
+ {{- if and (gt (len $lists) 0) (gt $i 0) (lt (add $i 1) (len $tokens)) (findRE `^\s+$` $token) (findRE `(?i)^</dt\s*>$` (index $tokens (sub $i 1))) (findRE `(?i)^<dd(?:\s|>)` (index $tokens (add $i 1))) -}}
13
+ {{- $token = "" -}}
14
+ {{- end -}}
15
+ {{- if and (gt (len $lists) 0) (gt $i 0) (lt (add $i 1) (len $tokens)) (findRE `^\s+$` $token) (findRE `(?i)^<dd(?:\s[^>]*)?>$` (index $tokens (sub $i 1))) (findRE `(?i)^<p(?:\s|>)` (index $tokens (add $i 1))) -}}
16
+ {{- $token = "" -}}
17
+ {{- end -}}
18
+ {{- $tag := "" -}}
19
+ {{- $opaque := findRE `(?i)^<(?:!--|script\b|style\b|pre\b|textarea\b)` $token -}}
20
+ {{- if and (not $opaque) (findRE `(?i)^</?[a-z]` $token) -}}
21
+ {{- $tag = lower (replaceRE `(?is)^</?([a-z][a-z0-9:-]*).*` "$1" $token) -}}
22
+ {{- $closing := hasPrefix $token "</" -}}
23
+ {{- if and (eq $tag "dl") (not $closing) -}}
24
+ {{- $list := newScratch -}}
25
+ {{- $list.Set "row" false -}}
26
+ {{- $lists = $lists | append $list -}}
27
+ {{- if or (findRE `(?i)\sclass\s*=\s*["'](?:[^"']*\s)?dictionary(?:\s|["'])` $token) (findRE `(?i)\sclass\s*=\s*dictionary(?:\s|>)` $token) -}}
28
+ {{- else if findRE `(?i)\sclass\s*=\s*["']` $token -}}
29
+ {{- $token = replaceRE `(?i)(\sclass\s*=\s*["'])` "${1}dictionary " $token -}}
30
+ {{- else if findRE `(?i)\sclass\s*=` $token -}}
31
+ {{- $token = replaceRE `(?i)(\sclass\s*=\s*)([^\s>]+)` `${1}"dictionary ${2}"` $token -}}
32
+ {{- else -}}
33
+ {{- $token = replaceRE `(?i)^<dl` `<dl class="dictionary"` $token -}}
34
+ {{- end -}}
35
+ {{- else if and (eq $tag "dt") (not $closing) (gt (len $lists) 0) (eq (index $elements (sub (len $elements) 1)) "dl") -}}
36
+ {{- $list := index $lists (sub (len $lists) 1) -}}
37
+ {{- if $list.Get "row" -}}{{- $parts = $parts | append "</div>" -}}{{- end -}}
38
+ {{- $parts = $parts | append "<div>" -}}
39
+ {{- $list.Set "row" true -}}
40
+ {{- else if and (eq $tag "dl") $closing (gt (len $lists) 0) -}}
41
+ {{- $list := index $lists (sub (len $lists) 1) -}}
42
+ {{- if $list.Get "row" -}}{{- $parts = $parts | append "</div>" -}}{{- end -}}
43
+ {{- $lists = first (sub (len $lists) 1) $lists -}}
44
+ {{- end -}}
45
+ {{- if $closing -}}
46
+ {{- if gt (len $elements) 0 -}}{{- $elements = first (sub (len $elements) 1) $elements -}}{{- end -}}
47
+ {{- else if not (or (findRE `/\s*>$` $token) (in (slice "area" "base" "br" "col" "embed" "hr" "img" "input" "link" "meta" "param" "source" "track" "wbr") $tag)) -}}
48
+ {{- $elements = $elements | append $tag -}}
49
+ {{- end -}}
50
+ {{- end -}}
51
+ {{- $parts = $parts | append $token -}}
52
+ {{- end -}}
53
+ {{- return (delimit $parts "" | safeHTML) -}}
@@ -27,3 +27,5 @@ document.querySelector('.nav-toggle')?.addEventListener('click', function() {
27
27
  this.classList.toggle('open');
28
28
  });
29
29
  </script>
30
+
31
+ <script src="{{ "js/inline-spoilers.js" | relURL }}" defer></script>
@@ -67,7 +67,7 @@
67
67
  {{ end }}
68
68
 
69
69
  <div class="single-body" data-pagefind-body>
70
- {{ .Content }}
70
+ {{ partial "dictionary-lists.html" .Content }}
71
71
  </div>
72
72
 
73
73
  {{ partial "related.html" . }}
@@ -2955,20 +2955,21 @@ summary.info-sidebar-title::after {
2955
2955
  }
2956
2956
  }
2957
2957
 
2958
- /* GitHub-style author alerts remain open and inherit the prose foreground. */
2958
+ /* Author alerts use the dark palette of this site's single-mode theme. */
2959
2959
  .single-content .alert {
2960
2960
  border-inline-start: 0.25rem solid var(--pb-alert-color);
2961
- padding: 0.5rem 1rem;
2961
+ border-radius: 0.375rem;
2962
+ padding: 1rem;
2962
2963
  margin: 1rem 0;
2963
- color: inherit;
2964
- background: transparent;
2964
+ color: #e6edf3;
2965
+ background: var(--pb-alert-background);
2965
2966
  }
2966
2967
  .single-content .alert-title {
2967
2968
  display: flex;
2968
2969
  align-items: center;
2969
2970
  gap: 0.5em;
2970
2971
  margin: 0 0 0.5rem;
2971
- font-weight: 600;
2972
+ font-weight: 700;
2972
2973
  color: var(--pb-alert-color);
2973
2974
  }
2974
2975
  .single-content .alert-icon {
@@ -2981,14 +2982,43 @@ summary.info-sidebar-title::after {
2981
2982
  margin-bottom: 0;
2982
2983
  }
2983
2984
  .single-content .alert-note {
2984
- --pb-alert-color: #4493f8;
2985
+ --pb-alert-color: #58a6ff;
2986
+ --pb-alert-background: #101a28;
2985
2987
  }
2986
2988
  .single-content .alert-tip {
2987
- --pb-alert-color: #3fb950;
2989
+ --pb-alert-color: #7ee787;
2990
+ --pb-alert-background: #102018;
2988
2991
  }
2989
2992
  .single-content .alert-important {
2990
- --pb-alert-color: #ab7df8;
2993
+ --pb-alert-color: #c4b0ff;
2994
+ --pb-alert-background: #1c172a;
2991
2995
  }
2992
2996
  .single-content .alert-warning {
2993
2997
  --pb-alert-color: #d29922;
2994
- }
2998
+ --pb-alert-background: #241d10;
2999
+ }
3000
+
3001
+ /* Inline spoiler controls remain inline, with links outside the button. */
3002
+ [data-inline-secret] .inline-secret-content[hidden] { display: none !important; }
3003
+ .inline-secret-toggle {
3004
+ display: inline;
3005
+ padding: 0.05em 0.35em;
3006
+ border: 1px solid currentColor;
3007
+ border-radius: 0.2em;
3008
+ color: inherit;
3009
+ background: transparent;
3010
+ font: inherit;
3011
+ font-size: 0.85em;
3012
+ cursor: pointer;
3013
+ }
3014
+ .inline-secret-toggle:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
3015
+ .inline-secret-content { margin-inline-start: 0.25em; }
3016
+
3017
+ /* Authored definition lists use dictionary rows; profile grids remain separate. */
3018
+ .single-body .dictionary { --indent:150px; --gap:.75em; margin:0; display:block; }
3019
+ .single-body .dictionary > div { padding-left:var(--indent); text-indent:calc(-1 * var(--indent)); margin-bottom:.5em; }
3020
+ .single-body .dictionary > div > dt { display:inline-block; min-width:calc(var(--indent) - var(--gap)); margin-right:var(--gap); font-weight:bold; white-space:nowrap; text-indent:0; text-align:left; }
3021
+ .single-body .dictionary > div > dd { display:inline; margin:0; font-weight:normal; }
3022
+ .single-body .dictionary > div > dd > :is(p, ul, ol, dl, blockquote, pre, table, div) { text-indent:0; }
3023
+ .single-body .dictionary > div > dd > p:first-child { display:inline; }
3024
+ @media (max-width:600px) { .single-body .dictionary { --indent:100px; } }
@@ -0,0 +1,23 @@
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
+ * SPDX-License-Identifier: GPL-3.0-or-later
6
+ */
7
+
8
+ // A native button provides pointer, Enter and Space activation without taking
9
+ // keyboard events away from links within the independently revealed content.
10
+ document.addEventListener("click", (event) => {
11
+ const button = event.target.closest?.(".inline-secret-toggle");
12
+ if (!button) return;
13
+ const wrapper = button.parentElement;
14
+ const content = button.nextElementSibling;
15
+ if (!wrapper?.hasAttribute("data-inline-secret") || !content?.classList.contains("inline-secret-content")) return;
16
+ const expanded = button.getAttribute("aria-expanded") !== "true";
17
+ if (!expanded && content.contains(document.activeElement)) button.focus();
18
+ content.hidden = !expanded;
19
+ button.setAttribute("aria-expanded", String(expanded));
20
+ const label = expanded ? "Hide spoiler" : "Show spoiler";
21
+ button.setAttribute("aria-label", label);
22
+ button.textContent = label;
23
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heroiclands/package-build",
3
- "version": "23.10.0",
3
+ "version": "23.12.0",
4
4
  "description": "Shared toolchain for building and shipping a HeroicLands Foundry VTT package — content compilation, manifest, localization, staging, bundle, release and deployment.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "type": "module",
@@ -35,6 +35,11 @@
35
35
  "types": "./types/hm3/*.d.mts",
36
36
  "import": "./hm3/*.mjs"
37
37
  },
38
+ "./preview": {
39
+ "types": "./types/engine/site-preview.d.mts",
40
+ "import": "./engine/site-preview.mjs",
41
+ "default": "./engine/site-preview.mjs"
42
+ },
38
43
  "./content-config": {
39
44
  "types": "./types/content-config.d.mts",
40
45
  "import": "./content-config.mjs"
@@ -95,7 +100,8 @@
95
100
  "./package.json": "./package.json"
96
101
  },
97
102
  "bin": {
98
- "package-build": "./bin/package-build.mjs"
103
+ "package-build": "./bin/package-build.mjs",
104
+ "package-build-preview": "./bin/package-build-preview.mjs"
99
105
  },
100
106
  "files": [
101
107
  "CHANGELOG-content-build.md",
@@ -628,6 +628,10 @@ export type ContentBuildConfigInput = {
628
628
  * depends on the working directory.
629
629
  */
630
630
  rootDir: string;
631
+ /**
632
+ * Setting-wide neutral current date; defaults to "720.1".
633
+ */
634
+ currentDate?: string | undefined;
631
635
  /**
632
636
  * Content package name — the address
633
637
  * namespace every note in this
@@ -748,6 +752,10 @@ export type ContentBuildConfigInput = {
748
752
  * The normalized, frozen configuration the toolchain reads.
749
753
  */
750
754
  export type ContentBuildConfig = {
755
+ /**
756
+ * Setting-wide neutral current date.
757
+ */
758
+ currentDate: string;
751
759
  rootDir: string;
752
760
  contentPackage: string;
753
761
  /**
@@ -14,7 +14,7 @@
14
14
  * {@link module:engine/content-figures.scanFigures}.
15
15
  * @param {import("./content-session.mjs").ContentSyntax} [syntax] - Exact-text scanner projections.
16
16
  * @returns {Array<{slug: string, name: string, level: number, line: number,
17
- * kind: string}>} In document order.
17
+ * kind: string, secret?: boolean}>} In document order.
18
18
  */
19
19
  export function collectAnchors(body: string, bodyLine?: number, resolveRole?: (address: string) => string | undefined, syntax?: import("./content-session.mjs").ContentSyntax): Array<{
20
20
  slug: string;
@@ -22,6 +22,7 @@ export function collectAnchors(body: string, bodyLine?: number, resolveRole?: (a
22
22
  level: number;
23
23
  line: number;
24
24
  kind: string;
25
+ secret?: boolean;
25
26
  }>;
26
27
  /** Collisions involving a div, verse or inline-span anchor.
27
28
  * Heading and caption collisions are diagnosed by their existing scanners.
@@ -358,7 +358,7 @@ export class BasePackCompiler {
358
358
  reportsCorpusProblems: boolean | undefined;
359
359
  linkIndex: any;
360
360
  sqlTables: any;
361
- worldPresent: unknown;
361
+ currentDate: string | undefined;
362
362
  /**
363
363
  * Report every finding {@link BasePackCompiler#convertBody} collected
364
364
  * across its passes, and decline the note if there were any.