@heroiclands/package-build 6.0.0 → 7.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 (43) hide show
  1. package/CHANGELOG.md +798 -0
  2. package/CONTENT.md +228 -4
  3. package/bin/content-build.mjs +196 -10
  4. package/bin/package-build.mjs +8 -1
  5. package/config.mjs +25 -3
  6. package/content-config.mjs +283 -29
  7. package/engine/address-diff.mjs +290 -0
  8. package/engine/base-compiler.mjs +25 -0
  9. package/engine/content-links.mjs +132 -27
  10. package/engine/content-lint.mjs +23 -2
  11. package/engine/diagnostics.mjs +61 -1
  12. package/engine/frontmatter-lint.mjs +22 -0
  13. package/engine/generate.mjs +10 -5
  14. package/engine/helpers.mjs +38 -0
  15. package/engine/homepage.mjs +206 -2
  16. package/engine/journals.mjs +8 -1
  17. package/engine/macros.mjs +2 -0
  18. package/engine/pack-config.mjs +143 -13
  19. package/engine/prose-lint.mjs +10 -2
  20. package/engine/scenes.mjs +2 -2
  21. package/engine/site-build.mjs +74 -19
  22. package/engine/web-wikilinks.mjs +13 -4
  23. package/engine/wikilink-syntax.mjs +25 -0
  24. package/engine/wikilinks.mjs +6 -3
  25. package/manifest.mjs +37 -2
  26. package/package.json +5 -3
  27. package/sohl/actors.mjs +23 -10
  28. package/sohl/items.mjs +1 -1
  29. package/types/content-config.d.mts +14 -0
  30. package/types/engine/address-diff.d.mts +108 -0
  31. package/types/engine/base-compiler.d.mts +18 -1
  32. package/types/engine/content-links.d.mts +11 -3
  33. package/types/engine/content-lint.d.mts +4 -1
  34. package/types/engine/diagnostics.d.mts +33 -1
  35. package/types/engine/generate.d.mts +3 -2
  36. package/types/engine/helpers.d.mts +29 -3
  37. package/types/engine/homepage.d.mts +117 -0
  38. package/types/engine/journals.d.mts +7 -1
  39. package/types/engine/pack-config.d.mts +22 -0
  40. package/types/engine/prose-lint.d.mts +10 -2
  41. package/types/engine/site-build.d.mts +28 -3
  42. package/types/engine/wikilink-syntax.d.mts +24 -0
  43. package/types/sohl/actors.d.mts +3 -3
@@ -80,7 +80,12 @@ import path from "node:path";
80
80
  import { createRequire } from "node:module";
81
81
  import YAML from "yaml";
82
82
 
83
- import { defineConfig } from "../content-config.mjs";
83
+ import { defineConfig, DERIVED_SYSTEM_VERSION } from "../content-config.mjs";
84
+ import {
85
+ formatDiagnostic,
86
+ positionOfYamlPath,
87
+ yamlKeyPath,
88
+ } from "./diagnostics.mjs";
84
89
 
85
90
  /** The stem every consuming repository declares its build under. */
86
91
  export const CONFIG_BASENAME = "package-build.config";
@@ -276,6 +281,33 @@ function shippedSystemVersion(rootDir, input) {
276
281
  input.relationships ?? {}
277
282
  ).systems;
278
283
 
284
+ // The `systems:` block declares without requiring (#48), so it is consulted
285
+ // first: a package that has adopted it needs no relationship, and one that
286
+ // ships for two systems could not express itself through a relationship at
287
+ // all. `requiresSystem` names the package-wide default when there is one;
288
+ // otherwise a single declared system is unambiguous. With several and no
289
+ // gate, there is no package-wide answer — each pack carries its own, and
290
+ // {@link statsForPack} is what reads it.
291
+ const systemsBlock =
292
+ /** @type {Record<string, {compatibility?: {verified?: string}}>} */ (
293
+ input.systems ?? {}
294
+ );
295
+ const systemIds = Object.keys(systemsBlock);
296
+ if (systemIds.length) {
297
+ const chosen =
298
+ typeof input.requiresSystem === "string" ? input.requiresSystem
299
+ : systemIds.length === 1 ? systemIds[0]
300
+ : null;
301
+ if (chosen) {
302
+ const verified = systemsBlock[chosen]?.compatibility?.verified;
303
+ if (typeof verified === "string" && verified.length)
304
+ return verified;
305
+ }
306
+ // Several declared and none required: the package-wide value is
307
+ // deliberately absent rather than one of them picked arbitrarily.
308
+ return null;
309
+ }
310
+
279
311
  // A module that names neither a system nor a relationship with one is
