@heroiclands/package-build 21.1.0 → 21.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/CHANGELOG.md +204 -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/ci/emit-asset-index.mjs +32 -0
  25. package/content-config.mjs +30 -1
  26. package/docs/api.md +100 -26
  27. package/docs/configuration.md +16 -7
  28. package/docs/content-format.md +512 -109
  29. package/engine/actor-compiler.mjs +58 -9
  30. package/engine/art-fields.mjs +292 -0
  31. package/engine/asset-index.mjs +397 -0
  32. package/engine/asset-types.mjs +192 -0
  33. package/engine/base-compiler.mjs +69 -1
  34. package/engine/bundles.mjs +5 -5
  35. package/engine/compile-corpus.mjs +4 -0
  36. package/engine/content-address.mjs +14 -2
  37. package/engine/content-charset.mjs +5 -1
  38. package/engine/content-embeds.mjs +314 -0
  39. package/engine/content-html.mjs +5 -1
  40. package/engine/content-icons.mjs +5 -1
  41. package/engine/content-images.mjs +26 -5
  42. package/engine/content-index.mjs +68 -33
  43. package/engine/content-links.mjs +164 -24
  44. package/engine/frontmatter-lint.mjs +36 -23
  45. package/engine/helpers.mjs +83 -31
  46. package/engine/index-records.mjs +63 -4
  47. package/engine/index.mjs +15 -0
  48. package/engine/infobox-registry.mjs +50 -3
  49. package/engine/infobox.mjs +12 -8
  50. package/engine/item-compiler.mjs +10 -10
  51. package/engine/journals.mjs +3 -0
  52. package/engine/macros.mjs +16 -13
  53. package/engine/map-notes.mjs +29 -22
  54. package/engine/metadata-index.mjs +44 -36
  55. package/engine/note-vocabulary.mjs +71 -29
  56. package/engine/pack-router.mjs +14 -0
  57. package/engine/packagebuild-index.mjs +124 -0
  58. package/engine/packages.mjs +83 -0
  59. package/engine/pathnames.mjs +74 -0
  60. package/engine/pdf-build.mjs +133 -8
  61. package/engine/pdf-render.mjs +9 -2
  62. package/engine/scenes.mjs +12 -9
  63. package/engine/site-build.mjs +43 -16
  64. package/engine/site-index.mjs +10 -1
  65. package/engine/subtype-registry.mjs +21 -0
  66. package/engine/web-wikilinks.mjs +46 -12
  67. package/engine/wikilink-syntax.mjs +20 -2
  68. package/engine/wikilinks.mjs +15 -2
  69. package/hm3/actors.mjs +11 -38
  70. package/package.json +5 -2
  71. package/packagebuild-metadata.jsonl +13 -0
  72. package/sohl/actors.mjs +12 -86
  73. package/sohl/note-schemas.mjs +4 -16
  74. package/types/content-config.d.mts +8 -0
  75. package/types/engine/actor-compiler.d.mts +29 -0
  76. package/types/engine/art-fields.d.mts +192 -0
  77. package/types/engine/asset-index.d.mts +92 -0
  78. package/types/engine/asset-types.d.mts +110 -0
  79. package/types/engine/base-compiler.d.mts +42 -1
  80. package/types/engine/compile-corpus.d.mts +4 -1
  81. package/types/engine/content-charset.d.mts +4 -0
  82. package/types/engine/content-embeds.d.mts +218 -0
  83. package/types/engine/content-html.d.mts +4 -0
  84. package/types/engine/content-icons.d.mts +4 -0
  85. package/types/engine/content-images.d.mts +15 -2
  86. package/types/engine/content-index.d.mts +27 -15
  87. package/types/engine/frontmatter-lint.d.mts +11 -14
  88. package/types/engine/helpers.d.mts +7 -2
  89. package/types/engine/index-records.d.mts +44 -3
  90. package/types/engine/index.d.mts +5 -0
  91. package/types/engine/infobox-registry.d.mts +36 -1
  92. package/types/engine/infobox.d.mts +6 -4
  93. package/types/engine/macros.d.mts +9 -5
  94. package/types/engine/map-notes.d.mts +10 -6
  95. package/types/engine/metadata-index.d.mts +2 -17
  96. package/types/engine/note-vocabulary.d.mts +30 -0
  97. package/types/engine/packagebuild-index.d.mts +56 -0
  98. package/types/engine/packages.d.mts +62 -0
  99. package/types/engine/pathnames.d.mts +41 -81
  100. package/types/engine/pdf-build.d.mts +58 -1
  101. package/types/engine/pdf-render.d.mts +3 -1
  102. package/types/engine/scenes.d.mts +2 -1
  103. package/types/engine/site-build.d.mts +5 -1
  104. package/types/engine/site-index.d.mts +4 -1
  105. package/types/engine/subtype-registry.d.mts +20 -0
  106. package/types/engine/web-wikilinks.d.mts +14 -4
  107. package/types/engine/wikilink-syntax.d.mts +13 -1
  108. package/types/engine/wikilinks.d.mts +7 -1
  109. package/types/sohl/actors.d.mts +0 -1
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heroiclands/package-build",
3
- "version": "21.1.0",
3
+ "version": "21.2.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 },
@@ -146,26 +146,14 @@ const BEING_FIELDS = Object.freeze([
146
146
  * business; their authored vocabulary is the same, so they share one
147
147
  * declaration rather than three copies that could drift.
148
148
  *
149
- * `img` is the one required field — the compiler refuses a map note without it,
150
- * since a scene with no background is not a map. It was spelled `image` and
151
- * read from the `sohl:` block; both were read while the trees were
152
- * swept; that retirement has since completed and `image` is gone.
149
+ * `data.bgImage` is the one required piece of art — the compiler refuses a map
150
+ * note without it, since a scene with no background is not a map. It is
151
+ * declared in the note vocabulary rather than here, because it is an art slot
152
+ * like every other and a Scene has no `img` for the shared key to reach.
153
153
  *
154
154
  * @type {readonly import("../engine/field-spec.mjs").FieldSpec[]}
155
155
  */
156
156
  const MAP_FIELDS = Object.freeze([
157
- {
158
- name: "img",
159
- ...STRING,
160
- required: true,
161
- // Art is not system-specific — a Scene is a core Foundry document, and
162
- // every other note type carries its `img` at the note's top level.
163
- shared: true,
164
- describe:
165
- "The scene's background image. Owned by whichever package its first segment " +
166
- "names — `systems/…` and `modules/…` are emitted unchanged, anything else is " +
167
- "this package's own and is rooted under its assets.",
168
- },
169
157
  {
170
158
  name: "dimensions",
171
159
  ...LIST,
@@ -66,6 +66,7 @@ export const PACKAGE_KINDS: readonly ["systems", "modules", "documentation"];
66
66
  export const DOCUMENTATION_KIND: string;
67
67
  export namespace DEFAULT_PATHS {
68
68
  let content: "assets/content";
69
+ let assets: "assets";
69
70
  let contentIndex: "build/content-index";
70
71
  let packJson: "build/packs-json";
71
72
  let stage: "build/stage/packs";
@@ -299,6 +300,12 @@ export type PathsInput = {
299
300
  * Content tree root.
300
301
  */
301
302
  content?: string | undefined;
303
+ /**
304
+ * The asset roots' parent — the directory
305
+ * holding `icons/`, `images/` and
306
+ * `audio/`.
307
+ */
308
+ assets?: string | undefined;
302
309
  /**
303
310
  * Where `content-index` writes this
304
311
  * package's note index. Outbound, and a
@@ -338,6 +345,7 @@ export type PathsInput = {
338
345
  */
339
346
  export type ResolvedPaths = {
340
347
  content: string;
348
+ assets: string;
341
349
  contentIndex: string;
342
350
  packJson: string;
343
351
  stage: string;
@@ -182,6 +182,29 @@ export class SystemActorCompiler extends BasePackCompiler {
182
182
  itemsSourceDirs?: never[] | undefined;
183
183
  foreignSourceDirs?: never[] | undefined;
184
184
  });
185
+ /**
186
+ * The actor's two pieces of art, resolved, with the being default beneath
187
+ * them.
188
+ *
189
+ * **The default is chosen from the note's tags**, which only a compiler can
190
+ * read: a `character` falls back to one file and a `creature` to another,
191
+ * and both are addresses in the package that ships them. A tree whose index
192
+ * cannot answer that address — a dependency not yet fetched — falls through
193
+ * to the subtype's own default, so the document is never left with no art.
194
+ *
195
+ * `tokenIcon` unset follows `icon`, and the fallback is applied after
196
+ * resolution rather than before: a note naming an icon and no token icon
197
+ * means "the token wears the profile art", not "the token wears the
198
+ * default".
199
+ *
200
+ * @param {object} fm - The note's frontmatter.
201
+ * @param {string} fallback - The subtype's own default art path.
202
+ * @returns {{img: string, token: string}} The two paths.
203
+ */
204
+ actorArt(fm: object, fallback: string): {
205
+ img: string;
206
+ token: string;
207
+ };
185
208
  /** @type {readonly string[]} */
186
209
  itemsSourceDirs: readonly string[];
187
210
  foreignSourceDirs: any;
@@ -260,6 +283,12 @@ export class SystemActorCompiler extends BasePackCompiler {
260
283
  * every embedded id exactly where it was.
261
284
  * Returns null if the descriptor cannot be resolved.
262
285
  *
286
+ * **An entry's `data:` is the authoring container, not a document field.**
287
+ * Its art slot is read here and the container itself goes no further, so an
288
+ * entry writing `data.icon` reaches the document as a resolved `img` and an
289
+ * entry writing none takes its type's default — the same two rules an item
290
+ * note compiles under, reached by the same functions.
291
+ *
263
292
  * @param {Map<string, object>} itemsMap - The predefined items, by address.
264
293
  * @param {string} actorId - The owning actor's id, seeding embedded ids.
265
294
  * @param {string} type - The **note** type the reference names.