@heroiclands/package-build 20.6.0 → 21.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 (65) hide show
  1. package/CHANGELOG.md +181 -0
  2. package/CONTENT.md +134 -44
  3. package/bin/content-build.mjs +37 -5
  4. package/bin/package-build.mjs +77 -0
  5. package/content-config.mjs +59 -1
  6. package/docs/api.md +149 -19
  7. package/docs/commands.md +75 -0
  8. package/docs/configuration.md +37 -10
  9. package/docs/content-format.md +450 -49
  10. package/engine/content-format.mjs +52 -3
  11. package/engine/content-images.mjs +699 -0
  12. package/engine/dependency-bump.mjs +218 -0
  13. package/engine/frontmatter-lint.mjs +89 -2
  14. package/engine/helpers.mjs +81 -142
  15. package/engine/index.mjs +15 -0
  16. package/engine/infobox-registry.mjs +81 -0
  17. package/engine/infobox-render.mjs +381 -0
  18. package/engine/infobox.mjs +963 -0
  19. package/engine/item-registry.mjs +5 -5
  20. package/engine/journals.mjs +22 -1
  21. package/engine/map-notes.mjs +11 -5
  22. package/engine/metadata-index.mjs +5 -0
  23. package/engine/note-vocabulary.mjs +57 -2
  24. package/engine/pathnames.mjs +374 -0
  25. package/engine/pdf-build.mjs +208 -9
  26. package/engine/pdf-render.mjs +461 -21
  27. package/engine/pdf-toc.mjs +77 -5
  28. package/engine/scenes.mjs +2 -1
  29. package/engine/site-build.mjs +115 -8
  30. package/engine/site-index.mjs +93 -4
  31. package/engine/wikilinks.mjs +93 -0
  32. package/hm3/default-item-art.mjs +14 -15
  33. package/hm3/index.mjs +3 -0
  34. package/hm3/infobox.mjs +64 -0
  35. package/package.json +3 -2
  36. package/sohl/being-info.mjs +9 -3
  37. package/sohl/default-item-art.mjs +18 -16
  38. package/sohl/index.mjs +3 -0
  39. package/sohl/infobox.mjs +499 -0
  40. package/types/content-config.d.mts +7 -0
  41. package/types/engine/content-format.d.mts +36 -0
  42. package/types/engine/content-images.d.mts +281 -0
  43. package/types/engine/dependency-bump.d.mts +89 -0
  44. package/types/engine/frontmatter-lint.d.mts +23 -0
  45. package/types/engine/helpers.d.mts +30 -72
  46. package/types/engine/index.d.mts +5 -0
  47. package/types/engine/infobox-registry.d.mts +36 -0
  48. package/types/engine/infobox-render.d.mts +87 -0
  49. package/types/engine/infobox.d.mts +443 -0
  50. package/types/engine/item-registry.d.mts +5 -5
  51. package/types/engine/journals.d.mts +9 -1
  52. package/types/engine/note-vocabulary.d.mts +51 -0
  53. package/types/engine/pathnames.d.mts +189 -0
  54. package/types/engine/pdf-build.d.mts +46 -0
  55. package/types/engine/pdf-render.d.mts +99 -1
  56. package/types/engine/pdf-toc.d.mts +10 -5
  57. package/types/engine/site-build.d.mts +19 -3
  58. package/types/engine/site-index.d.mts +35 -3
  59. package/types/engine/wikilinks.d.mts +22 -0
  60. package/types/hm3/default-item-art.d.mts +5 -6
  61. package/types/hm3/index.d.mts +1 -0
  62. package/types/hm3/infobox.d.mts +22 -0
  63. package/types/sohl/being-info.d.mts +4 -3
  64. package/types/sohl/index.d.mts +1 -0
  65. package/types/sohl/infobox.d.mts +145 -0
