@heroiclands/package-build 6.1.0 → 8.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 (41) hide show
  1. package/CHANGELOG.md +840 -0
  2. package/CONTENT.md +21 -1
  3. package/bin/content-build.mjs +105 -10
  4. package/bin/package-build.mjs +114 -1
  5. package/config.mjs +62 -3
  6. package/content-config.mjs +254 -22
  7. package/engine/base-compiler.mjs +25 -0
  8. package/engine/content-links.mjs +132 -27
  9. package/engine/diagnostics.mjs +61 -1
  10. package/engine/foreign-catalog.mjs +47 -0
  11. package/engine/generate.mjs +10 -5
  12. package/engine/helpers.mjs +38 -0
  13. package/engine/journals.mjs +8 -1
  14. package/engine/macros.mjs +2 -0
  15. package/engine/pack-config.mjs +143 -13
  16. package/engine/prose-lint.mjs +10 -2
  17. package/engine/scenes.mjs +2 -2
  18. package/engine/schema-check.mjs +332 -0
  19. package/engine/schema-extract.mjs +611 -0
  20. package/engine/web-wikilinks.mjs +13 -4
  21. package/engine/wikilink-syntax.mjs +25 -0
  22. package/engine/wikilinks.mjs +6 -3
  23. package/manifest.mjs +37 -2
  24. package/package.json +5 -3
  25. package/sohl/actors.mjs +23 -10
  26. package/sohl/item-fields.mjs +0 -35
  27. package/sohl/items.mjs +1 -1
  28. package/types/content-config.d.mts +14 -0
  29. package/types/engine/base-compiler.d.mts +18 -1
  30. package/types/engine/content-links.d.mts +11 -3
  31. package/types/engine/diagnostics.d.mts +33 -1
  32. package/types/engine/foreign-catalog.d.mts +15 -0
  33. package/types/engine/generate.d.mts +3 -2
  34. package/types/engine/helpers.d.mts +29 -3
  35. package/types/engine/journals.d.mts +7 -1
  36. package/types/engine/pack-config.d.mts +22 -0
  37. package/types/engine/prose-lint.d.mts +10 -2
  38. package/types/engine/schema-check.d.mts +176 -0
  39. package/types/engine/schema-extract.d.mts +61 -0
  40. package/types/engine/wikilink-syntax.d.mts +24 -0
  41. package/types/sohl/actors.d.mts +3 -3
package/CONTENT.md CHANGED
@@ -155,7 +155,9 @@ the file sits in, fills the optional halves with their defaults
155
155
  manifest switches off and `publish.site` at its `homepage` floor),
156
156
  derives `assetRoot`, `packDirectories`, `itemTypes` and `docEntryTypes`, and
157
157
  freezes the result. A malformed configuration throws a `TypeError` naming the
