@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.
- package/CHANGELOG.md +1100 -0
- package/CONTENT.md +264 -33
- package/README.md +43 -4
- package/bin/content-build.mjs +94 -3
- package/config.mjs +9 -1
- package/content-config.mjs +99 -19
- package/docs/content-format.md +394 -72
- package/e2e.mjs +297 -3
- package/engine/actor-compiler.mjs +197 -7
- package/engine/address-charset.mjs +23 -5
- package/engine/base-compiler.mjs +63 -2
- package/engine/bundles.mjs +9 -0
- package/engine/content-address.mjs +92 -1
- package/engine/content-charset.mjs +434 -0
- package/engine/content-format.mjs +102 -0
- package/engine/content-icons.mjs +388 -0
- package/engine/content-index.mjs +11 -8
- package/engine/content-links.mjs +37 -21
- package/engine/field-reference.mjs +57 -5
- package/engine/field-spec.mjs +214 -7
- package/engine/folder-notes.mjs +24 -1
- package/engine/foreign-catalog.mjs +112 -7
- package/engine/foundry-entries.mjs +14 -0
- package/engine/frontmatter-lint.mjs +377 -56
- package/engine/frontmatter.mjs +11 -11
- package/engine/generate.mjs +72 -12
- package/engine/helpers.mjs +96 -10
- package/engine/index.mjs +9 -0
- package/engine/item-compiler.mjs +37 -0
- package/engine/journals.mjs +21 -4
- package/engine/macros.mjs +8 -0
- package/engine/map-notes.mjs +7 -7
- package/engine/note-claims.mjs +208 -5
- package/engine/note-ids.mjs +25 -1
- package/engine/note-vocabulary.mjs +76 -9
- package/engine/pack-config.mjs +102 -12
- package/engine/pack-router.mjs +0 -0
- package/engine/prose-config.mjs +42 -0
- package/engine/prose-lint.mjs +126 -0
- package/engine/retired-fields.mjs +57 -16
- package/engine/runtime-only-fields.mjs +204 -0
- package/engine/scenes.mjs +12 -19
- package/engine/schema-check.mjs +23 -1
- package/engine/schema-extract.mjs +13 -0
- package/engine/site-index.mjs +17 -0
- package/engine/subtype-registry.mjs +30 -0
- package/engine/system-block.mjs +81 -3
- package/engine/web-wikilinks.mjs +33 -27
- package/engine/wikilink-syntax.mjs +7 -0
- package/engine/wikilinks.mjs +74 -16
- package/hm3/actors.mjs +63 -13
- package/package.json +2 -2
- package/sohl/actors.mjs +106 -7
- package/sohl/item-fields.mjs +203 -0
- package/sohl/note-schemas.mjs +6 -3
- package/types/config.d.mts +7 -0
- package/types/e2e.d.mts +130 -3
- package/types/engine/actor-compiler.d.mts +83 -3
- package/types/engine/address-charset.d.mts +22 -4
- package/types/engine/base-compiler.d.mts +54 -3
- package/types/engine/content-address.d.mts +64 -0
- package/types/engine/content-charset.d.mts +127 -0
- package/types/engine/content-format.d.mts +9 -0
- package/types/engine/content-icons.d.mts +151 -0
- package/types/engine/field-spec.d.mts +271 -3
- package/types/engine/folder-notes.d.mts +20 -0
- package/types/engine/foreign-catalog.d.mts +38 -2
- package/types/engine/foundry-entries.d.mts +6 -0
- package/types/engine/frontmatter-lint.d.mts +164 -30
- package/types/engine/frontmatter.d.mts +11 -11
- package/types/engine/generate.d.mts +27 -0
- package/types/engine/helpers.d.mts +45 -9
- package/types/engine/index.d.mts +3 -0
- package/types/engine/map-notes.d.mts +2 -2
- package/types/engine/note-claims.d.mts +67 -0
- package/types/engine/note-ids.d.mts +14 -0
- package/types/engine/pack-config.d.mts +35 -0
- package/types/engine/prose-config.d.mts +41 -0
- package/types/engine/prose-lint.d.mts +36 -0
- package/types/engine/retired-fields.d.mts +29 -13
- package/types/engine/runtime-only-fields.d.mts +102 -0
- package/types/engine/schema-check.d.mts +10 -1
- package/types/engine/subtype-registry.d.mts +21 -0
- package/types/engine/system-block.d.mts +28 -2
- 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({
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
42
|
+
* The shape every address segment must match: **lowercase** ASCII letters and
|
|
43
|
+
* digits only.
|
|
43
44
|
*
|
|
44
|
-
* Case
|
|
45
|
-
*
|
|
46
|
-
*
|
|
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 = /^[
|
|
68
|
+
export const ADDRESS_SEGMENT_PATTERN = /^[a-z0-9]+$/;
|
|
51
69
|
|
|
52
70
|
/**
|
|
53
71
|
* Whether a value is a well-formed address segment.
|