@heroiclands/package-build 19.0.0 → 20.2.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 (85) hide show
  1. package/CHANGELOG.md +1100 -0
  2. package/CONTENT.md +264 -33
  3. package/README.md +43 -4
  4. package/bin/content-build.mjs +94 -3
  5. package/config.mjs +9 -1
  6. package/content-config.mjs +99 -19
  7. package/docs/content-format.md +394 -72
  8. package/e2e.mjs +297 -3
  9. package/engine/actor-compiler.mjs +197 -7
  10. package/engine/address-charset.mjs +23 -5
  11. package/engine/base-compiler.mjs +63 -2
  12. package/engine/bundles.mjs +9 -0
  13. package/engine/content-address.mjs +92 -1
  14. package/engine/content-charset.mjs +434 -0
  15. package/engine/content-format.mjs +102 -0
  16. package/engine/content-icons.mjs +388 -0
  17. package/engine/content-index.mjs +11 -8
  18. package/engine/content-links.mjs +37 -21
  19. package/engine/field-reference.mjs +57 -5
  20. package/engine/field-spec.mjs +214 -7
  21. package/engine/folder-notes.mjs +24 -1
  22. package/engine/foreign-catalog.mjs +112 -7
  23. package/engine/foundry-entries.mjs +14 -0
  24. package/engine/frontmatter-lint.mjs +377 -56
  25. package/engine/frontmatter.mjs +11 -11
  26. package/engine/generate.mjs +72 -12
  27. package/engine/helpers.mjs +96 -10
  28. package/engine/index.mjs +9 -0
  29. package/engine/item-compiler.mjs +37 -0
  30. package/engine/journals.mjs +21 -4
  31. package/engine/macros.mjs +8 -0
  32. package/engine/map-notes.mjs +7 -7
  33. package/engine/note-claims.mjs +208 -5
  34. package/engine/note-ids.mjs +25 -1
  35. package/engine/note-vocabulary.mjs +76 -9
  36. package/engine/pack-config.mjs +102 -12
  37. package/engine/pack-router.mjs +0 -0
  38. package/engine/prose-config.mjs +42 -0
  39. package/engine/prose-lint.mjs +126 -0
  40. package/engine/retired-fields.mjs +57 -16
  41. package/engine/runtime-only-fields.mjs +204 -0
  42. package/engine/scenes.mjs +12 -19
  43. package/engine/schema-check.mjs +23 -1
  44. package/engine/schema-extract.mjs +13 -0
  45. package/engine/site-index.mjs +17 -0
  46. package/engine/subtype-registry.mjs +30 -0
  47. package/engine/system-block.mjs +81 -3
  48. package/engine/web-wikilinks.mjs +33 -27
  49. package/engine/wikilink-syntax.mjs +7 -0
  50. package/engine/wikilinks.mjs +74 -16
  51. package/hm3/actors.mjs +63 -13
  52. package/package.json +2 -2
  53. package/sohl/actors.mjs +106 -7
  54. package/sohl/item-fields.mjs +203 -0
  55. package/sohl/note-schemas.mjs +6 -3
  56. package/types/config.d.mts +7 -0
  57. package/types/e2e.d.mts +130 -3
  58. package/types/engine/actor-compiler.d.mts +83 -3
  59. package/types/engine/address-charset.d.mts +22 -4
  60. package/types/engine/base-compiler.d.mts +54 -3
  61. package/types/engine/content-address.d.mts +64 -0
  62. package/types/engine/content-charset.d.mts +127 -0
  63. package/types/engine/content-format.d.mts +9 -0
  64. package/types/engine/content-icons.d.mts +151 -0
  65. package/types/engine/field-spec.d.mts +271 -3
  66. package/types/engine/folder-notes.d.mts +20 -0
  67. package/types/engine/foreign-catalog.d.mts +38 -2
  68. package/types/engine/foundry-entries.d.mts +6 -0
  69. package/types/engine/frontmatter-lint.d.mts +164 -30
  70. package/types/engine/frontmatter.d.mts +11 -11
  71. package/types/engine/generate.d.mts +27 -0
  72. package/types/engine/helpers.d.mts +45 -9
  73. package/types/engine/index.d.mts +3 -0
  74. package/types/engine/map-notes.d.mts +2 -2
  75. package/types/engine/note-claims.d.mts +67 -0
  76. package/types/engine/note-ids.d.mts +14 -0
  77. package/types/engine/pack-config.d.mts +35 -0
  78. package/types/engine/prose-config.d.mts +41 -0
  79. package/types/engine/prose-lint.d.mts +36 -0
  80. package/types/engine/retired-fields.d.mts +29 -13
  81. package/types/engine/runtime-only-fields.d.mts +102 -0
  82. package/types/engine/schema-check.d.mts +10 -1
  83. package/types/engine/subtype-registry.d.mts +21 -0
  84. package/types/engine/system-block.d.mts +28 -2
  85. package/types/sohl/actors.d.mts +3 -3