158
- offending field, so it fails at load rather than as an empty pack much later.
158
+ offending field and the line and column it was written on, in the
159
+ [located form](#diagnostics) — so it fails at load rather than as an empty pack
160
+ much later.
159
161
 
160
162
  **Four values are derived rather than authored**, because each is something a
161
163
  file can be asked for rather than told:
@@ -1160,6 +1162,24 @@ Two rules keep it that way, both in `engine/diagnostics.mjs`:
1160
1162
  meaningless, `file: …` when only the note is known. Nothing defaults to
1161
1163
  `1:1`, which would send a reader to the frontmatter every time.
1162
1164
 
1165
+ **A configuration error is located the same way.** Every check in
1166
+ `content-config.mjs` and `config.mjs` reports through one `fail()`, naming the
1167
+ offending key's dotted path — a good description and a bad locator, in a file
1168
+ that runs to hundreds of lines with sibling entries flow-mapped onto one. The
1169
+ path now rides on the error, and the loader that read the file resolves it
1170
+ against the YAML, so all of them come out located:
1171
+
1172
+ ```text
1173
+ package-build.config.yaml:382:64: error: package-build config: `site.sections.being.descrption` is not a recognized option (expected one of: title, banner).
1174
+ ```
1175
+
1176
+ The same two rules apply. A key the file never declares — a required one that is
1177
+ simply missing — has no node of its own, so the position names the **mapping it
1178
+ belongs in** and no further out; a missing _top-level_ key has nothing above it
1179
+ but the document, and an `.mjs` configuration has no YAML to resolve a path
1180
+ against at all. Both report `package-build.config.yaml: error: …`, the file
1181
+ without a line, rather than a line that would be wrong.
1182
+
1163
1183
  Establishing a position at all takes three corrections, applied only where they
1164
1184
  hold — see `positionInBody`. A body offset is not a file line until the
1165
1185
  frontmatter's lines are added (`bodyLine`); the trim that strips the body can
@@ -73,6 +73,12 @@ import {
73
73
  import { renderItemFieldReference } from "../engine/field-reference.mjs";
74
74
  import { lintContentTree } from "../engine/content-lint.mjs";
75
75
  import { lintFrontmatter } from "../engine/frontmatter-lint.mjs";
76
+ import {
77
+ compareFields,
78
+ resolveSchemaArtifact,
79
+ undeclaredMessage,
80
+ unemittedMessage,
81
+ } from "../engine/schema-check.mjs";
76
82
  // The one vocabulary, loaded whole. Every content project authors the full type
77
83
  // set — an adventure module ships skills, beings and magic swords — so no
78
84
  // consumer gets a subset (#19, #20).
@@ -156,6 +162,25 @@ prefix.apply(log, {
156
162
  },
157
163
  });
158
164
 
165
+ /**
166
+ * Report a command's failure.
167
+ *
168
+ * A configuration error carries its own `file:line:column: error: ` locator
169
+ * (#95), and `loglevel`'s `[timestamp] [ERROR]:` prefix occupies exactly the
170
+ * position a parser reads the path from — so a located failure is printed
171
+ * unprefixed, as `emitDiagnostic` prints every other finding. Everything else
172
+ * is ordinary prose and keeps the log line it always had.
173
+ *
174
+ * @param {unknown} err - What was thrown.
175
+ * @returns {void}
176
+ */
177
+ function reportFailure(err) {
178
+ const message = err instanceof Error ? err.message : String(err);
179
+ if (/** @type {{located?: boolean}} */ (err)?.located)
180
+ console.error(message);
181
+ else log.error(message);
182
+ }
183
+
159
184
  const argv = yargs(hideBin(process.argv))
160
185
  .command(packageCommand())
161
186
  .command(depsCommand())
@@ -303,7 +328,7 @@ function docsCommand() {
303
328
  process.stdout.write(page);
304
329
  }
305
330
  } catch (err) {
306
- log.error(err.message);
331
+ reportFailure(err);
307
332
  process.exitCode = 1;
308
333
  }
309
334
  },
@@ -367,9 +392,79 @@ function lintCommand() {
367
392
  references: argv.references,
368
393
  });
369
394
 
