@heroiclands/package-build 22.21.0 → 22.23.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 (152) hide show
  1. package/CHANGELOG.md +223 -0
  2. package/assets/styles/content-base.css +162 -0
  3. package/bin/content-commands.mjs +77 -17
  4. package/bin/package-build.mjs +15 -2
  5. package/ci/content-format-reference.mjs +68 -1
  6. package/config.mjs +26 -0
  7. package/content-config.mjs +153 -6
  8. package/docs/api.md +120 -89
  9. package/docs/authoring/assets.md +14 -0
  10. package/docs/authoring/dates-and-calendars.md +41 -3
  11. package/docs/authoring/frontmatter.md +2 -2
  12. package/docs/authoring/links-and-markup.md +325 -35
  13. package/docs/commands.md +12 -10
  14. package/docs/configuration.md +117 -14
  15. package/docs/content-format.md +4 -2
  16. package/docs/guides/book.md +5 -4
  17. package/docs/guides/reference-pages.md +15 -0
  18. package/docs/reference/format-details.md +545 -104
  19. package/docs/reference/note-types.md +30 -13
  20. package/engine/actor-compiler.mjs +23 -0
  21. package/engine/anchored-sections.mjs +35 -4
  22. package/engine/anchors.mjs +26 -30
  23. package/engine/art-fields.mjs +118 -0
  24. package/engine/art-slots.mjs +7 -1
  25. package/engine/asset-index.mjs +160 -10
  26. package/engine/asset-replacement.mjs +93 -0
  27. package/engine/asset-types.mjs +240 -0
  28. package/engine/base-compiler.mjs +139 -22
  29. package/engine/base-styles.mjs +149 -0
  30. package/engine/bundles.mjs +13 -2
  31. package/engine/compendiums.mjs +8 -1
  32. package/engine/content-blocks.mjs +410 -0
  33. package/engine/content-embeds.mjs +19 -0
  34. package/engine/content-figures.mjs +468 -0
  35. package/engine/content-footnotes.mjs +197 -0
  36. package/engine/content-format-check.mjs +15 -12
  37. package/engine/content-format.yaml +1 -1
  38. package/engine/content-images.mjs +126 -25
  39. package/engine/content-index.mjs +38 -12
  40. package/engine/content-links.mjs +14 -6
  41. package/engine/content-tables.mjs +45 -4
  42. package/engine/content-tasklists.mjs +189 -0
  43. package/engine/docs-checks.mjs +4 -4
  44. package/engine/extension-attributes.mjs +48 -2
  45. package/engine/field-reference.mjs +31 -10
  46. package/engine/field-spec.mjs +10 -8
  47. package/engine/foreign-catalog.mjs +143 -18
  48. package/engine/frontmatter-lint.mjs +120 -65
  49. package/engine/frontmatter.mjs +3 -3
  50. package/engine/heading-attributes.mjs +321 -0
  51. package/engine/helpers.mjs +109 -41
  52. package/engine/homepage.mjs +8 -8
  53. package/engine/index.mjs +3 -0
  54. package/engine/infobox-registry.mjs +4 -1
  55. package/engine/infobox-render.mjs +15 -3
  56. package/engine/infobox.mjs +129 -15
  57. package/engine/itinerary-scenes.mjs +4 -10
  58. package/engine/journals.mjs +294 -50
  59. package/engine/lore-events.mjs +197 -0
  60. package/engine/macros.mjs +1 -1
  61. package/engine/markdown-expressions.mjs +434 -59
  62. package/engine/note-dates.mjs +354 -12
  63. package/engine/note-frontmatter.mjs +7 -16
  64. package/engine/note-vocabulary.mjs +183 -42
  65. package/engine/page-lists.mjs +358 -0
  66. package/engine/pathnames.mjs +36 -0
  67. package/engine/pdf-build.mjs +431 -74
  68. package/engine/pdf-images.mjs +64 -0
  69. package/engine/pdf-render.mjs +563 -66
  70. package/engine/rank-ladder.mjs +198 -0
  71. package/engine/reckoning-markers.mjs +1 -1
  72. package/engine/retired-fields.mjs +8 -13
  73. package/engine/scenes.mjs +26 -7
  74. package/engine/site-build.mjs +166 -23
  75. package/engine/site-config.mjs +10 -1
  76. package/engine/site-index.mjs +90 -3
  77. package/engine/site-preview.mjs +20 -7
  78. package/engine/social-ties.mjs +52 -14
  79. package/engine/sql-tables.mjs +66 -9
  80. package/engine/standings.mjs +89 -6
  81. package/engine/system-block.mjs +56 -30
  82. package/engine/web-wikilinks.mjs +48 -1
  83. package/hm3/actors.mjs +8 -5
  84. package/manifest.mjs +43 -24
  85. package/package.json +1 -1
  86. package/packagebuild-metadata.jsonl +13 -13
  87. package/sohl/actors.mjs +22 -99
  88. package/sohl/item-fields.mjs +5 -5
  89. package/sohl/items.mjs +1 -1
  90. package/sohl/note-schemas.mjs +11 -21
  91. package/types/config.d.mts +9 -0
  92. package/types/content-config.d.mts +48 -1
  93. package/types/engine/actor-compiler.d.mts +11 -0
  94. package/types/engine/anchored-sections.d.mts +24 -0
  95. package/types/engine/anchors.d.mts +9 -4
  96. package/types/engine/art-fields.d.mts +64 -0
  97. package/types/engine/asset-index.d.mts +43 -0
  98. package/types/engine/asset-replacement.d.mts +22 -0
  99. package/types/engine/asset-types.d.mts +18 -0
  100. package/types/engine/base-compiler.d.mts +19 -17
  101. package/types/engine/base-styles.d.mts +93 -0
  102. package/types/engine/content-blocks.d.mts +87 -0
  103. package/types/engine/content-embeds.d.mts +16 -0
  104. package/types/engine/content-figures.d.mts +101 -0
  105. package/types/engine/content-footnotes.d.mts +80 -0
  106. package/types/engine/content-format-check.d.mts +8 -6
  107. package/types/engine/content-images.d.mts +78 -12
  108. package/types/engine/content-index.d.mts +8 -2
  109. package/types/engine/content-tables.d.mts +12 -4
  110. package/types/engine/content-tasklists.d.mts +72 -0
  111. package/types/engine/extension-attributes.d.mts +22 -1
  112. package/types/engine/field-reference.d.mts +21 -0
  113. package/types/engine/field-spec.d.mts +20 -16
  114. package/types/engine/foreign-catalog.d.mts +39 -0
  115. package/types/engine/frontmatter.d.mts +1 -1
  116. package/types/engine/heading-attributes.d.mts +179 -0
  117. package/types/engine/helpers.d.mts +29 -10
  118. package/types/engine/homepage.d.mts +7 -7
  119. package/types/engine/index.d.mts +1 -0
  120. package/types/engine/infobox-registry.d.mts +4 -1
  121. package/types/engine/infobox-render.d.mts +6 -1
  122. package/types/engine/infobox.d.mts +6 -0
  123. package/types/engine/journals.d.mts +41 -12
  124. package/types/engine/lore-events.d.mts +14 -0
  125. package/types/engine/markdown-expressions.d.mts +46 -2
  126. package/types/engine/note-dates.d.mts +64 -1
  127. package/types/engine/note-frontmatter.d.mts +1 -4
  128. package/types/engine/note-vocabulary.d.mts +82 -6
  129. package/types/engine/page-lists.d.mts +126 -0
  130. package/types/engine/pathnames.d.mts +26 -0
  131. package/types/engine/pdf-build.d.mts +28 -6
  132. package/types/engine/pdf-images.d.mts +52 -0
  133. package/types/engine/pdf-render.d.mts +84 -2
  134. package/types/engine/rank-ladder.d.mts +32 -0
  135. package/types/engine/retired-fields.d.mts +4 -6
  136. package/types/engine/site-build.d.mts +27 -4
  137. package/types/engine/site-config.d.mts +9 -0
  138. package/types/engine/site-index.d.mts +67 -1
  139. package/types/engine/site-preview.d.mts +1 -1
  140. package/types/engine/sql-tables.d.mts +31 -7
  141. package/types/engine/standings.d.mts +21 -3
  142. package/types/engine/system-block.d.mts +37 -14
  143. package/types/engine/web-wikilinks.d.mts +3 -1
  144. package/types/hm3/actors.d.mts +21 -3
  145. package/types/manifest.d.mts +19 -23
  146. package/types/sohl/actors.d.mts +6 -7
  147. package/engine/content-admonitions.mjs +0 -114
  148. package/engine/content-captions.mjs +0 -181
  149. package/engine/content-secrets.mjs +0 -112
  150. package/types/engine/content-admonitions.d.mts +0 -24
  151. package/types/engine/content-captions.d.mts +0 -45
  152. package/types/engine/content-secrets.d.mts +0 -17
