@heroiclands/package-build 22.9.2 → 22.11.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 (97) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/CONTENT.md +54 -0
  3. package/bin/content-build.mjs +3 -1
  4. package/docs/api.md +11 -6
  5. package/docs/commands.md +1 -1
  6. package/docs/content-format.md +411 -201
  7. package/engine/actor-compiler.mjs +20 -7
  8. package/engine/address-fields.mjs +426 -0
  9. package/engine/address-values.mjs +80 -0
  10. package/engine/address.mjs +769 -0
  11. package/engine/art-fields.mjs +112 -97
  12. package/engine/art-slots.mjs +81 -0
  13. package/engine/base-compiler.mjs +61 -7
  14. package/engine/bundle-notes.mjs +5 -2
  15. package/engine/bundles.mjs +17 -10
  16. package/engine/content-address.mjs +23 -186
  17. package/engine/content-embeds.mjs +18 -4
  18. package/engine/content-format-check.mjs +50 -22
  19. package/engine/content-index.mjs +85 -43
  20. package/engine/content-links.mjs +105 -104
  21. package/engine/content-lint.mjs +11 -1
  22. package/engine/content-tables.mjs +9 -1
  23. package/engine/field-spec.mjs +21 -4
  24. package/engine/folder-notes.mjs +44 -15
  25. package/engine/foundry-entries.mjs +3 -0
  26. package/engine/frontmatter-lint.mjs +150 -47
  27. package/engine/frontmatter.mjs +55 -10
  28. package/engine/generate.mjs +7 -1
  29. package/engine/helpers.mjs +37 -9
  30. package/engine/holdings.mjs +41 -12
  31. package/engine/index-records.mjs +2 -0
  32. package/engine/infobox-render.mjs +2 -1
  33. package/engine/infobox.mjs +26 -6
  34. package/engine/item-compiler.mjs +5 -0
  35. package/engine/item-docs.mjs +7 -44
  36. package/engine/map-dot.mjs +2 -0
  37. package/engine/map-notes.mjs +7 -2
  38. package/engine/map-places.mjs +42 -10
  39. package/engine/metadata-index.mjs +60 -5
  40. package/engine/note-addresses.mjs +325 -0
  41. package/engine/note-vocabulary.mjs +140 -28
  42. package/engine/pdf-build.mjs +1 -1
  43. package/engine/pdf-toc.mjs +17 -2
  44. package/engine/place-relations.mjs +160 -50
  45. package/engine/populations.mjs +17 -9
  46. package/engine/reference-targets.mjs +54 -0
  47. package/engine/scenes.mjs +5 -2
  48. package/engine/shortcode-references.mjs +68 -0
  49. package/engine/site-build.mjs +39 -15
  50. package/engine/site-index.mjs +57 -75
  51. package/engine/sql-tables.mjs +43 -9
  52. package/engine/system-block.mjs +21 -0
  53. package/engine/system-vocabulary.mjs +46 -0
  54. package/engine/web-wikilinks.mjs +11 -9
  55. package/engine/wikilink-syntax.mjs +21 -0
  56. package/engine/wikilinks.mjs +92 -306
  57. package/hm3/actors.mjs +8 -24
  58. package/hm3/item-fields.mjs +15 -5
  59. package/package.json +1 -1
  60. package/sohl/infobox.mjs +111 -17
  61. package/sohl/item-fields.mjs +131 -41
  62. package/types/engine/address-fields.d.mts +95 -0
  63. package/types/engine/address-values.d.mts +34 -0
  64. package/types/engine/address.d.mts +452 -0
  65. package/types/engine/art-fields.d.mts +59 -53
  66. package/types/engine/art-slots.d.mts +40 -0
  67. package/types/engine/base-compiler.d.mts +24 -2
  68. package/types/engine/bundle-notes.d.mts +2 -2
  69. package/types/engine/content-address.d.mts +3 -139
  70. package/types/engine/content-embeds.d.mts +1 -1
  71. package/types/engine/content-format-check.d.mts +25 -0
  72. package/types/engine/content-index.d.mts +2 -2
  73. package/types/engine/content-links.d.mts +6 -5
  74. package/types/engine/field-spec.d.mts +46 -9
  75. package/types/engine/folder-notes.d.mts +11 -10
  76. package/types/engine/frontmatter-lint.d.mts +7 -97
  77. package/types/engine/frontmatter.d.mts +16 -1
  78. package/types/engine/helpers.d.mts +2 -2
  79. package/types/engine/infobox.d.mts +6 -0
  80. package/types/engine/item-docs.d.mts +1 -35
  81. package/types/engine/map-notes.d.mts +2 -2
  82. package/types/engine/note-addresses.d.mts +41 -0
  83. package/types/engine/note-vocabulary.d.mts +20 -3
  84. package/types/engine/place-relations.d.mts +12 -1
  85. package/types/engine/populations.d.mts +17 -2
  86. package/types/engine/reference-targets.d.mts +9 -0
  87. package/types/engine/shortcode-references.d.mts +14 -0
  88. package/types/engine/site-build.d.mts +3 -3
  89. package/types/engine/site-index.d.mts +9 -30
  90. package/types/engine/sql-tables.d.mts +2 -2
  91. package/types/engine/system-block.d.mts +17 -0
  92. package/types/engine/system-vocabulary.d.mts +16 -0
  93. package/types/engine/wikilink-syntax.d.mts +7 -0
  94. package/types/engine/wikilinks.d.mts +6 -109
  95. package/types/hm3/item-fields.d.mts +5 -0
  96. package/types/sohl/infobox.d.mts +6 -5
  97. package/types/sohl/item-fields.d.mts +8 -0
