@heroiclands/package-build 21.1.0 → 22.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 (115) hide show
  1. package/CHANGELOG.md +245 -0
  2. package/CONTENT.md +107 -3
  3. package/assets/fonts/libertinus/LibertinusMono-Regular.otf +0 -0
  4. package/assets/fonts/libertinus/LibertinusSans-Bold.otf +0 -0
  5. package/assets/fonts/libertinus/LibertinusSans-Italic.otf +0 -0
  6. package/assets/fonts/libertinus/LibertinusSans-Regular.otf +0 -0
  7. package/assets/fonts/libertinus/OFL.txt +94 -0
  8. package/assets/fonts/libertinus/provenance.yaml +11 -0
  9. package/assets/images/banners/afflictionbnr.webp +0 -0
  10. package/assets/images/banners/armorclothingbnr.webp +0 -0
  11. package/assets/images/banners/attributebnr.webp +0 -0
  12. package/assets/images/banners/containerbnr.webp +0 -0
  13. package/assets/images/banners/devdocsbnr.webp +0 -0
  14. package/assets/images/banners/miscgearbnr.webp +0 -0
  15. package/assets/images/banners/mysticalabilitybnr.webp +0 -0
  16. package/assets/images/banners/projectilebnr.webp +0 -0
  17. package/assets/images/banners/rulesbnr.webp +0 -0
  18. package/assets/images/banners/skillbnr.webp +0 -0
  19. package/assets/images/banners/traumabnr.webp +0 -0
  20. package/assets/images/banners/userguidebnr.webp +0 -0
  21. package/assets/images/banners/weapongearbnr.webp +0 -0
  22. package/assets/images/provenance.yaml +4 -0
  23. package/bin/content-build.mjs +19 -5
  24. package/bin/package-build.mjs +6 -5
  25. package/ci/emit-asset-index.mjs +32 -0
  26. package/content-config.mjs +30 -1
  27. package/docs/api.md +103 -27
  28. package/docs/commands.md +13 -11
  29. package/docs/configuration.md +16 -7
  30. package/docs/content-format.md +523 -111
  31. package/engine/actor-compiler.mjs +58 -9
  32. package/engine/art-fields.mjs +292 -0
  33. package/engine/asset-index.mjs +397 -0
  34. package/engine/asset-types.mjs +192 -0
  35. package/engine/base-compiler.mjs +69 -1
  36. package/engine/bundles.mjs +5 -5
  37. package/engine/compile-corpus.mjs +4 -0
  38. package/engine/content-address.mjs +14 -2
  39. package/engine/content-charset.mjs +5 -1
  40. package/engine/content-embeds.mjs +314 -0
  41. package/engine/content-html.mjs +5 -1
  42. package/engine/content-icons.mjs +5 -1
  43. package/engine/content-images.mjs +26 -5
  44. package/engine/content-index.mjs +68 -33
  45. package/engine/content-links.mjs +164 -24
  46. package/engine/dependency-bump.mjs +14 -16
  47. package/engine/frontmatter-lint.mjs +36 -23
  48. package/engine/helpers.mjs +83 -31
  49. package/engine/index-records.mjs +63 -4
  50. package/engine/index.mjs +15 -0
  51. package/engine/infobox-registry.mjs +50 -3
  52. package/engine/infobox.mjs +12 -8
  53. package/engine/item-compiler.mjs +10 -10
  54. package/engine/journals.mjs +3 -0
  55. package/engine/macros.mjs +16 -13
  56. package/engine/map-notes.mjs +29 -22
  57. package/engine/metadata-index.mjs +44 -36
  58. package/engine/note-vocabulary.mjs +71 -29
  59. package/engine/pack-router.mjs +14 -0
  60. package/engine/packagebuild-index.mjs +124 -0
  61. package/engine/packages.mjs +83 -0
  62. package/engine/pathnames.mjs +74 -0
  63. package/engine/pdf-build.mjs +133 -8
  64. package/engine/pdf-render.mjs +49 -12
  65. package/engine/scenes.mjs +12 -9
  66. package/engine/site-build.mjs +43 -16
  67. package/engine/site-index.mjs +10 -1
  68. package/engine/subtype-registry.mjs +21 -0
  69. package/engine/web-wikilinks.mjs +46 -12
  70. package/engine/wikilink-syntax.mjs +20 -2
  71. package/engine/wikilinks.mjs +15 -2
  72. package/hm3/actors.mjs +11 -38
  73. package/manifest.mjs +67 -13
  74. package/package.json +5 -2
  75. package/packagebuild-metadata.jsonl +13 -0
  76. package/sohl/actors.mjs +12 -86
  77. package/sohl/note-schemas.mjs +4 -16
  78. package/types/content-config.d.mts +8 -0
  79. package/types/engine/actor-compiler.d.mts +29 -0
  80. package/types/engine/art-fields.d.mts +192 -0
  81. package/types/engine/asset-index.d.mts +92 -0
  82. package/types/engine/asset-types.d.mts +110 -0
  83. package/types/engine/base-compiler.d.mts +42 -1
  84. package/types/engine/compile-corpus.d.mts +4 -1
  85. package/types/engine/content-charset.d.mts +4 -0
  86. package/types/engine/content-embeds.d.mts +218 -0
  87. package/types/engine/content-html.d.mts +4 -0
  88. package/types/engine/content-icons.d.mts +4 -0
  89. package/types/engine/content-images.d.mts +15 -2
  90. package/types/engine/content-index.d.mts +27 -15
  91. package/types/engine/dependency-bump.d.mts +2 -2
  92. package/types/engine/frontmatter-lint.d.mts +11 -14
  93. package/types/engine/helpers.d.mts +7 -2
  94. package/types/engine/index-records.d.mts +44 -3
  95. package/types/engine/index.d.mts +5 -0
  96. package/types/engine/infobox-registry.d.mts +36 -1
  97. package/types/engine/infobox.d.mts +6 -4
  98. package/types/engine/macros.d.mts +9 -5
  99. package/types/engine/map-notes.d.mts +10 -6
  100. package/types/engine/metadata-index.d.mts +2 -17
  101. package/types/engine/note-vocabulary.d.mts +30 -0
  102. package/types/engine/packagebuild-index.d.mts +56 -0
  103. package/types/engine/packages.d.mts +62 -0
  104. package/types/engine/pathnames.d.mts +41 -81
  105. package/types/engine/pdf-build.d.mts +58 -1
  106. package/types/engine/pdf-render.d.mts +3 -1
  107. package/types/engine/scenes.d.mts +2 -1
  108. package/types/engine/site-build.d.mts +5 -1
  109. package/types/engine/site-index.d.mts +4 -1
  110. package/types/engine/subtype-registry.d.mts +20 -0
  111. package/types/engine/web-wikilinks.d.mts +14 -4
  112. package/types/engine/wikilink-syntax.d.mts +13 -1
  113. package/types/engine/wikilinks.d.mts +7 -1
  114. package/types/manifest.d.mts +42 -1
  115. package/types/sohl/actors.d.mts +0 -1