395
+ // What the builders emit, against what the receiving system
396
+ // declares (#60). Reported here rather than at compile: it is
397
+ // a property of the *declarations*, not of any one note, so it
398
+ // is the same answer for every document and belongs where a
399
+ // reader is already being told about the vocabulary.
400
+ const schema = resolveSchemaArtifact(config);
401
+ const schemaFindings = [];
402
+ if (!schema && config.stats?.systemId) {
403
+ // Said out loud, because a check that quietly does nothing
404
+ // is indistinguishable from one that passed — and this
405
+ // whole issue exists because a defect went unnoticed for a
406
+ // release. A system before its first schema build, or a
407
+ // module pinning a version released before the artifact
408
+ // existed, lands here.
409
+ log.info(
410
+ `No published schema for ${config.stats.systemId}` +
411
+ `@${config.stats.systemVersion ?? "?"}, so emitted ` +
412
+ `\`system\` fields are unchecked. A system ` +
413
+ `generates its own; a module gets one from ` +
414
+ `\`content-build deps fetch\`.`,
415
+ );
416
+ }
417
+ const fieldSpecs = config.itemFields ?? {};
418
+ const comparable = Object.keys(fieldSpecs).length > 0;
419
+ if (schema && !comparable) {
420
+ // A schema, and nothing to compare it against. The emitted
421
+ // side of this check is the `fields:` of `itemBuilders`, so
422
+ // a package whose compendium content is committed JSON
423
+ // rather than built from field declarations has an empty
424
+ // one — and every field the system declares would be
425
+ // reported as unemitted. That is hundreds of findings whose
426
+ // only content is that this package does not build
427
+ // documents this way, which is not news and not a defect.
428
+ //
429
+ // Said out loud rather than skipped in silence, for the
430
+ // same reason the absent-schema case is: a check that
431
+ // quietly does nothing reads exactly like one that passed.
432
+ log.info(
433
+ `Read ${config.stats?.systemId ?? "the system"}'s ` +
434
+ `schema, but this package declares no ` +
435
+ `\`itemBuilders\` field specifications — so there ` +
436
+ `is nothing to compare it against and the ` +
437
+ `emitted-versus-declared check does not apply.`,
438
+ );
439
+ }
440
+ if (schema && comparable) {
441
+ const { undeclared, unemitted } = compareFields({
442
+ builders: fieldSpecs,
443
+ artifact: schema.artifact,
444
+ });
445
+ for (const f of undeclared) {
446
+ schemaFindings.push({
447
+ file: schema.source,
448
+ severity: "error",
449
+ message: undeclaredMessage(f),
450
+ });
451
+ }
452
+ for (const f of unemitted) {
453
+ // Advisory: a field the system fills at runtime, or one
454
+ // added ahead of the content that will use it, is not a
455
+ // defect.
456
+ emitDiagnostic({
457
+ file: schema.source,
458
+ severity: "warning",
459
+ message: unemittedMessage(f),
460
+ });
461
+ }
462
+ }
463
+
370
464
  const findings = [
371
465
  ...addresses.findings,
372
466
  ...frontmatter.findings,
467
+ ...schemaFindings,
373
468
  ];
374
469
  for (const finding of findings) emitDiagnostic(finding);
375
470
  if (findings.length) {
@@ -385,7 +480,7 @@ function lintCommand() {
385
480
  );
386
481
  }
387
482
  } catch (err) {
388
- log.error(err.message);
483
+ reportFailure(err);
389
484
  process.exitCode = 1;
390
485
  }
391
486
  },
@@ -466,7 +561,7 @@ function formatCommand() {
466
561
  log.info(`Formatting is clean (${checked} file(s)).`);
467
562
  }
468
563
  } catch (err) {
469
- log.error(err.message);
564
+ reportFailure(err);
470
565
  process.exitCode = 1;
471
566
  }
472
567
  },
@@ -515,7 +610,7 @@ function markdownCommand() {
515
610
  log.info("Markdown is clean.");
516
611
  }
517
612
  } catch (err) {
518
- log.error(err.message);
613
+ reportFailure(err);
519
614
  process.exitCode = 1;
520
615
  }
521
616
  },
@@ -663,7 +758,7 @@ function linksCommand() {
663
758
  );
664
759
  }
665
760
  } catch (err) {
666
- log.error(err.message);
761
+ reportFailure(err);
667
762
  process.exitCode = 1;
668
763
  }
669
764
  },
@@ -739,7 +834,7 @@ function manifestCommand() {
739
834
  });
740
835
  }
741
836
  } catch (err) {
742
- log.error(err.message);
837
+ reportFailure(err);
743
838
  process.exitCode = 1;
744
839
  }
745
840
  },
@@ -868,7 +963,7 @@ function siteCommand() {
868
963
  `landing(s) to ${path.relative(process.cwd(), s.out)}`,
869
964
  );
870
965
  } catch (err) {
871
- log.error(err.message);
966
+ reportFailure(err);
872
967
  process.exitCode = 1;
873
968
  }
