@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
@@ -0,0 +1,325 @@
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
+ * SPDX-License-Identifier: GPL-3.0-or-later
6
+ */
7
+
8
+ import { declaredSystems, systemBlocksFor } from "./system-vocabulary.mjs";
9
+ import { RETIRED_FIELD_ALIASES } from "./retired-fields.mjs";
10
+ import {
11
+ isAddressTuple,
12
+ parseAddress,
13
+ renderAddress,
14
+ acceptsType,
15
+ readCanonicalKey,
16
+ } from "./address.mjs";
17
+ import { NOTE_VOCABULARY, dataFields } from "./note-vocabulary.mjs";
18
+ import { ASSET_TYPE_NAMES } from "./asset-types.mjs";
19
+ import { ART_SLOTS } from "./art-slots.mjs";
20
+ import { SHIPPED_SYSTEMS } from "./subtype-registry.mjs";
21
+
22
+ import { AddressEntries, AddressLink } from "./address-values.mjs";
23
+ export { AddressEntries, encodeAddresses, cloneAddressState } from "./address-values.mjs";
24
+
25
+ /** Structured positions whose Address is nested inside a declared container. */
26
+ export const STRUCTURED_ADDRESSES = Object.freeze([
27
+ { path: ["packFolder"], shape: "scalar-or-map", type: "folder", accepts: ["folder"] },
28
+ {
29
+ noteType: "affiliation",
30
+ path: ["data", "governance", "ranks", "*", "lore"],
31
+ shape: "value",
32
+ type: "lore",
33
+ accepts: ["lore"],
34
+ },
35
+ {
36
+ noteType: "place",
37
+ path: ["data", "borders", "*", "to"],
38
+ shape: "value",
39
+ type: "place",
40
+ accepts: ["place"],
41
+ },
42
+ {
43
+ noteType: "place",
44
+ path: ["data", "routes", "*", "to"],
45
+ shape: "value",
46
+ type: "place",
47
+ accepts: ["place"],
48
+ },
49
+ ]);
50
+
51
+ /**
52
+ * All declared Address positions of a note, including embedded notes.
53
+ * @param {object} fm - The note.
54
+ * @returns {object[]} Position descriptors with container shape and defaults.
55
+ */
56
+ export function addressPositions(fm, context = {}) {
57
+ const out = [];
58
+ for (const field of dataFields(fm.type) ?? []) {
59
+ const shape =
60
+ field.kind === "address" ? "value"
61
+ : field.keyKind === "address" ? "keys"
62
+ : field.entryKind === "address" ? field.kind
63
+ : undefined;
64
+ if (shape)
65
+ out.push({
66
+ path: ["data", ...field.name.split(".")],
67
+ shape,
68
+ type: field.ref,
69
+ accepts: field.accepts,
70
+ });
71
+ }
72
+ out.push(...STRUCTURED_ADDRESSES.filter((p) => !p.noteType || p.noteType === fm.type));
73
+ for (const system of new Set([
74
+ ...SHIPPED_SYSTEMS,
75
+ ...Object.keys(context.systemBlocks ?? {}),
76
+ ])) {
77
+ out.push({
78
+ path: [system, "packFolder"],
79
+ shape: "scalar-or-map",
80
+ type: "folder",
81
+ accepts: ["folder"],
82
+ system,
83
+ });
84
+ out.push({
85
+ path: [system, "items", "*", "model"],
86
+ shape: "value",
87
+ system,
88
+ legacyShortcodeCase: true,
89
+ });
90
+ for (const slot of ART_SLOTS)
91
+ out.push({
92
+ path: [system, "items", "*", "data", slot.key],
93
+ shape: "value",
94
+ type: slot.type,
95
+ accepts: slot.accepts,
96
+ system,
97
+ });
98
+ const spec = context.systemBlocks?.[system];
99
+ for (const [index, entry] of (Array.isArray(fm[system]?.items) ?
100
+ fm[system].items
101
+ : []
102
+ ).entries()) {
103
+ const model =
104
+ entry?.model == null ?
105
+ null
106
+ : parseAddress(
107
+ entry.model,
108
+ {
109
+ ...context,
110
+ system,
111
+ types: new Set([...Object.keys(NOTE_VOCABULARY), ...ASSET_TYPE_NAMES]),
112
+ },
113
+ { declared: true, legacyShortcodeCase: true },
114
+ );
115
+ const type = model && !model.reason ? model.type : entry?.type;
116
+ for (const field of spec?.fields?.[type] ?? []) {
117
+ if (!field.address) continue;
118
+ out.push({
119
+ path: [system, "items", index, "system", ...field.to.split(".")],
120
+ shape:
121
+ field.address.holds === "items" ? "list" : (field.address.holds ?? "value"),
122
+ type: field.address.type,
123
+ accepts: field.address.accepts,
124
+ system,
125
+ });
126
+ }
127
+ }
128
+ const fields = [
129
+ ...(spec?.fields?.[fm.type] ?? []),
130
+ ...(spec?.fieldVocabulary ? (context.schemas?.[fm.type]?.fields ?? []) : []),
131
+ ];
132
+ for (const field of fields) {
133
+ if (!field.address) continue;
134
+ const position = {
135
+ shape: field.address.holds === "items" ? "list" : (field.address.holds ?? "value"),
136
+ type: field.address.type,
137
+ accepts: field.address.accepts,
138
+ system,
139
+ };
140
+ out.push({ ...position, path: [system, "system", ...field.to.split(".")] });
141
+ out.push({
142
+ ...position,
143
+ path: [system, ...(field.legacyKey ?? field.name).split(".")],
144
+ });
145
+ if (field.name?.startsWith("data.") && field.topLevelMeans === undefined)
146
+ out.push({ ...position, system: "none", path: field.name.slice(5).split(".") });
147
+ }
148
+ }
149
+ for (const position of [...out]) {
150
+ const last = position.path.at(-1);
151
+ const retired = RETIRED_FIELD_ALIASES[last];
152
+ if (retired) out.push({ ...position, path: [...position.path.slice(0, -1), retired] });
153
+ }
154
+ return out;
155
+ }
156
+
157
+ /**
158
+ * Decode declared properties at the note boundary. Raw mappings cannot impersonate tuples.
159
+ * @param {object} fm - Parsed YAML or an index record.
160
+ * @param {object} context - The builder's package and known types.
161
+ * @returns {object} The same note carrying typed Address properties.
162
+ */
163
+ export function decodeNoteAddresses(fm, context) {
164
+ if (!fm || typeof fm !== "object") return fm;
165
+ const types = new Set([
166
+ ...Object.keys(NOTE_VOCABULARY),
167
+ ...ASSET_TYPE_NAMES,
168
+ ...(context.types ?? []),
169
+ ]);
170
+ for (const position of addressPositions(fm, context)) {
171
+ const defaults = {
172
+ ...context,
173
+ types,
174
+ system: position.system ?? context.system ?? "none",
175
+ type: position.type,
176
+ };
177
+ const fail = (value, path, description) => {
178
+ const error = new Error(
179
+ `\`${path.join(".")}\` ${description}: ${JSON.stringify(value)}`,
180
+ );
181
+ error.keyPath = path;
182
+ error.addressKey = position.shape === "keys";
183
+ throw error;
184
+ };
185
+ const read = (value, path) => {
186
+ if (value == null || value === "") return value;
187
+ const tuple = parseAddress(value, defaults, {
188
+ declared: true,
189
+ legacyShortcodeCase: position.legacyShortcodeCase,
190
+ });
191
+ if (tuple.reason) fail(value, path, "is not an accepted Address");
192
+ if (position.accepts && !acceptsType(tuple, position.accepts))
193
+ fail(
194
+ value,
195
+ path,
196
+ `must name a ${position.accepts.join(" or ")} Address, but names ${tuple.type}`,
197
+ );
198
+ return tuple;
199
+ };
200
+ const convert = (value, path) => {
201
+ if (value == null || value === "") return value;
202
+ if (position.shape === "keys") {
203
+ if (value instanceof AddressEntries) return value;
204
+ if (Array.isArray(value) && value.length === 0) return value;
205
+ if (typeof value !== "object" || Array.isArray(value))
206
+ fail(value, path, "must be an Address-keyed map");
207
+ return new AddressEntries(
208
+ Object.entries(value).map(([key, v]) => ({
209
+ target: read(key, [...path, key]),
210
+ value: v,
211
+ sourceKey: key,
212
+ })),
213
+ );
214
+ }
215
+ if (position.shape === "list") {
216
+ if (!Array.isArray(value)) fail(value, path, "must be a list of Addresses");
217
+ return value.map((v, i) => read(v, [...path, i]));
218
+ }
219
+ if (
220
+ position.shape === "scalar-or-map" &&
221
+ typeof value === "object" &&
222
+ !isAddressTuple(value)
223
+ ) {
224
+ if (Array.isArray(value)) {
225
+ if (!value.length) return value;
226
+ fail(value, path, "must be an Address or pack map");
227
+ }
228
+ return Object.fromEntries(
229
+ Object.entries(value).map(([key, v]) => [key, read(v, [...path, key])]),
230
+ );
231
+ }
232
+ return read(value, path);
233
+ };
234
+ // Copy each traversed owner so YAML aliases retain their own read context.
235
+ const visit = (owner, path, offset = 0, actual = []) => {
236
+ if (owner == null || owner === "") return owner;
237
+ const key = path[offset];
238
+ if (key === "*") {
239
+ if (!Array.isArray(owner)) fail(owner, actual, "must be a list of entries");
240
+ return owner.map((value, i) => visit(value, path, offset + 1, [...actual, i]));
241
+ }
242
+ if (typeof owner !== "object") return owner;
243
+ if (!Object.hasOwn(owner, key)) return owner;
244
+ const copy = Array.isArray(owner) ? [...owner] : { ...owner };
245
+ copy[key] =
246
+ offset === path.length - 1 ?
247
+ convert(owner[key], [...actual, key])
248
+ : visit(owner[key], path, offset + 1, [...actual, key]);
249
+ return copy;
250
+ };
251
+ const decoded = visit(fm, position.path);
252
+ Object.assign(fm, decoded);
253
+ }
254
+ return fm;
255
+ }
256
+
257
+ /**
258
+ * Read context supplied by the package configuration.
259
+ * @param {object} config - Resolved package configuration.
260
+ * @returns {object} Builder defaults and system declarations.
261
+ */
262
+ export function noteAddressContext(config) {
263
+ const systemBlocks = { ...systemBlocksFor(config) };
264
+ // A module with one declared system uses its flat Item registry for that
265
+ // system's Address positions, including fields stored in its content index.
266
+ const systems = new Set([
267
+ ...declaredSystems(config),
268
+ ...(config.relationships?.systems ?? []).map((relationship) => relationship.id),
269
+ ]);
270
+ if (
271
+ systems.size === 1 &&
272
+ config.itemFields &&
273
+ Object.keys(config.itemFieldsBySystem ?? {}).length === 0
274
+ ) {
275
+ const [system] = systems;
276
+ systemBlocks[system] = { ...systemBlocks[system], fields: config.itemFields };
277
+ }
278
+ return {
279
+ package: config.contentPackage,
280
+ system: "none",
281
+ systemBlocks,
282
+ };
283
+ }
284
+
285
+ /**
286
+ * Decode Address properties contributed by an index record.
287
+ * @param {object} record - The record.
288
+ * @param {object} context - Builder defaults.
289
+ * @returns {object} The typed record.
290
+ */
291
+ export function decodeIndexAddresses(record, context) {
292
+ decodeNoteAddresses(record, context);
293
+ const canonical = (value, keyPath) => {
294
+ const target = readCanonicalKey(value);
295
+ if (!isAddressTuple(target)) {
296
+ const error = new Error(
297
+ `Index ${keyPath.join(".")} is not a complete Address: ${JSON.stringify(value)}`,
298
+ );
299
+ error.keyPath = keyPath;
300
+ throw error;
301
+ }
302
+ return target;
303
+ };
304
+ for (const key of ["documentation", "documents"]) {
305
+ if (record[key] != null) record[key] = canonical(record[key], [key]);
306
+ }
307
+ if (record.address?.canonical != null)
308
+ record.address.canonical = canonical(record.address.canonical, ["address", "canonical"]);
309
+ for (const anchor of record.anchors ?? []) {
310
+ if (!anchor.link || anchor.link instanceof AddressLink) continue;
311
+ const [written, section] = anchor.link.split("#");
312
+ const target = parseAddress(
313
+ written,
314
+ {
315
+ ...context,
316
+ system: "none",
317
+ types: new Set([...Object.keys(NOTE_VOCABULARY), ...ASSET_TYPE_NAMES]),
318
+ },
319
+ { declared: true },
320
+ );
321
+ if (target.reason) throw new Error(`Index anchor link is not an Address: ${anchor.link}`);
322
+ anchor.link = new AddressLink(target, section);
323
+ }
324
+ return record;
325
+ }
@@ -77,6 +77,7 @@
77
77
 