@@ -65,6 +65,27 @@ export const KNOWN_DOCUMENT_SUBTYPE_MAPS = Object.freeze([
65
65
  HM3_DOCUMENT_SUBTYPES,
66
66
  ]);
67
67
 
68
+ /**
69
+ * The map a pack declaring no `system:` compiles its documents against.
70
+ *
71
+ * A pack names the system its documents are shaped for, and one declaring none
72
+ * is compiled by the fallback pass for its document type — a single-system
73
+ * package's ordinary arrangement, and the one `sohl-thalorna` ships. That pass
74
+ * follows one map whatever a note carries, which makes this the answer to "a
75
+ * document compiled here belongs to *which* system?" wherever a pack has not
76
+ * said.
77
+ *
78
+ * Stated here rather than read off the compiler classes because those are not
79
+ * reachable from a leaf: `engine/item-compiler.mjs` imports the journals pass,
80
+ * which draws infoboxes, so anything the infobox reads cannot import a pass.
81
+ * `tests/default-document-subtypes.test.ts` holds this to `compilerFor`, which
82
+ * is where the fallback is actually decided — so the two cannot drift apart in
83
+ * silence.
84
+ *
85
+ * @type {import("./document-subtypes.mjs").DocumentSubtypeMap}
86
+ */
87
+ export const DEFAULT_DOCUMENT_SUBTYPES = SOHL_DOCUMENT_SUBTYPES;
88
+
68
89
  /**
69
90
  * Every note type any shipped map compiles into an **Actor**.
70
91
  *
@@ -61,6 +61,7 @@ import { slugify } from "./content-slug.mjs";
61
61
  /** KB heading/anchor slug: lowercase, non-alphanumerics to single hyphens. */
62
62
  export { slugify };
63
63
  import { authoredLabel, WIKILINK, isSamePage, parseWikilink } from "./wikilink-syntax.mjs";
64
+ import { resolveEmbeds } from "./content-embeds.mjs";
64
65
 
65
66
  /**
66
67
  * The index key a **piped** target resolves to, or `null` when it does not
@@ -300,15 +301,25 @@ function isPlainMap(value) {
300
301
  * web pages, so the author wrote a real address and there is simply nothing to
301
302
  * link to.
302
303
  *
304
+ * ## Embeds first, and they are not links
305
+ *
306
+ * `![[address|label]]` names a file rather than a note, so it is resolved into
307
+ * an ordinary markdown image before anything looks for a link — which is what
308
+ * stops the link pass reading an embed's interior as a link to a note nobody
309
+ * wrote. The image the rewrite leaves behind is what
310
+ * {@link module:engine/content-images.renderImageFigures} turns into a figure
311
+ * for the website, and what the book reads its staging list out of.
312
+ *
303
313
  * @param {string} body - The markdown body.
304
- * @param {object} ctx - `{ index, collide, sections, contentTypes, packages,
305
- * foreign, type, errors, src, file }`.
314
+ * @param {object} ctx - `{ index, assets, collide, sections, contentTypes,
315
+ * packages, foreign, type, errors, src, file }`.
306
316
  * `packages` is every package an address may name, without which the leading
307
317
  * package segment of a canonical address reads as an unknown type; `foreign`
308
- * is the cross-package manifest index. `src` is the page's display
318
+ * is the cross-package manifest index; `assets` is the address space an embed
319
+ * resolves against. `src` is the page's display
309
320
  * path and `file` the source file a diagnostic should name — absent, `src`
310
321
  * stands in.
311
- * @returns {string} The body with wikilinks rewritten.
322
+ * @returns {string} The body with embeds and wikilinks rewritten.
312
323
  */