@@ -0,0 +1,358 @@
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
+ * Page lists: a tag in, the pages carrying it out.
16
+ *
17
+ * ````markdown
18
+ * ```pagelist {tag="key-concept" descriptions=true}
19
+ * ```
20
+ * ````
21
+ *
22
+ * **A group expressed by intent.** A hand-enumerated list of links drifts — a
23
+ * page is written and nobody adds it to the landing that should name it. A page
24
+ * list names the *tag* instead: tag a page and it files itself, untag it and it
25
+ * leaves.
26
+ *
27
+ * **It is a sibling of the `sql` content table, and answered the same way.**
28
+ * The pages are resolved over the content index ahead of the synchronous
29
+ * expansion pass, then
30
+ * {@link module:engine/content-tables.expandContentTables} splices the result
31
+ * in as ordinary Markdown — a bullet list of wikilinks. So each surface's own
32
+ * wikilink resolver turns them into what that surface links with: a compendium
33
+ * UUID in Foundry, a page URL on the web and in the book. One authored
34
+ * construct, three real lists.
35
+ *
36
+ * **Attributes, never a body.** Everything the directive says about itself is
37
+ * said in the braced attribute grammar every other body extension uses, so
38
+ * there is one grammar to learn and one validator to maintain. The fence holds
39
+ * nothing, and text written inside it is reported rather than ignored.
40
+ *
41
+ * @module
42
+ */
43
+
44
+ import { FENCE_LINE, parseHeaderArgs } from "./code-fences.mjs";
45
+ import { booleanAttribute } from "./extension-attributes.mjs";
46
+ import { isNoteRecord } from "./index-records.mjs";
47
+ import { NOTE_VOCABULARY, hasTag } from "./note-vocabulary.mjs";
48
+
49
+ /** The fence language that opens a page list. */
50
+ export const PAGE_LIST_LANGUAGE = "pagelist";
51
+
52
+ /**
53
+ * The orders a page list may come out in.
54
+ *
55
+ * Ordering is about output rather than about the pages, so it is an attribute
56
+ * rather than something read off a note. `name` is alphabetical, case and
57
+ * accents folded; `type` groups the note types and orders by name inside each.
58
+ *
59
+ * @type {readonly string[]}
60
+ */
61
+ export const PAGE_LIST_SORTS = Object.freeze(["name", "type"]);
62
+
63
+ /**
64
+ * The attributes a page-list fence takes, with what each one is for.
65
+ *
66
+ * **This is the one list.** The fence is validated against it, the authoring
67
+ * document's table is generated from it, and the reference is checked against
68
+ * it — so an attribute added here is one both documents describe, and anything
69
+ * else an author writes is reported by name.
70
+ *
71
+ * @type {Readonly<Record<string, Readonly<{value: string, default: string, summary: string}>>>}
72
+ */
73
+ export const PAGE_LIST_ATTRIBUTES = Object.freeze({
74
+ tag: Object.freeze({
75
+ value: "a tag",
76
+ default: "required",
77
+ summary:
78
+ "The tag a page carries to join the list. Matched the way `tags:` is read — " +
79
+ "case and a leading `#` are not significant, the spelling of the tag itself is.",
80
+ }),
81
+ type: Object.freeze({
82
+ value: "a note type",
83
+ default: "every type",
84
+ summary:
85
+ "Restricts the list to one note type. A page's address is `<type>-<shortcode>`, " +
86
+ "so the type is what divides the corpus into kinds of page.",
87
+ }),
88
+ sort: Object.freeze({
89
+ value: "`name` or `type`",
90
+ default: "`name`",
91
+ summary:
92
+ "The order the pages come out in. `name` is alphabetical; `type` groups the " +
93
+ "note types and orders by name within each.",
94
+ }),
95
+ descriptions: Object.freeze({
96
+ value: "`true` or `false`",
97
+ default: "`false`",
98
+ summary:
99
+ "Whether each page's `description` follows its link. A page that states none " +
100
+ "carries its link alone.",
101
+ }),
102
+ "allow-empty": Object.freeze({
103
+ value: "`true` or `false`",
104
+ default: "`false`",
105
+ summary:
106
+ "Whether a tag carried by no page is allowed. It is a finding otherwise, " +
107
+ "because a heading with nothing under it is how a misspelled tag goes unnoticed.",
108
+ }),
109
+ });
110
+
111
+ /** The note types a `type` attribute may name, from the vocabulary itself. */
112
+ const noteTypes = () => Object.keys(NOTE_VOCABULARY);
113
+
114
+ /**
115
+ * Every `pagelist` fence in a markdown body, with the position each occupies.
116
+ *
117
+ * Positions are 0-based lines into the body as given, which is what a
118
+ * diagnostic about a directive needs and what the expander uses to splice
119
+ * results back in.
120
+ *
121
+ * **A malformed directive is still a directive.** It is returned carrying its
122
+ * problems rather than skipped, so the expander finds a prepared answer at its
123
+ * ordinal and reports the fault where the fence sits. That includes a fence
124
+ * nobody closed, which the author meant as a directive and which would
125
+ * otherwise swallow the rest of the note into a code block.
126
+ *
127
+ * @param {string} markdown - The note body, frontmatter already stripped.
128
+ * @returns {Array<{line: number, close: number, indent: string, tag: string,
129
+ * type: string, sort: string, descriptions: boolean, allowEmpty: boolean,
130
+ * problems: string[], block: string}>} One entry per fence, in document order.
131
+ */
132
+ export function findPageListBlocks(markdown) {
133
+ const lines = String(markdown ?? "").split("\n");
134
+ const blocks = [];
135
+ for (let i = 0; i < lines.length; i += 1) {
136
+ const opening = FENCE_LINE.exec(lines[i]);
137
+ if (!opening) continue;
138
+ const [, indent, marker, info] = opening;
139
+ const closer = new RegExp(`^[ \\t]*${marker[0]}{${marker.length},}[ \\t]*$`);
140
+ let close = i + 1;
141
+ while (close < lines.length && !closer.test(lines[close])) close += 1;
142
+ const { language, args, problems } = parseHeaderArgs(info);
143
+ if (language !== PAGE_LIST_LANGUAGE) {
144
+ // Not ours, but still a fence: skip its body so a directive shown
145
+ // as an example inside a longer fence is never read as one.
146
+ i = close;
147
+ continue;
148
+ }
149
+ const errors = [...problems];
150
+ for (const key of Object.keys(args)) {
151
+ if (!Object.hasOwn(PAGE_LIST_ATTRIBUTES, key))
152
+ errors.push(`${key} is not a page-list attribute`);
153
+ }
154
+ const tag = String(args.tag ?? "").trim();
155
+ if (!tag) errors.push("a pagelist fence needs a tag attribute");
156
+ const type = String(args.type ?? "").trim();
157
+ if (type && !noteTypes().includes(type))
158
+ errors.push(`type ${JSON.stringify(type)} is not a note type`);
159
+ const sort = String(args.sort ?? PAGE_LIST_SORTS[0]).trim();
160
+ if (!PAGE_LIST_SORTS.includes(sort))
161
+ errors.push(`sort needs ${PAGE_LIST_SORTS.join(" or ")}`);
162
+ const flags = { descriptions: false, allowEmpty: false };
163
+ for (const [key, field] of [
164
+ ["descriptions", "descriptions"],
165
+ ["allow-empty", "allowEmpty"],
166
+ ]) {
167
+ if (!Object.hasOwn(args, key)) continue;
168
+ try {
169
+ flags[field] = booleanAttribute(args[key], key);
170
+ } catch (error) {
171
+ errors.push(error.message);
172
+ }
173
+ }
174
+ if (close >= lines.length) errors.push("a pagelist fence needs a closing fence");
175
+ else if (
176
+ lines
177
+ .slice(i + 1, close)
178
+ .join("\n")
179
+ .trim()
180
+ )
181
+ errors.push("a pagelist fence takes no body; its tag is the tag attribute");
182
+ blocks.push({
183
+ line: i,
184
+ close,
185
+ indent,
186
+ tag,
187
+ type,
188
+ sort,
189
+ ...flags,
190
+ problems: errors,
191
+ block: lines.slice(i, Math.min(close + 1, lines.length)).join("\n"),
192
+ });
193
+ i = close;
194
+ }
195
+ return blocks;
196
+ }
197
+
198
+ /** A page's sort key, folded so a circumflex sorts with its letter. */
199
+ const nameKey = (record) => String(record?.nameAscii ?? record?.name?.full ?? "").toLowerCase();
200
+
201
+ /** The address a page is addressed by, as a stable tiebreak. */
202
+ const slugKey = (record) => String(record?.address?.slug ?? "");
203
+
204
+ /**
205
+ * By name, tiebroken on address.
206
+ *
207
+ * The tiebreak keeps the list stable where two pages share a name, which a
208
+ * corpus of a few thousand eventually does.
209
+ */
210
+ function byName(a, b) {
211
+ const an = nameKey(a);
212
+ const bn = nameKey(b);
213
+ if (an !== bn) return an < bn ? -1 : 1;
214
+ const as = slugKey(a);
215
+ const bs = slugKey(b);
216
+ return (
217
+ as < bs ? -1
218
+ : as > bs ? 1
219
+ : 0
220
+ );
221
+ }
222
+
223
+ /** By note type, then by name within it. */
224
+ function byType(a, b) {
225
+ const at = String(a?.type ?? "");
226
+ const bt = String(b?.type ?? "");
227
+ if (at !== bt) return at < bt ? -1 : 1;
228
+ return byName(a, b);
229
+ }
230
+
231
+ const COMPARATORS = Object.freeze({ name: byName, type: byType });
232
+
233
+ /** A link label, with the characters a wikilink reads replaced. */
234
+ function linkLabel(record) {
235
+ const written = String(record?.name?.full ?? record?.shortcode ?? "")
236
+ // A wikilink's own separator, so a name carrying one would truncate
237
+ // the label at it.
238
+ .replace(/\|/g, "/")
239
+ .replace(/[\r\n]+/g, " ")
240
+ .trim();
241
+ return written || slugKey(record);
242
+ }
243
+
244
+ /** A description on one line, so a list item stays a list item. */
245
+ const oneLine = (value) => (typeof value === "string" ? value.replace(/[\r\n]+/g, " ").trim() : "");
246
+
247
+ /**
248
+ * The pages a directive selects, as index records.
249
+ *
250
+ * A **stub** is left out: an empty body publishes no page, which the index says
251
+ * by holding no address, so there is nothing for the list to link to. A
252
+ * documentation journal and an asset are not pages an author tagged, which
253
+ * {@link module:engine/index-records.isNoteRecord} already answers.
254
+ *
255
+ * @param {object[]} records - Content-index records.
256
+ * @param {{tag: string, type: string}} directive - From {@link findPageListBlocks}.
257
+ * @returns {object[]} The selected records, unordered.
258
+ */
259
+ export function pagesForList(records, { tag, type }) {
260
+ return (records ?? []).filter((record) => {
261
+ if (!isNoteRecord(record) || !record.address) return false;
262
+ if (type && record.type !== type) return false;
263
+ return hasTag(record, tag);
264
+ });
265
+ }
266
+
267
+ /**
268
+ * Render one directive's answer as markdown.
269
+ *
270
+ * **The emitted link is the page's `address.slug`** — the `type-shortcode`
271
+ * form a wikilink resolves — which is exactly what a table's `_ref` cell
272
+ * writes. A short form names the package the link is written in, so it is
273
+ * unambiguous for a list of this package's own pages, and it resolves to the
274
+ * note's **page** on each surface: a compendium journal in Foundry, a URL on
275
+ * the web and in the book. An item note's page is its documentation journal,
276
+ * and the short form reaches it the way prose does.
277
+ *
278
+ * @param {object[]} records - Content-index records.
279
+ * @param {object} directive - From {@link findPageListBlocks}.
280
+ * @returns {{markdown: string, pages: number}} The list, and how many pages
281
+ * are in it.
282
+ */
283
+ export function renderPageList(records, directive) {
284
+ const selected = pagesForList(records, directive).sort(
285
+ COMPARATORS[directive.sort] ?? COMPARATORS.name,
286
+ );
287
+ const rows = selected.map((record) => {
288
+ const link = `- [[${slugKey(record)}|${linkLabel(record)}]]`;
289
+ if (!directive.descriptions) return link;
290
+ const summary = oneLine(record.description);
291
+ return summary ? `${link} — ${summary}` : link;
292
+ });
293
+ return { markdown: rows.join("\n"), pages: selected.length };
294
+ }
295
+
296
+ /**
297
+ * What the expander prints where a tag selects nothing.
298
+ *
299
+ * The tag is quoted, because a misspelled tag is the whole of what goes wrong
300
+ * here and a reader needs to see the exact spelling the directive asked for.
301
+ */
302
+ function emptyMessage({ tag, type }) {
303
+ const scope = type ? ` of type ${JSON.stringify(type)}` : "";
304
+ return (
305
+ `page list selects no page; no page${scope} carries the tag ${JSON.stringify(tag)}. ` +
306
+ "Check the spelling, tag some pages, or state {allow-empty=true}"
307
+ );
308
+ }
309
+
310
+ /**
311
+ * Answer every `pagelist` directive in a set of note bodies, ahead of expansion.
312
+ *
313
+ * **Why a separate pass.** The same reason the SQL tables have one:
314
+ * {@link module:engine/content-tables.expandContentTables} is synchronous and
315
+ * exported, and the corpus is enumerated once by whoever already holds it
316
+ * rather than derived again inside a renderer. A page list needs no database —
317
+ * it is a filter over the records — so preparing one costs a walk of what the
318
+ * caller already has.
319
+ *
320
+ * **Keyed by note, then by the directive's ordinal within it**, not by its
321
+ * line: the passes do not agree on what a body is, and trim it differently,
322
+ * but a directive's position in the sequence of fences is the same in each.
323
+ *
324
+ * A malformed directive is recorded rather than thrown, so one bad fence costs
325
+ * its own list and not the whole build's report.
326
+ *
327
+ * @param {object[]} records - Content-index records, already narrowed to the
328
+ * audience the surface publishes to.
329
+ * @param {Array<{source: string, markdown: string}>} sources - The bodies to scan.
330
+ * @returns {Map<string, Array<{markdown?: string, pages?: number, empty?: string,
331
+ * reason?: string, allowEmpty: boolean}>>} Note to results, in document order.
332
+ */
333
+ export function preparePageLists(records, sources) {
334
+ const prepared = new Map();
335
+ for (const { source, markdown } of sources ?? []) {
336
+ const blocks = findPageListBlocks(markdown);
337
+ if (!blocks.length) continue;
338
+ const forNote = [];
339
+ prepared.set(source, forNote);
340
+ for (const block of blocks) {
341
+ if (block.problems.length) {
342
+ forNote.push({
343
+ reason: block.problems.join("; "),
344
+ allowEmpty: block.allowEmpty,
345
+ });
346
+ continue;
347
+ }
348
+ const { markdown: rendered, pages } = renderPageList(records, block);
349
+ forNote.push({
350
+ markdown: rendered,
351
+ pages,
352
+ empty: emptyMessage(block),
353
+ allowEmpty: block.allowEmpty,
354
+ });
355
+ }
356
+ }
357
+ return prepared;
358
+ }
@@ -443,3 +443,39 @@ export function resolvePathname(raw, config) {
443
443
  pdf: `${ASSETS_SEGMENT}/${suffix}`,
444
444
  };
