@heroiclands/package-build 0.5.0 → 0.6.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/config.mjs CHANGED
@@ -70,6 +70,8 @@ const SECTION_KEYS = [
70
70
  "deploy",
71
71
  "release",
72
72
  "bundle",
73
+ "container",
74
+ "e2e",
73
75
  ];
74
76
 
75
77
  /**
@@ -96,9 +98,26 @@ export const DERIVED_MANIFEST_KEYS = Object.freeze({
96
98
  });
97
99
  const ASSET_KEYS = ["from", "to"];
98
100
  const CLEAN_KEYS = ["extra"];
99
- const LANG_KEYS = ["sources", "help"];
101
+ const LANG_KEYS = [
102
+ "sources",
103
+ "help",
104
+ "primary",
105
+ "scripts",
106
+ "templates",
107
+ "keyRoots",
108
+ "references",
109
+ "retained",
110
+ "allow",
111
+ ];
100
112
  const DEPLOY_KEYS = ["envPrefix"];
101
113
  const RELEASE_KEYS = ["artifact"];
114
+ const CONTAINER_KEYS = ["image", "stages"];
115
+ const CONTAINER_STAGE_KEYS = ["port", "world", "version"];
116
+ const E2E_KEYS = ["stage", "suite", "build", "world", "gm", "documents"];
117
+ const E2E_SUITE_KEYS = ["run", "open"];
118
+ const E2E_WORLD_KEYS = ["id", "title", "description"];
119
+ const E2E_GM_KEYS = ["name", "password"];
120
+ const E2E_BUILD_TARGET_KEYS = ["script", "recreate"];
102
121
  const BUNDLE_KEYS = ["entry"];
103
122
 
104
123
  /**
@@ -218,6 +237,194 @@ function normalizeManifest(value) {
218
237
  return Object.freeze(structuredClone(input));
219
238
  }
220
239
 
240
+ /**
241
+ * Normalize a setting that is one glob or several.
242
+ *
243
+ * A repository with a single conventional directory writes a string and a
244
+ * repository with two writes a list; requiring the list form from both would
245
+ * make the common case read like the exception.
246
+ *
247
+ * @param {unknown} value - What was declared, or `undefined`.
248
+ * @param {string[]} fallback - The conventional layout.
249
+ * @param {string} where - Dotted path, for the error.
250
+ * @returns {readonly string[]} The globs, frozen.
251
+ */
252
+ function normalizeGlobs(value, fallback, where) {
253
+ if (value === undefined) return Object.freeze(fallback);
254
+ const list = Array.isArray(value) ? value : [value];
255
+ return Object.freeze(
256
+ list.map((glob, index) =>
257
+ requireNonEmptyString(
258
+ glob,
259
+ Array.isArray(value) ? `${where}[${index}]` : where,
260
+ ),
261
+ ),
262
+ );
263
+ }
264
+
265
+ /**
266
+ * A container stage a repository runs beyond the conventional four.
267
+ *
268
+ * The four every HeroicLands package deploys to — dev, qa, prod, test — need no
269
+ * entry: their data-root variable is derived and their ports are conventional.
270
+ * This is for a stage that is genuinely one repository's, such as an older
271
+ * Foundry serving a previous generation of the package.
272
+ *
273
+ * @param {unknown} value - The stage entry.
274
+ * @param {string} name - The stage name, for the error path.
275
+ * @returns {Readonly<{port: number|null, world: string|null, version: string|null}>}
276
+ */
277
+ function normalizeContainerStage(value, name) {
278
+ const where = `packageBuild.container.stages.${name}`;
279
+ if (!isMapping(value)) fail(where, "must be a mapping");
280
+ const stage = /** @type {Record<string, unknown>} */ (value);
281
+ rejectUnknownKeys(stage, CONTAINER_STAGE_KEYS, `${where}.`);
282
+ if (stage.port !== undefined && typeof stage.port !== "number") {
283
+ fail(`${where}.port`, "must be a number");
284
+ }
285
+ if (stage.world !== undefined && typeof stage.world !== "string") {
286
+ // An empty string is meaningful — it declares "never auto-launch" —
287
+ // which is exactly why the type has to be checked rather than coerced.
288
+ fail(`${where}.world`, 'must be a string ("" forces no auto-launch)');
289
+ }
290
+ return Object.freeze({
291
+ port: /** @type {number|null} */ (stage.port ?? null),
292
+ world: /** @type {string|null} */ (stage.world ?? null),
293
+ version:
294
+ stage.version === undefined ?
295
+ null
296
+ : requireNonEmptyString(stage.version, `${where}.version`),
297
+ });
298
+ }
299
+
300
+ /**
301
+ * One end-to-end build target: the npm script that produces it, and whether
302
+ * producing it means the world has to relaunch.
303
+ *
304
+ * A bare string is the common case and reads better than a mapping with one
305
+ * key, so both are accepted. `recreate` is for a target that writes something
306
+ * Foundry reads **once, at world launch** — the manifest — where deploying it
307
+ * into a running world deploys a file nothing will look at.
308
+ *
309
+ * @param {unknown} value - The target entry.
310
+ * @param {string} name - The target name, for the error path.
311
+ * @returns {Readonly<{script: string, recreate: boolean}>}
312
+ */
313
+ function normalizeE2EBuildTarget(value, name) {
314
+ const where = `packageBuild.e2e.build.${name}`;
315
+ if (typeof value === "string") {
316
+ return Object.freeze({
317
+ script: requireNonEmptyString(value, where),
318
+ recreate: false,
319
+ });
320
+ }
321
+ if (!isMapping(value)) fail(where, "must be a script name or a mapping");
322
+ const target = /** @type {Record<string, unknown>} */ (value);
323
+ rejectUnknownKeys(target, E2E_BUILD_TARGET_KEYS, `${where}.`);
324
+ if (target.recreate !== undefined && typeof target.recreate !== "boolean") {
325
+ fail(`${where}.recreate`, "must be a boolean");
326
+ }
327
+ return Object.freeze({
328
+ script: requireNonEmptyString(target.script, `${where}.script`),
329
+ recreate: Boolean(target.recreate),
330
+ });
331
+ }
332
+
333
+ /**
334
+ * The suite a repository runs against the served world.
335
+ *
336
+ * **This is the one thing the harness does not own.** Standing Foundry up,
337
+ * seeding a world and waiting for it to activate are nobody's local problem;
338
+ * what runs against it — a Cypress suite full of one system's helpers — is
339
+ * entirely the repository's. So it is named here, the way an asset transform or
340
+ * a manifest-flags module is named.
341
+ *
342
+ * @param {unknown} value - The `suite` block, or `undefined`.
343
+ * @returns {Readonly<{run: readonly string[], open: readonly string[]|null}>|null}
344
+ */
345
+ function normalizeE2ESuite(value) {
346
+ if (value === undefined) return null;
347
+ if (!isMapping(value)) fail("packageBuild.e2e.suite", "must be a mapping");
348
+ const suite = /** @type {Record<string, unknown>} */ (value);
349
+ rejectUnknownKeys(suite, E2E_SUITE_KEYS, "packageBuild.e2e.suite.");
350
+
351
+ /**
352
+ * @param {unknown} value_ - The declared command.
353
+ * @param {string} where - Its path, for the error.
354
+ * @returns {readonly string[]} The program and its arguments.
355
+ */
356
+ const commandList = (value_, where) => {
357
+ if (!Array.isArray(value_) || value_.length === 0) {
358
+ fail(where, "must be a non-empty list naming a program to run");
359
+ }
360
+ return Object.freeze(
361
+ value_.map((part, i) =>
362
+ requireNonEmptyString(part, `${where}[${i}]`),
363
+ ),
364
+ );
365
+ };
366
+
367
+ return Object.freeze({
368
+ run: commandList(suite.run, "packageBuild.e2e.suite.run"),
369
+ open:
370
+ suite.open === undefined ?
371
+ null
372
+ : commandList(suite.open, "packageBuild.e2e.suite.open"),
373
+ });
374
+ }
375
+
376
+ /**
377
+ * A mapping whose every value is a non-empty string, frozen.
378
+ *
379
+ * @param {unknown} value - The mapping, or `undefined`.
380
+ * @param {string} where - Its path, for the error.
381
+ * @param {readonly string[]} [allowed] - Keys it may declare.
382
+ * @returns {Readonly<Record<string, string>>}
383
+ */
384
+ function normalizeStringMap(value, where, allowed) {
385
+ if (value === undefined) return Object.freeze({});
386
+ if (!isMapping(value)) fail(where, "must be a mapping");
387
+ const input = /** @type {Record<string, unknown>} */ (value);
388
+ if (allowed) rejectUnknownKeys(input, allowed, `${where}.`);
389
+ return Object.freeze(
390
+ Object.fromEntries(
391
+ Object.entries(input).map(([key, entry]) => [
392
+ key,
393
+ requireNonEmptyString(entry, `${where}.${key}`),
394
+ ]),
395
+ ),
396
+ );
397
+ }
398
+
399
+ /**
400
+ * Normalize an escape-hatch list: entries of `{ <field>, reason }`.
401
+ *
402
+ * Both escape hatches — a key kept despite looking unreferenced, a literal kept
403
+ * despite looking like prose — are a claim about something no scan can see, so
404
+ * each entry states the claim in prose a reviewer can check. Requiring the
405
+ * reason is what keeps the list from silently becoming the place unexplained
406
+ * exceptions accumulate.
407
+ *
408
+ * @param {unknown} value - The declared list, or `undefined`.
409
+ * @param {string} field - The name of the entry's own field.
410
+ * @param {string} where - Dotted path, for the error.
411
+ * @returns {readonly string[]} The field values, frozen; `[]` when absent.
412
+ */
413
+ function normalizeExceptions(value, field, where) {
414
+ if (value === undefined) return Object.freeze([]);
415
+ if (!Array.isArray(value)) fail(where, "must be a list");
416
+ return Object.freeze(
417
+ value.map((entry, index) => {
418
+ const at = `${where}[${index}]`;
419
+ if (!isMapping(entry)) fail(at, "must be a mapping");
420
+ const item = /** @type {Record<string, unknown>} */ (entry);
421
+ rejectUnknownKeys(item, [field, "reason"], `${at}.`);
422
+ requireNonEmptyString(item.reason, `${at}.reason`);
423
+ return requireNonEmptyString(item[field], `${at}.${field}`);
424
+ }),
425
+ );
426
+ }
427
+
221
428
  /**
222
429
  * The resolved `packageBuild` section, every optional half filled in.
223
430
  *
@@ -241,9 +448,43 @@ function normalizeManifest(value) {
241
448
  * conventional build artifacts.
242
449
  * @property {string} langSources Glob for the localization files to check.
243
450
  * @property {string|null} langHelp Extra guidance printed after a failure.
451
+ * @property {string} langPrimary The localization file coverage is measured
452
+ * against — the one the package authors.
453
+ * @property {readonly string[]} langScripts Globs for the sources scanned
454
+ * for key references.
455
+ * @property {readonly string[]} langTemplates Globs for the templates scanned
456
+ * for references and for hardcoded text.
457
+ * @property {readonly string[]|null} langKeyRoots The key roots, when the
458
+ * package references one its file does not
459
+ * yet declare. `null` derives them.
460
+ * @property {string|null} langReferences Module to load a `references`
461
+ * function from, contributing the keys only
462
+ * this repository's conventions can find.
463
+ * @property {readonly string[]} langRetained Key prefixes exempt from the
464
+ * unreferenced advisory.
465
+ * @property {readonly string[]} langAllow Template literals that are
466
+ * deliberately not localization keys.
244
467
  * @property {string} envPrefix Prefix of the deploy environment variables.
245
468
  * @property {string} bundleEntry The bundle file Foundry loads, as the
246
469
  * manifest spells it. Derived from the package id.
470
+ * @property {string|null} compatibilityMinimum The Foundry floor the package
471
+ * claims, read from the shared configuration's top level.
472
+ * @property {string} systemId The system a world runs — the package
473
+ * itself for a system, its target for a module.
474
+ * @property {string|null} systemVersion That system's version, when the shared
475
+ * configuration stamps one.
476
+ * @property {string|null} containerImage Image override for every stage.
477
+ * @property {Readonly<Record<string, Readonly<{port: number|null, world: string|null, version: string|null}>>>} containerStages
478
+ * Container stages beyond the conventional four.
479
+ * @property {string} e2eStage Which stage the suite runs against.
480
+ * @property {Readonly<{run: readonly string[], open: readonly string[]|null}>|null} e2eSuite
481
+ * What to run against the served world; `null` when the repository has none.
482
+ * @property {Readonly<Record<string, Readonly<{script: string, recreate: boolean}>>>} e2eBuild
483
+ * Build targets the fast loop can produce, in declaration order.
484
+ * @property {Readonly<Record<string, string>>} e2eWorld Declared world identity.
485
+ * @property {Readonly<Record<string, string>>} e2eGm Declared GM credentials.
486
+ * @property {Readonly<Record<string, string>>} e2eDocuments Extra world
487
+ * collections, as collection → source directory.
247
488
  */