@@ -15,16 +15,22 @@
15
15
  * The art slots a note declares, and turning one of them into a path.
16
16
  *
17
17
  * A note names the art a *document field* needs, and every other image in it is
18
- * inline. Each slot is an ordinary `WikiLink` field declaring a default type,
18
+ * inline. Each slot is an ordinary `Address` field declaring a default type,
19
19
  * exactly as `seat` declares `place`: a bare shortcode takes its type from the
20
20
  * declaration, and a value that qualifies itself climbs the same short-form
21
- * ladder every other link uses.
21
+ * ladder every other link uses. {@link module:engine/address.parseAddress}
22
+ * reads it, taking the slot's `type` as the default an omitted segment fills
23
+ * in — this module states no separator rule of its own.
22
24
  *
23
- * **A bare shortcode is told from a written address by the separator alone.** A
24
- * shortcode is lowercase letters and digits (`ADDRESS_SEGMENT_PATTERN`), so a
25
- * hyphen can only be a segment boundary — which makes `anvil` the bare form and
26
- * `icon-anvil`, `none-icon-anvil` and `sohl-none-icon-anvil` the written ones,
27
- * with nothing to guess and no vocabulary to match against.
25
+ * **The accepted set is a slot's second declaration, and it is not the
26
+ * default.** A slot's `type` says what a bare shortcode names; its `accepts`
27
+ * says what a qualified one may name, and {@link module:engine/address.acceptsType}
28
+ * checks a parsed value against it. `icon` defaults to `icon` and accepts
29
+ * `image` too, because a faith tradition's profile art is a full illustration
30
+ * rather than a game icon — twenty notes in `sohl-kethira-basic` author exactly
31
+ * that. A type outside the set is an error naming the set, never a silent fall
32
+ * back to the default art: the parser never refuses on type grounds, only the
33
+ * slot does.
28
34
  *
29
35
  * **Resolution is one step, because the record carries the path.** The address
30
36
  * names a record, the record names the file's path inside its own package, and
@@ -38,60 +44,17 @@
38
44
  * @module
39
45
  */
40
46
 
41
- import { expandAddress } from "./content-address.mjs";
47
+ import { encodeAddresses } from "./address-values.mjs";
42
48
  import { hasTag } from "./note-vocabulary.mjs";
43
49
  import { ASSET_SYSTEM, isAssetType } from "./asset-types.mjs";
44
50
  import { ASSETS_SEGMENT } from "./pathnames.mjs";
45
51
  import { isAssetRecord } from "./index-records.mjs";
46
52
  import { ASSET_TYPE_NAMES } from "./asset-types.mjs";
47
- import { readQualifier } from "./wikilinks.mjs";
53
+ import { acceptsType, parseAddress, renderAddress } from "./address.mjs";
48
54
 