package/e2e.mjs CHANGED
@@ -40,6 +40,7 @@
40
40
  */
41
41
 
42
42
  import crypto from "node:crypto";
43
+ import { readdirSync, statSync } from "node:fs";
43
44
  import fs from "node:fs/promises";
44
45
  import path from "node:path";
45
46
  import process from "node:process";
@@ -557,6 +558,253 @@ function captureContainerLog(container) {
557
558
  return `${result.stdout ?? ""}${result.stderr ?? ""}`;
558
559
  }
559
560
 
561
+ /**
562
+ * Programs a package runner is only a way of *naming*.
563
+ *
564
+ * `npx cypress run` does not fail when Cypress is gone. `npx` is a way of
565
+ * saying "find `cypress`", and what it does when it cannot find one is fetch
566
+ * some other copy from the registry — so the executable the harness checks has
567
+ * to be the tool, not the runner, or the only thing it ever verifies is the one
568
+ * program that is never missing.
569
+ *
570
+ * @type {readonly string[]}
571
+ */
572
+ const DIRECT_RUNNERS = Object.freeze(["npx", "pnpx", "bunx"]);
573
+
574
+ /**
575
+ * The same idea spelled as a subcommand: `pnpm dlx cypress`, `npm exec
576
+ * cypress`. The subcommand matters — `npm run e2e` names a *script*, not a
577
+ * program, and there is nothing there for the harness to resolve.
578
+ *
579
+ * @type {Readonly<Record<string, readonly string[]>>}
580
+ */
581
+ const SUBCOMMAND_RUNNERS = Object.freeze({
582
+ npm: ["exec"],
583
+ pnpm: ["dlx", "exec"],
584
+ yarn: ["dlx"],
585
+ bun: ["x"],
586
+ });
587
+
588
+ /**
589
+ * Runner flags that name the package themselves instead of taking it from the
590
+ * first bare word. Meeting one means the harness cannot read this command, and
591
+ * a guess would put the wrong name in the diagnostic — which is worse than
592
+ * checking the runner alone.
593
+ *
594
+ * @type {readonly string[]}
595
+ */
596
+ const OPAQUE_RUNNER_FLAGS = Object.freeze(["-p", "--package", "-c", "--call"]);
597
+
598
+ /**
599
+ * A program's lookup name: its basename, minus a Windows executable extension,
600
+ * lower-cased. `C:\\…\\npx.cmd` and `npx` are the same question.
601
+ *
602
+ * @param {string} program - The program as the command line spells it.
603
+ * @returns {string} The name to compare against the runner tables.
604
+ */
605
+ function executableName(program) {
606
+ return path
607
+ .basename(program)
608
+ .replace(/\.(cmd|exe|bat|ps1)$/i, "")
609
+ .toLowerCase();
610
+ }
611
+
612
+ /**
613
+ * Every executable that must exist for a suite command to run at all.
614
+ *
615
+ * One name for a plain command, two when a package runner is standing in for a
616
+ * tool. This is deliberately a *reading* of the command rather than a guess:
617
+ * anything it cannot read reduces to the program alone, because naming the
618
+ * wrong missing thing would send someone after a dependency they already have.
619
+ *
620
+ * @param {readonly string[]} command - The program and its arguments.
621
+ * @returns {string[]} The executables to resolve, in the order to report them.
622
+ */
623
+ export function suiteExecutables(command) {
624
+ const [program, ...rest] = command;
625
+ if (!program) return [];
626
+ const name = executableName(program);
627
+
628
+ let args = rest;
629
+ const subcommands = SUBCOMMAND_RUNNERS[name];
630
+ if (subcommands) {
631
+ if (args.length === 0 || !subcommands.includes(args[0])) return [program];
632
+ args = args.slice(1);
633
+ } else if (!DIRECT_RUNNERS.includes(name)) return [program];
634
+
635
+ for (let i = 0; i < args.length; i += 1) {
636
+ const arg = args[i];
637
+ if (arg === "--") return args[i + 1] ? [program, args[i + 1]] : [program];
638
+ if (OPAQUE_RUNNER_FLAGS.includes(arg)) return [program];
639
+ if (arg.startsWith("--package=") || arg.startsWith("--call=")) return [program];
640
+ if (arg.startsWith("-")) continue;
641
+ return [program, arg];
642
+ }
643
+ return [program];
644
+ }
645
+
646
+ /**
647
+ * @param {string} candidate - An absolute path.
648
+ * @returns {boolean} Whether it is there and is a file.
649
+ */
650
+ function isExecutableFile(candidate) {
651
+ return statSync(candidate, { throwIfNoEntry: false })?.isFile() ?? false;
652
+ }
653
+
654
+ /**
655
+ * Find an executable the way the child process will: a path is a path, and a
656
+ * bare name is looked for in the repository's `node_modules/.bin` first, then
657
+ * along `PATH`.
658
+ *
659
+ * @param {string} name - The program, as the command line spells it.
660
+ * @param {object} [opts]
661
+ * @param {string} [opts.cwd] - The repository root, for `node_modules/.bin`.
662
+ * @param {NodeJS.ProcessEnv} [opts.env] - Environment to read `PATH` from.
663
+ * @returns {string|null} Where it is, or `null` if it is nowhere.
664
+ */
665
+ export function findExecutable(name, { cwd, env = process.env } = {}) {
666
+ if (name.includes("/") || name.includes(path.sep)) {
667
+ const candidate = path.resolve(cwd ?? process.cwd(), name);
668
+ return isExecutableFile(candidate) ? candidate : null;
669
+ }
670
+ const directories = cwd ? [path.join(cwd, "node_modules", ".bin")] : [];
671
+ directories.push(...(env.PATH ?? "").split(path.delimiter).filter(Boolean));
672
+ const extensions =
673
+ process.platform === "win32" ? (env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";") : [""];
674
+ for (const directory of directories) {
675
+ for (const extension of extensions) {
676
+ const candidate = path.join(directory, `${name}${extension}`);
677
+ if (isExecutableFile(candidate)) return candidate;
678
+ }
679
+ }
680
+ return null;
681
+ }
682
+
683
+ /**
684
+ * Which of a suite command's executables are not there.
685
+ *
686
+ * Asked twice per run, and the second asking is the point: an install running
687
+ * alongside the suite can take the runner out from under it mid-flight, which
688
+ * is precisely the failure that reported itself as green (#153).
689
+ *
690
+ * @param {object} opts
691
+ * @param {readonly string[]} opts.command - The program and its arguments.
692
+ * @param {string} [opts.cwd] - The repository root.
693
+ * @param {NodeJS.ProcessEnv} [opts.env] - Environment to read.
694
+ * @returns {string[]} The names that resolve to nothing.
695
+ */
696
+ export function missingExecutables({ command, cwd, env = process.env }) {
697
+ return suiteExecutables(command).filter((name) => findExecutable(name, { cwd, env }) === null);
698
+ }
699
+
700
+ /**
701
+ * Names, quoted, for a diagnostic.
702
+ *
703
+ * @param {readonly string[]} names - What to list.
704
+ * @returns {string} A comma-separated list, back-quoted.
705
+ */
706
+ function quotedList(names) {
707
+ return names.map((name) => `\`${name}\``).join(", ");
708
+ }
709
+
710
+ /**
711
+ * Filesystem timestamps are not all millisecond-precise, and a suite can write
712
+ * its first result in the same tick the harness spawned it. A second of slack
713
+ * costs nothing: a *stale* result is one from a previous run, minutes or days
714
+ * old, not one written a moment early.
715
+ */
716
+ const RESULT_MTIME_SLACK_MS = 1000;
717
+
718
+ /**
719
+ * Which declared result paths the suite actually wrote to during this run.
720
+ *
721
+ * Existence is not the test. A results directory left behind by the previous
722
+ * run exists, and reading that as evidence would make the check agree with
723
+ * exactly the thing it was built to catch. What counts is a file modified since
724
+ * the spawn.
725
+ *
726
+ * @param {object} opts
727
+ * @param {readonly string[]} opts.paths - Declared result paths, repo-relative.
728
+ * @param {number} opts.since - Milliseconds since the epoch, at spawn time.
729
+ * @param {string} [opts.cwd] - The repository root.
730
+ * @returns {string[]} The declared paths carrying something new.
731
+ */
732
+ export function freshResults({ paths, since, cwd }) {
733
+ const floor = since - RESULT_MTIME_SLACK_MS;
734
+
735
+ /**
736
+ * @param {string} candidate - An absolute path.
737
+ * @returns {boolean} Whether it, or anything under it, is newer than the floor.
738
+ */
739
+ const touched = (candidate) => {
740
+ const stats = statSync(candidate, { throwIfNoEntry: false });
741
+ if (!stats) return false;
742
+ if (stats.isFile()) return stats.mtimeMs >= floor;
743
+ if (!stats.isDirectory()) return false;
744
+ return readdirSync(candidate, { withFileTypes: true }).some((entry) =>
745
+ touched(path.join(candidate, entry.name)),
746
+ );
747
+ };
748
+
749
+ return paths.filter((declared) => touched(path.resolve(cwd ?? process.cwd(), declared)));
750
+ }
751
+
752
+ /**
753
+ * What the harness reports for a finished suite.
754
+ *
755
+ * @typedef {object} SuiteVerdict
756
+ * @property {number} status The exit status to hand back.
757
+ * @property {string|null} message What to say about it, if anything.
758
+ */
759
+
760
+ /**
761
+ * Decide what a finished suite is worth, given what it exited with and what it
762
+ * left behind.
763
+ *
764
+ * The point of the e2e suite is to be *evidence*: `compatibility.verified`
765
+ * moves on a green run, and a sweep exists to produce a citable result. So an
766
+ * exit status on its own cannot call a run green, because every way of stopping
767
+ * a runner before it starts — a corrupt install, a missing browser, a killed
768
+ * process, the concurrent `npm ci` that surfaced this — produces a run that
769
+ * executed nothing, and nothing is not a pass (#153).
770
+ *
771
+ * This can only ever make a verdict worse. A suite that failed keeps its own
772
+ * status; a suite that passed on no evidence loses the claim. Never the other
773
+ * way round — a harness that could *upgrade* a result would be a second way to
774
+ * report a green that did not happen.
775
+ *
776
+ * @param {object} opts
777
+ * @param {number} opts.status - What the suite process exited with.
778
+ * @param {readonly string[]} [opts.vanished] - Executables gone since it started.
779
+ * @param {readonly string[]} [opts.declared] - Result paths the repository declares.
780
+ * @param {readonly string[]} [opts.fresh] - Those of them it wrote to.
781
+ * @returns {SuiteVerdict} The status to report, and why.
782
+ */
783
+ export function suiteVerdict({ status, vanished = [], declared = [], fresh = [] }) {
784
+ if (vanished.length > 0) {
785
+ return {
786
+ status: status || 1,
787
+ message:
788
+ `✗ ${quotedList(vanished)} disappeared while the suite was ` +
789
+ `running, so it cannot have finished. Something reinstalled ` +
790
+ `\`node_modules\` underneath it — \`npm ci\` removes the tree ` +
791
+ `before it rebuilds it. Reporting the run as failed: whatever ` +
792
+ `status it exited with, it ran nothing to completion.`,
793
+ };
794
+ }
795
+ if (declared.length > 0 && fresh.length === 0) {
796
+ return {
797
+ status: status || 1,
798
+ message:
799
+ `✗ The suite exited ${status} but wrote nothing to ` +
800
+ `${quotedList(declared)} while it ran, so there is no evidence ` +
801
+ `it executed anything. Reporting the run as failed: a run that ` +
802
+ `produced no results is not a pass.`,
803
+ };
804
+ }
805
+ return { status, message: null };
806
+ }
807
+
560
808
  /**
561
809
  * Run the repository's suite.
562
810
  *
@@ -565,19 +813,51 @@ function captureContainerLog(container) {
565
813
  * runner launches as plain Node, rejects its own flags, and dies with a
566
814
  * `MODULE_NOT_FOUND` naming nothing relevant.
567
815
  *
816
+ * The suite is bracketed by checks rather than trusted on its exit status,
817
+ * because a run that never started used to report as green (#153):
818
+ *
819
+ * - **Before.** Every executable the command needs is resolved, and a missing
820
+ * one is an error naming it — rather than a container stood up, a world
821
+ * seeded, and a failure three minutes later that names nothing.
822
+ * - **After.** The same question again, because the reported failure was an
823
+ * install pulling the runner out from under a run already in progress; and,
824
+ * where the repository declares where its results land, whether anything was
825
+ * written there while the suite ran.
826
+ *
568
827
  * @param {object} opts
569
- * @param {string[]} opts.command - The program and its arguments.
828
+ * @param {readonly string[]} opts.command - The program and its arguments.
570
829
  * @param {string[]} [opts.args] - Extra arguments, appended verbatim.
571
830
  * @param {string} opts.cwd - The repository root.
831
+ * @param {readonly string[]} [opts.results] - Declared result paths to check.
572
832
  * @param {NodeJS.ProcessEnv} [opts.env] - Environment for the child.
573
833
  * @param {(message: string) => void} [opts.log] - Progress reporting.
574
834
  * @returns {number} The suite's exit status.
835
+ * @throws {Error} When the command names an executable that is not installed.
575
836
  */
