@heroiclands/package-build 8.1.0 → 10.0.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 (122) hide show
  1. package/CHANGELOG.md +748 -0
  2. package/CONTENT.md +273 -13
  3. package/bin/content-build.mjs +479 -123
  4. package/bin/package-build.mjs +27 -69
  5. package/bin/report.mjs +1 -2
  6. package/bundle.mjs +2 -10
  7. package/config.mjs +31 -106
  8. package/container.mjs +13 -57
  9. package/content-config.mjs +300 -188
  10. package/coverage.mjs +14 -55
  11. package/deploy.mjs +4 -13
  12. package/docs/content-format.md +1418 -0
  13. package/e2e.mjs +16 -55
  14. package/engine/address-charset.mjs +62 -0
  15. package/engine/address-diff.mjs +1 -4
  16. package/engine/alias-index.mjs +153 -0
  17. package/engine/base-compiler.mjs +203 -31
  18. package/engine/code-fences.mjs +4 -13
  19. package/engine/compendiums.mjs +13 -37
  20. package/engine/content-address.mjs +6 -10
  21. package/engine/content-format-check.mjs +570 -0
  22. package/engine/content-format.mjs +253 -0
  23. package/engine/content-links.mjs +144 -99
  24. package/engine/content-lint.mjs +12 -16
  25. package/engine/content-slug.mjs +2 -6
  26. package/engine/content-tables.mjs +26 -79
  27. package/engine/diagnostics.mjs +37 -15
  28. package/engine/document-subtypes.mjs +440 -0
  29. package/engine/field-reference.mjs +6 -20
  30. package/engine/field-spec.mjs +49 -45
  31. package/engine/foreign-catalog.mjs +7 -22
  32. package/engine/foreign-manifests.mjs +1 -4
  33. package/engine/frontmatter-lint.mjs +347 -43
  34. package/engine/frontmatter.mjs +3 -8
  35. package/engine/generate.mjs +36 -20
  36. package/engine/helpers.mjs +54 -81
  37. package/engine/homepage.mjs +4 -15
  38. package/engine/ids.mjs +21 -12
  39. package/engine/index.mjs +15 -0
  40. package/engine/item-registry.mjs +72 -9
  41. package/engine/journals.mjs +4 -14
  42. package/engine/kb-manifest.mjs +41 -24
  43. package/engine/macros.mjs +2 -10
  44. package/engine/manifest-emit.mjs +6 -17
  45. package/engine/map-notes.mjs +53 -87
  46. package/engine/note-claims.mjs +383 -0
  47. package/engine/note-package.mjs +1 -4
  48. package/engine/note-vocabulary.mjs +678 -0
  49. package/engine/pack-config.mjs +56 -60
  50. package/engine/pack-router.mjs +18 -8
  51. package/engine/prose-config.mjs +20 -4
  52. package/engine/prose-lint.mjs +61 -17
  53. package/engine/region-events.mjs +1 -3
  54. package/engine/retired-fields.mjs +117 -3
  55. package/engine/scene-levels.mjs +8 -22
  56. package/engine/scenes.mjs +31 -47
  57. package/engine/schema-check.mjs +348 -7
  58. package/engine/schema-extract.mjs +11 -39
  59. package/engine/site-build.mjs +13 -38
  60. package/engine/site-index.mjs +40 -35
  61. package/engine/system-block.mjs +513 -0
  62. package/engine/web-wikilinks.mjs +115 -92
  63. package/engine/wikilink-syntax.mjs +30 -0
  64. package/engine/wikilinks.mjs +147 -183
  65. package/index.mjs +1 -5
  66. package/lang.mjs +1 -3
  67. package/manifest.mjs +10 -37
  68. package/markdownlint-config.mjs +1 -5
  69. package/package.json +6 -2
  70. package/sohl/actors.mjs +251 -68
  71. package/sohl/being-info.mjs +3 -6
  72. package/sohl/document-subtypes.mjs +82 -0
  73. package/sohl/index.mjs +4 -6
  74. package/sohl/item-builders.mjs +1 -3
  75. package/sohl/item-fields.mjs +16 -34
  76. package/sohl/items.mjs +111 -17
  77. package/sohl/kb-passes.mjs +29 -39
  78. package/sohl/note-schemas.mjs +11 -7
  79. package/sohl/skill-base.mjs +7 -23
  80. package/stage.mjs +3 -13
  81. package/templates.mjs +4 -15
  82. package/types/bundle.d.mts +1 -1
  83. package/types/container.d.mts +2 -2
  84. package/types/content-config.d.mts +48 -4
  85. package/types/coverage.d.mts +1 -1
  86. package/types/e2e.d.mts +4 -4
  87. package/types/engine/address-charset.d.mts +45 -0
  88. package/types/engine/alias-index.d.mts +122 -0
  89. package/types/engine/base-compiler.d.mts +132 -4
  90. package/types/engine/content-address.d.mts +2 -2
  91. package/types/engine/content-format-check.d.mts +163 -0
  92. package/types/engine/content-format.d.mts +101 -0
  93. package/types/engine/content-links.d.mts +16 -1
  94. package/types/engine/content-lint.d.mts +6 -0
  95. package/types/engine/diagnostics.d.mts +29 -0
  96. package/types/engine/document-subtypes.d.mts +233 -0
  97. package/types/engine/field-spec.d.mts +76 -23
  98. package/types/engine/frontmatter-lint.d.mts +47 -2
  99. package/types/engine/generate.d.mts +15 -2
  100. package/types/engine/helpers.d.mts +22 -14
  101. package/types/engine/ids.d.mts +10 -0
  102. package/types/engine/index.d.mts +5 -0
  103. package/types/engine/item-registry.d.mts +21 -2
  104. package/types/engine/kb-manifest.d.mts +35 -8
  105. package/types/engine/map-notes.d.mts +21 -11
  106. package/types/engine/note-claims.d.mts +113 -0
  107. package/types/engine/note-vocabulary.d.mts +251 -0
  108. package/types/engine/pack-config.d.mts +4 -3
  109. package/types/engine/pack-router.d.mts +4 -4
  110. package/types/engine/prose-lint.d.mts +6 -2
  111. package/types/engine/retired-fields.d.mts +73 -2
  112. package/types/engine/schema-check.d.mts +182 -0
  113. package/types/engine/schema-extract.d.mts +1 -1
  114. package/types/engine/site-index.d.mts +1 -1
  115. package/types/engine/system-block.d.mts +281 -0
  116. package/types/engine/web-wikilinks.d.mts +23 -12
  117. package/types/engine/wikilink-syntax.d.mts +29 -0
  118. package/types/manifest.d.mts +1 -1
  119. package/types/sohl/actors.d.mts +62 -6
  120. package/types/sohl/document-subtypes.d.mts +14 -0
  121. package/types/sohl/index.d.mts +1 -0
  122. package/types/sohl/items.d.mts +21 -0