49
- /**
50
- * One art slot: the key a note authors, and the type a bare value takes.
51
- *
52
- * @typedef {object} ArtSlot
53
- * @property {string} key - The key under `data:`.
54
- * @property {string} type - The asset type a bare shortcode defaults to.
55
- * @property {boolean} document - Whether the slot reaches a compiled document.
56
- * @property {string} describe - One line, for the author-facing reference.
57
- */
58
-
59
- /**
60
- * The four art slots, in the order the specification tabulates them.
61
- *
62
- * `banner` is the one that reaches no compiled document: it is the page's hero
63
- * image, read by the site and by the book's section plates and by nothing else.
64
- * That is what `document: false` states, and it is why the inert-art check
65
- * skips it — a key that is *meant* to reach no document is not an inert key.
66
- *
67
- * @type {readonly ArtSlot[]}
68
- */
69
- export const ART_SLOTS = Object.freeze([
70
- Object.freeze({
71
- key: "icon",
72
- type: "icon",
73
- document: true,
74
- describe: "The document's profile art, resolved into `img`.",
75
- }),
76
- Object.freeze({
77
- key: "tokenIcon",
78
- type: "icon",
79
- document: true,
80
- describe: "What a token on the canvas wears; unset, it follows `icon`.",
81
- }),
82
- Object.freeze({
83
- key: "bgImage",
84
- type: "image",
85
- document: true,
86
- describe: "A map's background art, resolved into `background.src`.",
87
- }),
88
- Object.freeze({
89
- key: "banner",
90
- type: "image",
91
- document: false,
92
- describe: "The page's hero image. Reaches no compiled document.",
93
- }),
94
- ]);
55
+ import { ART_SLOTS } from "./art-slots.mjs";
56
+ export { ART_SLOTS } from "./art-slots.mjs";
57
+ /** @typedef {import("./art-slots.mjs").ArtSlot} ArtSlot */
95
58
 
96
59
  /**
97
60
  * The art slot one key names, or `undefined`.
@@ -104,15 +67,33 @@ export function artSlot(key) {
104
67
  }
105
68
 
106
69
  /**
107
- * The address an authored art value names.
70
+ * The Address an authored art value names, checked against what this position
71
+ * accepts.
108
72
  *
109
- * @param {string} value - The value as authored.
110
- * @param {string} defaultType - The type the field declares.
111
- * @returns {string} A written address, which may be partial.
73
+ * The two questions {@link module:engine/address} keeps separate: does the
74
+ * value parse, and is what it names acceptable *here*. {@link parseAddress}
75
+ * answers the first, taking the slot's own `type` as the default an omitted
76
+ * segment fills in; {@link acceptsType} answers the second, against the set
77
+ * the position declares rather than against that same default.
78
+ *
79
+ * @param {unknown} value - The value as authored.
80
+ * @param {string} defaultType - The type a bare value takes.
81
+ * @param {Iterable<string>|undefined} accepts - The types this position
82
+ * accepts, or `undefined` to accept whatever parses.
83
+ * @param {import("./address.mjs").AddressDefaults} [vocabulary] - The
84
+ * position's remaining defaults and the tree's vocabularies.
85
+ * @returns {import("./address.mjs").AddressTuple
86
+ * |import("./address.mjs").AddressProblem
87
+ * |{reason: "not-accepted", type: string}} The Address; a reason it does
88
+ * not parse; or `not-accepted` when it parses to a type outside `accepts`.
112
89
  */
