@heroiclands/package-build 22.9.1 → 22.10.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 (100) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/CONTENT.md +33 -0
  3. package/bin/content-build.mjs +3 -1
  4. package/docs/api.md +10 -6
  5. package/docs/content-format.md +384 -200
  6. package/docs/project-setup.md +23 -0
  7. package/engine/actor-compiler.mjs +20 -7
  8. package/engine/address-fields.mjs +404 -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 +59 -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 +20 -4
  24. package/engine/folder-notes.mjs +44 -15
  25. package/engine/foundry-entries.mjs +3 -0
  26. package/engine/frontmatter-lint.mjs +144 -43
  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 +310 -0
  41. package/engine/note-vocabulary.mjs +145 -26
  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/githooks/attribution.mjs +82 -0
  58. package/githooks/commit-message.mjs +26 -0
  59. package/githooks/commit-msg +3 -35
  60. package/hm3/actors.mjs +8 -24
  61. package/hm3/item-fields.mjs +15 -5
  62. package/package.json +1 -1
  63. package/sohl/infobox.mjs +80 -17
  64. package/sohl/item-fields.mjs +114 -41
  65. package/types/engine/address-fields.d.mts +90 -0
  66. package/types/engine/address-values.d.mts +34 -0
  67. package/types/engine/address.d.mts +452 -0
  68. package/types/engine/art-fields.d.mts +59 -53
  69. package/types/engine/art-slots.d.mts +40 -0
  70. package/types/engine/base-compiler.d.mts +24 -2
  71. package/types/engine/bundle-notes.d.mts +2 -2
  72. package/types/engine/content-address.d.mts +3 -139
  73. package/types/engine/content-embeds.d.mts +1 -1
  74. package/types/engine/content-format-check.d.mts +25 -0
  75. package/types/engine/content-index.d.mts +2 -2
  76. package/types/engine/content-links.d.mts +6 -5
  77. package/types/engine/field-spec.d.mts +44 -9
  78. package/types/engine/folder-notes.d.mts +11 -10
  79. package/types/engine/frontmatter-lint.d.mts +7 -97
  80. package/types/engine/frontmatter.d.mts +16 -1
  81. package/types/engine/helpers.d.mts +2 -2
  82. package/types/engine/infobox.d.mts +6 -0
  83. package/types/engine/item-docs.d.mts +1 -35
  84. package/types/engine/map-notes.d.mts +2 -2
  85. package/types/engine/note-addresses.d.mts +41 -0
  86. package/types/engine/note-vocabulary.d.mts +20 -3
  87. package/types/engine/place-relations.d.mts +12 -1
  88. package/types/engine/populations.d.mts +17 -2
  89. package/types/engine/reference-targets.d.mts +9 -0
  90. package/types/engine/shortcode-references.d.mts +14 -0
  91. package/types/engine/site-build.d.mts +3 -3
  92. package/types/engine/site-index.d.mts +9 -30
  93. package/types/engine/sql-tables.d.mts +2 -2
  94. package/types/engine/system-block.d.mts +17 -0
  95. package/types/engine/system-vocabulary.d.mts +16 -0
  96. package/types/engine/wikilink-syntax.d.mts +7 -0
  97. package/types/engine/wikilinks.d.mts +6 -109
  98. package/types/hm3/item-fields.d.mts +5 -0
  99. package/types/sohl/infobox.d.mts +4 -5
  100. package/types/sohl/item-fields.d.mts +8 -0