874
969
  },
@@ -962,7 +1057,7 @@ function reachabilityCommand() {
962
1057
  );
963
1058
  }
964
1059
  } catch (err) {
965
- log.error(err.message);
1060
+ reportFailure(err);
966
1061
  process.exitCode = 1;
967
1062
  }
968
1063
  },
@@ -1052,7 +1147,7 @@ function depsCommand() {
1052
1147
  if (count)
1053
1148
  log.info(`Fetched ${count} dependency catalogue(s).`);
1054
1149
  } catch (err) {
1055
- log.error(err.message);
1150
+ reportFailure(err);
1056
1151
  process.exitCode = 1;
1057
1152
  }
1058
1153
  },
@@ -1262,7 +1357,7 @@ function packageCommand() {
1262
1357
  });
1263
1358
  }
1264
1359
  } catch (err) {
1265
- log.error(err.message);
1360
+ reportFailure(err);
1266
1361
  process.exitCode = 1;
1267
1362
  }
1268
1363
  },
@@ -80,6 +80,8 @@ import { hideBin } from "yargs/helpers";
80
80
  import { loadPackageBuildConfig } from "../config.mjs";
81
81
  import { loadPackConfig, packConfigPath } from "../engine/pack-config.mjs";
82
82
  import { cleanBuildArtifacts, stageAssets } from "../stage.mjs";
83
+ import { buildSchemaArtifact } from "../engine/schema-extract.mjs";
84
+ import { SCHEMA_ARTIFACT_FILE } from "../engine/foreign-catalog.mjs";
83
85
  import { validateLangSource } from "../lang.mjs";