576
- export function runSuite({ command, args = [], cwd, env = process.env, log = () => {} }) {
837
+ export function runSuite({
838
+ command,
839
+ args = [],
840
+ cwd,
841
+ results = [],
842
+ env = process.env,
843
+ log = () => {},
844
+ }) {
845
+ const missing = missingExecutables({ command, cwd, env });
846
+ if (missing.length > 0) {
847
+ throw new Error(
848
+ `The end-to-end suite cannot start: ${quotedList(missing)} ` +
849
+ `${missing.length === 1 ? "is" : "are"} not installed — ` +
850
+ `nothing of that name is in \`node_modules/.bin\` or on ` +
851
+ `\`PATH\`. The declared command is \`${command.join(" ")}\`. ` +
852
+ `Install the repository's dependencies and run again.`,
853
+ );
854
+ }
855
+
577
856
  const [program, ...rest] = command;
578
857
  const childEnv = { ...env };
579
858
  delete childEnv.ELECTRON_RUN_AS_NODE;
580
859
  log(`▸ ${[...command, ...args].join(" ")}`);
860
+ const startedAt = Date.now();
581
861
  const result = spawnSync(/** @type {string} */ (program), [...rest, ...args], {
582
862
  stdio: "inherit",
583
863
  cwd,
@@ -585,7 +865,15 @@ export function runSuite({ command, args = [], cwd, env = process.env, log = ()
585
865
  shell: process.platform === "win32",
586
866
  });
587
867
  if (result.error) throw result.error;
588
- return result.status ?? 1;
868
+
869
+ const verdict = suiteVerdict({
870
+ status: result.status ?? 1,
871
+ vanished: missingExecutables({ command, cwd, env }),
872
+ declared: results,
873
+ fresh: freshResults({ paths: results, since: startedAt, cwd }),
874
+ });
875
+ if (verdict.message) log(verdict.message);
876
+ return verdict.status;
589
877
  }
590
878
 
591
879
  /**
@@ -696,6 +984,11 @@ export async function e2eRun({
696
984
  command,
697
985
  args: suiteArgs,
698
986
  cwd: config.rootDir,
987
+ // Only a headless run makes a claim worth checking. `open` hands
988
+ // the runner to a person, who decides what to execute and when to
989
+ // close it; "it wrote no results" is a description of that session,
990
+ // not a fault in it.
991
+ results: mode === "run" ? config.e2eResults : [],
699
992
  env: runEnv,
700
993
  log,
701
994
  });
@@ -781,6 +1074,7 @@ export async function e2eFast({ config, argv = [], env = process.env, log = () =
781
1074
  command,
782
1075
  args: suiteArgs,
783
1076
  cwd: config.rootDir,
1077
+ results: config.e2eResults,
784
1078
  env,
785
1079
  log,
786
1080
  });
@@ -63,6 +63,14 @@ import { contentPackage } from "./content-package.mjs";
63
63
  // inferred from the type itself (#79).
64
64
  import { mapsNoteType, noteTypesFor, referencedSubtype } from "./document-subtypes.mjs";
65
65
  import { locateFrontmatterKey } from "./retired-fields.mjs";
66
+ // An `items:` entry's `system:` overlay is merged verbatim, so it reaches the
67
+ // document by a path no field declaration sits on — including, until #330, the
68
+ // fields the document is supposed to write for itself in play.
69
+ import { itemFields } from "./item-registry.mjs";
70
+ import { runtimeOnlyIn, runtimeOnlyMessage } from "./runtime-only-fields.mjs";
71
+ // A `model:` is an address, read by the same grammar every wikilink is (#336),
72
+ // so an author writes one form and meets one set of messages.
73
+ import { readQualifier } from "./wikilinks.mjs";
66
74
 
67
75
  /**
68
76
  * Strip compendium-only fields from a predefined item before embedding it
@@ -134,6 +142,61 @@ export function itemAddress(subType, shortcode) {
134
142
  return `${subType}:${shortcode}`;
135
143
  }
136
144
 
145
+ /**
146
+ * The key one predefined item is held under **for the package that publishes
147
+ * it** — the address a `model:` naming that package resolves through (#334).
148
+ *
149
+ * The unqualified {@link itemAddress} stays beside it, and the two answer
150
+ * different questions. A `model` that names no package means *this* one and
151
+ * takes the unqualified key, where a local definition still shadows a
152
+ * dependency's. A `model` that names a package takes this one, which nothing
153
+ * can shadow: that is the whole point of writing the package down.
154
+ *
155
+ * Not the canonical wikilink address, because this map is keyed in the
156
+ * **document's** vocabulary — a Foundry Item subtype — while a canonical address
157
+ * carries the *note* type. The two differ wherever a system maps a type to a
158
+ * differently-named subtype, and translating here would put the translation in
159
+ * two places.
160
+ *
161
+ * @param {string} pkg - The content package that publishes the item.
162
+ * @param {string} subType - The Foundry Item subtype.
163
+ * @param {string} shortcode - The item's `system.shortcode`.
164
+ * @returns {string} The address, `package:subType:shortcode`.
165
+ */
166
+ export function packagedItemAddress(pkg, subType, shortcode) {
167
+ return `${pkg}:${subType}:${shortcode}`;
168
+ }
169
+
170
+ /**
171
+ * The key one predefined item is held under **in the catalogue**, with the
172
+ * shortcode folded to lower case.
173
+ *
174
+ * A shortcode is case-sensitive and routinely mixed — `Clb`, `LtShoe`,
175
+ * `HsTunic` — while an **address** is not: `readQualifier` normalises what it
176
+ * reads, and every canonical address is lowercase. So the moment a `model:` is
177
+ * read as an address (#334), `weapongear-clb` has to find the document whose
178
+ * `system.shortcode` is `Clb`, and an exact match cannot (#346).
179
+ *
180
+ * Folding is safe because the fold is already the address: no two items in any
181
+ * published tree differ only by the case of their shortcode, and #340 will make
182
+ * that impossible rather than merely true.
183
+ *
184
+ * **This is not {@link itemAddress}, and must not become it.** That one seeds
185
+ * {@link embeddedItemId}, so folding there would change the `_id` of every
186
+ * embedded item whose identity carries a capital — a silent re-identification of
187
+ * documents nothing about which had changed. The catalogue is a lookup table;
188
+ * an id is a promise.
189
+ *
190
+ * @param {string} subType - The Foundry Item subtype.
191
+ * @param {string} shortcode - The item's `system.shortcode`, in any case.
192
+ * @param {string} [pkg] - The publishing package, for the qualified form.
193
+ * @returns {string} The catalogue key.
194
+ */
195
+ export function catalogueKey(subType, shortcode, pkg) {
196
+ const folded = String(shortcode).toLowerCase();
197
+ return pkg ? packagedItemAddress(pkg, subType, folded) : itemAddress(subType, folded);
198
+ }
199
+
137
200
  /**
138
201
  * What identifies one embedded item on its actor.
139
202
  *
@@ -243,7 +306,7 @@ export function loadItemsMap(itemsSourceDirs, foreignSourceDirs = []) {
243
306
  }
244
307
  const shortcode = doc?.system?.shortcode;
245
308
  if (!doc?.type || !shortcode) continue;
246
- const address = itemAddress(doc.type, shortcode);
309
+ const address = catalogueKey(doc.type, shortcode);
247
310
  const owner = source.get(address);
248
311
  if (owner && owner !== itemsSourceDir) {
249
312
  throw new Error(
@@ -257,9 +320,17 @@ export function loadItemsMap(itemsSourceDirs, foreignSourceDirs = []) {
257
320
  // eslint-disable-next-line no-unused-vars
258
321
  const { _key, ...rest } = doc;
259
322
  map.set(address, rest);
323
+ // And under this package's own name, so a `model:` that names this
324
+ // package explicitly resolves to the same item (#334).
325
+ map.set(catalogueKey(doc.type, shortcode, contentPackage()), rest);
260
326
  }
261
327
  }
262
- for (const foreignDir of foreignSourceDirs) {
328
+ for (const foreignEntry of foreignSourceDirs) {
329
+ // Each dependency's directory arrives with the package that published
330
+ // it (#334), so a foreign template gets its own canonical address
331
+ // rather than sharing the local address space.
332
+ const foreignDir = typeof foreignEntry === "string" ? foreignEntry : foreignEntry.dir;
333
+ const foreignPackage = typeof foreignEntry === "string" ? null : foreignEntry.package;
263
334
  for (const name of fs.readdirSync(foreignDir)) {
264
335
  if (!name.endsWith(".json")) continue;
265
336
  if (name.startsWith("folder_")) continue;
@@ -277,14 +348,19 @@ export function loadItemsMap(itemsSourceDirs, foreignSourceDirs = []) {
277
348
  }
278
349
  const shortcode = doc?.system?.shortcode;
279
350
  if (!doc?.type || !shortcode) continue;
280
- const address = itemAddress(doc.type, shortcode);
351
+ const address = catalogueKey(doc.type, shortcode);
352
+ // eslint-disable-next-line no-unused-vars
353
+ const { _key, ...rest } = doc;
354
+ // Its own package-qualified address, which a `model:` naming that
355
+ // package resolves through and nothing local can shadow (#334).
356
+ if (foreignPackage) {
357
+ map.set(catalogueKey(doc.type, shortcode, foreignPackage), rest);
358
+ }
281
359
  if (map.has(address)) {
282
360
  // Deliberate: this repository defines it, so its version wins.
283
361
  if (source.has(address)) shadowed.push(address);
284
362
  continue;
285
363
  }
286
- // eslint-disable-next-line no-unused-vars
287
- const { _key, ...rest } = doc;
288
364
  map.set(address, rest);
289
365
  }
290
366
  }
@@ -310,6 +386,21 @@ export class SystemActorCompiler extends BasePackCompiler {
310
386
  static id = "actors";
311
387
  static label = "actor";
312
388
 
389
+ /**
390
+ * **Both**, and they are two independent pictures: `img` is the actor's
391
+ * token art — written onto `document.img` and the prototype token's texture
392
+ * — and `portrait` is the sheet portrait, a declared field each system
393
+ * lands under its own name (`system.portrait` for SoHL,
394
+ * `system.bioImage` for HM3).
395
+ *
396
+ * Declared on the shared class because both subclasses emit both. A system
397
+ * whose actor genuinely carried only one would override it here rather than
398
+ * leave the claim standing.
399
+ *
400
+ * @type {readonly string[]}
401
+ */
402
+ static emitsArt = Object.freeze(["img", "portrait"]);
403
+
313
404
  /**
314
405
  * Which `(actor, subType:identity)` each resolved entry claimed, and the
315
406
  * entry that claimed it first.
@@ -356,6 +447,20 @@ export class SystemActorCompiler extends BasePackCompiler {
356
447
  itemsSourceDirs;
357
448
  foreignSourceDirs;
358
449
 
450
+ /**
451
+ * Every package a `model:` may name besides this one — the dependencies
452
+ * whose item catalogues were supplied (#334).
453
+ *
454
+ * @returns {Set<string>} The dependency package ids.
455
+ */
456
+ get foreignPackages() {
457
+ return new Set(
458
+ (this.foreignSourceDirs ?? [])
459
+ .map((entry) => (typeof entry === "string" ? null : entry?.package))
460
+ .filter(Boolean),
461
+ );
462
+ }
463
+
359
464
  constructor({ itemsSourceDirs = [], foreignSourceDirs = [], ...options }) {
360
465
  super(options);
361
466
  // Where the items passes wrote their JSON. Stated by the caller rather
@@ -498,7 +603,66 @@ export class SystemActorCompiler extends BasePackCompiler {
498
603
  * @returns {object|null} The embedded item, or null when it resolved to
499
604
  * nothing — always with a finding emitted.
500
605
  */
501
- resolveEmbedded(itemsMap, actorId, type, shortcode, overlay, indexKey, ctx, { fmKey } = {}) {
606
+ /**
607
+ * Read an entry's `model:` — the address of the item it is a copy of.
608
+ *
609
+ * The address grammar is the wikilink one (#336), so a `model` is written at
610
+ * whatever length says what it means: `skill-wpnc` within this package,
611
+ * `sohl-sohl-skill-wpnc` to reach another. The system segment defaults from
612
+ * the block the entry sits in — `<system>.items` — which is what makes the
613
+ * short form name an *Item* here while the same string in body prose names
614
+ * a page.
615
+ *
616
+ * It replaced a top-level `shortcode:` that meant something different from
617
+ * the `system.shortcode` beside it and could not say which package a
618
+ * template came from (#334).
619
+ *
620
+ * @param {unknown} model - The authored value.
621
+ * @param {number} index - The entry's position, for the message.
622
+ * @param {string} ctx - Diagnostic context (the actor's label).
623
+ * @returns {{type: string, shortcode: string, package: string|null}|null}
624
+ * The parsed address, or `null` after reporting why it is not one.
625
+ */
626
+ readModel(model, index, ctx) {
627
+ const key = `${this.documentSubtypes.block}.items`;
628
+ const where = () =>
629
+ locateFrontmatterKey(this.currentNote?.absPath, "items", String(model ?? ""));
630
+ if (typeof model !== "string" || !model.trim()) {
631
+ this.noteError(`${ctx}: ${key}[${index}] \`model\` must be an address`, where());
632
+ this.errorCount++;
633
+ return null;
634
+ }
635
+ // The types this system maps, which are the ones a `model` may name, and
636
+ // every package one may reach: this repository's own plus each
637
+ // dependency whose item catalogue was loaded.
638
+ const types = new Set(Object.keys(this.documentSubtypes.types));
639
+ const packages = new Set([contentPackage(), ...this.foreignPackages]);
640
+ const read = readQualifier(model.trim(), types, packages);
641
+ if (!read || read.reason) {
642
+ const why =
643
+ read?.reason === "not-lowercase" ?
644
+ "capitalises a package, system or type segment — those three " +
645
+ "are lowercase, and only the shortcode keeps its case"
646
+ : read?.reason === "unknown-type" ? "names no known content type"
647
+ : "is not an address — write `type-shortcode`, or " +
648
+ "`package-system-type-shortcode` for another package's item";
649
+ this.noteError(`${ctx}: ${key}[${index}] \`model: ${model}\` ${why}`, where());
650
+ this.errorCount++;
651
+ return null;
652
+ }
653
+ return { type: read.type, shortcode: read.shortcode, package: read.package ?? null };
654
+ }
655
+
656
+ resolveEmbedded(
657
+ itemsMap,
658
+ actorId,
659
+ type,
660
+ shortcode,
661
+ overlay,
662
+ indexKey,
663
+ ctx,
664
+ { fmKey, modelPackage = null } = {},
665
+ ) {
502
666
  // Where a finding about this reference points. The value locates the
503
667
  // exact entry in a list; the key is the fallback when it cannot be
504
668
  // found, which still beats naming the note alone.
@@ -511,7 +675,33 @@ export class SystemActorCompiler extends BasePackCompiler {
511
675
  this.errorCount++;
512
676
  return null;
513
677
  }
514
- const address = itemAddress(/** @type {string} */ (subType), shortcode ?? "");
678
+ // A `model:` may name the package its template comes from (#334). Where
679
+ // it does, the packaged address is used and nothing local can shadow
680
+ // it; where it does not, the unqualified one is, and a local definition
681
+ // still wins over a dependency's as it always has.
682
+ const address = catalogueKey(
683
+ /** @type {string} */ (subType),
684
+ shortcode ?? "",
685
+ modelPackage ?? undefined,
686
+ );
687
+
688
+ // The entry's `system:` overlay is merged verbatim, so it reaches the
689
+ // document without passing a single field declaration — which left it
690
+ // the one position a runtime-only field stayed authorable at once #330
691
+ // closed the item note's own. Asked of the **overlay** rather than of
692
+ // the merged result: the template it merges onto is a compiled
693
+ // document, which by then carries none, and a finding has to name what
694
+ // this note wrote.
695
+ const [runtimeOnly] = runtimeOnlyIn(overlay?.system, itemFields(type, this.system));
696
+ if (runtimeOnly) {
697
+ this.noteError(
698
+ `${ctx}: ${indexKey}: ` +
699
+ `${runtimeOnlyMessage(`${indexKey}.system.${runtimeOnly.to}`, runtimeOnly)}.`,
700
+ where(),
701
+ );
702
+ this.errorCount++;
703
+ return null;
704
+ }
515
705
 
516
706
  let base = null;
517
707
  if (shortcode) {
@@ -39,15 +39,33 @@
39
39
  */
40
40
 
41
41
  /**
42
- * The shape every address segment must match: ASCII letters and digits only.
42
+ * The shape every address segment must match: **lowercase** ASCII letters and
43
+ * digits only.
43
44
  *
44
- * Case is deliberately **not** constrained. Hundreds of authored shortcodes are
45
- * mixed-case and collide with nothing, and case has no bearing on the
46
- * separator, which is the whole of what this pattern is protecting.
45
+ * Case *was* deliberately unconstrained, on the reasoning that case has no
46
+ * bearing on the separator — which is true, and beside the point (#340).
47
+ *
48
+ * **Two names that differ only in case are two names nobody can tell apart.** A
49
+ * shortcode is how a person names a thing when writing a reference —
50
+ * `model: weapongear-dgr`, `[[skill-melee|…]]` — and `Dgr` beside `dgr` is a
51
+ * distinction you cannot say out loud and can only see by looking twice.
52
+ *
53
+ * The toolchain had already half-decided it: {@link canonicalKey} lowercases the
54
+ * address it builds, so a note declaring `Clb` published
55
+ * `sohl-sohl-weapongear-clb` and its `_id` derived from that. The authored name
56
+ * and its address disagreed, and everything downstream keys on the address —
57
+ * which left two notes differing only in case sharing one address, one `_id` and
58
+ * one URL, with nothing to report it. It also forced two exceptions elsewhere:
59
+ * #336 had to exempt the shortcode from the lowercase rule it pinned on every
60
+ * other segment, and #346 had to fold the shortcode's case in the item catalogue
61
+ * because an address is lowercased when read.
62
+ *
63
+ * One case, one spelling, no exceptions. Every tree already complies but two,
64
+ * and nothing in any of them collides when folded.
47
65
  *
48
66
  * @type {RegExp}
49
67
  */
50
- export const ADDRESS_SEGMENT_PATTERN = /^[A-Za-z0-9]+$/;
68
+ export const ADDRESS_SEGMENT_PATTERN = /^[a-z0-9]+$/;
51
69
 
52
70
  /**
53
71
  * Whether a value is a well-formed address segment.