78
78
  // The one charset, read rather than restated. A second spelling of the pattern
79
79
  // is how a disagreement between the three arises.
80
+ import { ART_SLOTS } from "./art-slots.mjs";
80
81
  import { ADDRESS_SEGMENT_PATTERN, isAddressSegment } from "./address-charset.mjs";
81
82
  // The retirement window for a renamed type, read rather than restated: a
82
83
  // vocabulary that answered only to the current spelling would report every key
@@ -114,20 +115,25 @@ import { MARKET_CLASSES, checkMarket } from "./market-class.mjs";
114
115
  * @typedef {object} DataFieldSpec
115
116
  * @property {string} name - The key under `data:`, dotted for a nested one
116
117
  * (`charges.value`).
117
- * @property {"string"|"number"|"boolean"|"list"|"map"|"scalar-or-map"} [kind] -
118
+ * @property {"string"|"number"|"boolean"|"list"|"map"|"scalar-or-map"|"address"|"shortcode"} [kind] -
118
119
  * The value's shape, for the lint. Absent means no claim is made about the
119
120
  * value — which is the honest answer wherever the specification's stated
120
121
  * shape and the shape notes are authored in today disagree.
122
+ * @property {"address"|"shortcode"} [entryKind] - Type of each list or pack-map value.
123
+ * @property {"address"|"shortcode"} [keyKind] - Type of each map key.
124
+ * @property {"subType"} [keySelector] - An alternative `subType:<skill-subtype>` key.
125
+ * @property {readonly string[]} [accepts] - Allowed Address types, separate from `ref`.
121
126
  * @property {string} [shape] - Human-readable shape, for a finding and for