280
312
  // system-agnostic on purpose: its packs are core document types carrying no
281
313
  // system data, and it installs under any system. There is no version to
@@ -314,6 +346,95 @@ function shippedSystemVersion(rootDir, input) {
314
346
  return verified;
315
347
  }
316
348
 
349
+ /**
350
+ * Where in the configuration file a dotted field path was written.
351
+ *
352
+ * Only a YAML configuration has text to resolve a path against. An `.mjs` one
353
+ * is deliberately not searched: JavaScript source fed to a YAML parser is not
354
+ * an error — it parses as *something*, and a path could resolve to a line that
355
+ * has nothing to do with the key. A position that is wrong is worse than none,
356
+ * so the extension decides.
357
+ *
358
+ * A path that names a key the file never declared — a missing required one —
359
+ * has no node of its own. The position then names the **mapping it belongs
360
+ * in**, one level up and no further: that entry is a real node, and it is the
361
+ * one the reader has to edit. Walking further would drift away from the key
362
+ * with each step, so a top-level key that is simply absent gets no position at
363
+ * all.
364
+ *
365
+ * @param {string} configPath - Absolute path of the configuration file.
366
+ * @param {string} field - The dotted path the diagnostic names.
367
+ * @returns {{line?: number, column?: number}} Spreadable position fields,
368
+ * empty when nothing can be established honestly.
369
+ */
370
+ function positionInConfig(configPath, field) {
371
+ if (!/\.ya?ml$/i.test(configPath)) return {};
372
+
373
+ let text;
374
+ try {
375
+ text = fs.readFileSync(configPath, "utf8");
376
+ } catch {
377
+ return {};
378
+ }
379
+
380
+ const keyPath = yamlKeyPath(field);
381
+ if (keyPath.length === 0) return {};
382
+
383
+ const declared = positionOfYamlPath(text, keyPath, { key: true });
384
+ if (declared.line !== undefined) return declared;
385
+ if (keyPath.length === 1) return {};
386
+ return positionOfYamlPath(text, keyPath.slice(0, -1), { key: true });
387
+ }
388
+
389
+ /**
390
+ * Attach the position of the key a configuration error names.
391
+ *
392
+ * Eighty-one checks across `content-config.mjs` and `config.mjs` report through
393
+ * one `fail()`, which knows the offending key's dotted path and nothing
394
+ * about where it was written. Locating one of them and not the rest would be
395
+ * worse than locating none — a reader would learn that some configuration
396
+ * errors carry a position and could not predict which — so the path rides on
397
+ * the error and every one of them is located here, at the boundary that knows
398
+ * which file was read (#95).
399
+ *
400
+ * The message keeps its body and gains the `file:line:column: error: ` prefix
401
+ * every other finding in this build already uses, so nothing a reader has today
402
+ * is lost. `located` marks it done, so an error crossing two boundaries is
403
+ * decorated once; the fields are also left on the error, for a caller that
404
+ * wants to re-render it.
405
+ *
406
+ * @param {unknown} err - What was thrown.
407
+ * @param {string} [configPath] - The configuration file that was read.
408
+ * @returns {unknown} The same error, decorated when it named a field.
409
+ */
410
+ export function locateConfigError(err, configPath) {
411
+ const failure =
412
+ /** @type {{field?: unknown, located?: boolean, message?: string, file?: string, line?: number, column?: number}} */ (
413
+ err
414
+ );
415
+ if (!(err instanceof Error)) return err;
416
+ if (
417
+ failure.located ||
418
+ typeof failure.field !== "string" ||
419
+ !failure.field
420
+ ) {
421
+ return err;
422
+ }
423
+ if (!configPath) return err;
424
+
425
+ const at = {
426
+ file: configPath,
427
+ ...positionInConfig(configPath, failure.field),
428
+ };
429
+ Object.assign(failure, at, { located: true });
430
+ failure.message = formatDiagnostic({
431
+ ...at,
432
+ severity: "error",
433
+ message: /** @type {string} */ (failure.message),
434
+ });
435
+ return err;
436
+ }
437
+
317
438
  /**
318
439
  * Turn a parsed YAML configuration into the frozen one the engine reads.
319
440
  *
@@ -400,23 +521,28 @@ export function configFromData(data, configPath) {
400
521
  const stats = input.stats;
401
522
  if (stats !== null && typeof stats === "object" && !Array.isArray(stats)) {
402
523
  const declared = /** @type {Record<string, unknown>} */ (stats);
