@heroiclands/package-build 22.21.0 → 22.22.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 (110) hide show
  1. package/CHANGELOG.md +191 -0
  2. package/assets/styles/content-base.css +162 -0
  3. package/bin/content-commands.mjs +26 -3
  4. package/bin/package-build.mjs +15 -2
  5. package/ci/content-format-reference.mjs +68 -1
  6. package/config.mjs +26 -0
  7. package/content-config.mjs +73 -4
  8. package/docs/api.md +36 -18
  9. package/docs/authoring/dates-and-calendars.md +4 -2
  10. package/docs/authoring/frontmatter.md +2 -2
  11. package/docs/authoring/links-and-markup.md +237 -28
  12. package/docs/configuration.md +27 -1
  13. package/docs/content-format.md +3 -1
  14. package/docs/guides/reference-pages.md +15 -0
  15. package/docs/reference/format-details.md +224 -46
  16. package/docs/reference/note-types.md +21 -4
  17. package/engine/actor-compiler.mjs +23 -0
  18. package/engine/anchored-sections.mjs +35 -4
  19. package/engine/anchors.mjs +10 -20
  20. package/engine/base-compiler.mjs +86 -21
  21. package/engine/base-styles.mjs +149 -0
  22. package/engine/bundles.mjs +0 -1
  23. package/engine/compendiums.mjs +8 -1
  24. package/engine/content-blocks.mjs +410 -0
  25. package/engine/content-captions.mjs +57 -8
  26. package/engine/content-footnotes.mjs +189 -0
  27. package/engine/content-format-check.mjs +15 -12
  28. package/engine/content-index.mjs +2 -2
  29. package/engine/content-links.mjs +14 -6
  30. package/engine/content-tables.mjs +45 -4
  31. package/engine/content-tasklists.mjs +189 -0
  32. package/engine/docs-checks.mjs +4 -4
  33. package/engine/extension-attributes.mjs +48 -2
  34. package/engine/frontmatter-lint.mjs +47 -64
  35. package/engine/frontmatter.mjs +3 -3
  36. package/engine/heading-attributes.mjs +308 -0
  37. package/engine/helpers.mjs +14 -13
  38. package/engine/homepage.mjs +8 -8
  39. package/engine/index.mjs +3 -0
  40. package/engine/infobox-render.mjs +15 -3
  41. package/engine/infobox.mjs +83 -8
  42. package/engine/journals.mjs +36 -17
  43. package/engine/macros.mjs +1 -1
  44. package/engine/markdown-expressions.mjs +178 -53
  45. package/engine/note-dates.mjs +49 -0
  46. package/engine/note-frontmatter.mjs +7 -16
  47. package/engine/note-vocabulary.mjs +172 -37
  48. package/engine/page-lists.mjs +358 -0
  49. package/engine/pdf-build.mjs +76 -42
  50. package/engine/pdf-render.mjs +422 -49
  51. package/engine/rank-ladder.mjs +198 -0
  52. package/engine/reckoning-markers.mjs +1 -1
  53. package/engine/retired-fields.mjs +8 -13
  54. package/engine/scenes.mjs +10 -3
  55. package/engine/site-build.mjs +41 -12
  56. package/engine/site-config.mjs +10 -1
  57. package/engine/site-index.mjs +18 -2
  58. package/engine/site-preview.mjs +18 -7
  59. package/engine/social-ties.mjs +52 -14
  60. package/engine/sql-tables.mjs +66 -9
  61. package/engine/standings.mjs +89 -6
  62. package/engine/system-block.mjs +12 -14
  63. package/engine/web-wikilinks.mjs +48 -1
  64. package/hm3/actors.mjs +6 -3
  65. package/manifest.mjs +13 -0
  66. package/package.json +1 -1
  67. package/sohl/actors.mjs +22 -99
  68. package/sohl/item-fields.mjs +4 -5
  69. package/sohl/items.mjs +1 -1
  70. package/sohl/note-schemas.mjs +11 -21
  71. package/types/config.d.mts +9 -0
  72. package/types/engine/actor-compiler.d.mts +11 -0
  73. package/types/engine/anchored-sections.d.mts +24 -0
  74. package/types/engine/base-compiler.d.mts +19 -0
  75. package/types/engine/base-styles.d.mts +93 -0
  76. package/types/engine/content-blocks.d.mts +87 -0
  77. package/types/engine/content-captions.d.mts +14 -1
  78. package/types/engine/content-footnotes.d.mts +73 -0
  79. package/types/engine/content-format-check.d.mts +8 -6
  80. package/types/engine/content-tables.d.mts +12 -4
  81. package/types/engine/content-tasklists.d.mts +72 -0
  82. package/types/engine/extension-attributes.d.mts +22 -1
  83. package/types/engine/frontmatter.d.mts +1 -1
  84. package/types/engine/heading-attributes.d.mts +174 -0
  85. package/types/engine/helpers.d.mts +7 -4
  86. package/types/engine/homepage.d.mts +7 -7
  87. package/types/engine/index.d.mts +1 -0
  88. package/types/engine/infobox-render.d.mts +6 -1
  89. package/types/engine/journals.d.mts +14 -7
  90. package/types/engine/markdown-expressions.d.mts +23 -0
  91. package/types/engine/note-frontmatter.d.mts +1 -4
  92. package/types/engine/note-vocabulary.d.mts +82 -6
  93. package/types/engine/page-lists.d.mts +126 -0
  94. package/types/engine/pdf-render.d.mts +72 -2
  95. package/types/engine/rank-ladder.d.mts +32 -0
  96. package/types/engine/retired-fields.d.mts +4 -6
  97. package/types/engine/site-build.d.mts +13 -1
  98. package/types/engine/site-config.d.mts +9 -0
  99. package/types/engine/site-index.d.mts +13 -1
  100. package/types/engine/site-preview.d.mts +1 -1
  101. package/types/engine/sql-tables.d.mts +31 -7
  102. package/types/engine/standings.d.mts +21 -3
  103. package/types/engine/system-block.d.mts +11 -12
  104. package/types/engine/web-wikilinks.d.mts +3 -1
  105. package/types/hm3/actors.d.mts +3 -3
  106. package/types/sohl/actors.d.mts +6 -7
  107. package/engine/content-admonitions.mjs +0 -114
  108. package/engine/content-secrets.mjs +0 -112
  109. package/types/engine/content-admonitions.d.mts +0 -24
  110. package/types/engine/content-secrets.d.mts +0 -17