package/e2e.mjs CHANGED
@@ -93,9 +93,7 @@ const EXACT_BUILD = /^\d+\.\d+$/;
93
93
  * @returns {string} The hex hash.
94
94
  */
95
95
  export function hashPassword(password, salt) {
96
- return crypto
97
- .pbkdf2Sync(password, salt, 1000, 64, "sha512")
98
- .toString("hex");
96
+ return crypto.pbkdf2Sync(password, salt, 1000, 64, "sha512").toString("hex");
99
97
  }
100
98
 
101
99
  /**
@@ -127,23 +125,13 @@ export function resolveE2EWorld(config, env = process.env) {
127
125
  const world = config.e2eWorld ?? {};
128
126
  const gm = config.e2eGm ?? {};
129
127
  return {
130
- worldId:
131
- env[`${prefix}WORLD_ID`]?.trim() ||
132
- world.id ||
133
- `${config.packageId}-e2e`,
134
- worldTitle:
135
- env[`${prefix}WORLD_TITLE`]?.trim() ||
136
- world.title ||
137
- `${config.packageId} E2E`,
128
+ worldId: env[`${prefix}WORLD_ID`]?.trim() || world.id || `${config.packageId}-e2e`,
129
+ worldTitle: env[`${prefix}WORLD_TITLE`]?.trim() || world.title || `${config.packageId} E2E`,
138
130
  worldDescription:
139
- world.description ||
140
- `Disposable world for ${config.packageId} end-to-end tests.`,
131
+ world.description || `Disposable world for ${config.packageId} end-to-end tests.`,
141
132
  gmId: E2E_GM_ID,
142
133
  gmName: env[`${prefix}GM_NAME`]?.trim() || gm.name || "Gamemaster",
143
- gmPassword:
144
- env[`${prefix}GM_PASSWORD`]?.trim() ||
145
- gm.password ||
146
- `${config.packageId}-e2e`,
134
+ gmPassword: env[`${prefix}GM_PASSWORD`]?.trim() || gm.password || `${config.packageId}-e2e`,
147
135
  };
148
136
  }
149
137
 
@@ -330,8 +318,7 @@ export function parseFastArgs(argv, build) {
330
318
 
331
319
  for (let i = 0; i < own.length; i += 1) {
332
320
  const arg = /** @type {string} */ (own[i]);