122
127
  * documentation.
123
128
  * @property {string} [entryShape] - For a `scalar-or-map` field, what one
124
129
  * entry of the map is. A finding names the entry at fault rather than
125
130
  * quoting the whole map back, so the string an author has to correct is the
126
131
  * one the message holds.
127
- * @property {string} [ref] - The type a bare value takes. An art slot declares
132
+ * @property {string} [ref] - The Address's default type, an art slot declares
128
133
  * one — `icon` for `icon` and `tokenIcon`, `image` for `bgImage` and
129
134
  * `banner` — so `icon: anvil` names `icon-anvil` while a value carrying the
130
- * separator states its own address.
135
+ * separator states its own address. Distinct from a `FieldSpec`'s `code`
136
+ * ({@link module:engine/field-spec}), which names a **Shortcode** instead.
131
137
  * @property {"pack"} [keys] - For a `scalar-or-map` field, what its keys name.
132
138
  * `"pack"` means each is a pack this package declares, so a key naming none
133
139
  * is a finding of its own: it addresses a hierarchy nothing will ever read.
@@ -189,14 +195,14 @@ const TEXT = Object.freeze({ shape: "string", kind: "string" });
189
195
  /** A list. */
190
196
  const LIST = Object.freeze({ shape: "list", kind: "list" });