@@ -0,0 +1,310 @@
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 { 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
+ return {
264
+ package: config.contentPackage,
265
+ system: "none",
266
+ systemBlocks: systemBlocksFor(config),
267
+ };
268
+ }
269
+
270
+ /**
271
+ * Decode Address properties contributed by an index record.
272
+ * @param {object} record - The record.
273
+ * @param {object} context - Builder defaults.
274
+ * @returns {object} The typed record.
275
+ */
276
+ export function decodeIndexAddresses(record, context) {
277
+ decodeNoteAddresses(record, context);
278
+ const canonical = (value, keyPath) => {
279
+ const target = readCanonicalKey(value);
280
+ if (!isAddressTuple(target)) {
281
+ const error = new Error(
282
+ `Index ${keyPath.join(".")} is not a complete Address: ${JSON.stringify(value)}`,
283
+ );
284
+ error.keyPath = keyPath;
285
+ throw error;
286
+ }
287
+ return target;
288
+ };
289
+ for (const key of ["documentation", "documents"]) {
290
+ if (record[key] != null) record[key] = canonical(record[key], [key]);
291
+ }
292
+ if (record.address?.canonical != null)
293
+ record.address.canonical = canonical(record.address.canonical, ["address", "canonical"]);
294
+ for (const anchor of record.anchors ?? []) {
295
+ if (!anchor.link || anchor.link instanceof AddressLink) continue;
296
+ const [written, section] = anchor.link.split("#");
297
+ const target = parseAddress(
298
+ written,
299
+ {
300
+ ...context,
301
+ system: "none",
302
+ types: new Set([...Object.keys(NOTE_VOCABULARY), ...ASSET_TYPE_NAMES]),
303
+ },
304
+ { declared: true },
305
+ );
306
+ if (target.reason) throw new Error(`Index anchor link is not an Address: ${anchor.link}`);
307
+ anchor.link = new AddressLink(target, section);
308
+ }
309
+ return record;
310
+ }
@@ -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,
@@ -732,27 +764,54 @@ export const NOTE_VOCABULARY = Object.freeze({
732
764
  {
733
765
  name: "commonSkills",
734
766
  ...LINKS,
767
+ ref: "skill",
768
+ accepts: ["skill"],
735
769
  describe: "Skills common among members — languages first among them.",
736
770
  },
737
- { name: "seat", ...LINK, describe: "Where the affiliation's authority sits." },
738
- { name: "domains", ...LINKS, describe: "Places over which it holds sway." },
771
+ {
772
+ name: "seat",
773
+ ...LINK,
774
+ ref: "place",
775
+ accepts: ["place"],
776
+ describe: "Where the affiliation's authority sits.",
777
+ },
778
+ {
779
+ name: "domains",
780
+ ...LINKS,
781
+ ref: "place",
782
+ accepts: ["place"],
783
+ describe: "Places over which it holds sway.",
784
+ },
739
785
  { name: "population", ...NUM, describe: "How many people it counts." },
740
786
  {
741
787
  name: "economy",
742
788
  ...LINKS,
789
+ accepts: ["affiliation", "lore"],
743
790
  describe: "What its economic life runs on — currencies, banking bodies, goods.",
744
791
  },
745
792
  {
746
793
  name: "lore",
747
794
  ...LINKS,
795
+ ref: "lore",
796
+ accepts: ["lore"],
748
797
  describe:
749
798
  "Lore concerning it — the peoples it draws on, the god a faith " +
750
799
  "venerates, its law, its calendar.",
751
800
  },
752
- { name: "parents", ...LINKS, describe: "Affiliations it is subordinate to." },
801
+ {
802
+ name: "parents",
803
+ ...LINKS,
804
+ ref: "affiliation",
805
+ accepts: ["affiliation"],
806
+ describe: "Affiliations it is subordinate to.",
807
+ },
753
808
  {
754
809
  name: "relations",
755
- ...ANY,
810
+ kind: "map",
811
+ keyKind: "address",
812
+ ref: "affiliation",
813
+ accepts: ["affiliation"],
814
+ shape: "a map keyed by Address",
756
815
  describe: "Standing with other affiliations — aligned, unaligned, rival, nemesis.",
757
816
  },
758
817
  ]),