84
86
  import {
85
87
  analyzeCoverage,
@@ -135,7 +137,14 @@ function ownVersion() {
135
137
  */
136
138
  function die(err) {
137
139
  const message = err instanceof Error ? err.message : String(err);
138
- console.error(`package-build: ${message}`);
140
+ // A located diagnostic already starts with `file:line:column:`, which is
141
+ // exactly the position a parser reads the path from — prefixing it would
142
+ // yield a filename no editor can open (#95).
143
+ console.error(
144
+ /** @type {{located?: boolean}} */ (err)?.located ? message : (
145
+ `package-build: ${message}`
146
+ ),
147
+ );
139
148
  process.exit(1);
140
149
  }
141
150
 
@@ -305,6 +314,109 @@ function assetsCommand() {
305
314
  };
306
315
  }
307
316
 
317
+ /**
318
+ * Format generated text the way the repository formats everything else.
319
+ *
320
+ * Not cosmetic. A generated file that Prettier would reformat leaves
321
+ * `lint:format` and the generator's own `--check` each demanding what the other
322
+ * forbids, and the repository cannot be made green. Resolving the config from
323
+ * the *output path* is what makes one implementation here serve repositories
324
+ * with different Prettier settings.
325
+ *
326
+ * Imported on use, as `prose-lint.mjs` does, so that commands which never
327
+ * format do not pay to load it.
328
+ *
329
+ * @param {string} text - The unformatted content.
330
+ * @param {string} filepath - Where it will be written.
331
+ * @returns {Promise<string>} The formatted content.
332
+ */
333
+ async function formatGenerated(text, filepath) {
334
+ const prettier = await import("prettier");
335
+ const config = await prettier.resolveConfig(filepath);
336
+ return prettier.format(text, { ...config, filepath });
337
+ }
338
+
339
+ /**
340
+ * `schema` — publish this package's DataModel field sets as `schema.json`.
341
+ *
342
+ * The producing half of the check `content-build lint` runs: Foundry discards
343
+ * an unknown `system` key at construction and says nothing, so a content build
344
+ * needs to know what a document will actually receive (#60). It cannot ask a
345
+ * running Foundry, and it cannot read `defineSchema()` from a sibling checkout,
346
+ * so the system publishes the field sets as data — the same shape the link
347
+ * manifest already uses for addresses.
348
+ *
349
+ * `--check` fails when the committed copy disagrees with what the source would
350
+ * produce now, because a generated file nothing checks drifts from its
351
+ * generator silently — and this one is read by other repositories.
352
+ *
353
+ * @returns {object} The yargs command module.
354
+ */
355
+ function schemaCommand() {
356
+ return {
357
+ command: "schema",
358
+ describe: "Publish this package's DataModel field sets as schema.json",
359
+ builder: (y) =>
360
+ y.option("check", {
361
+ type: "boolean",
362
+ default: false,
363
+ describe:
364
+ "Fail when the committed schema.json is out of date " +
365
+ "rather than rewriting it",
366
+ }),
367
+ handler: handler(async (argv) => {
368
+ const config = loadPackageBuildConfig();
369
+ if (!config.schema.length) {
370
+ console.log(
371
+ "package-build: no `packageBuild.schema` declared; " +
372
+ "nothing to publish.",
373
+ );
374
+ return;
375
+ }
376
+
377
+ const pkg = readPackageJson(config);
378
+ const artifact = buildSchemaArtifact({
379
+ rootDir: config.rootDir,
380
+ registries: config.schema,
381
+ packageId: config.packageId,
382
+ version: pkg.version,
383
+ });
384
+
385
+ const out = path.join(config.rootDir, SCHEMA_ARTIFACT_FILE);
386
+ const text = await formatGenerated(JSON.stringify(artifact), out);
387
+ const counts = Object.entries(artifact.documents)
388
+ .map(
389
+ ([kind, subtypes]) =>
390
+ `${Object.keys(subtypes).length} ${kind}`,
391
+ )
392
+ .join(", ");
393
+
394
+ if (argv.check) {
395
+ const current =
396
+ fs.existsSync(out) ? fs.readFileSync(out, "utf8") : null;
397
+ if (current !== text) {
398
+ die(
399
+ `${SCHEMA_ARTIFACT_FILE} does not match what this ` +
400
+ `package's data models would produce — regenerate ` +
401
+ `it with \`package-build schema\`.`,
402
+ );
403
+ }
404
+ console.log(
405
+ `✅ ${SCHEMA_ARTIFACT_FILE} is up to date ` +
406
+ `(${counts} subtypes).`,
407
+ );
408
+ return;
409
+ }
410
+
411
+ fs.writeFileSync(out, text, "utf8");
412
+ console.log(
413
+ `✅ Wrote ${SCHEMA_ARTIFACT_FILE} for ${artifact.system} ` +
414
+ `${artifact.systemVersion} (${counts} subtypes).`,
415
+ );
416
+ }),
417
+ };
418
+ }
419
+
308
420
  /**
309
421
  * `manifest` — generate `system.json` / `module.json` into the build stage.
310
422
  *
@@ -920,6 +1032,7 @@ yargs(hideBin(process.argv))
920
1032
  .command(cleanCommand())
921
1033
  .command(assetsCommand())
922
1034
  .command(manifestCommand())
1035
+ .command(schemaCommand())
923
1036
  .command(langCommand())
924
1037
  .command(bundleCommand())
925
1038
  .command(releaseCommand())
package/config.mjs CHANGED
@@ -59,7 +59,11 @@
59
59
  */
60
60
 
61
61
  import path from "node:path";
62
- import { loadPackConfig } from "./engine/pack-config.mjs";
62
+ import {
63
+ loadPackConfig,
64
+ locateConfigError,
65
+ packConfigPath,
66
+ } from "./engine/pack-config.mjs";
63
67
 
64
68
  /** Keys the reserved section may declare. */
65
69
  const SECTION_KEYS = [
@@ -68,6 +72,7 @@ const SECTION_KEYS = [
68
72
  "assetTransform",
69
73
  "manifest",
70
74
  "manifestFlags",
75
+ "schema",
71
76
  "clean",
72
77
  "lang",
73
78
  "deploy",
@@ -145,12 +150,21 @@ const ARTIFACT_OF_KIND = Object.freeze({
145
150
  });
146
151
 
147
152
  /**
153
+ * Reject a configured value, naming the key it was written under.
154
+ *
155
+ * The dotted path rides on the error as `field` as well as appearing in the
156
+ * message, so {@link loadPackageBuildConfig} — the half that knows which file
157
+ * was read — can resolve it to a line and column (#95). This half stays pure.
158
+ *
148
159
  * @param {string} where - Dotted path of the offending key.
149
160
  * @param {string} problem - What is wrong with it.
150
161
  * @returns {never}
151
162
  */
152
163
  function fail(where, problem) {
153
- throw new TypeError(`package-build config: \`${where}\` ${problem}.`);
164
+ throw Object.assign(
165
+ new TypeError(`package-build config: \`${where}\` ${problem}.`),
166
+ { field: where },
167
+ );
154
168
  }
155
169
 
156
170
  /**
@@ -214,6 +228,41 @@ function normalizeAsset(value, index) {
214
228
  });
215
229
  }
216
230
 
231
+ /**
232
+ * Validate the DataModel registries `package-build schema` reads.
233
+ *
234
+ * Each entry names a file and the binding in it that maps subtype to DataModel
235
+ * class. Two keys rather than a convention because the two repositories that
236
+ * declare this disagree on both: one keeps `ITEM_DM_DEF` and `ACTOR_DM_DEF` in
237
+ * a single configuration module, the other keeps `itemModels` and `actorModels`
238
+ * in a file apiece. Neither layout is more correct, and guessing between them
239
+ * would fail by reading nothing rather than by complaining.
240
+ *
241
+ * The document type is the key, so a package that models only Items simply says
242
+ * so — there is no empty `Actor` entry to write.
243
+ *
244
+ * @param {unknown} value - The `schema` block, or `undefined`.
245
+ * @returns {Readonly<object[]>} Registry entries; empty when absent.
246
+ */
247
+ function normalizeSchema(value) {
248
+ if (value === undefined) return Object.freeze([]);
249
+ if (!isMapping(value)) fail("packageBuild.schema", "must be a mapping");
250
+ const input = /** @type {Record<string, unknown>} */ (value);
251
+
252
+ const entries = Object.entries(input).map(([documentType, entry]) => {
253
+ const where = `packageBuild.schema.${documentType}`;
254
+ if (!isMapping(entry)) fail(where, "must be a mapping");
255
+ const row = /** @type {Record<string, unknown>} */ (entry);
256
+ rejectUnknownKeys(row, ["from", "registry"], `${where}.`);
257
+ return Object.freeze({
258
+ documentType,
259
+ from: requireNonEmptyString(row.from, `${where}.from`),
260
+ registry: requireNonEmptyString(row.registry, `${where}.registry`),
261
+ });
262
+ });
263
+ return Object.freeze(entries);
264
+ }
265
+
217
266
  /**
218
267
  * Validate the manifest specification.
219
268
  *
@@ -682,6 +731,7 @@ export function resolvePackageBuildConfig(shared) {
682
731
  "packageBuild.release.artifact",
683
732
  ),
684
733
  assets: Object.freeze(assets),
734
+ schema: normalizeSchema(section.schema),
685
735
  assetTransform:
686
736
  section.assetTransform === undefined ?
687
737
  null
@@ -842,5 +892,14 @@ export function resolvePackageBuildConfig(shared) {
842
892
  * declares something malformed.
843
893
  */
844
894
  export function loadPackageBuildConfig() {
845
- return resolvePackageBuildConfig(loadPackConfig());
895
+ const shared = loadPackConfig();
896
+ try {
897
+ return resolvePackageBuildConfig(shared);
898
+ } catch (err) {
899
+ // The pure half names the offending key and nothing else; this half
900
+ // knows the file it was read from, so the position is attached here
901
+ // (#95) — the same boundary `configFromData` is for the rest of the
902
+ // configuration.
903
+ throw locateConfigError(err, packConfigPath());
904
+ }
846
905
  }