191
197
 
192
- /** A single wikilink, which is a string until it is resolved. */
193
- const LINK = Object.freeze({ shape: "a wikilink", kind: "string" });
198
+ /** A single Address. */
199
+ const LINK = Object.freeze({ shape: "an Address", kind: "address" });
194
200
 
195
- /** A list of wikilinks. */
196
- const LINKS = Object.freeze({ shape: "list of wikilinks", kind: "list" });
201
+ /** A list of Addresses. */
202
+ const LINKS = Object.freeze({ shape: "list of Addresses", kind: "list", entryKind: "address" });
197
203
 
198
204
  /**
199
- * A single wikilink, or one per pack.
205
+ * A single Address, or one per pack.
200
206
  *
201
207
  * The map form is not a convenience spelling of the scalar: it says something
202
208
  * the scalar cannot, that the answer *differs by pack*. A folder's `parent` is
@@ -209,9 +215,10 @@ const LINKS = Object.freeze({ shape: "list of wikilinks", kind: "list" });
209
215
  * specification prescribes was a finding and no note using it was not.
210
216
  */
211
217
  const LINK_BY_PACK = Object.freeze({
212
- shape: "a wikilink, or a map of wikilinks keyed by pack",
218
+ shape: "an Address, or a map of Addresses keyed by pack",
219
+ entryKind: "address",
213
220
  kind: "scalar-or-map",
214
- entryShape: "a wikilink",
221
+ entryShape: "an Address",
215
222
  keys: "pack",
216
223
  });