@@ -22,23 +22,16 @@
22
22
  * anchors does this note declare?" had two answers and they disagreed on
23
23
  * `{#CalendarFormat}`.
24
24
  *
25
+ * The suffix itself is read by {@link module:engine/heading-attributes}, which
26
+ * {@link module:engine/journals.splitPages} also reads it through — so the index
27
+ * cannot name an anchor the pages do not carry.
28
+ *
25
29
  * @module
26
30
  */
27
31
 
28
32
  import { scanCaptions } from "./content-captions.mjs";
33
+ import { HEADING_LINE, splitHeadingAttributes } from "./heading-attributes.mjs";
29
34
 
30
- /**
31
- * A heading, and the `{#slug}` anchor it declares.
32
- *
33
- * Kept identical to the pair {@link splitPages} matches, because the two must
34
- * agree about what an anchor is: that pass decides which sections become
35
- * addressable journal pages, and an index naming an anchor it does not produce
36
- * would advertise a link that resolves nowhere. `tests/content-index.test.ts`
37
- * asserts the two find the same anchors, so drift fails the suite rather than
38
- * shipping.
39
- */
40
- const HEADING = /^\s*(#{1,6})\s+(.+?)\s*#*\s*$/;
41
- const ANCHOR = /^(.*?)\s*\{#([^}]+)\}\s*$/;
42
35
  /**
43
36
  * The `{#slug}` and `:::caption {#slug}` anchors a note declares.
44
37
  *
@@ -66,16 +59,13 @@ export function collectAnchors(body, bodyLine = 1) {
66
59
  }
67
60
  if (inCodeBlock) continue;
68
61
 
69
- const heading = HEADING.exec(lines[i]);
62
+ const heading = HEADING_LINE.exec(lines[i]);
70
63
  if (!heading) continue;
71
- const anchor = ANCHOR.exec(heading[2].trim());
72
- if (!anchor) continue;
73
-
74
- const slug = anchor[2].trim();
75
- if (!slug) continue;
64
+ const attributes = splitHeadingAttributes(heading[2]);
65
+ if (!attributes.id) continue;
76
66
  anchors.push({
77
- slug,
78
- name: anchor[1].trim(),
67
+ slug: attributes.id,
68
+ name: attributes.text,
79
69
  level: heading[1].length,
80
70
  line: bodyLine + i,
81
71
  });
@@ -62,9 +62,10 @@
62
62
  */
63
63
 
64
64
  import { authoredFrontmatter } from "./index-records.mjs";
65
- import { renderSecretBlocks } from "./content-secrets.mjs";
66
- import { scanAdmonitions } from "./content-admonitions.mjs";
65
+ import { scanBlocks } from "./content-blocks.mjs";
66
+ import { scanHeadingAttributes, withheldSections } from "./heading-attributes.mjs";
67
67
  import { scanCaptions } from "./content-captions.mjs";
68
+ import { footnoteFindings } from "./content-footnotes.mjs";
68
69
  import { renderMarkdownExpressions } from "./markdown-expressions.mjs";
69
70
  import { isGmNote } from "./note-vocabulary.mjs";
70
71
  import { cloneAddressState } from "./address-values.mjs";
@@ -614,6 +615,34 @@ export class BasePackCompiler {
614
615
  this.unresolvedLinks = 0;
615
616
  }
616
617
 
618
+ /**
619
+ * Report every finding {@link BasePackCompiler#convertBody} collected
620
+ * across its passes, and decline the note if there were any.
621
+ *
622
+ * Reported directly, one `noteError` per finding, rather than thrown as a
623
+ * single wrapped message: the site build and the book report the same way,
624
+ * each finding at its own line, and a note with three problems meets all
625
+ * three in the run that found them instead of one per rebuild. The thrown
626
+ * sentinel carries `alreadyReported` so the compile loop's generic catch
627
+ * does not add a fourth, vaguer line on top of the three already said.
628
+ *
629
+ * @param {readonly {message: string, line?: number, column?: number}[]} findings
630
+ * @throws {Error} When `findings` is non-empty, after reporting all of them.
631
+ */
632
+ reportConvertBodyFindings(findings) {
633
+ for (const finding of findings) {
634
+ this.errorCount++;
635
+ this.noteError(finding.message, { line: finding.line, column: finding.column });
636
+ }
637
+ if (findings.length) {
638
+ const error = new Error(
639
+ `${findings.length} finding(s) in the note body, each reported above`,
640
+ );
641
+ error.alreadyReported = true;
642
+ throw error;
643
+ }
644
+ }
645
+
617
646
  /**
618
647
  * The body {@link BasePackCompiler#buildEntry} receives.
619
648
  *
@@ -626,37 +655,70 @@ export class BasePackCompiler {
626
655
  * that does not convert.
627
656
  */
628
657
  convertBody(fm, body) {
629
- const secretError =
630
- renderSecretBlocks(body, "book").errors[0] ?? scanAdmonitions(body).errors[0];
631
- if (secretError) {
632
- const error = new Error(secretError.message);
633
- error.position = {
658
+ // Every pass's own findings are collected before any of them is
659
+ // reported, so a note with a malformed block *and* a malformed
660
+ // caption *and* a bad expression surfaces all three in one run
661
+ // instead of one per rebuild. Each pass already returns every finding
662
+ // it made — `errors` or `findings`, never just the first — so nothing
663
+ // here re-scans; it only stops taking `[0]`.
664
+ const findings = [];
665
+ for (const secretError of scanBlocks(body).errors) {
666
+ findings.push({
667
+ message: secretError.message,
634
668
  line: (this.currentNote?.bodyLine ?? 1) + secretError.line - 1,
635
669
  column:
636
670
  secretError.line === 1 ?
637
671
  (this.currentNote?.bodyColumn ?? 1)
638
672
  : secretError.column,
639
- };
640
- throw error;
673
+ });
674
+ }
675
+ // A heading's attribute block, and a `.secret` written where no surface
676
+ // can honour it. Read off the body as authored, before the tables
677
+ // expand, because a heading is a heading whatever a pass does with the
678
+ // prose under it — so a pass that converts nothing reports these too.
679
+ for (const headingError of [
680
+ ...scanHeadingAttributes(body, this.currentNote?.bodyLine ?? 1).errors,
681
+ ...withheldSections(body, this.currentNote?.bodyLine ?? 1).errors,
682
+ ]) {
683
+ findings.push({
684
+ message: headingError.message,
685
+ line: headingError.line,
686
+ column:
687
+ headingError.line === (this.currentNote?.bodyLine ?? 1) ?
688
+ (this.currentNote?.bodyColumn ?? 1) + headingError.column - 1
689
+ : headingError.column,
690
+ });
691
+ }
692
+ for (const footnoteError of footnoteFindings(body)) {
693
+ findings.push({
694
+ message: footnoteError.message,
695
+ line: (this.currentNote?.bodyLine ?? 1) + footnoteError.line - 1,
696
+ column:
697
+ footnoteError.line === 1 ?
698
+ (this.currentNote?.bodyColumn ?? 1)
699
+ : footnoteError.column,
700
+ });
701
+ }
702
+ if (!this.constructor.convertsWikilinks) {
703
+ this.reportConvertBodyFindings(findings);
704
+ return body;
641
705
  }
642
- if (!this.constructor.convertsWikilinks) return body;
643
706
  const name = resolveName(fm);
644
707
  const { absPath, bodyLine, bodyColumn } = this.currentNote ?? {};
645
708
  const { markdown: tabulated, lineMap } = expandNoteTables(body, {
646
709
  name,
647
710
  bodyLine,
648
711
  sqlTables: absPath ? this.sqlTables?.get(absPath) : undefined,
712
+ pageLists: absPath ? this.sqlTables?.pageLists?.get(absPath) : undefined,
649
713
  });
650
- const captionError = scanCaptions(tabulated).errors[0];
651
- if (captionError) {
652
- const error = new Error(captionError.message);
653
- error.position = {
714
+ for (const captionError of scanCaptions(tabulated).errors) {
715
+ findings.push({
716
+ message: captionError.message,
654
717
  line:
655
718
  (bodyLine ?? 1) +
656
719
  (lineMap[captionError.line - 1]?.line ?? captionError.line - 1),
657
720
  column: captionError.column,
658
- };
659
- throw error;
721
+ });
660
722
  }
661
723
  const expressions = renderMarkdownExpressions(tabulated, {
662
724
  fm,
@@ -665,12 +727,10 @@ export class BasePackCompiler {
665
727
  file: absPath,
666
728
  bodyLine,
667
729
  });
668
- if (expressions.findings.length) {
669
- const finding = expressions.findings[0];
670
- const error = new Error(finding.message);
671
- error.position = { line: finding.line, column: finding.column };
672
- throw error;
730
+ for (const finding of expressions.findings) {
731
+ findings.push({ message: finding.message, line: finding.line, column: finding.column });
673
732
  }
733
+ this.reportConvertBodyFindings(findings);
674
734
  const { markdown, unresolved } = convertNoteWikilinks(expressions.markdown, {
675
735
  type: fm.type,
676
736
  id: fm.id,
@@ -1237,6 +1297,11 @@ export class BasePackCompiler {
1237
1297
  stats.compiled++;
1238
1298
  this.onCompiled(fm, doc);
1239
1299
  } catch (err) {
1300
+ // `convertBody` already reported every finding it collected,
1301
+ // one `noteError` each, before throwing this sentinel — so the
1302
+ // generic wrap below would only restate "failed to compile" on
1303
+ // top of findings an author has already been told about.
1304
+ if (err.alreadyReported) continue;
1240
1305
  this.errorCount++;
1241
1306
  const repeatedAnchor = err.message.match(
1242
1307
  /declares the anchor \{#([^}]+)\} on more than one heading/,
@@ -0,0 +1,149 @@
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
+ /**
15
+ * The **base stylesheet** every package ships, and where it lands.
16
+ *
17
+ * Compiled content relies on a handful of rules to be legible — a definition
18
+ * list is the whole of it today — and five packages need them. Four of those
19
+ * have no stylesheet at all, so writing the rules by hand means one edit and
20
+ * four new files, each with a configuration entry, each free to diverge from
21
+ * the first time one of them is touched. This repository already ships
22
+ * configuration to consumers and already generates the manifest that declares
23
+ * a package's `styles`, so it can ship the sheet the same way: one file,
24
+ * staged into every package, corrected in one place.
25
+ *
26
+ * A package keeps its own stylesheet for its own needs. The base sheet carries
27
+ * only what all of them share.
28
+ *
29
+ * Everything here is pure but for {@link baseStylesheetSource}, which resolves
30
+ * a path against this module's own location.
31
+ *
32
+ * @module
33
+ */
34
+
35
+ import path from "node:path";
36
+ import { fileURLToPath } from "node:url";
37
+
38
+ /**
39
+ * Where the shipped sheet lands inside the staged package.
40
+ *
41
+ * Under `styles/` beside a package's own sheet, and named for the toolchain so
42
+ * nothing a repository writes can collide with it. The path is relative to the
43
+ * staged package root, which is what both the stage entry and the manifest's
44
+ * `styles` are addressed in.
45
+ *
46
+ * @type {string}
47
+ */
48
+ export const BASE_STYLESHEET_DEST = "styles/package-build-base.css";
49
+
50
+ /**
51
+ * The cascade layer the sheet loads into, per package kind.
52
+ *
53
+ * Foundry declares its layer order in `css/foundry2.css`, ending
54
+ * `… layouts, system, modules, exceptions` — the eighth and ninth being, by its
55
+ * own comments, default game system styles and default module styles. That is
56
+ * what this sheet is, so it takes the layer its kind is given and is therefore
57
+ * the weakest thing a package can load:
58
+ *
59
+ * - **An unlayered package sheet beats it**, because an unlayered rule beats
60
+ * every layered one whatever its specificity.
61
+ * - **A package sheet naming any other layer beats it too**, because a layer
62
+ * Foundry's order does not mention is appended after the ones it does.
63
+ *
64
+ * So a package overrides the base sheet from its own, whichever way it declares
65
+ * one, and the override needs no `!important` and no specificity contest.
66
+ *
67
+ * @param {"system"|"module"} artifact - Which artifact is shipped.
68
+ * @returns {string} The layer name.
69
+ */
70
+ export function baseStyleLayer(artifact) {
71
+ return artifact === "system" ? "system" : "modules";
72
+ }
73
+
74
+ /**
75
+ * The manifest `styles` entry for the base sheet.
76
+ *
77
+ * Foundry's `styles` is an array of `{layer?, src}`, and a bare string is
78
+ * migrated into that shape on read, so the object form is written directly.
79
+ *
80
+ * @param {"system"|"module"} artifact - Which artifact is shipped.
81
+ * @returns {{src: string, layer: string}} The entry.
82
+ */
83
+ export function baseStyleEntry(artifact) {
84
+ return { src: BASE_STYLESHEET_DEST, layer: baseStyleLayer(artifact) };
85
+ }
86
+
87
+ /**
88
+ * The absolute path of the sheet as installed, so the stage can copy it.
89
+ *
90
+ * Resolved against this module rather than against the consumer's working
91
+ * directory, because the file belongs to this package and arrives through
92
+ * `node_modules`.
93
+ *
94
+ * @returns {string} Absolute path of the shipped stylesheet.
95
+ */
96
+ export function baseStylesheetSource() {
97
+ return path.resolve(
98
+ fileURLToPath(new URL("../assets/styles", import.meta.url)),
99
+ "content-base.css",
100
+ );
101
+ }
102
+
103
+ /**
104
+ * The `[source, destination]` pair that stages the sheet.
105
+ *
106
+ * The destination is relative, in the same spelling the repository's own asset
107
+ * table uses, so the caller resolves both against the repository root exactly
108
+ * as it resolves every other entry.
109
+ *
110
+ * @param {string} stageDir - The configured stage directory, relative to the
111
+ * repository root.
112
+ * @returns {[string, string]} The pair, for `stageAssets`.
113
+ */
114
+ export function baseStyleStageEntry(stageDir) {
115
+ return [baseStylesheetSource(), path.join(stageDir, BASE_STYLESHEET_DEST)];
116
+ }
117
+
118
+ /**
119
+ * The manifest's `styles`, with the base sheet named first.
120
+ *
121
+ * **First rather than last**, because `styles` is load order: a package's own
122
+ * sheet has to be read after the base one for an override written at equal
123
+ * specificity in an equal layer to take. The cascade layer makes the override
124
+ * work anyway, and the order makes it work for the right reason.
125
+ *
126
+ * A declared entry addressing the same file is not duplicated — a repository
127
+ * that named the staged path itself gets one entry, keeping whatever layer it
128
+ * chose, so declaring it is a way of placing it rather than an error.
129
+ *
130
+ * @param {object} options
131
+ * @param {unknown} [options.declared] - The declared `styles`, whatever shape
132
+ * it was written in; anything that is not a list is returned untouched.
133
+ * @param {"system"|"module"} options.artifact - Which artifact is shipped.
134
+ * @param {boolean} [options.enabled] - Whether the package takes the base
135
+ * sheet. `false` returns the declared list unchanged.
136
+ * @returns {unknown} The `styles` to emit, or `undefined` when there is
137
+ * nothing to emit at all.
138
+ */
139
+ export function withBaseStyle({ declared, artifact, enabled = true }) {
140
+ if (!enabled) return declared;
141
+ const list =
142
+ Array.isArray(declared) ? declared
143
+ : declared === undefined ? []
144
+ : declared;
145
+ if (!Array.isArray(list)) return list;
146
+ const names = (entry) => (typeof entry === "string" ? entry : entry?.src);
147
+ if (list.some((entry) => names(entry) === BASE_STYLESHEET_DEST)) return list;
148
+ return [baseStyleEntry(artifact), ...list];
149
+ }
@@ -325,7 +325,6 @@ export class Bundles extends BasePackCompiler {
325
325
  // it compiles into already has somewhere to put the prose.
326
326
  description: markdown.trim() ? renderFoundryMarkdown(markdown) : "",
327
327
  folder: this.folderResolver(authoredFolder, { isAddress: folderIsAddress }),
328
- flags: fm.flags,
329
328
  stats: this.stats,
330
329
  contents,
331
330
  });
@@ -191,7 +191,14 @@ function cleanPackEntry(
191
191
 
192
192
  if (data.effects) data.effects.forEach((i) => cleanPackEntry(i, { clearSourceId: false }));
193
193
  if (data.items) data.items.forEach((i) => cleanPackEntry(i, { clearSourceId: false }));
194
- if (data.pages) data.pages.forEach((i) => cleanPackEntry(i, { ownership: -1 }));
194
+ // A page's own stated default survives, because a page compiled as GM-only
195
+ // says so here and nowhere else; the per-user grants beside it do not, which
196
+ // is what the reset is for. `-1` is `INHERIT`, the value a page that says
197
+ // nothing takes.
198
+ if (data.pages)
199
+ data.pages.forEach((page) =>
200
+ cleanPackEntry(page, { ownership: page.ownership?.default ?? -1 }),
201
+ );
195
202
  if (data.system?.description) data.system.description = cleanString(data.system.description);
196
203
  if (data.system?.biography) data.system.biography = cleanString(data.system.biography);
197
204
  if (data.system?.textReference)