248
489
 
249
490
  /**
@@ -321,6 +562,56 @@ export function resolvePackageBuildConfig(shared) {
321
562
  );
322
563
  const bundleInput = /** @type {Record<string, unknown>} */ (bundle);
323
564
 
565
+ const container = section.container ?? {};
566
+ if (!isMapping(container)) {
567
+ fail("packageBuild.container", "must be a mapping");
568
+ }
569
+ rejectUnknownKeys(
570
+ /** @type {Record<string, unknown>} */ (container),
571
+ CONTAINER_KEYS,
572
+ "packageBuild.container.",
573
+ );
574
+ const containerInput = /** @type {Record<string, unknown>} */ (container);
575
+ const declaredStages = containerInput.stages ?? {};
576
+ if (!isMapping(declaredStages)) {
577
+ fail("packageBuild.container.stages", "must be a mapping");
578
+ }
579
+ const containerStages = Object.freeze(
580
+ Object.fromEntries(
581
+ Object.entries(
582
+ /** @type {Record<string, unknown>} */ (declaredStages),
583
+ ).map(([name, entry]) => [
584
+ name,
585
+ normalizeContainerStage(entry, name),
586
+ ]),
587
+ ),
588
+ );
589
+
590
+ const e2e = section.e2e ?? {};
591
+ if (!isMapping(e2e)) fail("packageBuild.e2e", "must be a mapping");
592
+ rejectUnknownKeys(
593
+ /** @type {Record<string, unknown>} */ (e2e),
594
+ E2E_KEYS,
595
+ "packageBuild.e2e.",
596
+ );
597
+ const e2eInput = /** @type {Record<string, unknown>} */ (e2e);
598
+ const declaredBuild = e2eInput.build ?? {};
599
+ if (!isMapping(declaredBuild)) {
600
+ fail("packageBuild.e2e.build", "must be a mapping");
601
+ }
602
+ // Declaration order is build order — a mapping preserves it, and the
603
+ // bundler has to run before the passes that copy into the stage it empties.
604
+ const e2eBuild = Object.freeze(
605
+ Object.fromEntries(
606
+ Object.entries(
607
+ /** @type {Record<string, unknown>} */ (declaredBuild),
608
+ ).map(([name, entry]) => [
609
+ name,
610
+ normalizeE2EBuildTarget(entry, name),
611
+ ]),
612
+ ),
613
+ );
614
+
324
615
  return Object.freeze({
325
616
  rootDir: shared.rootDir,
326
617
  // Where the package is assembled before it is zipped or deployed. Every
@@ -381,6 +672,57 @@ export function resolvePackageBuildConfig(shared) {
381
672
  langInput.help === undefined ?
382
673
  null
383
674
  : requireNonEmptyString(langInput.help, "packageBuild.lang.help"),
675
+ // The file the package authors, and the one every other translation is
676
+ // measured against. Coverage is a question about *this* file: another
677
+ // language missing a key is a translation in progress, not a defect.
678
+ langPrimary:
679
+ langInput.primary === undefined ?
680
+ "lang/en.json"
681
+ : requireNonEmptyString(
682
+ langInput.primary,
683
+ "packageBuild.lang.primary",
684
+ ),
685
+ langScripts: normalizeGlobs(
686
+ langInput.scripts,
687
+ ["src/**/*.{ts,mjs}"],
688
+ "packageBuild.lang.scripts",
689
+ ),
690
+ langTemplates: normalizeGlobs(
691
+ langInput.templates,
692
+ ["templates/**/*.hbs"],
693
+ "packageBuild.lang.templates",
694
+ ),
695
+ // Derived from the file's own keys unless stated — a package states
696
+ // them only when it references a root the file does not yet declare at
697
+ // all, which is the one case deriving them cannot cover.
698
+ langKeyRoots:
699
+ langInput.keyRoots === undefined ?
700
+ null
701
+ : normalizeGlobs(
702
+ langInput.keyRoots,
703
+ [],
704
+ "packageBuild.lang.keyRoots",
705
+ ),
706
+ langReferences:
707
+ langInput.references === undefined ?
708
+ null
709
+ : path.resolve(
710
+ shared.rootDir,
711
+ requireNonEmptyString(
712
+ langInput.references,
713
+ "packageBuild.lang.references",
714
+ ),
715
+ ),
716
+ langRetained: normalizeExceptions(
717
+ langInput.retained,
718
+ "prefix",
719
+ "packageBuild.lang.retained",
720
+ ),
721
+ langAllow: normalizeExceptions(
722
+ langInput.allow,
723
+ "literal",
724
+ "packageBuild.lang.allow",
725
+ ),
384
726
  envPrefix:
385
727
  deployInput.envPrefix === undefined ?
386
728
  "SOHL"
@@ -404,6 +746,43 @@ export function resolvePackageBuildConfig(shared) {
404
746
  bundleInput.entry,
405
747
  "packageBuild.bundle.entry",
406
748
  ),
749
+ // Read from the top level, where the package already claims it. The
750
+ // end-to-end pin derives from this, so the claim and the evidence for
751
+ // it are the same number and cannot drift apart.
752
+ compatibilityMinimum: shared.compatibility?.minimum ?? null,
753
+ // The system a seeded world runs. For a system package that is itself;
754
+ // for a module it is the system it targets, which the shared
755
+ // configuration already names.
756
+ systemId: shared.stats?.systemId ?? shared.foundryPackage,
757
+ systemVersion: shared.stats?.systemVersion ?? null,
758
+ containerImage:
759
+ containerInput.image === undefined ?
760
+ null
761
+ : requireNonEmptyString(
762
+ containerInput.image,
763
+ "packageBuild.container.image",
764
+ ),
765
+ containerStages,
766
+ e2eStage:
767
+ e2eInput.stage === undefined ?
768
+ "test"
769
+ : requireNonEmptyString(e2eInput.stage, "packageBuild.e2e.stage"),
770
+ e2eSuite: normalizeE2ESuite(e2eInput.suite),
771
+ e2eBuild,
772
+ e2eWorld: normalizeStringMap(
773
+ e2eInput.world,
774
+ "packageBuild.e2e.world",
775
+ E2E_WORLD_KEYS,
776
+ ),
777
+ e2eGm: normalizeStringMap(
778
+ e2eInput.gm,
779
+ "packageBuild.e2e.gm",
780
+ E2E_GM_KEYS,
781
+ ),
782
+ e2eDocuments: normalizeStringMap(
783
+ e2eInput.documents,
784
+ "packageBuild.e2e.documents",
785
+ ),
407
786
  });
408
787
  }
409
788