113
- export function artTarget(value, defaultType) {
114
- const written = String(value);
115
- return written.includes("-") ? written : `${defaultType}-${written}`;
90
+ export function artTarget(value, defaultType, accepts, vocabulary = {}) {
91
+ const tuple = parseAddress(value, { ...vocabulary, type: defaultType });
92
+ if (tuple.reason) return tuple;
93
+ if (accepts && !acceptsType(tuple, accepts)) {
94
+ return { reason: "not-accepted", type: tuple.type };
95
+ }
96
+ return tuple;
116
97
  }
117
98
 
118
99
  /**
@@ -145,7 +126,7 @@ export function assetAddressIndex(records = [], { config, foreign, types = [] }
145
126
  assets: new Map(
146
127
  records
147
128
  .filter(isAssetRecord)
148
- .map((record) => [record.address?.canonical, record])
129
+ .map((record) => [encodeAddresses(record.address?.canonical), record])
149
130
  .filter(([key]) => key),
150
131
  ),
151
132
  foreign: foreign?.index ?? new Map(),
@@ -169,33 +150,39 @@ export function assetAddressIndex(records = [], { config, foreign, types = [] }
169
150
  * or the equivalent the site and the book build.
170
151
  * @param {unknown} value - The value as authored.
171
152
  * @param {string} defaultType - The type a bare value takes.
153
+ * @param {Iterable<string>} [accepts] - The types this position accepts, for a
154
+ * position that declares a set narrower than "every asset type" — an art
155
+ * slot's. Omitted, any asset type is taken, which is an embed's rule: it
156
+ * draws a file, and every asset type is a file.
172
157
  * @returns {{record: {package: string, asset: {path: string}}, pathname: string}
173
158
  * |{record: null, reason: string, type?: string}} The asset and the pathname
174
159
  * it is at, or a reason from
175
- * {@link module:engine/wikilink-syntax.LINK_FINDING_REASONS}.
160
+ * {@link module:engine/wikilink-syntax.LINK_FINDING_REASONS}, or `not-accepted`
161
+ * when `accepts` is given and the parsed type falls outside it.
176
162
  */
177
- export function readAssetAddress(index, value, defaultType) {
178
- if (typeof value !== "string" || !value) {
163
+ export function readAssetAddress(index, value, defaultType, accepts) {
164
+ if (!value) {
179
165
  return { record: null, reason: "not-an-address" };
180
166
  }
181
- const target = artTarget(value, defaultType);
182
- const read = readQualifier(target, index?.types, index?.packages);
183
- if (!read) return { record: null, reason: "not-an-address" };
184
- if (read.reason) return { record: null, reason: read.reason };
185
- // **Asset types only.** An address may name any type the vocabulary holds,
186
- // and most of them name a note — which has no file to draw. Refused by its
187
- // own reason rather than left to resolve to nothing, because the fix is a
188
- // different one: an ordinary link, not a corrected shortcode.
189
- if (!isAssetType(read.type)) {
190
- return { record: null, reason: "not-an-asset", type: String(read.type) };
191
- }
192
- // The system segment is fixed at `none` for an asset type, so where the
193
- // value was written does not enter into it — an embedded item's art is
194
- // authored inside a system block and still names the same file.
195
- const canonical = expandAddress(read, {
196
- package: index?.contentPackage ?? index?.packageId,
167
+ const read = artTarget(value, defaultType, accepts, {
197
168
  system: ASSET_SYSTEM,
169
+ package: index?.contentPackage ?? index?.packageId,
170
+ types: index?.types,
171
+ packages: index?.packages,
198
172
  });
173
+ if (read.reason === "not-accepted") {
174
+ return { record: null, reason: "not-accepted", type: read.type };
175
+ }
176
+ if (read.reason) return { record: null, reason: read.reason, type: read.type };
177
+ // **Asset types only**, where the position declares no narrower set. An
178
+ // address may name any type the vocabulary holds, and most of them name a
179
+ // note — which has no file to draw. Refused by its own reason rather than
180
+ // left to resolve to nothing, because the fix is a different one: an
181
+ // ordinary link, not a corrected shortcode.
182
+ if (!accepts && !isAssetType(read.type)) {
183
+ return { record: null, reason: "not-an-asset", type: String(read.type) };
184
+ }
185
+ const canonical = renderAddress(read);
199
186
  const hit = index?.assets?.get(canonical) ?? index?.foreign?.get(canonical) ?? null;
200
187
  if (!hit?.asset?.path || !hit.package) return { record: null, reason: "unresolved" };
201
188
  return { record: hit, pathname: `${hit.package}/${ASSETS_SEGMENT}/${hit.asset.path}` };
@@ -207,10 +194,11 @@ export function readAssetAddress(index, value, defaultType) {
207
194
  * @param {object} index - From {@link module:engine/wikilinks.buildWikilinkIndex}.
208
195
  * @param {unknown} value - The value as authored.
209
196
  * @param {string} defaultType - The type the field declares.
197
+ * @param {Iterable<string>} [accepts] - The types this position accepts.
210
198
  * @returns {{package: string, asset: {path: string}}|null} The record.
211
199
  */
212
- export function resolveArtRecord(index, value, defaultType) {
213
- return readAssetAddress(index, value, defaultType).record;
200
+ export function resolveArtRecord(index, value, defaultType, accepts) {
201
+ return readAssetAddress(index, value, defaultType, accepts).record;
214
202
  }
215
203
 
216
204
  /**
@@ -224,15 +212,21 @@ export function resolveArtRecord(index, value, defaultType) {
224
212
  * @param {object} index - From {@link module:engine/wikilinks.buildWikilinkIndex}.
225
213
  * @param {unknown} value - The value as authored.
226
214
  * @param {string} defaultType - The type the field declares.
227
- * @returns {{pathname: string|null, resolved: boolean}} The pathname, and
228
- * whether an address was actually answered — which tells a caller applying a
229
- * default apart from one whose address named nothing.
215
+ * @param {Iterable<string>} [accepts] - The types this position accepts.
216
+ * @returns {{pathname: string|null, resolved: boolean, reason?: string,
217
+ * type?: string}} The pathname, and whether an address was actually
218
+ * answered — which tells a caller applying a default apart from one whose
219
+ * address named nothing. `reason` and `type` carry why, unresolved — in
220
+ * particular `not-accepted`, which the caller reports as an error rather
221
+ * than falling back silently.
230
222
  */
231
- export function artPathname(index, value, defaultType) {
223
+ export function artPathname(index, value, defaultType, accepts) {
232
224
  if (value == null) return { pathname: null, resolved: true };
233
225
  if (value === "") return { pathname: "", resolved: true };
234
- const read = readAssetAddress(index, value, defaultType);
235
- if (!read.record) return { pathname: null, resolved: false };
226
+ const read = readAssetAddress(index, value, defaultType, accepts);
227
+ if (!read.record) {
228
+ return { pathname: null, resolved: false, reason: read.reason, type: read.type };
229
+ }
236
230
  return { pathname: read.pathname, resolved: true };
237
231
  }
238
232
 
@@ -276,17 +270,38 @@ export function beingDefaultArt(fm) {
276
270
  * What an unresolved art address is reported as.
277
271
  *
278
272
  * One wording, so the four compilers that can meet the case do not each invent
279
- * their own.
273
+ * their own. A **warning**: nothing in this package or a fetched index answers
274
+ * the address, so the document falls back to its default art rather than
275
+ * shipping a path that installs nowhere.
280
276
  *
281
277
  * @param {string} key - The key that was authored.
282
- * @param {unknown} value - The value it carried.
283
- * @param {string} defaultType - The type the field declares.
278
+ * @param {unknown} value - The value it carried, named exactly as written.
279
+ * @returns {string} The message.
280
+ */
281
+ export function unresolvedArtMessage(key, value) {
282
+ return (
283
+ `\`data.${key}\` names \`${encodeAddresses(value)}\`, and no asset in this package or in ` +
284
+ `a fetched index carries that address — the document takes its default art ` +
285
+ `instead`
286
+ );
287
+ }
288
+
289
+ /**
290
+ * What an art address naming a type this slot does not accept is reported as.
291
+ *
292
+ * An **error**, not a warning: unlike an address nothing answers, this one
293
+ * names a real type, and no default art repairs an author asking for a sound
294
+ * where the slot takes an icon or an image.
295
+ *
296
+ * @param {string} key - The key that was authored.
297
+ * @param {unknown} value - The value it carried, named exactly as written.
298
+ * @param {string} type - The type the address named.
299
+ * @param {Iterable<string>} accepts - The types this slot accepts.
284
300
  * @returns {string} The message.
285
301
  */
286
- export function unresolvedArtMessage(key, value, defaultType) {
302
+ export function unacceptedArtMessage(key, value, type, accepts) {
287
303
  return (
288
- `\`data.${key}\` names \`${artTarget(String(value), defaultType)}\`, and no ` +
289
- `asset in this package or in a fetched index carries that address — the ` +
290
- `document takes its default art instead`
304
+ `\`data.${key}\` names \`${encodeAddresses(value)}\`, whose type \`${type}\` this slot does ` +
305
+ `not accept — it accepts ${[...accepts].join(" or ")}`
291
306
  );
292
307
  }
@@ -0,0 +1,81 @@
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 art slot: the key a note authors, and the type a bare value takes.
16
+ *
17
+ * @typedef {object} ArtSlot
18
+ * @property {string} key - The key under `data:`.
19
+ * @property {string} type - The asset type a bare shortcode defaults to.
20
+ * @property {readonly string[]} accepts - The types an authored value may
21
+ * name. **Not the default** — a faith tradition's profile art is a full
22
+ * illustration rather than a game icon, so an `icon` slot's default stays
23
+ * `icon` while its accepted set also takes `image`. A value naming a type
24
+ * outside it is an error, against this set rather than against `type`.
25
+ * @property {boolean} document - Whether the slot reaches a compiled document.
26
+ * @property {string} describe - One line, for the author-facing reference.
27
+ */
28
+
29
+ /**
30
+ * What every art slot accepts, whichever type it defaults to.
31
+ *
32
+ * A sound is not art: `audio` is refused at all four slots, and every slot
33
+ * that defaults to `icon` or to `image` accepts either — the corpus is why:
34
+ * `sohl-kethira-basic` writes a deity's profile art as `icon: image-…` twenty
35
+ * times, one full illustration per faith tradition, and a slot refusing the
36
+ * type it did not default to would refuse that content.
37
+ *
38
+ * @type {readonly string[]}
39
+ */
40
+ const ART_TYPES = Object.freeze(["icon", "image"]);
41
+
42
+ /**
43
+ * The four art slots, in the order the specification tabulates them.
44
+ *
45
+ * `banner` is the one that reaches no compiled document: it is the page's hero
46
+ * image, read by the site and by the book's section plates and by nothing else.
47
+ * That is what `document: false` states, and it is why the inert-art check
48
+ * skips it — a key that is *meant* to reach no document is not an inert key.
49
+ *
50
+ * @type {readonly ArtSlot[]}
51
+ */
52
+ export const ART_SLOTS = Object.freeze([
53
+ Object.freeze({
54
+ key: "icon",
55
+ type: "icon",
56
+ accepts: ART_TYPES,
57
+ document: true,
58
+ describe: "The document's profile art, resolved into `img`.",
59
+ }),
60
+ Object.freeze({
61
+ key: "tokenIcon",
62
+ type: "icon",
63
+ accepts: ART_TYPES,
64
+ document: true,
65
+ describe: "What a token on the canvas wears; unset, it follows `icon`.",
66
+ }),
67
+ Object.freeze({
68
+ key: "bgImage",
69
+ type: "image",
70
+ accepts: ART_TYPES,
71
+ document: true,
72
+ describe: "A map's background art, resolved into `background.src`.",
73
+ }),
74
+ Object.freeze({
75
+ key: "banner",
76
+ type: "image",
77
+ accepts: ART_TYPES,
78
+ document: false,
79
+ describe: "The page's hero image. Reaches no compiled document.",
80
+ }),
81
+ ]);
@@ -61,6 +61,8 @@
61
61
  * @module
62
62
  */
63
63
 
64
+ import { authoredFrontmatter } from "./index-records.mjs";
65
+ import { cloneAddressState } from "./address-values.mjs";
64
66
  import fs from "fs";
65
67
  import path from "path";
66
68
  import log from "loglevel";
@@ -77,7 +79,8 @@ import {
77
79
  // The record accessors only — see `engine/index-records.mjs` for why they live
78
80
  // apart from the index that builds them.
79
81
  import { isNoteRecord, noteFile } from "./index-records.mjs";
80
- import { artPathname, artSlot, unresolvedArtMessage } from "./art-fields.mjs";
82
+ import { reduceAddressFields } from "./address-fields.mjs";
83
+ import { artPathname, artSlot, unacceptedArtMessage, unresolvedArtMessage } from "./art-fields.mjs";
81
84
  import { emitDiagnostic } from "./diagnostics.mjs";
82
85
  import { assertNoDeclaredPackage } from "./note-package.mjs";
83
86
  import { assertNoDeclaredFolder } from "./folder-notes.mjs";
@@ -660,23 +663,73 @@ export class BasePackCompiler {
660
663
  *
661
664
  * An address nothing answers is reported against the note and treated as
662
665
  * unnamed, so the document takes its default rather than shipping a path
663
- * that installs nowhere.
666
+ * that installs nowhere. **A type this slot does not accept is different**:
667
+ * the address is real, so no default repairs it, and it is an error rather
668
+ * than a fallback.
664
669
  *
665
670
  * @param {unknown} value - The value as authored.
666
671
  * @param {string} key - The key it was authored at, for the message.
667
672
  * @param {string} type - The asset type a bare value takes.
673
+ * @param {Iterable<string>} [accepts] - The types this slot accepts.
668
674
  * @returns {string|null} The Foundry-relative path, `""` for a deliberate
669
675
  * blank, or `null` where the note names none.
670
676
  */
671
- artPathOf(value, key, type) {
672
- const { pathname, resolved } = artPathname(this.linkIndex, value, type);
677
+ artPathOf(value, key, type, accepts) {
678
+ const {
679
+ pathname,
680
+ resolved,
681
+ reason,
682
+ type: named,
683
+ } = artPathname(this.linkIndex, value, type, accepts);
673
684
  if (!resolved) {
674
- this.noteWarn(unresolvedArtMessage(key, value, type));
685
+ if (reason === "not-accepted") {
686
+ this.errorCount++;
687
+ this.noteError(unacceptedArtMessage(key, value, named, accepts));
688
+ } else {
689
+ this.noteWarn(unresolvedArtMessage(key, value));
690
+ }
675
691
  return null;
676
692
  }
677
693
  return resolveImg(pathname);
678
694
  }
679
695
 
696
+ /**
697
+ * Reduce every Address an assembled `system` block holds to the Shortcode
698
+ * the document reads.
699
+ *
700
+ * The positions come from the type's own field declarations, so a system
701
+ * adding a field that names a note gets the reduction by declaring it; see
702
+ * {@link module:engine/address-fields}.
703
+ *
704
+ * Called **after** the builder and the authored merge have both written, so
705
+ * one step reduces a value the field read and a value authored straight at
706
+ * the destination alike. Each finding is located at the value inside the
707
+ * note, which for a list or a map is the entry that is wrong rather than the
708
+ * key that introduces it.
709
+ *
710
+ * @param {object} emitted - The `system` block, assembled.
711
+ * @param {readonly object[]} [fields] - The type's field declaration.
712
+ * @returns {number} How many findings were reported.
713
+ */
714
+ reduceEmittedAddresses(emitted, fields) {
715
+ const findings = reduceAddressFields(emitted, fields ?? [], {
716
+ package: this.linkIndex?.contentPackage,
717
+ types: this.linkIndex?.types,
718
+ packages: this.linkIndex?.packages,
719
+ noIndexPackages: this.linkIndex?.noIndexPackages,
720
+ referenceTargets: this.linkIndex?.referenceTargets,
721
+ foreignReferences: this.linkIndex?.foreignReferences,
722
+ });
723
+ for (const finding of findings) {
724
+ this.errorCount++;
725
+ this.noteError(
726
+ finding.message,
727
+ locateFrontmatterKey(this.currentNote?.absPath, finding.key, finding.written),
728
+ );
729
+ }
730
+ return findings.length;
731
+ }
732
+
680
733
  /**
681
734
  * The Foundry path one art slot of a note names, or `null`.
682
735
  *
@@ -690,7 +743,7 @@ export class BasePackCompiler {
690
743
  const data = fm?.data;
691
744
  const value =
692
745
  data && typeof data === "object" && !Array.isArray(data) ? data[key] : undefined;
693
- return this.artPathOf(value, key, slot.type);
746
+ return this.artPathOf(value, key, slot.type, slot.accepts);
694
747
  }
695
748
 
696
749
  /**
@@ -1015,7 +1068,8 @@ export class BasePackCompiler {
1015
1068
  for (const record of this.corpus.records) {
1016
1069
  if (!isNoteRecord(record)) continue;
1017
1070
  const absPath = noteFile(this.contentBase, record);
1018
- const { frontmatter: fm, body, bodyLine, bodyColumn } = parseMarkdownFile(absPath);
1071
+ const { body, bodyLine, bodyColumn } = parseMarkdownFile(absPath);
1072
+ const fm = cloneAddressState(authoredFrontmatter(record));
1019
1073
  // Which note this pass is on, so anything it calls can report a
1020
1074
  // position without every method having to be handed one.
1021
1075
  this.currentNote = { absPath, bodyLine, bodyColumn };
@@ -11,6 +11,8 @@
11
11
  * SPDX-License-Identifier: GPL-3.0-or-later
12
12
  */
13
13
 
14
+ import { isAddressTuple } from "./address.mjs";
15
+
14
16
  /**
15
17
  * Bundles, as notes — the Foundry `Adventure` a `type: bundle` note compiles
16
18
  * into.
@@ -114,8 +116,8 @@ export function stripAdventureKeys(value) {
114
116
  /**
115
117
  * An authored address with any wikilink brackets and label stripped.
116
118
  *
117
- * The specification types `contents` as a `WikiLink[]`, and a frontmatter link
118
- * is written as a bare address — but `[[address]]` is what an author reaches
119
+ * The specification types `contents` as an `Address[]`, written bare — but
120
+ * `[[address]]` is what an author reaches
119
121
  * for, and it is what a folder note's `parent` already accepts. Accepting both
120
122
  * costs one regex and removes a failure whose message would have to explain the
121
123
  * difference.
@@ -124,6 +126,7 @@ export function stripAdventureKeys(value) {
124
126
  * @returns {string|null} The bare address, or `null` for a blank entry.
125
127
  */
126
128
  export function bareAddress(value) {
129
+ if (isAddressTuple(value)) return value;
127
130
  if (value == null) return null;
128
131
  const text = String(value).trim();
129
132
  if (!text) return null;
@@ -55,6 +55,9 @@
55
55
  * @module
56
56
  */
57
57
 
58
+ import { isAddressTuple, resolveItemDocType } from "./address.mjs";
59
+ import { encodeAddresses } from "./address-values.mjs";
60
+
58
61
  import fs from "node:fs";
59
62
  import path from "node:path";
60
63
 
@@ -214,7 +217,7 @@ export class Bundles extends BasePackCompiler {
214
217
  const read = readQualifier(address, index.types, index.packages);
215
218
  if (!read || read.reason) {
216
219
  throw new Error(
217
- `bundle "${bundleName}" lists "${address}", which is not an ` +
220
+ `bundle "${bundleName}" lists "${encodeAddresses(address)}", which is not an ` +
218
221
  `address — a member is named "<type>-<shortcode>", the ` +
219
222
  `same form a wikilink uses`,
220
223
  );
@@ -224,7 +227,7 @@ export class Bundles extends BasePackCompiler {
224
227
  // is a different fact from a typo and is worth its own sentence.
225
228
  if (read.package && read.package !== contentPackage()) {
226
229
  throw new Error(
227
- `bundle "${bundleName}" lists "${address}", which belongs to ` +
230
+ `bundle "${bundleName}" lists "${encodeAddresses(address)}", which belongs to ` +
228
231
  `package "${read.package}" — an Adventure carries copies ` +
229
232
  `of compiled documents, so a bundle can only hold this ` +
230
233
  `package's own`,
@@ -232,7 +235,7 @@ export class Bundles extends BasePackCompiler {
232
235
  }
233
236
  if (read.type === FOLDER_TYPE) {
234
237
  throw new Error(
235
- `bundle "${bundleName}" lists the folder "${address}". A ` +
238
+ `bundle "${bundleName}" lists the folder "${encodeAddresses(address)}". A ` +
236
239
  `folder is not a member: it materialises in every pack ` +
237
240
  `holding something filed in it, so it belongs to no ` +
238
241
  `one pack and there is no single copy to bundle. List the ` +
@@ -241,15 +244,19 @@ export class Bundles extends BasePackCompiler {
241
244
  }
242
245
  if (read.type === HOMEPAGE_TYPE || read.type === BUNDLE_TYPE) {
243
246
  throw new Error(
244
- `bundle "${bundleName}" lists "${address}", a ${read.type} — ` +
247
+ `bundle "${bundleName}" lists "${encodeAddresses(address)}", a ${read.type} — ` +
245
248
  `which compiles into no document an Adventure can hold`,
246
249
  );
247
250
  }
248
251
 
249
- const note = index.byShortcode.get(`${read.type}/${read.shortcode}`);
252
+ const baseType =
253
+ isAddressTuple(read) ? resolveItemDocType(read.type, index.types) : undefined;
254
+ const itemDoc = read.itemDoc || Boolean(baseType);
255
+ const type = baseType ?? read.type;
256
+ const note = index.byShortcode.get(`${type}/${read.shortcode}`);
250
257
  if (!note) {
251
258
  throw new Error(
252
- `bundle "${bundleName}" lists "${address}", which no note in ` +
259
+ `bundle "${bundleName}" lists "${encodeAddresses(address)}", which no note in ` +
253
260
  `this tree publishes`,
254
261
  );
255
262
  }
@@ -257,8 +264,8 @@ export class Bundles extends BasePackCompiler {
257
264
  // The note's own document, which is the router's rule for `pack:` too.
258
265
  // Its documentation journal is a second document with a second address,
259
266
  // and is bundled only when the note names that one.
260
- const docType = read.itemDoc ? "JournalEntry" : packForType(read.type).docType;
261
- const id = read.itemDoc ? itemDocEntryId(note.id) : note.id;
267
+ const docType = itemDoc ? "JournalEntry" : packForType(type).docType;
268
+ const id = itemDoc ? itemDocEntryId(note.id) : note.id;
262
269
 
263
270
  return { docType, id, name: note.name ?? address };
264
271
  }
@@ -286,7 +293,7 @@ export class Bundles extends BasePackCompiler {
286
293
  // away. See `missingMemberVerdict`.
287
294
  if (missingMemberVerdict(this.packSystem) === "omit") {
288
295
  this.noteWarn(
289
- `bundle "${name}" leaves out "${address}": it publishes ` +
296
+ `bundle "${name}" leaves out "${encodeAddresses(address)}": it publishes ` +
290
297
  `no ${this.packSystem} ${member.docType}, and pack ` +
291
298
  `"${this.packName}" declares ` +
292
299
  `\`system: ${this.packSystem}\``,
@@ -294,7 +301,7 @@ export class Bundles extends BasePackCompiler {
294
301
  continue;
295
302
  }
296
303
  throw new Error(
297
- `bundle "${name}" lists "${address}", which resolves to ` +
304
+ `bundle "${name}" lists "${encodeAddresses(address)}", which resolves to ` +
298
305
  `${member.docType} ${member.id} — and no compiled ` +
299
306
  `${member.docType} pack holds it. The note publishes ` +
300
307
  `nothing into a pack this bundle can read`,