403
- if (declared.systemVersion !== undefined) {
404
- throw new Error(
405
- `package-build: ${configPath} declares ` +
406
- `\`stats.systemVersion\`, which a data configuration may ` +
407
- `not: a system derives it from the \`version\` of the ` +
408
- `\`package.json\` beside it, and a module from the ` +
409
- `\`compatibility.verified\` of the system it declares a ` +
410
- `relationship with. Remove the key.`,
411
- );
412
- }
524
+ // `stats.systemId` and `stats.systemVersion` are both refused by
525
+ // `defineConfig`, which reports them with a locator — so nothing is
526
+ // rejected here. This half only supplies the value the validator cannot
527
+ // compute: resolving a system package's version means reading the
528
+ // adjacent `package.json`, and `defineConfig` performs no I/O.
529
+ //
530
+ // Passed under a symbol so the channel is not a second, forgeable
531
+ // spelling of the key that was just refused (see
532
+ // {@link DERIVED_SYSTEM_VERSION}).
413
533
  input.stats = {
414
534
  ...declared,
415
- systemVersion: shippedSystemVersion(rootDir, input),
535
+ [DERIVED_SYSTEM_VERSION]: shippedSystemVersion(rootDir, input),
416
536
  };
417
537
  }
418
538
 
419
- return defineConfig(/** @type {never} */ (input));
539
+ try {
540
+ return defineConfig(/** @type {never} */ (input));
541
+ } catch (err) {
542
+ // Every `fail()` in the validator names a key and knows nothing about
543
+ // the file; this is where the two meet.
544
+ throw locateConfigError(err, configPath);
545
+ }
420
546
  }
421
547
 
422
548
  /**
@@ -432,6 +558,10 @@ function loadCodeConfig(configPath) {
432
558
  try {
433
559
  module = require(configPath);
434
560
  } catch (err) {
561
+ // A code configuration calls `defineConfig` itself, so its rejections
562
+ // arrive here. There is no YAML to locate into, but the file is known —
563
+ // `locateConfigError` names it and drops the line.
564
+ locateConfigError(err, configPath);
435
565
  if (
436
566
  /** @type {{ code?: string }} */ (err)?.code ===
437
567
  "ERR_REQUIRE_ASYNC_MODULE"