@@ -170,11 +170,11 @@ export function itemFields(type, system) {
170
170
  * `defaultItemArt` was written for. Only the error's *owner* changed: it now
171
171
  * names the registry the consumer declares and can add to.
172
172
  *
173
- * **Resolved by the same rule a note's `img:` is.** The path goes through
174
- * {@link resolveImg}, so `icons/relic.svg` means the consumer's own asset root
175
- * in the registry exactly as it does on a note, and an already-served path
176
- * (`systems/sohl/assets/…`, as every SoHL default is) passes through untouched.
177
- * One spelling, one meaning, wherever it is written.
173
+ * **Resolved by the same rule a note's `img:` is.** The pathname goes through
174
+ * {@link resolveImg}, so `icons/relic.svg` means the consumer's own package in
175
+ * the registry exactly as it does on a note, and `sohl/assets/…` as every
176
+ * SoHL default is written — names the `sohl` package's file wherever it is
177
+ * compiled. One spelling, one meaning, wherever it is written.
178
178
  *
179
179
  * @param {string} type - the item type.
180
180
  * @param {string} [system] - The system compiling it, where a build declares
@@ -50,7 +50,9 @@ import log from "loglevel";
50
50
 
51
51
  import { sohlField, makeId, resolveName, defaultStats, md, folderField } from "./helpers.mjs";
52
52
  import { BasePackCompiler } from "./base-compiler.mjs";
53
- import { anchorPageId } from "./wikilinks.mjs";
53
+ import { anchorPageId, resolveReference } from "./wikilinks.mjs";
54
+ import { infoboxesToHtml, linkToUuid } from "./infobox-render.mjs";
55
+ import { noteInfoboxes } from "./infobox-registry.mjs";
54
56
  import { hasDocEntry, itemDocEntryId } from "./item-docs.mjs";
55
57
  import { JOURNAL_TYPES } from "./ids.mjs";
56
58
 
@@ -277,6 +279,13 @@ export function buildPages(rawPages, entryId, noteName) {
277
279
  * entry: a module may ship the same content for two systems, and each pack's
278
280
  * documents record the system version they were built against. A
279
281
  * caller with no pack in hand gets the package-wide block.
282
+ * @param {string} [params.infobox] - The note's infobox, already rendered to
283
+ * HTML. Prepended to the entry's first page, which is where the format puts
284
+ * it: the box is generated content in document order, before the prose, and
285
+ * a Foundry page is narrow enough that inlining it is the only arrangement
286
+ * that reads. It is **not** a page of its own — a page is what a UUID
287
+ * addresses, and a summary a reader has to navigate to is a summary they do
288
+ * not see.
280
289
  * @returns {object} The JournalEntry document, keyed for the pack.
281
290
  */
282
291
  export function buildJournalEntry({
@@ -287,9 +296,13 @@ export function buildJournalEntry({
287
296
  folder = null,
288
297
  flags,
289
298
  stats = defaultStats(),
299
+ infobox = "",
290
300
  }) {
291
301
  const rawPages = splitPages(markdown, leadName);
292
302
  const pages = buildPages(rawPages, id, name);
303
+ if (infobox.trim() && pages.length) {
304
+ pages[0].text.content = `${infobox}\n${pages[0].text.content}`;
305
+ }
293
306
  return {
294
307
  name,
295
308
  pages,
@@ -422,10 +435,18 @@ export class Journals extends BasePackCompiler {
422
435
  const { value: authoredFolder } = folderField(fm);
423
436
  const folder = this.folderResolver(authoredFolder, { isAddress: true });
424
437
 
438
+ // What the note summarises, in the panel every medium draws from one
439
+ // definition. Links are compendium references rather than website
440
+ // URLs: a player reading this at the table stays in Foundry.
441
+ const boxes = noteInfoboxes(fm, {
442
+ resolve: (ref, hint) => resolveReference(this.linkIndex, ref, hint),
443
+ });
444
+
425
445
  return buildJournalEntry({
426
446
  id,
427
447
  name,
428
448
  markdown,
449
+ infobox: infoboxesToHtml(boxes, { link: linkToUuid }),
429
450
  // A doc-carrying note's lead page is the document itself, not an
430
451
  // "Introduction" — see {@link splitPages}.
431
452
  leadName: ownsDoc ? name : undefined,
@@ -59,6 +59,7 @@ import { CURATED_REGION_EVENTS, EXCLUDED_REGION_EVENTS } from "./region-events.m
59
59
  // A map's background art is `img`, as every other note type's art is. `image`,
60
60
  // the spelling a map alone once used, is retired and gone.
61
61
  import { sohlField } from "./frontmatter.mjs";
62
+ import { resolveImg } from "./helpers.mjs";
62
63
 
63
64
  /* -------------------------------------------------------------------- */
64
65
  /* Note types and their canvas profiles */
@@ -907,7 +908,7 @@ export function buildScene(fm, ctx) {
907
908
  // Read from the note rather than from its `sohl:` block: art is not
908
909
  // system-specific, so `img` is authored at the top level like every other
909
910
  // type's, and `sohlField` honours the block for anything already there.
910
- const img = sohlField(fm, "img");
911
+ const img = resolveImg(sohlField(fm, "img"));
911
912
  if (!img) throw new Error("a map note needs an `img`");
912
913
 
913
914
  const warn = (message) => {
@@ -985,7 +986,7 @@ export function buildScene(fm, ctx) {
985
986
  * still works.
986
987
  * @returns {object} The Level document, keyed for the pack.
987
988
  */
988
- export function buildLevel(sohl, sceneId, img = sohlField({ sohl }, "img")) {
989
+ export function buildLevel(sohl, sceneId, img = resolveImg(sohlField({ sohl }, "img"))) {
989
990
  const level = {
990
991
  _id: DEFAULT_LEVEL_ID,
991
992
  name: sohl.levelName ?? "Ground",
@@ -994,7 +995,9 @@ export function buildLevel(sohl, sceneId, img = sohlField({ sohl }, "img")) {
994
995
  color: sohl.backgroundColor ?? "#999999",
995
996
  src: img,
996
997
  },
997
- foreground: { src: sohl.overlay ?? null },
998
+ // The overlay is a pathname like the background, and resolves by the
999
+ // same rule — a scene draws the two from one authored statement each.
1000
+ foreground: { src: resolveImg(sohl.overlay ?? null) },
998
1001
  sort: 0,
999
1002
  _key: `!scenes.levels!${sceneId}.${DEFAULT_LEVEL_ID}`,
1000
1003
  };
@@ -1117,7 +1120,9 @@ export function buildTiles(sohl, geom, ctx) {
1117
1120
  rotation: spec.rotation ?? 0,
1118
1121
  alpha: spec.alpha ?? 1,
1119
1122
  sort: 0,
1120
- texture: { src: spec.image },
1123
+ // A tile's texture is a pathname, resolved by the same rule the
1124
+ // scene's own background is.
1125
+ texture: { src: resolveImg(spec.image) },
1121
1126
  _key: `!scenes.tiles!${ctx.sceneId}.${id}`,
1122
1127
  };
1123
1128
  });
@@ -1146,7 +1151,8 @@ export function buildSounds(sohl, geom, ctx) {
1146
1151
  x,
1147
1152
  y,
1148
1153
  radius: spec.radius ?? 0,
1149
- path: spec.path,
1154
+ // An ambient sound is a file a package ships, like the pictures.
1155
+ path: resolveImg(spec.path),
1150
1156
  repeat: spec.repeat ?? true,
1151
1157
  volume: spec.volume ?? 0.5,
1152
1158
  walls: spec.walls ?? true,
@@ -362,6 +362,11 @@ export function loadForeignIndexes(config, localPackages, bases = PACKAGE_BASE)
362
362
  doc: record.documentation ?? undefined,
363
363
  anchors: foundry?.anchors,
364
364
  type: parts.type,
365
+ // What the note *is*, not only where it lives. A consumer
366
+ // grouping a reference by the family its target declares —
367
+ // an infobox sorting a being's skills — has no other way to
368
+ // learn it about a note another package publishes.
369
+ subType: record.subType ?? undefined,
365
370
  package: pkg,
366
371
  });
367
372
  }
@@ -262,6 +262,21 @@ export const DRAFT_TAG = "draft";
262
262
  * Kind and character are separate groups because one slot could not hold both: a
263
263
  * fishing village is a `village` that is `fishing`, and the single-valued field
264
264
  * this replaced had to spell it `Fishing Village` as a value of its own.
265
+ *
266
+ * **A group carrying `exclusive` is a single-valued slot**, and that is the one
267
+ * closure a tag vocabulary can make. Its tags are not several things the subject
268
+ * may be at once — they are the alternative answers to one question, so a note
269
+ * naming two of them has named none, and both together are refused as an error.
270
+ * The property is opt-in and changes nothing for a group without it: a place is
271
+ * freely a `port` and a `town`, and `draft` is orthogonal to everything. The
272
+ * value is what the slot is called, for the message a reader gets.
273
+ *
274
+ * **Closure stops at the slot, and deliberately.** A tag outside an exclusive
275
+ * group's list does not fill that group's slot and is not refused for failing
276
+ * to — `tags:` is open and a being tagged `undead` is describing the subject in
277
+ * the author's own words. What is refused is a near miss of a declared value,
278
+ * and two values of one slot; there is no third refusal to make without taking
279
+ * back the openness of the region these tags sit in.
265
280
  */
266
281
  export const DECLARED_TAGS = Object.freeze({
267
282
  /** What a place *is*. */
@@ -343,6 +358,15 @@ export const DECLARED_TAGS = Object.freeze({
343
358
  "unguilded",
344
359
  ]),
345
360
  }),
361
+ /**
362
+ * What kind of being this is — a person, or one of the beasts and made
363
+ * things. A being is one or the other, so the group is a slot.
364
+ */
365
+ beingKind: Object.freeze({
366
+ types: ["being"],
367
+ exclusive: "kind",
368
+ tags: Object.freeze(["character", "creature"]),
369
+ }),
346
370
  /** A note's working state, which any note may carry. */
347
371
  state: Object.freeze({ types: null, tags: Object.freeze([DRAFT_TAG]) }),
348
372
  });
@@ -355,8 +379,39 @@ export const DECLARED_TAGS = Object.freeze({
355
379
  * @returns {readonly string[]} The tags, in declaration order.
356
380
  */
357
381
  export function declaredTags(type, groups = DECLARED_TAGS) {
358
- const applies = Object.values(groups).filter((g) => !g.types || g.types.includes(type));
359
- return Object.freeze(applies.flatMap((g) => g.tags));
382
+ return Object.freeze(applicableTagGroups(type, groups).flatMap((g) => g.tags));
383
+ }
384
+
385
+ /**
386
+ * The declared groups scoped to this type, in declaration order.
387
+ *
388
+ * The one reading of `types` that the flattened list and the slot check share,
389
+ * so the two can never disagree about which groups a `being` is held to.
390
+ *
391
+ * @param {string} type - The note's type.
392
+ * @param {object} [groups] - The grouped declaration.
393
+ * @returns {object[]} The groups that apply.
394
+ */
395
+ export function applicableTagGroups(type, groups = DECLARED_TAGS) {
396
+ return Object.values(groups).filter((g) => !g.types || g.types.includes(type));
397
+ }
398
+
399
+ /**
400
+ * The single-valued slots a note of this type has, in declaration order.
401
+ *
402
+ * A group carrying `exclusive` states alternatives rather than attributes, so a
403
+ * note carrying two of its tags has answered one question twice. Only such a
404
+ * group is returned: the check has nothing to say about a group whose tags
405
+ * genuinely accumulate.
406
+ *
407
+ * @param {string} type - The note's type.
408
+ * @param {object} [groups] - The grouped declaration.
409
+ * @returns {{slot: string, tags: readonly string[]}[]} The slots and their values.
410
+ */
411
+ export function exclusiveTagGroups(type, groups = DECLARED_TAGS) {
412
+ return applicableTagGroups(type, groups)
413
+ .filter((g) => g.exclusive)
414
+ .map((g) => ({ slot: g.exclusive, tags: g.tags }));
360
415
  }
361
416
 
362
417
  /**
@@ -0,0 +1,374 @@
1
+ /*
2
+ * This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
3
+ * Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
4
+ *
5
+ * This work is licensed under the GNU General Public License v3.0 (GPLv3).
6
+ * You may copy, modify, and distribute it under the terms of that license.
7
+ *
8
+ * For full terms, see the LICENSE.md file in the project root or visit:
9
+ * https://www.gnu.org/licenses/gpl-3.0.html
10
+ *
11
+ * SPDX-License-Identifier: GPL-3.0-or-later
12
+ */
13
+
14
+ /**
15
+ * One authored pathname, and the four addresses it resolves to.
16
+ *
17
+ * A note names a file once — in `img:`, in `data.portrait:`, in the body of a
18
+ * markdown image — and four surfaces have to serve it: a Foundry install, this
19
+ * repository's own working tree, the website, and the book. Each addresses the
20
+ * same file differently, so the authored pathname is a *statement of
21
+ * ownership* and every surface derives its own address from it. One statement,
22
+ * four derivations, one rule.
23
+ *
24
+ * ## The rule
25
+ *
26
+ * **The first segment says which package owns the file, when it is followed by
27
+ * `assets/`.** Everything after `assets/` is the *suffix* — the path inside
28
+ * that package's shipped tree, and the one piece every form is built from.
29
+ *
30
+ * | Authored | Owner | Suffix |
31
+ * | --------------------------- | ---------------- | ------------------- |
32
+ * | `sohl/assets/icons/a.svg` | the `sohl` package | `icons/a.svg` |
33
+ * | `images/beings/b.webp` | **this** package | `images/beings/b.webp` |
34
+ *
35
+ * A pathname that does not open with `<package>/assets/` belongs to the package
36
+ * being built, and the whole of it is the suffix. That is the ordinary case and
37
+ * the one nearly every note writes.
38
+ *
39
+ * The four forms, for a `thalorna` note writing `images/map.webp` (`thalorna`
40
+ * ships as the Foundry module `sohl-thalorna`):
41
+ *
42
+ * | Form | Address |
43
+ * | --------- | -------------------------------------------------- |
44
+ * | `foundry` | `modules/sohl-thalorna/assets/images/map.webp` |
45
+ * | `local` | `assets/images/map.webp` |
46
+ * | `web` | `https://cdn.heroiclands.org/thalorna/images/map.webp` |
47
+ * | `pdf` | `assets/images/map.webp` |
48
+ *
49
+ * **`<package>` and `<foundry-id>` are two different names.** The package is
50
+ * `thalorna` — what the content is called, what the website serves it under,
51
+ * and what a note writes. The Foundry id is `sohl-thalorna` — what Foundry
52
+ * installs the module as, and the only place that name appears. They coincide
53
+ * for `sohl` and `hm3`, which is exactly why the two are kept apart here rather
54
+ * than treated as one value.
55
+ *
56
+ * `local` and `pdf` read the same and mean different places: `local` is the file
57
+ * in the owning repository's working tree, `pdf` is where the book stages a copy
58
+ * beside its Typst source. They are derived separately because only one of them
59
+ * is a file a build may open — see {@link PathnameForms.own}.
60
+ *
61
+ * ## What is not a package pathname
62
+ *
63
+ * **An off-install address passes through on every surface**: a URL, a
64
+ * protocol-relative `//host/…`, or a `/`-rooted path, which Foundry serves from
65
+ * the data root and which names no package at all. That is how a note addresses
66
+ * core Foundry art (`/icons/svg/mystery-man.svg`) or a package this build knows
67
+ * nothing about (`/systems/dnd5e/icons/spell.webp`).
68
+ *
69
+ * **A package this build has never heard of keeps its ownership.** The website
70
+ * and the book need only the package's name and the suffix, so both resolve;
71
+ * the Foundry address needs the package's kind and its Foundry id, which only a
72
+ * declared relationship carries, so that one form comes back `null` and the
73
+ * caller that needs it refuses. Reading such a pathname as this package's own
74
+ * would file one package's name inside another's tree and say nothing.
75
+ *
76
+ * **A `systems/…` or `modules/…` pathname is refused.** It is a Foundry address
77
+ * written where an ownership statement belongs: it resolves for Foundry and for
78
+ * nothing else, because neither the website nor the book has any such directory.
79
+ * {@link pathnameProblem} names the replacement, and every surface refuses the
80
+ * value rather than deriving an address from it — a wrong address that resolves
81
+ * to a 404 is the failure this module exists to remove, and inventing one here
82
+ * would reintroduce it one directory along.
83
+ *
84
+ * ## The two empties
85
+ *
86
+ * `null` — or an absent key, which arrives as `undefined` — means **unset**: the
87
+ * note names no file and the caller's default applies. `""` means **blank on
88
+ * purpose**: the note names no file and wants none, so no default may replace
89
+ * it. `resolvePathname` returns `null` for the first and a form object whose
90
+ * every address is `""` for the second, so the two stay distinguishable all the
91
+ * way to the caller.
92
+ *
93
+ * @module
94
+ */
95
+
96
+ /**
97
+ * The directory a package ships its files in, and the segment that marks a
98
+ * pathname's first segment as a package name.
99
+ *
100
+ * One constant rather than a literal in six places: it is the second segment of
101
+ * an authored package pathname, the last segment of a Foundry asset root, the
102
+ * whole of the `local` form's prefix, and the directory the book stages into.
103
+ *
104
+ * @type {string}
105
+ */
106
+ export const ASSETS_SEGMENT = "assets";
107
+
108
+ /**
109
+ * The Foundry roots that mark a pathname as written in Foundry's own spelling.
110
+ *
111
+ * `worlds/` is left out for the reason it was always left out: a package may not
112
+ * ship files out of a world, so a note writing one has made a different mistake
113
+ * and gets the ordinary "this package owns it" reading, which yields a plainly
114
+ * broken path rather than a plausible one.
115
+ *
116
+ * @type {readonly string[]}
117
+ */
118
+ const FOUNDRY_ROOTS = Object.freeze(["systems", "modules"]);
119
+
120
+ /**
121
+ * The relationship kinds whose packages a note may address.
122
+ *
123
+ * `conflicts` is absent: a package this one cannot run beside is not one whose
124
+ * artwork it cites.
125
+ *
126
+ * @type {readonly string[]}
127
+ */
128
+ const ADDRESSABLE_RELATIONSHIPS = Object.freeze(["systems", "requires", "recommends"]);
129
+
130
+ /**
131
+ * The surfaces one authored pathname resolves for.
132
+ *
133
+ * Exported so a test can assert that every form a resolution carries is one of
134
+ * these, and that none is missing — the guard against a fifth surface being
135
+ * added to one caller and forgotten in the resolver.
136
+ *
137
+ * @type {readonly string[]}
138
+ */
139
+ export const PATHNAME_SURFACES = Object.freeze(["foundry", "local", "web", "pdf"]);
140
+
141
+ /**
142
+ * One authored pathname, resolved.
143
+ *
144
+ * @typedef {object} PathnameForms
145
+ * @property {string} authored The pathname exactly as the note wrote it.
146
+ * @property {"blank"|"external"|"package"} state `blank` for `""`, `external`
147
+ * for an address no package owns, `package` for an owned file.
148
+ * @property {string|null} package The content package that owns the file, or
149
+ * `null` when no package does.
150
+ * @property {string|null} suffix The path inside that package's shipped tree.
151
+ * @property {boolean} own Whether the owner is the package being built, and so
152
+ * whether `local` names a file this build may open.
153
+ * @property {string|null} foundry The address inside a Foundry install.
154
+ * @property {string|null} local The file in the owning repository's tree.
155
+ * @property {string|null} web The address the website serves.
156
+ * @property {string|null} pdf Where the book stages its copy.
157
+ */
158
+
159
+ /**
160
+ * Whether a pathname addresses something outside every package.
161
+ *
162
+ * Three shapes, each a different kind of "no package owns this": a URI scheme
163
+ * (`https:`, `data:`), a protocol-relative `//host/…`, and a `/`-rooted path,
164
+ * which Foundry serves from the data root rather than from any package.
165
+ *
166
+ * @param {string} s - A non-empty authored pathname.
167
+ * @returns {boolean} Whether every surface emits it unchanged.
168
+ */
169
+ function isExternal(s) {
170
+ // Checked before the single-slash case, which would otherwise claim it.
171
+ if (s.startsWith("//")) return true;
172
+ if (s.startsWith("/")) return true;
173
+ return /^[a-z][a-z0-9+.-]*:/i.test(s);
174
+ }
175
+
176
+ /**
177
+ * The pathname a Foundry-spelled one should be written as.
178
+ *
179
+ * Both shapes a tree carries fold into the same answer: a served path that
180
+ * already names an `assets/` directory keeps its suffix, and one that does not —
181
+ * `hm3` serves its pictures from `images/` at its own root — gains the segment,
182
+ * because `assets/` is where a package's files sit in every form this module
183
+ * derives.
184
+ *
185
+ * @param {string} s - A pathname opening with a Foundry root.
186
+ * @returns {string} What to write instead.
187
+ */
188
+ function convertedSpelling(s) {
189
+ const [, id, ...rest] = s.split("/");
190
+ const tail = rest.join("/");
191
+ if (rest[0] === ASSETS_SEGMENT) return `${id}/${tail}`;
192
+ return `${id}/${ASSETS_SEGMENT}/${tail}`;
193
+ }
194
+
195
+ /**
196
+ * What is wrong with an authored pathname, or `""` when nothing is.
197
+ *
198
+ * Config-free, and a sentence rather than a code, so the lint that has a line
199
+ * and a column to attach it to and the resolver that has only a file say the
200
+ * same thing about the same value.
201
+ *
202
+ * @param {string|null|undefined} raw - The pathname, as authored.
203
+ * @returns {string} The problem, as a finding's sentence, or `""`.
204
+ */
205
+ export function pathnameProblem(raw) {
206
+ if (raw == null) return "";
207
+ const s = String(raw);
208
+ if (!s || isExternal(s)) return "";
209
+ const root = s.split("/")[0];
210
+ if (!FOUNDRY_ROOTS.includes(root)) return "";
211
+ return (
212
+ `\`${s}\` is a Foundry address — write \`${convertedSpelling(s)}\`. A pathname ` +
213
+ "names the package that owns the file and the path inside that package's " +
214
+ "`assets/`, and each surface derives its own address from it: Foundry gets " +
215
+ "the path inside the install, the website gets one on the asset host, and " +
216
+ "the book gets a staged copy. A `" +
217
+ `${root}/` +
218
+ "` path is only one of those three, so the other two serve a file that " +
219
+ "is not there"
220
+ );
221
+ }
222
+
223
+ /**
224
+ * Every game system this build compiles content for.
225
+ *
226
+ * Four declarations say so, and a package makes whichever of them its situation
227
+ * calls for: `systems:` states a system it stamps content against, `packs[].system`
228
+ * gates a pack on one, `requiresSystem` restricts the package to one, and
229
+ * `relationships.systems` names one in the shipped manifest. `harn-ensemble`
230
+ * ships an HM3 pack and a SoHL pack and declares neither relationship, because
231
+ * naming a system in a relationship is what would stop Foundry loading the
232
+ * module in the other one's world — so reading any single declaration would
233
+ * miss the package whose content cites both systems' artwork.
234
+ *
235
+ * @param {object} config - The resolved build configuration.
236
+ * @returns {string[]} The system ids, deduplicated.
237
+ */
238
+ function declaredSystemIds(config) {
239
+ return [
240
+ ...new Set(
241
+ [
242
+ ...Object.keys(config.systems ?? {}),
243
+ config.requiresSystem,
244
+ ...(config.packs ?? []).map((pack) => pack.system),
245
+ ...(config.relationships?.systems ?? []).map((rel) => rel.id),
246
+ ].filter(Boolean),
247
+ ),
248
+ ];
249
+ }
250
+
251
+ /**
252
+ * Every content package this build can resolve a pathname against.
253
+ *
254
+ * The package being built; every game system it compiles content for, which
255
+ * Foundry serves from `systems/<id>`; and every other package it declares a
256
+ * relationship with, which states that package's Foundry id and — where the two
257
+ * words differ — what its content is called.
258
+ *
259
+ * @param {object} config - The resolved build configuration.
260
+ * @returns {Map<string, {root: string|null, id: string|null, own: boolean}>} The
261
+ * packages, by content package name. `root` is the Foundry directory the
262
+ * package is served from, `null` where the package ships no Foundry package.
263
+ */
264
+ export function packageAddresses(config) {
265
+ /** @type {Map<string, {root: string|null, id: string|null, own: boolean}>} */
266
+ const out = new Map();
267
+ if (config.contentPackage) {
268
+ out.set(config.contentPackage, {
269
+ // `assetRoot` is `<root>/<id>/assets`, and `null` for a
270
+ // `documentation` package — which is the same "Foundry serves no
271
+ // files for this" the `root` below says.
272
+ root: config.assetRoot ? config.packageKind : null,
273
+ id: config.foundryPackage ?? null,
274
+ own: true,
275
+ });
276
+ }
277
+ // The package being built wins every collision: it is the one whose files
278
+ // this repository actually holds, and another declaration of the same name
279
+ // describes that very package from outside.
280
+ for (const id of declaredSystemIds(config)) {
281
+ if (out.has(id)) continue;
282
+ out.set(id, { root: "systems", id, own: false });
283
+ }
284
+ for (const kind of ADDRESSABLE_RELATIONSHIPS) {
285
+ for (const rel of config.relationships?.[kind] ?? []) {
286
+ const name = rel.contentPackage ?? rel.id;
287
+ if (out.has(name)) continue;
288
+ const type = rel.type ?? (kind === "systems" ? "system" : "module");
289
+ out.set(name, { root: `${type}s`, id: rel.id, own: false });
290
+ }
291
+ }
292
+ return out;
293
+ }
294
+
295
+ /**
296
+ * Resolve one authored pathname into the address each surface serves.
297
+ *
298
+ * @param {string|null|undefined} raw - The pathname, as authored.
299
+ * @param {object} config - The resolved build configuration. Required rather
300
+ * than defaulted, which is what keeps this module a leaf: it reads a
301
+ * configuration and never loads one, so the lint can import it without a
302
+ * repository to resolve.
303
+ * @returns {PathnameForms|null} The four forms, or `null` when the note names
304
+ * no file at all.
305
+ * @throws {Error} When the pathname is written in Foundry's own spelling, which
306
+ * resolves on one surface and nowhere else.
307
+ */
308
+ export function resolvePathname(raw, config) {
309
+ if (raw == null) return null;
310
+ const authored = String(raw);
311
+ if (authored === "") {
312
+ return {
313
+ authored,
314
+ state: "blank",
315
+ package: null,
316
+ suffix: null,
317
+ own: false,
318
+ foundry: "",
319
+ local: "",
320
+ web: "",
321
+ pdf: "",
322
+ };
323
+ }
324
+ if (isExternal(authored)) {
325
+ return {
326
+ authored,
327
+ state: "external",
328
+ package: null,
329
+ suffix: null,
330
+ own: false,
331
+ foundry: authored,
332
+ local: authored,
333
+ web: authored,
334
+ pdf: authored,
335
+ };
336
+ }
337
+ const problem = pathnameProblem(authored);
338
+ if (problem) throw new Error(`package-build: ${problem}.`);
339
+
340
+ const packages = packageAddresses(config);
341
+ const segments = authored.split("/");
342
+ // The first segment names a package when an `assets/` follows it. That is
343
+ // the whole test, and it is deliberately not "when the name is one this
344
+ // build knows": a pathname whose owner this build has never heard of still
345
+ // has a package, a suffix, and a correct address on the website, and
346
+ // reading it as this package's own would put one package's name inside
347
+ // another's tree and report nothing.
348
+ const named = segments.length > 2 && segments[1] === ASSETS_SEGMENT ? segments[0] : "";
349
+ const owner = named || (config.contentPackage ?? null);
350
+ const suffix = named ? segments.slice(2).join("/") : authored;
351
+ const entry = owner ? packages.get(owner) : undefined;
352
+ const host = String(config.site?.assets ?? "").replace(/\/+$/, "");
353
+
354
+ return {
355
+ authored,
356
+ state: "package",
357
+ package: owner,
358
+ suffix,
359
+ own: entry?.own === true,
360
+ // `null` where the owning package ships no Foundry package, and where
361
+ // the first segment names a package this build has never heard of:
362
+ // both are "there is no install path to derive", and guessing one
363
+ // writes an address into a document nobody would check.
364
+ foundry:
365
+ entry?.root && entry.id ?
366
+ `${entry.root}/${entry.id}/${ASSETS_SEGMENT}/${suffix}`
367
+ : null,
368
+ local: `${ASSETS_SEGMENT}/${suffix}`,
369
+ // `null` where no asset host is configured. The website is the one
370
+ // surface whose address is not derivable from the repository itself.
371
+ web: host && owner ? `${host}/${owner}/${suffix}` : null,
372
+ pdf: `${ASSETS_SEGMENT}/${suffix}`,
373
+ };
374
+ }