445
445
  }
446
+
447
+ /**
448
+ * The pathname one authored image address keys an asset's role and pixel
449
+ * size under — `<package>/assets/<suffix>`, the same key
450
+ * {@link module:engine/art-fields.assetImageInfoByPathname} builds from the
451
+ * asset record itself.
452
+ *
453
+ * **Why this exists apart from `resolvePathname`'s own forms.** An embed
454
+ * rewrites to that exact pathname — see
455
+ * {@link module:engine/content-embeds.resolveEmbeds} — but an author's own
456
+ * body image is free to write the bare, own-package form instead
457
+ * (`images/m.webp`), which names the identical file without naming the
458
+ * package. The two forms have to key the same lookup, and `resolvePathname`
459
+ * already answers which package a bare form belongs to; this just restates
460
+ * that answer in the one shape a renderer looks a picture's role up by.
461
+ *
462
+ * **Never throws.** A lookup is a best-effort read, not a validation — the
463
+ * pathname lint is what refuses a Foundry-spelled address — so a value
464
+ * {@link resolvePathname} refuses outright answers `null` here as well, the
465
+ * same as a blank or an external one.
466
+ *
467
+ * @param {string} src - The address, as authored or as an embed resolved it.
468
+ * @param {object} config - The resolved build configuration.
469
+ * @returns {string|null} The pathname, or `null` for an address naming no
470
+ * asset record to look up.
471
+ */
472
+ export function assetPathnameKey(src, config) {
473
+ let forms;
474
+ try {
475
+ forms = resolvePathname(src, config);
476
+ } catch {
477
+ return null;
478
+ }
479
+ if (!forms || forms.state !== "package" || !forms.package) return null;
480
+ return `${forms.package}/${ASSETS_SEGMENT}/${forms.suffix}`;
481
+ }