217
224
 
@@ -246,6 +253,7 @@ const TOKEN_ICON = Object.freeze({
246
253
  name: "tokenIcon",
247
254
  ...LINK,
248
255
  ref: "icon",
256
+ accepts: ART_SLOTS.find((slot) => slot.key === "tokenIcon").accepts,
249
257
  describe: "What a token on the canvas wears — an `icon` address; unset, it follows `icon`.",
250
258
  });
251
259
 
@@ -273,12 +281,14 @@ export const SHARED_DATA_FIELDS = Object.freeze([
273
281
  name: "icon",
274
282
  ...LINK,
275
283
  ref: "icon",
284
+ accepts: ART_SLOTS.find((slot) => slot.key === "icon").accepts,
276
285
  describe: "The document's profile art — an `icon` address, resolved into `img`.",
277
286
  }),
278
287
  Object.freeze({
279
288
  name: "banner",
280
289
  ...LINK,
281
290
  ref: "image",
291
+ accepts: ART_SLOTS.find((slot) => slot.key === "banner").accepts,
282
292
  describe: "The page's hero image — an `image` address. Reaches no compiled document.",
283
293
  }),
284
294
  ]);
@@ -599,22 +609,44 @@ export const NOTE_VOCABULARY = Object.freeze({
599
609
  TEMPLATE_PRIORITY,
600
610
  { name: "archetypes", ...LIST, describe: "Archetypal behaviours the being fits." },
601
611
  { name: "occupation", ...TEXT, describe: "What the being does for a living." },
602
- { name: "stations", ...LINKS, describe: "Stations the being holds." },
612
+ {
613
+ name: "stations",
614
+ ...LINKS,
615
+ ref: "lore",
616
+ accepts: ["lore"],
617
+ describe: "Stations the being holds.",
618
+ },
603
619
  {
604
620
  name: "lore",
605
621
  ...LINKS,
622
+ ref: "lore",
623
+ accepts: ["lore"],
606
624
  describe:
607
625
  "Lore concerning this being — the people it is of, the standing it " +
608
626
  "holds, the law it lives under.",
609
627
  },
610
- { name: "homes", ...LINKS, describe: "Places the being calls home." },
628
+ {
629
+ name: "homes",
630
+ ...LINKS,
631
+ ref: "place",
632
+ accepts: ["place"],
633
+ describe: "Places the being calls home.",
634
+ },
611
635
  {
612
636
  name: "affiliations",
613
637
  ...LINKS,
638
+ ref: "affiliation",
639
+ accepts: ["affiliation"],
614
640
  describe: "Affiliations the being belongs to — traditions, polities, and the rest.",
615
641
  },
616
642
  { name: "gender", ...TEXT, describe: "`male`, `female` or `other`." },
617
- { name: "species", ...LINK, describe: "The being's species, as a lore note." },
643
+ {
644
+ name: "species",
645
+ ...LINK,
646
+ ref: "lore",
647
+ accepts: ["lore"],
648
+ describe: "The being's species, as a lore note.",
649
+ },
618
650
  {
619
651
  name: "born",
620
652
  ...TEXT,
@@ -730,29 +762,49 @@ export const NOTE_VOCABULARY = Object.freeze({
730
762
  describe: "The named offices it appoints, each with what the office does.",
731
763
  },
732
764
  {
733
- name: "commonSkills",
765
+ name: "seat",
766
+ ...LINK,
767
+ ref: "place",
768
+ accepts: ["place"],
769
+ describe: "Where the affiliation's authority sits.",
770
+ },
771
+ {
772
+ name: "domains",
734
773
  ...LINKS,
735
- describe: "Skills common among members — languages first among them.",
774
+ ref: "place",
775
+ accepts: ["place"],
776
+ describe: "Places over which it holds sway.",
736
777
  },
737
- { name: "seat", ...LINK, describe: "Where the affiliation's authority sits." },
738
- { name: "domains", ...LINKS, describe: "Places over which it holds sway." },
739
778
  { name: "population", ...NUM, describe: "How many people it counts." },
740
779
  {
741
780
  name: "economy",
742
781
  ...LINKS,
782
+ accepts: ["affiliation", "lore"],
743
783
  describe: "What its economic life runs on — currencies, banking bodies, goods.",
744
784
  },
745
785
  {
746
786
  name: "lore",
747
787
  ...LINKS,
788
+ ref: "lore",
789
+ accepts: ["lore"],
748
790
  describe:
749
791
  "Lore concerning it — the peoples it draws on, the god a faith " +
750
792
  "venerates, its law, its calendar.",
751
793
  },
752
- { name: "parents", ...LINKS, describe: "Affiliations it is subordinate to." },
794
+ {
795
+ name: "parents",
796
+ ...LINKS,
797
+ ref: "affiliation",
798
+ accepts: ["affiliation"],
799
+ describe: "Affiliations it is subordinate to.",
800
+ },
753
801
  {
754
802
  name: "relations",
755
- ...ANY,
803
+ kind: "map",
804
+ keyKind: "address",
805
+ ref: "affiliation",
806
+ accepts: ["affiliation"],
807
+ shape: "a map keyed by Address",
756
808
  describe: "Standing with other affiliations — aligned, unaligned, rival, nemesis.",
757
809
  },
758
810
  ]),
@@ -862,15 +914,26 @@ export const NOTE_VOCABULARY = Object.freeze({
862
914
  subTypes: Object.freeze(["boon", "boost", "fate", "grace", "birthsign", "other", "piety"]),
863
915
  data: Object.freeze([
864
916
  TEMPLATE_PRIORITY,
865
- { name: "assocSkill", ...LINK, describe: "The skill it is associated with." },
917
+ {
918
+ name: "assocSkill",
919
+ ...LINK,
920
+ ref: "skill",
921
+ accepts: ["skill"],
922
+ describe: "The skill it is associated with.",
923
+ },
866
924
  {
867
925
  name: "assocAffiliation",
868
926
  ...LINK,
927
+ ref: "affiliation",
928
+ accepts: ["affiliation"],
869
929
  describe: "The affiliation it is associated with.",
870
930
  },
871
931
  {
872
932
  name: "skillAptitudes",
873
- ...ANY,
933
+ kind: "map",
934
+ keyKind: "shortcode",
935
+ keySelector: "subType",
936
+ shape: "a map keyed by Shortcode or subType selector",
874
937
  describe:
875
938
  "Bonuses and penalties, each naming a skill or a `subType:<skill-subtype>`.",
876
939
  },
@@ -895,10 +958,18 @@ export const NOTE_VOCABULARY = Object.freeze({
895
958
  ]),
896
959
  data: Object.freeze([
897
960
  TEMPLATE_PRIORITY,
898
- { name: "assocSkill", ...LINK, describe: "The skill it is associated with." },
961
+ {
962
+ name: "assocSkill",
963
+ ...LINK,
964
+ ref: "skill",
965
+ accepts: ["skill"],
966
+ describe: "The skill it is associated with.",
967
+ },
899
968
  {
900
969
  name: "assocAffiliation",
901
970
  ...LINK,
971
+ ref: "affiliation",
972
+ accepts: ["affiliation"],
902
973
  describe: "The affiliation it is associated with.",
903
974
  },
904
975
  { name: "masteryLevel", ...NUM, describe: "Mastery before any modifier." },
@@ -930,7 +1001,13 @@ export const NOTE_VOCABULARY = Object.freeze({
930
1001
  data: Object.freeze([
931
1002
  TEMPLATE_PRIORITY,
932
1003
  { name: "masteryLevel", ...NUM, describe: "Mastery before any modifier." },
933
- { name: "parentSkill", ...LINK, describe: "The skill this one specialises." },
1004
+ {
1005
+ name: "parentSkill",
1006
+ ...LINK,
1007
+ ref: "skill",
1008
+ accepts: ["skill"],
1009
+ describe: "The skill this one specialises.",
1010
+ },
934
1011
  ]),
935
1012
  }),
936
1013
 
@@ -1024,6 +1101,8 @@ export const NOTE_VOCABULARY = Object.freeze({
1024
1101
  {
1025
1102
  name: "parent",
1026
1103
  ...LINK_BY_PACK,
1104
+ ref: "folder",
1105
+ accepts: ["folder"],
1027
1106
  describe:
1028
1107
  "The folder this one sits in, as an address — or one " +
1029
1108
  "address per pack, keyed by pack name with `default` for " +
@@ -1110,6 +1189,8 @@ export const NOTE_VOCABULARY = Object.freeze({
1110
1189
  {
1111
1190
  name: "lore",
1112
1191
  ...LINKS,
1192
+ ref: "lore",
1193
+ accepts: ["lore"],
1113
1194
  describe:
1114
1195
  "Lore concerning this place — its peoples, its law, its calendar, " +
1115
1196
  "its history.",
@@ -1117,6 +1198,8 @@ export const NOTE_VOCABULARY = Object.freeze({
1117
1198
  {
1118
1199
  name: "parents",
1119
1200
  ...LINKS,
1201
+ ref: "place",
1202
+ accepts: ["place"],
1120
1203
  describe: "Enclosing places this one sits within.",
1121
1204
  },
1122
1205
  {
@@ -1162,13 +1245,39 @@ export const NOTE_VOCABULARY = Object.freeze({
1162
1245
  stubbable: true,
1163
1246
  subTypes: Object.freeze(["campaign", "adventure", "encounter"]),
1164
1247
  data: Object.freeze([
1165
- { name: "parents", ...LINKS, describe: "Scenarios this one sits within." },
1166
- { name: "locations", ...LINKS, describe: "Places the scenario takes place in." },
1167
- { name: "cast", ...LINKS, describe: "Beings who appear in it." },
1168
- { name: "factions", ...LINKS, describe: "Affiliations with a stake in it." },
1248
+ {
1249
+ name: "parents",
1250
+ ...LINKS,
1251
+ ref: "scenario",
1252
+ accepts: ["scenario"],
1253
+ describe: "Scenarios this one sits within.",
1254
+ },
1255
+ {
1256
+ name: "locations",
1257
+ ...LINKS,
1258
+ ref: "place",
1259
+ accepts: ["place"],
1260
+ describe: "Places the scenario takes place in.",
1261
+ },
1262
+ {
1263
+ name: "cast",
1264
+ ...LINKS,
1265
+ ref: "being",
1266
+ accepts: ["being"],
1267
+ describe: "Beings who appear in it.",
1268
+ },
1269
+ {
1270
+ name: "factions",
1271
+ ...LINKS,
1272
+ ref: "affiliation",
1273
+ accepts: ["affiliation"],
1274
+ describe: "Affiliations with a stake in it.",
1275
+ },
1169
1276
  {
1170
1277
  name: "follows",
1171
1278
  ...LINKS,
1279
+ ref: "scenario",
1280
+ accepts: ["scenario"],
1172
1281
  describe: "Scenarios that should be played before this one.",
1173
1282
  },
1174
1283
  {
@@ -1203,6 +1312,7 @@ export const NOTE_VOCABULARY = Object.freeze({
1203
1312
  name: "bgImage",
1204
1313
  ...LINK,
1205
1314
  ref: "image",
1315
+ accepts: ART_SLOTS.find((slot) => slot.key === "bgImage").accepts,
1206
1316
  describe: "The map's background art — an `image` address.",
1207
1317
  },
1208
1318
  {
@@ -1240,7 +1350,9 @@ export const NOTE_VOCABULARY = Object.freeze({
1240
1350
  },
1241
1351
  {
1242
1352
  name: "place",
1243
- ...TEXT,
1353
+ ...LINK,
1354
+ ref: "place",
1355
+ accepts: ["place"],
1244
1356
  describe:
1245
1357
  "The place this map depicts. Named here and not on the place, " +
1246
1358
  "because a place has several maps and a map depicts one place.",
@@ -379,7 +379,7 @@ export async function buildPdf({ config, out, version = "", compile = true } = {
379
379
  scheme,
380
380
  };
381
381
  const collected = collectContentPages(contentBase, ctx);
382
- const gates = siteGates(collected.pages, collected, { config: resolved });
382
+ const gates = siteGates(collected.pages, collected, { config: resolved, records });
383
383
  if (gatesFailed(gates)) {
384
384
  for (const [name, list] of Object.entries(gates)) {
385
385
  if (!Array.isArray(list)) continue;