@@ -862,15 +921,26 @@ export const NOTE_VOCABULARY = Object.freeze({
862
921
  subTypes: Object.freeze(["boon", "boost", "fate", "grace", "birthsign", "other", "piety"]),
863
922
  data: Object.freeze([
864
923
  TEMPLATE_PRIORITY,
865
- { name: "assocSkill", ...LINK, describe: "The skill it is associated with." },
924
+ {
925
+ name: "assocSkill",
926
+ ...LINK,
927
+ ref: "skill",
928
+ accepts: ["skill"],
929
+ describe: "The skill it is associated with.",
930
+ },
866
931
  {
867
932
  name: "assocAffiliation",
868
933
  ...LINK,
934
+ ref: "affiliation",
935
+ accepts: ["affiliation"],
869
936
  describe: "The affiliation it is associated with.",
870
937
  },
871
938
  {
872
939
  name: "skillAptitudes",
873
- ...ANY,
940
+ kind: "map",
941
+ keyKind: "shortcode",
942
+ keySelector: "subType",
943
+ shape: "a map keyed by Shortcode or subType selector",
874
944
  describe:
875
945
  "Bonuses and penalties, each naming a skill or a `subType:<skill-subtype>`.",
876
946
  },
@@ -895,10 +965,18 @@ export const NOTE_VOCABULARY = Object.freeze({
895
965
  ]),
896
966
  data: Object.freeze([
897
967
  TEMPLATE_PRIORITY,
898
- { name: "assocSkill", ...LINK, describe: "The skill it is associated with." },
968
+ {
969
+ name: "assocSkill",
970
+ ...LINK,
971
+ ref: "skill",
972
+ accepts: ["skill"],
973
+ describe: "The skill it is associated with.",
974
+ },
899
975
  {
900
976
  name: "assocAffiliation",
901
977
  ...LINK,
978
+ ref: "affiliation",
979
+ accepts: ["affiliation"],
902
980
  describe: "The affiliation it is associated with.",
903
981
  },
904
982
  { name: "masteryLevel", ...NUM, describe: "Mastery before any modifier." },
@@ -930,7 +1008,13 @@ export const NOTE_VOCABULARY = Object.freeze({
930
1008
  data: Object.freeze([
931
1009
  TEMPLATE_PRIORITY,
932
1010
  { name: "masteryLevel", ...NUM, describe: "Mastery before any modifier." },
933
- { name: "parentSkill", ...LINK, describe: "The skill this one specialises." },
1011
+ {
1012
+ name: "parentSkill",
1013
+ ...LINK,
1014
+ ref: "skill",
1015
+ accepts: ["skill"],
1016
+ describe: "The skill this one specialises.",
1017
+ },
934
1018
  ]),
935
1019
  }),
936
1020
 
@@ -1024,6 +1108,8 @@ export const NOTE_VOCABULARY = Object.freeze({
1024
1108
  {
1025
1109
  name: "parent",
1026
1110
  ...LINK_BY_PACK,
1111
+ ref: "folder",
1112
+ accepts: ["folder"],
1027
1113
  describe:
1028
1114
  "The folder this one sits in, as an address — or one " +
1029
1115
  "address per pack, keyed by pack name with `default` for " +
@@ -1110,6 +1196,8 @@ export const NOTE_VOCABULARY = Object.freeze({
1110
1196
  {
1111
1197
  name: "lore",
1112
1198
  ...LINKS,
1199
+ ref: "lore",
1200
+ accepts: ["lore"],
1113
1201
  describe:
1114
1202
  "Lore concerning this place — its peoples, its law, its calendar, " +
1115
1203
  "its history.",
@@ -1117,6 +1205,8 @@ export const NOTE_VOCABULARY = Object.freeze({
1117
1205
  {
1118
1206
  name: "parents",
1119
1207
  ...LINKS,
1208
+ ref: "place",
1209
+ accepts: ["place"],
1120
1210
  describe: "Enclosing places this one sits within.",
1121
1211
  },
1122
1212
  {
@@ -1162,13 +1252,39 @@ export const NOTE_VOCABULARY = Object.freeze({
1162
1252
  stubbable: true,
1163
1253
  subTypes: Object.freeze(["campaign", "adventure", "encounter"]),
1164
1254
  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." },
1255
+ {
1256
+ name: "parents",
1257
+ ...LINKS,
1258
+ ref: "scenario",
1259
+ accepts: ["scenario"],
1260
+ describe: "Scenarios this one sits within.",
1261
+ },
1262
+ {
1263
+ name: "locations",
1264
+ ...LINKS,
1265
+ ref: "place",
1266
+ accepts: ["place"],
1267
+ describe: "Places the scenario takes place in.",
1268
+ },
1269
+ {
1270
+ name: "cast",
1271
+ ...LINKS,
1272
+ ref: "being",
1273
+ accepts: ["being"],
1274
+ describe: "Beings who appear in it.",
1275
+ },
1276
+ {
1277
+ name: "factions",
1278
+ ...LINKS,
1279
+ ref: "affiliation",
1280
+ accepts: ["affiliation"],
1281
+ describe: "Affiliations with a stake in it.",
1282
+ },
1169
1283
  {
1170
1284
  name: "follows",
1171
1285
  ...LINKS,
1286
+ ref: "scenario",
1287
+ accepts: ["scenario"],
1172
1288
  describe: "Scenarios that should be played before this one.",
1173
1289
  },
1174
1290
  {
@@ -1203,6 +1319,7 @@ export const NOTE_VOCABULARY = Object.freeze({
1203
1319
  name: "bgImage",
1204
1320
  ...LINK,
1205
1321
  ref: "image",
1322
+ accepts: ART_SLOTS.find((slot) => slot.key === "bgImage").accepts,
1206
1323
  describe: "The map's background art — an `image` address.",
1207
1324
  },
1208
1325
  {
@@ -1240,7 +1357,9 @@ export const NOTE_VOCABULARY = Object.freeze({
1240
1357
  },
1241
1358
  {
1242
1359
  name: "place",
1243
- ...TEXT,
1360
+ ...LINK,
1361
+ ref: "place",
1362
+ accepts: ["place"],
1244
1363
  describe:
1245
1364
  "The place this map depicts. Named here and not on the place, " +
1246
1365
  "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;