@@ -240,8 +240,16 @@ function toDiagnostic(directory, result) {
240
240
  *
241
241
  * {@link MARKDOWNLINT_CONFIG} is passed as markdownlint's `optionsDefault`,
242
242
  * which is precisely the "shipped default, consumer overrides" behaviour the
243
- * command promises: a `.markdownlint-cli2.jsonc` found in the tree replaces it,
244
- * and a repository with none gets these rules.
243
+ * command promises: a repository with no configuration of its own gets these
244
+ * rules, and a `.markdownlint-cli2.jsonc` found in the tree overrides them.
245
+ *
246
+ * The override is **key by key, and each key wholesale** — which is not the same
247
+ * as "replaces it", and the difference is the one worth stating. A consumer file
248
+ * declaring only `ignores` keeps this rule set intact, including `default: false`
249
+ * and every per-rule option; but its `ignores` *replaces*
250
+ * {@link MARKDOWN_IGNORES} rather than extending it, so such a file must restate
251
+ * every shared entry it still wants. Omitting `CHANGELOG.md` there silently
252
+ * starts linting a generated file.
245
253
  *
246
254
  * @param {string} root - Repository to lint.
247
255
  * @param {object} [opts]
package/engine/scenes.mjs CHANGED
@@ -414,7 +414,7 @@ export class Scenes extends BasePackCompiler {
414
414
  packageId: foundryPackageId(),
415
415
  name,
416
416
  folder,
417
- stats: defaultStats(),
417
+ stats: this.stats,
418
418
  journalEntryId: entryId,
419
419
  // A map note's prose is a derived JournalEntry: it lands in the
420
420
  // default JournalEntry pack, not in whichever Scene pack the map
@@ -521,7 +521,7 @@ export class Scenes extends BasePackCompiler {
521
521
  sort: 0,
522
522
  flags: {},
523
523
  _id: id,
524
- _stats: defaultStats(),
524
+ _stats: this.stats,
525
525
  _key: `!adventures!${id}`,
526
526
  };
527
527
  }
@@ -65,6 +65,7 @@ import { loadPackConfig } from "./pack-config.mjs";
65
65
  import { searchableFrontmatter } from "./note-package.mjs";
66
66
  import {
67
67
  HOMEPAGE_DESTINATION,
68
+ checkHomepageCount,
68
69
  homepageFrontmatter,
69
70
  homepageTitle,
70
71
  isHomepage,
@@ -269,9 +270,9 @@ export function collectTreePages(tree, ctx) {
269
270
  * packages ship under is a property of the code path rather than of a
270
271
  * configuration that happens to be empty (#55).
271
272
  *
272
- * Returned as a list rather than as the one note there should be. Requiring
273
- * exactly one is #52's, and it is a separate decision — this reports what it
274
- * found so a count is visible either way.
273
+ * Returned as a list rather than as the one note there should be, because the
274
+ * count is what {@link checkHomepageCount} judges (#52) — this walk reports
275
+ * what it found, and {@link buildSite} decides whether that is one.
275
276
  *
276
277
  * @param {string} contentBase - Absolute path to the content tree.
277
278
  * @param {object} ctx - `{ skipDirectories }`.
@@ -345,6 +346,10 @@ export function writeHomepages(outRoot, pages, config) {
345
346
  */
346
347
  export function siteGates(pages, findings, { manifestDir }) {
347
348
  const out = {
349
+ // Always empty here: the homepage count is decided in `buildSite`
350
+ // before the content walk, and a failing count returns without ever
351
+ // reaching these gates (#52). Present so every caller reads one shape.
352
+ homepages: [],
348
353
  frontmatterLinks: findings.fmLinkFindings ?? [],
349
354
  slugErrors: findings.slugFindings ?? [],
350
355
  collisions: [],
@@ -403,6 +408,7 @@ export function siteGates(pages, findings, { manifestDir }) {
403
408
  */
404
409
  export function emptyGates() {
405
410
  return {
411
+ homepages: [],
406
412
  frontmatterLinks: [],
407
413
  slugErrors: [],
408
414
  collisions: [],
@@ -418,6 +424,7 @@ export function emptyGates() {
418
424
  /** Whether any gate produced a finding. */
419
425
  export function gatesFailed(gates) {
420
426
  return Boolean(
427
+ gates.homepages.length ||
421
428
  gates.frontmatterLinks.length ||
422
429
  gates.slugErrors.length ||
423
430
  gates.collisions.length ||
@@ -455,6 +462,39 @@ export function tableUniverse(pages) {
455
462
  return byPackage;
456
463
  }
457
464
 
465
+ /**
466
+ * The front matter a section's landing states about itself.
467
+ *
468
+ * The section metadata a configuration resolved, ready to be written or merged
469
+ * onto a page. Two things happen here and nothing else does:
470
+ *
471
+ * - **`title` leads.** It is the one key every landing has carried since the
472
+ * first one, and a landing whose block opened with `banner:` would be a
473
+ * gratuitous diff on every consumer's tree.
474
+ * - **An absent value is left off**, not written as `undefined` — which is not
475
+ * a value YAML can carry, and would abort the serializer.
476
+ *
477
+ * Everything else the section declared is passed through. That is the point of
478
+ * the function: before #91 both writers transcribed `title` and `banner` by
479
+ * name, so the vocabulary lived in three places — the schema that admits a key
480
+ * and the two writers that copy it — and a key added to the schema alone
481
+ * validated cleanly and then reached no page. The *schema* is the bound worth
482
+ * keeping (see `normalizeSectionMeta`, which refuses a key it does not know and
483
+ * names it); a second, silent bound in the writers is not.
484
+ *
485
+ * @param {object} meta - A resolved `site.sections` / `site.readmeSections`
486
+ * entry.
487
+ * @returns {object} Its front matter, `title` first.
488
+ */
489
+ export function sectionFrontmatter(meta) {
490
+ const data = { title: meta.title };
491
+ for (const [key, value] of Object.entries(meta)) {
492
+ if (key === "title" || value === undefined) continue;
493
+ data[key] = value;
494
+ }
495
+ return data;
496
+ }
497
+
458
498
  /**
459
499
  * The frontmatter a page publishes with.
460
500
  *
@@ -494,12 +534,11 @@ export function pageFrontmatter(page, { readmeSections = {}, decorate }) {
494
534
  if (decorate) decorate(data, page);
495
535
  if (isReadme) {
496
536
  const meta = readmeSections[sec];
497
- if (meta) {
498
- data.title = meta.title;
499
- // Guarded: a title-only entry would otherwise emit
500
- // `banner: undefined`, which the YAML serializer rejects.
501
- if (meta.banner) data.banner = meta.banner;
502
- }
537
+ // What the section says about itself wins over what its README
538
+ // happens to carry — the landing has to match the card linking to
539
+ // it. Assigned rather than transcribed key by key, so a section's
540
+ // vocabulary is decided in one place (#91).
541
+ if (meta) Object.assign(data, sectionFrontmatter(meta));
503
542
  }
504
543
  } else {
505
544
  // A tree's own landing describes the *mount*, and nothing beneath it. A
@@ -509,7 +548,7 @@ export function pageFrontmatter(page, { readmeSections = {}, decorate }) {
509
548
  const isSectionRoot = path.posix.dirname(page.rel) === ".";
510
549
  const meta = isReadme && isSectionRoot ? readmeSections[sec] : null;
511
550
  data = { ...fm, title: meta?.title ?? fm.title ?? name };
512
- if (meta?.banner) data.banner = meta.banner;
551
+ if (meta) Object.assign(data, sectionFrontmatter(meta));
513
552
  }
514
553
  delete data.aliases;
515
554
  return data;
@@ -660,15 +699,11 @@ export function writeSectionLandings(
660
699
  for (const [sec, meta] of Object.entries(sections)) {
661
700
  const dir = path.join(outRoot, sec);
662
701
  fs.mkdirSync(dir, { recursive: true });
663
- // A section may have no hero: the images are CDN assets and not every
664
- // section has one. An explicit `banner: undefined` is not a value YAML
665
- // can carry, so the key is left off entirely.
702
+ // Whatever the section declared, not a list of keys named here see
703
+ // {@link sectionFrontmatter} for why the two lists were one too many.
666
704
  fs.writeFileSync(
667
705
  path.join(dir, "_index.md"),
668
- matter.stringify("", {
669
- title: meta.title,
670
- ...(meta.banner ? { banner: meta.banner } : {}),
671
- }),
706
+ matter.stringify("", sectionFrontmatter(meta)),
672
707
  );
673
708
  written += 1;
674
709
  }
@@ -848,12 +883,32 @@ export function buildSite({ config, outRoot } = {}) {
848
883
  scheme,
849
884
  };
850
885
 
886
+ const homepages = collectHomepages(resolved.paths.content, ctx).pages;
887
+
888
+ // Exactly one homepage, and checked here — before the output tree is
889
+ // cleared and before either mode branches (#52). Before the clear, because
890
+ // a gate that fired after it would have destroyed a good site to report a
891
+ // bad tree. Before the branch, because the requirement does not vary by
892
+ // mode: `publish.site` chooses whether the *content* surfaces are
893
+ // published, and the homepage is the floor beneath both.
894
+ const homepageFindings = checkHomepageCount(homepages, {
895
+ contentBase: resolved.paths.content,
896
+ contentPackage: resolved.contentPackage,
897
+ });
898
+ if (homepageFindings.length) {
899
+ return {
900
+ gates: { ...emptyGates(), homepages: homepageFindings },
901
+ manifests: null,
902
+ tableErrors: [],
903
+ wikiErrors: [],
904
+ stats: null,
905
+ };
906
+ }
907
+
851
908
  // The whole tree is a build artifact, regenerated every run: a page whose
852
909
  // note was deleted or renamed would otherwise linger and keep publishing.
853
910
  fs.rmSync(outBase, { recursive: true, force: true });
854
911
 
855
- const homepages = collectHomepages(resolved.paths.content, ctx).pages;
856
-
857
912
  // Homepage-only stops here, and stopping is the point: nothing below reads
858
913
  // the content tree for pages, so `sohl-kethira-basic` and `harn-adventures`
859
914
  // cannot publish one whatever else their `site:` block declares (#55).
@@ -46,7 +46,12 @@ import { slugify } from "./content-slug.mjs";
46
46
  // Re-exported so a site build keeps one import path for the whole of link
47
47
  // resolution: the same rule that names a page also names an anchor within it.
48
48
  export { slugify };
49
- import { WIKILINK, isSamePage, parseWikilink } from "./wikilink-syntax.mjs";
49
+ import {
50
+ authoredLabel,
51
+ WIKILINK,
52
+ isSamePage,
53
+ parseWikilink,
54
+ } from "./wikilink-syntax.mjs";
50
55
 
51
56
  /** KB heading/anchor slug: lowercase, non-alphanumerics to single hyphens. */
52
57
 
@@ -240,10 +245,14 @@ export function resolveWebWikilinks(body, ctx) {
240
245
  // inline span is source text, not a link (#1505).
241
246
  return replaceOutsideCode(body, WIKILINK, (_m, rawInner) => {
242
247
  const { target, anchor, display } = parseWikilink(rawInner);
248
+ // An empty label is not a label: `[[x|]]` addresses the target and
249
+ // shows its name, so `""` falls through to the same place `null` does
250
+ // (#113). One reading, from {@link authoredLabel}.
251
+ const label = authoredLabel({ display });
243
252
 
244
253
  // `[[#section-slug|Text]]` — a section of this same page.
245
254
  if (isSamePage({ target, anchor })) {
246
- return `[${display ?? anchor}](#${slugify(anchor)})`;
255
+ return `[${label ?? anchor}](#${slugify(anchor)})`;
247
256
  }
248
257
 
249
258
  const key = target.toLowerCase();
@@ -272,7 +281,7 @@ export function resolveWebWikilinks(body, ctx) {
272
281
  // (#1398), and a hyphen inside a note *name* ("Grukar-ahk") is not
273
282
  // one, which is why the rule is the packs' own (#1409).
274
283
  const text =
275
- display ??
284
+ label ??
276
285
  (isAddress(target, ctx.contentTypes) ? hit.name : target);
277
286
  // A pack-only package publishes Foundry addresses and no pages
278
287
  // (#1516), so its entries carry no `path` and resolve to no URL.
@@ -325,6 +334,6 @@ export function resolveWebWikilinks(body, ctx) {
325
334
  reason: "broken type/shortcode",
326
335
  });
327
336
  }
328
- return unresolvedLink(display ?? target, target);
337
+ return unresolvedLink(label ?? target, target);
329
338
  });
330
339
  }
@@ -91,6 +91,31 @@ export function parseWikilink(rawInner) {
91
91
  return { inner: inner.trim(), target, anchor, display, labelled };
92
92
  }
93
93
 
94
+ /**
95
+ * The label an author actually supplied, or `null` when they supplied none.
96
+ *
97
+ * **An empty label is not a label.** `[[x|]]` is deliberately writable — it
98
+ * means "address this target, and show the target's own name" — so `display:
99
+ * ""` has to read as *absent* everywhere a fallback is chosen, exactly as
100
+ * `display: null` does. The two are still distinguishable through
101
+ * {@link ParsedWikilink.labelled}, which is the thing that genuinely differs
102
+ * and which #1409 depends on.
103
+ *
104
+ * Stated here because the two resolvers had already drawn the line in two
105
+ * places and drawn it differently: the packs tested falsiness and were right,
106
+ * the web tested `??` — which falls through on `null` only — and emitted
107
+ * `[](/url/)`, a link with no clickable text, through every build (#113). That
108
+ * is the same drift this module exists to prevent, in the case its own
109
+ * {@link ParsedWikilink} docstring calls out. One reading, one place.
110
+ *
111
+ * @param {{display: string|null}} parsed - A parsed wikilink, or anything
112
+ * carrying its `display`.
113
+ * @returns {string|null} The label, or `null` when there is none to show.
114
+ */
115
+ export function authoredLabel({ display }) {
116
+ return display ? display : null;
117
+ }
118
+
94
119
  /**
95
120
  * Whether a parsed link addresses a section of the page it is written on.
96
121
  *
@@ -93,7 +93,7 @@ import { hasDocEntry, itemDocEntryId } from "./item-docs.mjs";
93
93
  import { replaceOutsideCode } from "./code-fences.mjs";
94
94
  // The syntax lives in `./wikilink-syntax.mjs`, so the web resolver and this
95
95
  // one cannot disagree about what counts as a link.
96
- import { WIKILINK, parseWikilink } from "./wikilink-syntax.mjs";
96
+ import { authoredLabel, WIKILINK, parseWikilink } from "./wikilink-syntax.mjs";
97
97
 
98
98
  export { ITEM_PACK, PACK_BY_TYPE, packForType };
99
99
 
@@ -498,8 +498,11 @@ export function convertWikilinks(markdown, { type, id, pack, docPack, index }) {
498
498
  const { labelled } = parsed;
499
499
  let target = parsed.target;
500
500
  // An unlabelled link shows its interior verbatim, anchor included;
501
- // a labelled one shows its label.
502
- let text = labelled ? (parsed.display ?? "") : parsed.inner;
501
+ // a labelled one shows its label. An *empty* label is not a label
502
+ // `[[x|]]` means "show the target's name" and that reading
503
+ // comes from {@link authoredLabel} so the web resolver cannot draw
504
+ // the line somewhere else (#113).
505
+ let text = labelled ? (authoredLabel(parsed) ?? "") : parsed.inner;
503
506
  const slug = parsed.anchor || null;
504
507
 
505
508
  // Resolve the document: same-page (empty target), type-shortcode, or alias.
package/manifest.mjs CHANGED
@@ -430,8 +430,43 @@ export function buildManifest({ config, packageJson, artifact, flags }) {
430
430
  ...releaseUrls({ repoUrl, version: packageJson.version, artifact }),
431
431
  };
432
432
  if (config.compatibility) derived.compatibility = config.compatibility;
433
- if (config.relationships && Object.keys(config.relationships).length) {
434
- derived.relationships = publishedRelationships(config.relationships);
433
+
434
+ // `requiresSystem` is the gate half of the declare/require split (#48). It
435
+ // emits the `relationships.systems` entry Foundry's `supportsSystem` reads,
436
+ // reusing the `systems:` declaration rather than restating it — a second
437
+ // transcription is free to disagree with what it copied, which is how
438
+ // `stats.systemVersion` came to sit at `0.6.0` for four releases.
439
+ //
440
+ // Declaring a system emits nothing on its own. That is the point: a module
441
+ // shipping content for two systems names both under `systems:`, stamps each
442
+ // pack accordingly, and stays loadable everywhere because it requires
443
+ // neither.
444
+ const relationships = { ...(config.relationships ?? {}) };
445
+ if (config.requiresSystem) {
446
+ const declaredSystem = config.systems?.[config.requiresSystem];
447
+ const entry = {
448
+ id: config.requiresSystem,
449
+ type: "system",
450
+ ...(declaredSystem?.manifest ?
451
+ { manifest: declaredSystem.manifest }
452
+ : {}),
453
+ ...(declaredSystem?.compatibility ?
454
+ {
455
+ compatibility: Object.fromEntries(
456
+ Object.entries(declaredSystem.compatibility).filter(
457
+ ([, v]) => v != null,
458
+ ),
459
+ ),
460
+ }
461
+ : {}),
462
+ };
463
+ // An explicit `relationships.systems` still wins, so a repository
464
+ // mid-migration is never told two different things about itself.
465
+ relationships.systems =
466
+ relationships.systems?.length ? relationships.systems : [entry];
467
+ }
468
+ if (Object.keys(relationships).length) {
469
+ derived.relationships = publishedRelationships(relationships);
435
470
  }
436
471
 
437
472
  const merged = { ...declared, ...derived };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heroiclands/package-build",
3
- "version": "6.0.0",
3
+ "version": "7.0.0",
4
4
  "description": "Shared toolchain for building and shipping a HeroicLands Foundry VTT package — content compilation, manifest, localization, staging, bundle, release and deployment.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "type": "module",
@@ -122,12 +122,12 @@
122
122
  "classic-level": "^3.0.0",
123
123
  "dotenv": "^17.2.3",
124
124
  "fflate": "^0.8.3",
125
- "glob": "^11.0.3",
125
+ "glob": "^13.0.6",
126
126
  "gray-matter": "^4.0.3",
127
127
  "handlebars": "^4.7.9",
128
128
  "loglevel": "^1.9.2",
129
129
  "loglevel-plugin-prefix": "^0.8.4",
130
- "markdown-it": "^14.1.0",
130
+ "markdown-it": "^15.0.0",
131
131
  "markdownlint-cli2": "^0.23.2",
132
132
  "prettier": "^3.9.6",
133
133
  "ssh2-sftp-client": "^12.1.1",
@@ -148,7 +148,9 @@
148
148
  "build:types": "tsc -p tsconfig.dts.json",
149
149
  "format": "prettier --write .",
150
150
  "format:check": "prettier --check .",
151
+ "lint": "npm run format:check && npm run lint:markdown",
151
152
  "lint:markdown": "node bin/content-build.mjs markdown",
153
+ "lint:markdown:fix": "node bin/content-build.mjs markdown --fix",
152
154
  "changeset": "changeset",
153
155
  "changeset:check": "changeset status --since=origin/main",
154
156
  "changeset:version": "changeset version && npm install --package-lock-only",
package/sohl/actors.mjs CHANGED
@@ -327,20 +327,33 @@ export class Actors extends BasePackCompiler {
327
327
  itemsSourceDirs;
328
328
  foreignSourceDirs;
329
329
 
330
- constructor({ itemsSourceDirs, foreignSourceDirs = [], ...options }) {
330
+ constructor({ itemsSourceDirs = [], foreignSourceDirs = [], ...options }) {
331
331
  super(options);
332
332
  // Where the items passes wrote their JSON. Stated by the caller rather
333
333
  // than assumed to be this pack's sibling: the packs' locations are
334
334
  // configuration, and a consumer may put them anywhere (#1508). Every
335
335
  // Item pack, because a repository may ship more than one (#1566).
336
- if (!itemsSourceDirs?.length) {
337
- throw new Error(
338
- "Actors compiler requires `itemsSourceDirs` the generated JSON " +
339
- "of every Item pack, which each being's embedded items are " +
340
- "resolved against. Declare at least one pack of type " +
341
- '"Item" in package-build.config.yaml.',
342
- );
343
- }
336
+ //
337
+ // **Optional, and empty is a legitimate package (#49).** This used to
338
+ // throw unless at least one Item pack was declared, which asked a
339
+ // package to declare the very thing it may exist not to have. An Item
340
+ // pack is system-bound by construction Foundry requires `system` on
341
+ // Item packs — so a deliberately system-agnostic module could satisfy
342
+ // the guard only by naming a system. `harn-ensemble` is the case:
343
+ // 2,512 beings whose embedded items address the `sohl` and `hm3`
344
+ // catalogues, and five affiliation notes of its own.
345
+ //
346
+ // The guard also did not test what it claimed. It counted *declared
347
+ // directories*, not resolvable items, so an empty Item pack satisfied
348
+ // it while a being naming a missing item still failed later. The
349
+ // condition actually cared about is checked where it can be reported
350
+ // precisely: {@link Actors#resolveEmbedded} already errors per
351
+ // unresolved `(type, shortcode)`, naming the being. A package whose
352
+ // beings embed nothing, or whose every address resolves against a
353
+ // dependency catalogue through `foreignSourceDirs`, now compiles with
354
+ // no Item pack at all — and one that is genuinely missing an item
355
+ // still fails, saying which item and which actor rather than which
356
+ // pack is absent.
344
357
  Object.defineProperty(this, "itemsSourceDirs", {
345
358
  value: Object.freeze([...itemsSourceDirs]),
346
359
  writable: false,
@@ -641,7 +654,7 @@ export class Actors extends BasePackCompiler {
641
654
  // `sohl.archetype` (required nullable number) drives
642
655
  // `flags.sohl.docArchetype` (#640 / archetype contract #604).
643
656
  flags: withArchetypeFlag(fm, fm.flags, ctx),
644
- _stats: defaultStats(),
657
+ _stats: this.stats,
645
658
  _key: `!actors!${id}`,
646
659
  };
647
660
  }
package/sohl/items.mjs CHANGED
@@ -167,7 +167,7 @@ export class Items extends BasePackCompiler {
167
167
  // `sohl.archetype` (required nullable number) drives
168
168
  // `flags.sohl.docArchetype` (#640 / archetype contract #604).
169
169
  flags: withArchetypeFlag(fm, fm.flags, `item "${name}"`),
170
- _stats: defaultStats(),
170
+ _stats: this.stats,
171
171
  ownership: { default: 0 },
172
172
  folder,
173
173
  _key: `!items!${id}`,