313
324
  export function resolveWebWikilinks(body, ctx) {
314
325
  // How many times each authored link has been seen, so two identical links
@@ -317,15 +328,13 @@ export function resolveWebWikilinks(body, ctx) {
317
328
  // `file:line:column:` diagnostic rather than a note-wide one.
318
329
  const seen = new Map();
319
330
  /**
320
- * Records a finding, and returns the marked-up link it renders as.
331
+ * Records a finding against one authored literal.
321
332
  *
322
- * @param {string} all - The authored link, brackets and all.
323
- * @param {object} finding - `{ target, reason }`, plus any extras the class
324
- * carries.
325
- * @param {string} text - What the link renders as.
326
- * @returns {string} The marked span.
333
+ * @param {string} all - The literal, exactly as authored.
334
+ * @param {object} finding - The finding's own fields.
335
+ * @returns {void}
327
336
  */
328
- const report = (all, finding, text) => {
337
+ const record = (all, finding) => {
329
338
  const occurrence = (seen.get(all) ?? 0) + 1;
330
339
  seen.set(all, occurrence);
331
340
  ctx.errors.push({
@@ -337,12 +346,37 @@ export function resolveWebWikilinks(body, ctx) {
337
346
  occurrence,
338
347
  ...finding,
339
348
  });
349
+ };
350
+
351
+ /**
352
+ * Records a finding, and returns the marked-up link it renders as.
353
+ *
354
+ * @param {string} all - The authored link, brackets and all.
355
+ * @param {object} finding - `{ target, reason }`, plus any extras the class
356
+ * carries.
357
+ * @param {string} text - What the link renders as.
358
+ * @returns {string} The marked span.
359
+ */
360
+ const report = (all, finding, text) => {
361
+ record(all, finding);
340
362
  return unresolvedLink(text, finding.target);
341
363
  };
342
364
 
365
+ // An embed names a file, so it resolves before anything looks for a link.
366
+ // Its findings are recorded rather than rendered: the rewrite already
367
+ // leaves an embed it cannot resolve exactly as authored, which is the
368
+ // visible degradation an unresolved link gets from `unresolvedLink`.
369
+ const embedded = resolveEmbeds(body, { index: ctx.assets });
370
+ for (const u of embedded.unresolved) {
371
+ record(u.link, { target: u.target, reason: u.reason, ...(u.type ? { type: u.type } : {}) });
372
+ }
373
+ for (const problem of embedded.problems) {
374
+ record(problem.link, { target: problem.link, message: problem.message });
375
+ }
376
+
343
377
  // Code is verbatim: a `[[…]]` inside a code fence, an indented block or an
344
378
  // inline span is source text, not a link.
345
- return replaceOutsideCode(body, WIKILINK, (all, rawInner) => {
379
+ return replaceOutsideCode(embedded.markdown, WIKILINK, (all, rawInner) => {
346
380
  const parsed = parseWikilink(rawInner);
347
381
  const { target, anchor, display } = parsed;
348
382
  // An empty label is not a label: `[[x|]]` addresses the target and
@@ -57,8 +57,15 @@
57
57
  * unclosed `[[` is a typo, and the alternative is letting it swallow arbitrary
58
58
  * prose in search of a closer. Erring towards "not a link" leaves the author's
59
59
  * text as written, which is the safe direction for a rewriter.
60
+ *
61
+ * **A leading `!` makes it an embed, not a link**, and the lookbehind is what
62
+ * keeps every reader from seeing one as the other. Stated here rather than in
63
+ * each reader for the reason the rest of this module exists: the checker, the
64
+ * pack compilers and the web resolver all match on this pattern, and three
65
+ * copies of the exclusion is three places for it to be forgotten. An embed's
66
+ * own grammar is {@link module:engine/content-embeds}.
60
67
  */
61
- export const WIKILINK = /\[\[([^\]\n]+)\]\]/g;
68
+ export const WIKILINK = /(?<!!)\[\[([^\]\n]+)\]\]/g;
62
69
 
63
70
  /**
64
71
  * The parts of a wikilink's interior.
@@ -171,7 +178,9 @@ export function unlabelledLinkMessage(target) {
171
178
  *
172
179
  * - `unlabelled` — no `|`, so the link addresses nothing.
173
180
  * - `not-an-address` — labelled, but the target does not parse as an address.
181
+ * - `not-lowercase` — a package, system or type segment carries a capital.
174
182
  * - `unknown-type` — definitely qualified, but names no type this build knows.
183
+ * - `not-an-asset` — a real address, embedded where only a file can be drawn.
175
184
  * - `unresolved` — parses as an address, and nothing publishes it.
176
185
  * - `ambiguous` — more than one package publishes the short address.
177
186
  * - `unknown-anchor` — the address resolved, the `#section` it names did not.
@@ -184,6 +193,7 @@ export const LINK_FINDING_REASONS = Object.freeze(
184
193
  "not-an-address",
185
194
  "not-lowercase",
186
195
  "unknown-type",
196
+ "not-an-asset",
187
197
  "unresolved",
188
198
  "ambiguous",
189
199
  "unknown-anchor",
@@ -262,11 +272,13 @@ export function ambiguousAddressMessage(target, packages = []) {
262
272
  * @param {Iterable<string>} [finding.packages] - For `ambiguous`, the
263
273
  * claimants.
264
274
  * @param {string} [finding.anchor] - For `unknown-anchor`, the section named.
275
+ * @param {string} [finding.type] - For `not-an-asset`, the type the address
276
+ * named.
265
277
  * @returns {string} The message.
266
278
  * @throws {Error} On a reason outside the closed set — a resolver inventing one
267
279
  * would otherwise report a link with no explanation at all.
268
280
  */
269
- export function linkFindingMessage({ reason, target, packages, anchor }) {
281
+ export function linkFindingMessage({ reason, target, packages, anchor, type }) {
270
282
  switch (reason) {
271
283
  case "unlabelled":
272
284
  return unlabelledLinkMessage(target);
@@ -285,6 +297,12 @@ export function linkFindingMessage({ reason, target, packages, anchor }) {
285
297
  );
286
298
  case "unknown-type":
287
299
  return `address [[${target}]] names no known content type`;
300
+ case "not-an-asset":
301
+ return (
302
+ `![[${target}]] embeds a \`${type ?? "note"}\`, and an embed draws a ` +
303
+ `file — only an \`icon\`, an \`image\` or an \`audio\` address can be ` +
304
+ `embedded. Write [[${target}|Text]] to link to it instead`
305
+ );
288
306
  case "ambiguous":
289
307
  return ambiguousAddressMessage(target, packages ?? []);
290
308
  case "unknown-anchor":
@@ -86,6 +86,7 @@ import crypto from "crypto";
86
86
 
87
87
  import { compendiumUuid, ITEM_PACK, packForType, pageUuid, PACK_BY_TYPE } from "./ids.mjs";
88
88
  import { readCanonicalKey } from "./content-address.mjs";
89
+ import { ASSET_TYPE_NAMES } from "./asset-types.mjs";
89
90
  import { isSystemSegment, NO_SYSTEM } from "./systems.mjs";
90
91
  import { systemOf } from "./document-subtypes.mjs";
91
92
  import { KNOWN_DOCUMENT_SUBTYPE_MAPS } from "./subtype-registry.mjs";
@@ -354,11 +355,15 @@ export function anchorPageId(noteId, anchorSlug) {
354
355
  * vendored manifests of packages this build links into but does not publish.
355
356
  * @param {string} [contentPackage] - This build's *content* package, which an
356
357
  * authored address may name explicitly. Defaults to `packageId`.
358
+ * @param {object} [opts] - Options.
359
+ * @param {Map<string, object>} [opts.assets] - The files this package ships, by
360
+ * canonical address. They resolve no link — an asset is not a document — and
361
+ * answer only the art fields, which name a file and never a document.
357
362
  * @returns {{byShortcode: Map<string, object>, types: Set<string>}} `types` is
358
363
  * every type the tree actually contains, so a qualifier naming no real type
359
364
  * can be told apart from a missing target.
360
365
  */
361
- export function buildWikilinkIndex(docs, packageId, foreign, contentPackage) {
366
+ export function buildWikilinkIndex(docs, packageId, foreign, contentPackage, { assets } = {}) {
362
367
  if (!packageId) {
363
368
  throw new Error(
364
369
  "buildWikilinkIndex: packageId is required — it is the first " +
@@ -368,7 +373,11 @@ export function buildWikilinkIndex(docs, packageId, foreign, contentPackage) {
368
373
  }
369
374
 
370
375
  const byShortcode = new Map();
371
- const types = new Set();
376
+ // The **asset** types join unconditionally, whether or not this tree holds
377
+ // a file of each: they are a closed vocabulary rather than a census of what
378
+ // was found, and an art field hands the resolver `icon-<shortcode>` whose
379
+ // type has to parse before anything can be looked up.
380
+ const types = new Set(ASSET_TYPE_NAMES);
372
381
 
373
382
  // Each note's address is computed once, here, and every reference to it is
374
383
  // that stored value. Nothing downstream assembles a UUID from parts, so a
@@ -439,8 +448,12 @@ export function buildWikilinkIndex(docs, packageId, foreign, contentPackage) {
439
448
  types,
440
449
  uuidByDoc,
441
450
  packageId,
451
+ /** The content package this build publishes, which an art address defaults to. */
452
+ contentPackage: contentPackage ?? packageId,
442
453
  packages,
443
454
  foreign: foreignByKey,
455
+ /** The files this package ships, by canonical address. */
456
+ assets: assets ?? new Map(),
444
457
  };
445
458
  }
446
459
 
package/hm3/actors.mjs CHANGED
@@ -49,8 +49,8 @@
49
49
  * @module
50
50
  */
51
51
 
52
- import { resolveName, resolveImg } from "../engine/helpers.mjs";
53
- import { buildFromFields, readField, retiredTopLevelKey, STRING } from "../engine/field-spec.mjs";
52
+ import { resolveName } from "../engine/helpers.mjs";
53
+ import { buildFromFields, retiredTopLevelKey, STRING } from "../engine/field-spec.mjs";
54
54
  import { SystemActorCompiler } from "../engine/actor-compiler.mjs";
55
55
  import { renderSection } from "../engine/anchored-sections.mjs";
56
56
  import { documentSubtype } from "../engine/document-subtypes.mjs";
@@ -99,33 +99,6 @@ const ACTOR_FIELDS = Object.freeze([
99
99
  },
100
100
  ]);
101
101
 
102
- /**
103
- * The actor's bio image — the fourth row of the specification's `being` table,
104
- * and the last one that was still read by hand.
105
- *
106
- * Declared for the reason {@link ACTOR_FIELDS} is, and fixed for the reason
107
- * `data.species` was: read with `blockProperty(fm, block, "portrait")` it saw
108
- * the block and the note's top level and nothing else, so the `data.portrait`
109
- * the specification names never reached the document and the `?? defaultImg`
110
- * beside it dressed the miss up as "this note names no art".
111
- *
112
- * It is **not** in `ACTOR_FIELDS`, because `buildFromFields` has no seam for
113
- * the subtype default that has to follow it — the `?? defaultImg` is the whole
114
- * of what distinguishes an unnamed portrait from a deliberately blank one,
115
- * and it needs a subtype the coercion is not handed.
116
- *
117
- * @type {import("../engine/field-spec.mjs").FieldSpec}
118
- */
119
- const BIO_IMAGE_FIELD = Object.freeze({
120
- name: "data.portrait",
121
- legacyKey: "portrait",
122
- to: "bioImage",
123
- shape: "path",
124
- read: (raw) => resolveImg(raw),
125
- default: null,
126
- describe: "Path to the portrait image.",
127
- });
128
-
129
102
  /**
130
103
  * The two `data:` facts HM3 declares on a `character` and not on a `creature`.
131
104
  *
@@ -285,7 +258,10 @@ export class Hm3Actors extends SystemActorCompiler {
285
258
  absPath: this.currentNote?.absPath,
286
259
  })
287
260
  );
288
- const defaultImg = defaultActorImg(subType);
261
+ // The being's own default sits above the subtype's: only the note's
262
+ // tags say whether it is a person or a creature, and only this pass
263
+ // reads them.
264
+ const art = this.actorArt(fm, defaultActorImg(subType));
289
265
 
290
266
  const items = this.buildEmbeddedItems(itemsMap, id, fm, ctx);
291
267
 
@@ -326,11 +302,9 @@ export class Hm3Actors extends SystemActorCompiler {
326
302
  });
327
303
 
328
304
  const system = {
329
- // Nullish, not `||`: a note that names no portrait gets the
330
- // subtype's default, one that writes `""` ships blank on purpose.
331
- // Resolved through the declaration so `data.portrait` is reached at
332
- // all — see {@link BIO_IMAGE_FIELD}.
333
- bioImage: readField(BIO_IMAGE_FIELD, fm, reports) ?? defaultImg,
305
+ // `system.bioImage` is **not** written here, for the reason SoHL's
306
+ // `system.portrait` is not: a being's portrait is the lead image of
307
+ // its `{#appearance}` section, which is the markup below.
334
308
  description: renderSection(body || "", "appearance"),
335
309
  biography: renderSection(body || "", "dossier"),
336
310
  ...buildFromFields(ACTOR_FIELDS, reports)(fm),
@@ -356,12 +330,11 @@ export class Hm3Actors extends SystemActorCompiler {
356
330
  });
357
331
 
358
332
  const effects = blockProperty(fm, block, "effects");
359
- const img = resolveImg(blockProperty(fm, block, "img")) ?? defaultImg;
360
333
 
361
334
  return {
362
335
  name,
363
336
  type: subType,
364
- img,
337
+ img: art.img,
365
338
  _id: id,
366
339
  system,
367
340
  items,
@@ -369,7 +342,7 @@ export class Hm3Actors extends SystemActorCompiler {
369
342
  name,
370
343
  displayName: 0,
371
344
  actorLink: false,
372
- texture: { src: img },
345
+ texture: { src: art.token },
373
346
  width: 1,
374
347
  height: 1,
375
348
  sight: { enabled: false },
package/manifest.mjs CHANGED
@@ -94,6 +94,49 @@ export function normalizeRepoUrl(repository) {
94
94
  return url;
95
95
  }
96
96
 
97
+ /**
98
+ * Where every HeroicLands package's homepage is served from.
99
+ *
100
+ * Stated once here and read by {@link packageHomepage}, so the origin a
101
+ * manifest advertises and the origin the configuration documents cannot come
102
+ * to disagree.
103
+ *
104
+ * @type {string}
105
+ */
106
+ export const HOMEPAGE_ORIGIN = "https://www.heroiclands.org";
107
+
108
+ /**
109
+ * A package's homepage — the page `url` sends a reader to.
110
+ *
111
+ * Derived from the content package rather than declared, because the address is
112
+ * already fixed: every package publishes an authored homepage at
113
+ * `<origin>/<contentPackage>/`, and the shared site-deploy workflow publishes
114
+ * it there. A declared copy would be a second spelling of a settled fact, free
115
+ * to drift — and the one that drifts is this one, because nothing fetches `url`
116
+ * the way Foundry fetches `manifest`, so a wrong value is never reported.
117
+ *
118
+ * The site's own `baseURL` says the same thing, and is deliberately not the
119
+ * source: it sits at `site/hugo.toml` in most repositories and `kb/hugo.toml`
120
+ * in the system, so reading it would mean the packaging half knowing where each
121
+ * repository keeps its site configuration.
122
+ *
123
+ * @param {string} contentPackage - The package's content-package name.
124
+ * @returns {string} The homepage URL, with its trailing slash.
125
+ * @throws {TypeError} When no name is given. Interpolating a missing one yields
126
+ * `<origin>/undefined/`, a URL that resolves and is wrong — and nothing
127
+ * fetches `url`, so it would be advertised for as long as nobody clicked it.
128
+ */
129
+ export function packageHomepage(contentPackage) {
130
+ const name = String(contentPackage ?? "").trim();
131
+ if (!name) {
132
+ throw new TypeError(
133
+ "the configuration declares no `contentPackage`, so the manifest has " +
134
+ "no homepage to advertise.",
135
+ );
136
+ }
137
+ return `${HOMEPAGE_ORIGIN}/${name}/`;
138
+ }
139
+
97
140
  /**
98
141
  * The addresses a Foundry manifest advertises.
99
142
  *
@@ -103,15 +146,23 @@ export function normalizeRepoUrl(repository) {
103
146
  * install at that release forever. `download` points at this exact version,
104
147
  * because that is the archive this manifest describes.
105
148
  *
149
+ * **`url` is the homepage, not the repository.** It is the Project Homepage
150
+ * link a reader follows from the package listing *before* installing anything,
151
+ * so it answers "what is this?" — which a page written for that question does
152
+ * and a source tree does not. `bugs`, `manifest` and `download` address release
153
+ * artefacts and stay on the repository that holds them.
154
+ *
106
155
  * @param {object} opts
107
156
  * @param {string} opts.repoUrl - Normalised repository URL.
157
+ * @param {string} opts.homeUrl - The package's homepage, from
158
+ * {@link packageHomepage}.
108
159
  * @param {string} opts.version - The version being built.
109
160
  * @param {"system"|"module"} opts.artifact - Which artifact is shipped.
110
161
  * @returns {{url: string, bugs: string, manifest: string, download: string}}
111
162
  */
112
- export function releaseUrls({ repoUrl, version, artifact }) {
163
+ export function releaseUrls({ repoUrl, homeUrl, version, artifact }) {
113
164
  return {
114
- url: repoUrl,
165
+ url: homeUrl,
115
166
  bugs: `${repoUrl}/issues`,
116
167
  manifest: `${repoUrl}/releases/latest/download/${artifact}.json`,
117
168
  download: `${repoUrl}/releases/download/v${version}/${artifact}.zip`,
@@ -434,7 +485,12 @@ export function buildManifest({ config, packageJson, artifact, flags }) {
434
485
  id: config.foundryPackage,
435
486
  version: packageJson.version,
436
487
  packs: manifestPacks(config),
437
- ...releaseUrls({ repoUrl, version: packageJson.version, artifact }),
488
+ ...releaseUrls({
489
+ repoUrl,
490
+ homeUrl: packageHomepage(config.contentPackage),
491
+ version: packageJson.version,
492
+ artifact,
493
+ }),
438
494
  };
439
495
  if (config.compatibility) derived.compatibility = config.compatibility;
440
496
 
@@ -482,16 +538,14 @@ export function buildManifest({ config, packageJson, artifact, flags }) {
482
538
  // A flag rather than a top-level key because Foundry's manifest schema is
483
539
  // closed and `flags` is its declared extension point; an unknown key at the
484
540
  // top level is dropped by some readers and rejected by others.
485
- if (config.contentPackage) {
486
- merged.flags = {
487
- ...(declared.flags ?? {}),
488
- metadataUrl: metadataUrl({
489
- repoUrl,
490
- version: packageJson.version,
491
- contentPackage: config.contentPackage,
492
- }),
493
- };
494
- }
541
+ merged.flags = {
542
+ ...(declared.flags ?? {}),
543
+ metadataUrl: metadataUrl({
544
+ repoUrl,
545
+ version: packageJson.version,
546
+ contentPackage: config.contentPackage,
547
+ }),
548
+ };
495
549
 
496
550
  if (flags && Object.keys(flags).length) {
497
551
  merged.flags = { ...(merged.flags ?? declared.flags ?? {}) };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heroiclands/package-build",
3
- "version": "21.1.0",
3
+ "version": "22.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",
@@ -102,6 +102,8 @@
102
102
  "CHANGELOG.md",
103
103
  "CONTENT.md",
104
104
  "README.md",
105
+ "assets",
106
+ "packagebuild-metadata.jsonl",
105
107
  "bin",
106
108
  "bundle.mjs",
107
109
  "ci",
@@ -160,8 +162,9 @@
160
162
  "scripts": {
161
163
  "test": "vitest run",
162
164
  "test:watch": "vitest",
163
- "prepack": "npm run build:types",
165
+ "prepack": "run-s build:types build:asset-index",
164
166
  "build:types": "tsc -p tsconfig.dts.json",
167
+ "build:asset-index": "node ci/emit-asset-index.mjs",
165
168
  "format": "prettier --write .",
166
169
  "format:check": "prettier --check .",
167
170
  "lint": "run-p --aggregate-output -c --max-parallel 4 format:check lint:markdown lint:yaml lint:labels lint:content-format",
@@ -0,0 +1,13 @@
1
+ {"address":{"canonical":"packagebuild-none-image-afflictionbnr"},"asset":{"attribution":"Tom Rodriguez","license":"CC-BY-SA-4.0","notes":"","path":"images/banners/afflictionbnr.webp","source":""},"package":"packagebuild","shortcode":"afflictionbnr","type":"image"}
2
+ {"address":{"canonical":"packagebuild-none-image-armorclothingbnr"},"asset":{"attribution":"Tom Rodriguez","license":"CC-BY-SA-4.0","notes":"","path":"images/banners/armorclothingbnr.webp","source":""},"package":"packagebuild","shortcode":"armorclothingbnr","type":"image"}
3
+ {"address":{"canonical":"packagebuild-none-image-attributebnr"},"asset":{"attribution":"Tom Rodriguez","license":"CC-BY-SA-4.0","notes":"","path":"images/banners/attributebnr.webp","source":""},"package":"packagebuild","shortcode":"attributebnr","type":"image"}
4
+ {"address":{"canonical":"packagebuild-none-image-containerbnr"},"asset":{"attribution":"Tom Rodriguez","license":"CC-BY-SA-4.0","notes":"","path":"images/banners/containerbnr.webp","source":""},"package":"packagebuild","shortcode":"containerbnr","type":"image"}
5
+ {"address":{"canonical":"packagebuild-none-image-devdocsbnr"},"asset":{"attribution":"Tom Rodriguez","license":"CC-BY-SA-4.0","notes":"","path":"images/banners/devdocsbnr.webp","source":""},"package":"packagebuild","shortcode":"devdocsbnr","type":"image"}
6
+ {"address":{"canonical":"packagebuild-none-image-miscgearbnr"},"asset":{"attribution":"Tom Rodriguez","license":"CC-BY-SA-4.0","notes":"","path":"images/banners/miscgearbnr.webp","source":""},"package":"packagebuild","shortcode":"miscgearbnr","type":"image"}
7
+ {"address":{"canonical":"packagebuild-none-image-mysticalabilitybnr"},"asset":{"attribution":"Tom Rodriguez","license":"CC-BY-SA-4.0","notes":"","path":"images/banners/mysticalabilitybnr.webp","source":""},"package":"packagebuild","shortcode":"mysticalabilitybnr","type":"image"}
8
+ {"address":{"canonical":"packagebuild-none-image-projectilebnr"},"asset":{"attribution":"Tom Rodriguez","license":"CC-BY-SA-4.0","notes":"","path":"images/banners/projectilebnr.webp","source":""},"package":"packagebuild","shortcode":"projectilebnr","type":"image"}
9
+ {"address":{"canonical":"packagebuild-none-image-rulesbnr"},"asset":{"attribution":"Tom Rodriguez","license":"CC-BY-SA-4.0","notes":"","path":"images/banners/rulesbnr.webp","source":""},"package":"packagebuild","shortcode":"rulesbnr","type":"image"}
10
+ {"address":{"canonical":"packagebuild-none-image-skillbnr"},"asset":{"attribution":"Tom Rodriguez","license":"CC-BY-SA-4.0","notes":"","path":"images/banners/skillbnr.webp","source":""},"package":"packagebuild","shortcode":"skillbnr","type":"image"}
11
+ {"address":{"canonical":"packagebuild-none-image-traumabnr"},"asset":{"attribution":"Tom Rodriguez","license":"CC-BY-SA-4.0","notes":"","path":"images/banners/traumabnr.webp","source":""},"package":"packagebuild","shortcode":"traumabnr","type":"image"}
12
+ {"address":{"canonical":"packagebuild-none-image-userguidebnr"},"asset":{"attribution":"Tom Rodriguez","license":"CC-BY-SA-4.0","notes":"","path":"images/banners/userguidebnr.webp","source":""},"package":"packagebuild","shortcode":"userguidebnr","type":"image"}
13
+ {"address":{"canonical":"packagebuild-none-image-weapongearbnr"},"asset":{"attribution":"Tom Rodriguez","license":"CC-BY-SA-4.0","notes":"","path":"images/banners/weapongearbnr.webp","source":""},"package":"packagebuild","shortcode":"weapongearbnr","type":"image"}
package/sohl/actors.mjs CHANGED
@@ -46,13 +46,7 @@
46
46
  * @module
47
47
  */
48
48
 
49
- import {
50
- sohlField,
51
- resolveName,
52
- resolveImg,
53
- systemTemplatePriority,
54
- folderField,
55
- } from "../engine/helpers.mjs";
49
+ import { sohlField, resolveName, systemTemplatePriority, folderField } from "../engine/helpers.mjs";
56
50
  import { openingMasteryLevel } from "./skill-base.mjs";
57
51
  import { SystemActorCompiler, renderSection } from "../engine/actor-compiler.mjs";
58
52
  // Which Foundry Actor subtype a note's `type` compiles into. Looked up in the
@@ -63,14 +57,6 @@ import { SOHL_DOCUMENT_SUBTYPES } from "./document-subtypes.mjs";
63
57
  // verbatim, and `sohl.img` / `sohl.effects` / `sohl.flags` overriding their
64
58
  // shared top-level forms for this system alone.
65
59
  import { blockProperty, mergeSystemData } from "../engine/system-block.mjs";
66
- import { readField, retiredTopLevelKey } from "../engine/field-spec.mjs";
67
- // The retirement window's reports, shared with the frontmatter lint so the two
68
- // cannot say different things about the same key.
69
- import {
70
- legacyKeyMessage,
71
- locateFrontmatterKey,
72
- retiredTopLevelMessage,
73
- } from "../engine/retired-fields.mjs";
74
60
 
75
61
  /**
76
62
  * The system this pass compiles for — the block its notes write.
@@ -89,38 +75,6 @@ const DEFAULT_IMG = {
89
75
  being: "systems/sohl/assets/icons/game-icons/delapouite/person.svg",
90
76
  };
91
77
 
92
- /**
93
- * The being's sheet portrait — the one row of the content format's actor
94
- * mapping table that is authored rather than derived.
95
- *
96
- * **Declared, because the position is not a spelling anyone can guess.** This
97
- * was read with `blockProperty(fm, SYSTEM, "portrait")`, which knows the block
98
- * and the note's top level and nothing else — so `data.portrait`, the position
99
- * the specification names and `sohl-thalorna` writes on 646 beings, was
100
- * invisible, and `?? defaultImg` on the next line turned every miss into the
101
- * generic person icon rather than into a complaint. Going through
102
- * {@link module:engine/field-spec.readField} is what makes the mapping table
103
- * executable here as it already is for HM3's `data.species`.
104
- *
105
- * `img` is deliberately **not** declared beside it: the mapping table keeps a
106
- * note's token art at the top level, so `blockProperty` is the whole of its
107
- * resolution and there is no `data.img` to reach for.
108
- *
109
- * @type {import("../engine/field-spec.mjs").FieldSpec}
110
- */
111
- const PORTRAIT_FIELD = Object.freeze({
112
- name: "data.portrait",
113
- legacyKey: "portrait",
114
- to: "portrait",
115
- shape: "path",
116
- // The two empties survive, because the caller's `?? defaultImg` is what
117
- // tells them apart: `null` and an absent key mean "no art named, default
118
- // me", `""` means "ship blank on purpose".
119
- read: (raw) => resolveImg(raw),
120
- default: null,
121
- describe: "Path to the portrait image.",
122
- });
123
-
124
78
  /**
125
79
  * The default art for an actor subtype.
126
80
  *
@@ -366,43 +320,16 @@ export class Actors extends SystemActorCompiler {
366
320
  absPath: this.currentNote?.absPath,
367
321
  })
368
322
  );
369
- const defaultImg = defaultActorImg(subType);
323
+ // The being's own default sits above the subtype's: only the note's
324
+ // tags say whether it is a person or a creature, and only this pass
325
+ // reads them.
326
+ const art = this.actorArt(fm, defaultActorImg(subType));
370
327
 
371
328
  const items = this.buildEmbeddedItems(itemsMap, id, fm, ctx);
372
329
 
373
330
  const { value: authoredFolder, isAddress } = folderField(fm);
374
331
  const folder = this.folderResolver(authoredFolder, { isAddress });
375
332
 
376
- // The two retiring positions a declared field may be read from.
377
- // **Warnings**, on the pattern every retirement in this package
378
- // follows: the note compiles to the correct document either way, so
379
- // reddening a tree over one would refuse before the sweep rather than
380
- // after it. What they buy is a count — without one, nothing says which
381
- // position a value came from, and a default is indistinguishable from
382
- // a miss.
383
- const portraitReports = {
384
- block: SYSTEM,
385
- onLegacyKey: (field) =>
386
- this.noteWarn(
387
- legacyKeyMessage(SYSTEM, field),
388
- locateFrontmatterKey(this.currentNote?.absPath, field.legacyKey),
389
- ),
390
- // Anchored at column 1: the two positions share a spelling here —
391
- // `sohl.portrait` and `portrait` — so a locator that took the first
392
- // match would point at the block key while the message named the
393
- // top-level one.
394
- onRetiredTopLevel: (field) =>
395
- this.noteWarn(
396
- retiredTopLevelMessage(field),
397
- locateFrontmatterKey(
398
- this.currentNote?.absPath,
399
- retiredTopLevelKey(field),
400
- undefined,
401
- { topLevel: true },
402
- ),
403
- ),
404
- };
405
-
406
333
  const system = {
407
334
  // The frontmatter shortcode is the actor's stable `(type, shortcode)`
408
335
  // key — and, for a being that is an archetype, its archetype
@@ -414,11 +341,11 @@ export class Actors extends SystemActorCompiler {
414
341
  // with the schema, since an undeclared `system` key is discarded at
415
342
  // construction without a warning.
416
343
  templatePriority: systemTemplatePriority(fm, ctx),
417
- // Nullish, not `||`: a note that names no portrait gets the
418
- // subtype's default, one that writes `""` ships blank on purpose.
419
- // Resolved through the declaration so `data.portrait` is reached at
420
- // all see {@link PORTRAIT_FIELD}.
421
- portrait: readField(PORTRAIT_FIELD, fm, portraitReports) ?? defaultImg,
344
+ // `system.portrait` is **not** written here. A picture of the
345
+ // subject is a picture, so a being's portrait is the lead image of
346
+ // its `{#appearance}` section — which is the markup below and the
347
+ // key that used to declare one is not a key. With no authored
348
+ // source left, the field keeps the schema's own initial.
422
349
  appearance: renderSection(body || "", "appearance"),
423
350
  dossier: renderSection(body || "", "dossier"),
424
351
  };
@@ -470,8 +397,7 @@ export class Actors extends SystemActorCompiler {
470
397
  return {
471
398
  name,
472
399
  type: subType,
473
- // Nullish, not `||` — see the portrait above.
474
- img: resolveImg(blockProperty(fm, SYSTEM, "img")) ?? defaultImg,
400
+ img: art.img,
475
401
  _id: id,
476
402
  system,
477
403
  items,
@@ -479,7 +405,7 @@ export class Actors extends SystemActorCompiler {
479
405
  name,
480
406
  displayName: 0,
481
407
  actorLink: false,
482
- texture: { src: resolveImg(blockProperty(fm, SYSTEM, "img")) ?? defaultImg },
408
+ texture: { src: art.token },
483
409
  width: 1,
484
410
  height: 1,
485
411
  sight: { enabled: false },