333
- if (arg.startsWith("--build="))
334
- requested = arg.slice("--build=".length);
321
+ if (arg.startsWith("--build=")) requested = arg.slice("--build=".length);
335
322
  else if (arg === "--recreate") recreate = true;
336
323
  else if (arg === "--no-run") runSuite = false;
337
324
  else if (arg === "--spec") {
@@ -387,12 +374,7 @@ export function parseFastArgs(argv, build) {
387
374
  * @param {(message: string) => void} [opts.log] - Progress reporting.
388
375
  * @returns {Promise<{worldDir: string, world: E2EWorld}>} Where it landed.
389
376
  */
390
- export async function seedTestWorld({
391
- config,
392
- packageJson,
393
- env = process.env,
394
- log = () => {},
395
- }) {
377
+ export async function seedTestWorld({ config, packageJson, env = process.env, log = () => {} }) {
396
378
  const stage = config.e2eStage;
397
379
  const dataRoot = requireIsolatedDataRoot(stage, env);
398
380
  const world = resolveE2EWorld(config, env);
@@ -530,13 +512,7 @@ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
530
512
  * @returns {Promise<void>} Resolves once the world is active.
531
513
  * @throws {Error} On a licence failure or a timeout.
532
514
  */
533
- export async function waitForWorld({
534
- url,
535
- container,
536
- stage,
537
- timeoutMs = 180_000,
538
- log = () => {},
539
- }) {
515
+ export async function waitForWorld({ url, container, stage, timeoutMs = 180_000, log = () => {} }) {
540
516
  const join = `${url}/join`;
541
517
  const deadline = Date.now() + timeoutMs;
542
518
  log(`Waiting for the world at ${join} …`);
@@ -597,27 +573,17 @@ function captureContainerLog(container) {
597
573
  * @param {(message: string) => void} [opts.log] - Progress reporting.
598
574
  * @returns {number} The suite's exit status.
599
575
  */
600
- export function runSuite({
601
- command,
602
- args = [],
603
- cwd,
604
- env = process.env,
605
- log = () => {},
606
- }) {
576
+ export function runSuite({ command, args = [], cwd, env = process.env, log = () => {} }) {
607
577
  const [program, ...rest] = command;
608
578
  const childEnv = { ...env };
609
579
  delete childEnv.ELECTRON_RUN_AS_NODE;
610
580
  log(`▸ ${[...command, ...args].join(" ")}`);
611
- const result = spawnSync(
612
- /** @type {string} */ (program),
613
- [...rest, ...args],
614
- {
615
- stdio: "inherit",
616
- cwd,
617
- env: childEnv,
618
- shell: process.platform === "win32",
619
- },
620
- );
581
+ const result = spawnSync(/** @type {string} */ (program), [...rest, ...args], {
582
+ stdio: "inherit",
583
+ cwd,
584
+ env: childEnv,
585
+ shell: process.platform === "win32",
586
+ });
621
587
  if (result.error) throw result.error;
622
588
  return result.status ?? 1;
623
589
  }
@@ -756,12 +722,7 @@ export async function e2eRun({
756
722
  * @param {(message: string) => void} [opts.log] - Progress reporting.
757
723
  * @returns {Promise<number>} The suite's exit status.
758
724
  */
759
- export async function e2eFast({
760
- config,
761
- argv = [],
762
- env = process.env,
763
- log = () => {},
764
- }) {
725
+ export async function e2eFast({ config, argv = [], env = process.env, log = () => {} }) {
765
726
  const {
766
727
  targets,
767
728
  recreate,
@@ -0,0 +1,62 @@
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 one charset every segment of a canonical address is held to (#59).
16
+ *
17
+ * An address is a hyphen-joined tuple — `sohl-skill-clmb` — and it is read back
18
+ * by **counting segments**, with a fixed meaning per position. That is sound
19
+ * for exactly one reason: the hyphen is *purely* a separator, because no
20
+ * segment may contain one. Take that away and reading an address needs a
21
+ * vocabulary to match against, a longest-match rule, and an answer for every
22
+ * name that is a prefix of another — none of which exist.
23
+ *
24
+ * So the charset is not a tidiness rule. It is the premise the address grammar
25
+ * rests on, and the issue's word for how it should be held is **enforced rather
26
+ * than assumed**: a value that breaks it is refused where it is written, not
27
+ * discovered later as addresses that fail to parse and report nothing about
28
+ * why. `harn-adventures` was that case — its keys read as four segments and
29
+ * failed as a `null` return.
30
+ *
31
+ * This module is a **leaf with no local imports**, so the validator a
32
+ * consumer's `package-build.config.mjs` reaches (`content-config.mjs`) can name
33
+ * it without closing a cycle around that file.
34
+ *
35
+ * @module
36
+ */
37
+
38
+ /**
39
+ * The shape every address segment must match: ASCII letters and digits only.
40
+ *
41
+ * Case is deliberately **not** constrained. Hundreds of authored shortcodes are
42
+ * mixed-case and collide with nothing, and case has no bearing on the
43
+ * separator, which is the whole of what this pattern is protecting.
44
+ *
45
+ * @type {RegExp}
46
+ */
47
+ export const ADDRESS_SEGMENT_PATTERN = /^[A-Za-z0-9]+$/;
48
+
49
+ /**
50
+ * Whether a value is a well-formed address segment.
51
+ *
52
+ * A blank value is **not** valid: this answers "is this an acceptable segment",
53
+ * never "is a segment present". Presence is a separate question, asked
54
+ * wherever the value is required, and conflating the two would report a missing
55
+ * key as a charset violation.
56
+ *
57
+ * @param {unknown} value - The candidate segment.
58
+ * @returns {boolean} `true` when it matches {@link ADDRESS_SEGMENT_PATTERN}.
59
+ */
60
+ export function isAddressSegment(value) {
61
+ return typeof value === "string" && ADDRESS_SEGMENT_PATTERN.test(value);
62
+ }
@@ -198,10 +198,7 @@ export function diffItemAddresses(baseline, current, { baseline: label }) {
198
198
  export function noteFilesById(contentBase, { skipDirectories } = {}) {
199
199
  const byId = new Map();
200
200
  const walkOpts = skipDirectories ? { skipDirectories } : undefined;
201
- for (const { frontmatter: fm, absPath } of walkMarkdownTree(
202
- contentBase,
203
- walkOpts,
204
- )) {
201
+ for (const { frontmatter: fm, absPath } of walkMarkdownTree(contentBase, walkOpts)) {
205
202
  if (fm?.id && !byId.has(fm.id)) byId.set(fm.id, absPath);
206
203
  }
207
204
  return byId;
@@ -0,0 +1,153 @@
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 **alias** namespace: what a note can be called, and who may claim a name.
16
+ *
17
+ * A wikilink resolves through one of two namespaces, and the pipe chooses
18
+ * which (#131): `[[x|…]]` is an *address*, parsed by the address grammar;
19
+ * `[[x]]` is an *alias*, looked up here. This module owns the second half —
20
+ * what goes into the index, how a key is spelled, and what happens when two
21
+ * notes claim one name.
22
+ *
23
+ * **An alias is scoped to the claiming note's own type.** The key is
24
+ * `(type, alias)`, so `Shock` may be a `skill` in one place and a `trauma` in
25
+ * another without the two ever meeting. A link resolves against the *source*
26
+ * note's type, which is why a bare name reaches a sibling and never a
27
+ * cross-type target — that one is written as an address.
28
+ *
29
+ * **Three sources, all authored.** `aliases`, `name.aliases`, and `name.full`.
30
+ * Each is something a person wrote down as a name for the note, which is
31
+ * exactly what a bare `[[…]]` cites.
32
+ *
33
+ * **The filename is deliberately not one of them**, and it used to be — every
34
+ * one of the three copies of this index added `basename(file, ".md")` with
35
+ * underscores turned to spaces. That admitted keys no author could ever cite
36
+ * and no author had ever written:
37
+ *
38
+ * - `_Introduction.md` yields the alias `" introduction"`, *with a leading
39
+ * space*. A wikilink target is trimmed, so nothing can ever match it. In one
40
+ * repository thirteen notes — one per documentation section — claimed that
41
+ * key, making it the largest alias collision in the corpus and every one of
42
+ * its claimants blameless.
43
+ * - `README.md` yields `readme`, claimed once per section for the same reason.
44
+ *
45
+ * Since a collision is now a build failure rather than a silent deletion, an
46
+ * index entry that cannot be cited can only ever *cause* one. Removing the
47
+ * source was measured first, across all five content trees: not one link that
48
+ * resolves today resolves through the filename alone, so nothing loses a
49
+ * target — while the collision count falls without a note being edited.
50
+ *
51
+ * @module
52
+ */
53
+
54
+ /**
55
+ * Every alias a note claims, in the order the sources are consulted.
56
+ *
57
+ * @param {object} fm - Parsed frontmatter.
58
+ * @returns {string[]} The claimed aliases, each a non-empty string.
59
+ */
60
+ export function aliasesOf(fm) {
61
+ return [
62
+ ...(Array.isArray(fm?.aliases) ? fm.aliases : []),
63
+ ...(Array.isArray(fm?.name?.aliases) ? fm.name.aliases : []),
64
+ fm?.name?.full,
65
+ ].filter((a) => typeof a === "string" && a);
66
+ }
67
+
68
+ /**
69
+ * The index key one note's claim on one alias is filed under.
70
+ *
71
+ * Stated here so the three indexes — the pack build's, the site build's and
72
+ * the link checker's — cannot spell it differently. All three already used
73
+ * `type|alias`, lowercased; the risk was never that they disagreed today.
74
+ *
75
+ * @param {string} type - The claiming note's content type.
76
+ * @param {string} alias - The alias, as authored.
77
+ * @returns {string} The key.
78
+ */
79
+ export function aliasKey(type, alias) {
80
+ return `${String(type).trim()}|${String(alias).trim()}`.toLowerCase();
81
+ }
82
+
83
+ /**
84
+ * One alias claimed by more than one note of a single type.
85
+ *
86
+ * @typedef {object} AliasCollision
87
+ * @property {string} key - The index key, `type|alias`.
88
+ * @property {string} type - The type both claimants share.
89
+ * @property {string} alias - The alias, as the first claimant wrote it.
90
+ * @property {unknown[]} claimants - Every note claiming it, in walk order.
91
+ */
92
+
93
+ /**
94
+ * Build the type-scoped alias index, and report every collision in it.
95
+ *
96
+ * **A collision resolves to nothing, and is reported naming every claimant.**
97
+ * Both halves matter. Resolving to whichever note happened to be walked first
98
+ * makes a link silently point at the wrong document, and which one it is
99
+ * depends on directory order. Reporting it at the *citing* note blames a file
100
+ * whose author did nothing wrong — whoever added the second claimant broke
101
+ * every existing citation (#13) — so the claimants are kept rather than
102
+ * discarded along with the entry.
103
+ *
104
+ * @template T
105
+ * @param {Iterable<{type: string, aliases: Iterable<string>, value: T}>} entries
106
+ * One per note: the type that scopes its claims, the aliases it claims, and
107
+ * whatever the caller wants an alias to resolve to.
108
+ * @param {object} [opts]
109
+ * @param {(a: T, b: T) => boolean} [opts.same] - Whether two values are the
110
+ * same note. Defaults to identity; a caller whose values are freshly built
111
+ * records supplies its own.
112
+ * @returns {{byKey: Map<string, T>, claims: Map<string, T[]>,
113
+ * collisions: AliasCollision[]}} `byKey` omits every colliding key, so a
114
+ * lookup in it can never resolve an ambiguous alias.
115
+ */
116
+ export function indexAliases(entries, { same = Object.is } = {}) {
117
+ const claims = new Map();
118
+ /** The alias as first written, per key, for a message that reads. */
119
+ const written = new Map();
120
+ const typeOf = new Map();
121
+
122
+ for (const { type, aliases, value } of entries) {
123
+ for (const alias of aliases ?? []) {
124
+ if (typeof alias !== "string" || !alias) continue;
125
+ const key = aliasKey(type, alias);
126
+ const claimants = claims.get(key);
127
+ if (!claimants) {
128
+ claims.set(key, [value]);
129
+ written.set(key, alias);
130
+ typeOf.set(key, String(type).toLowerCase());
131
+ } else if (!claimants.some((c) => same(c, value))) {
132
+ claimants.push(value);
133
+ }
134
+ }
135
+ }
136
+
137
+ const byKey = new Map();
138
+ const collisions = [];
139
+ for (const [key, claimants] of claims) {
140
+ if (claimants.length === 1) {
141
+ byKey.set(key, claimants[0]);
142
+ } else {
143
+ collisions.push({
144
+ key,
145
+ type: typeOf.get(key),
146
+ alias: written.get(key),
147
+ claimants,
148
+ });
149
+ }
150
+ }
151
+
152
+ return { byKey, claims, collisions };
153
+ }