@heroiclands/package-build 22.14.2 → 22.14.4

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 (67) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/bin/content-build.mjs +6 -6
  3. package/bin/package-build.mjs +10 -0
  4. package/config.mjs +18 -1
  5. package/content-config.mjs +2 -2
  6. package/docs/api.md +66 -37
  7. package/docs/configuration.md +16 -7
  8. package/docs/content-format.md +172 -200
  9. package/docs/getting-started.md +46 -14
  10. package/docs/project-setup.md +5 -6
  11. package/engine/address.mjs +3 -5
  12. package/engine/asset-bindings.mjs +66 -0
  13. package/engine/calendar-notes.mjs +74 -0
  14. package/engine/changelog-lint.mjs +2 -2
  15. package/engine/content-images.mjs +20 -10
  16. package/engine/content-index.mjs +63 -36
  17. package/engine/content-lint.mjs +1 -2
  18. package/engine/content-package.mjs +2 -2
  19. package/engine/content-tables.mjs +4 -8
  20. package/engine/folder-notes.mjs +1 -2
  21. package/engine/foundry-entries.mjs +21 -6
  22. package/engine/frontmatter-lint.mjs +3 -4
  23. package/engine/frontmatter.mjs +5 -8
  24. package/engine/helpers.mjs +3 -4
  25. package/engine/index.mjs +3 -0
  26. package/engine/infobox.mjs +66 -2
  27. package/engine/item-registry.mjs +2 -2
  28. package/engine/note-vocabulary.mjs +83 -5
  29. package/engine/pack-config.mjs +2 -2
  30. package/engine/pack-router.mjs +2 -2
  31. package/engine/pdf-render.mjs +31 -8
  32. package/engine/pdf-toc.mjs +3 -3
  33. package/engine/retired-fields.mjs +1 -1
  34. package/engine/scene-levels.mjs +4 -4
  35. package/engine/schema-check.mjs +1 -1
  36. package/engine/site-build.mjs +134 -121
  37. package/engine/site-preview.mjs +293 -0
  38. package/engine/site-root.mjs +20 -2
  39. package/engine/social-tie-terms.mjs +11 -0
  40. package/engine/social-ties.mjs +84 -0
  41. package/engine/sql-tables.mjs +4 -9
  42. package/hm3/actors.mjs +1 -1
  43. package/package.json +3 -3
  44. package/sohl/item-fields.mjs +2 -2
  45. package/types/config.d.mts +8 -0
  46. package/types/content-config.d.mts +1 -1
  47. package/types/engine/address.d.mts +3 -5
  48. package/types/engine/asset-bindings.d.mts +6 -0
  49. package/types/engine/calendar-notes.d.mts +12 -0
  50. package/types/engine/content-images.d.mts +7 -4
  51. package/types/engine/content-index.d.mts +11 -0
  52. package/types/engine/content-package.d.mts +2 -2
  53. package/types/engine/folder-notes.d.mts +1 -2
  54. package/types/engine/frontmatter-lint.d.mts +1 -2
  55. package/types/engine/frontmatter.d.mts +2 -4
  56. package/types/engine/helpers.d.mts +3 -4
  57. package/types/engine/index.d.mts +1 -0
  58. package/types/engine/item-registry.d.mts +2 -2
  59. package/types/engine/note-vocabulary.d.mts +5 -6
  60. package/types/engine/pack-config.d.mts +1 -1
  61. package/types/engine/pack-router.d.mts +1 -1
  62. package/types/engine/pdf-render.d.mts +10 -1
  63. package/types/engine/pdf-toc.d.mts +1 -1
  64. package/types/engine/site-build.d.mts +36 -3
  65. package/types/engine/site-preview.d.mts +34 -0
  66. package/types/engine/social-tie-terms.d.mts +5 -0
  67. package/types/engine/social-ties.d.mts +10 -0
@@ -163,7 +163,7 @@ export function entriesForNote(fm, name, address, body, ctx) {
163
163
  // A published address must name the pack the document actually shipped in:
164
164
  // a consumer resolves the UUID verbatim, and a repository may ship several
165
165
  // packs of one type.
166
- const uuidFor = (type, id, routeFm) => {
166
+ const uuidFor = (type, id, routeFm, system) => {
167
167
  // **A document that would be empty is not created, so nothing names
168
168
  // one.** A JournalEntry's content is the note's prose, and an empty
169
169
  // body compiles no pages, so the pass emits no entry — and a manifest
@@ -201,7 +201,7 @@ export function entriesForNote(fm, name, address, body, ctx) {
201
201
  !id ||
202
202
  NEVER_PACKED_TYPES.has(String(type)) ||
203
203
  DERIVED_PACKED_TYPES.has(String(type)) ||
204
- (routeFm && declaresNoPack(routeFm))
204
+ (routeFm && declaresNoPack(routeFm, system))
205
205
  ) {
206
206
  return undefined;
207
207
  }
@@ -209,7 +209,7 @@ export function entriesForNote(fm, name, address, body, ctx) {
209
209
  const docType = routeFm ? packForType(type).docType : "JournalEntry";
210
210
  const pack =
211
211
  routeFm ?
212
- packRouter.resolveOrNull(routeFm, docType)
212
+ packRouter.resolveOrNull(routeFm, docType, system)
213
213
  : packRouter.defaultOf("JournalEntry");
214
214
 
215
215
  // **A system block is what makes a game document**, so a note carrying
@@ -224,14 +224,27 @@ export function entriesForNote(fm, name, address, body, ctx) {
224
224
  // and one declaring none by the fallback, which reads a single block of
225
225
  // its own.
226
226
  if (SYSTEM_DOCUMENT_CLASSES.has(docType)) {
227
- const system =
228
- (pack && packRouter.systemOf?.(pack)) || DEFAULT_DOCUMENT_SUBTYPES.system;
229
- if (!carriesSystemBlock(routeFm, system)) return undefined;
227
+ const targetSystem =
228
+ system || (pack && packRouter.systemOf?.(pack)) || DEFAULT_DOCUMENT_SUBTYPES.system;
229
+ if (!carriesSystemBlock(routeFm, targetSystem)) return undefined;
230
230
  }
231
231
 
232
232
  return compendiumUuid(foundryPackageId, type, id, pack);
233
233
  };
234
234
 
235
+ const systemUuidsFor = (type, id) => {
236
+ const docType = packForType(type)?.docType;
237
+ if (!SYSTEM_DOCUMENT_CLASSES.has(docType)) return undefined;
238
+ const systems = packRouter.systemsOfType?.(docType) ?? [];
239
+ if (!systems.length) return undefined;
240
+ return Object.fromEntries(
241
+ systems.flatMap((system) => {
242
+ const uuid = uuidFor(type, id, fm, system);
243
+ return uuid ? [[system, uuid]] : [];
244
+ }),
245
+ );
246
+ };
247
+
235
248
  const carriesDoc =
236
249
  ctx.docEntryTypes ?
237
250
  ctx.docEntryTypes.has(String(currentType(fm.type)))
@@ -251,6 +264,7 @@ export function entriesForNote(fm, name, address, body, ctx) {
251
264
  url,
252
265
  id: fm.id,
253
266
  uuid: uuidFor(fm.type, fm.id, fm),
267
+ systemUuids: systemUuidsFor(fm.type, fm.id),
254
268
  doc: docKey,
255
269
  },
256
270
  {
@@ -283,6 +297,7 @@ export function entriesForNote(fm, name, address, body, ctx) {
283
297
  url,
284
298
  id: fm.id,
285
299
  uuid: own,
300
+ systemUuids: systemUuidsFor(fm.type, fm.id),
286
301
  anchors: own && fm.type === "doc" ? anchorsOf(own, fm.id, body ?? "", name) : undefined,
287
302
  },
288
303
  ];
@@ -24,7 +24,7 @@
24
24
  * - a **wrong type** threw deep inside a builder, or was coerced;
25
25
  * - a **misspelled property** was simply *ignored* — the builders are an
26
26
  * allow-list, so an unrecognised `sohl:` key is dropped without a word. That
27
- * is how 204 kethira mystical abilities shipped with no affiliation (#3), and
27
+ * is how 204 kethira mystical abilities shipped with no affiliation, and
28
28
  * why the silence mattered more than the missing field: an author could not
29
29
  * tell a builder that forgot the field apart from a field that does
30
30
  * not belong on the type at all;
@@ -312,8 +312,7 @@ function nearest(key, candidates) {
312
312
  *
313
313
  * Deliberately lenient about the spellings YAML makes ambiguous: `"12"` is a
314
314
  * number, because a quoted scalar is how a number arrives from many editors,
315
- * and a map authored as an empty list is a map, because Obsidian's property
316
- * editor serialises an emptied map that way (#8). What it rejects is a value
315
+ * and a map authored as an empty list has no entries, just like `{}`. What it rejects is a value
317
316
  * that cannot mean what the field is for — `weight: heavy`.
318
317
  *
319
318
  * @param {unknown} value - The authored value.
@@ -378,7 +377,7 @@ function mapEntries(value) {
378
377
  * The `data:` container a note authored.
379
378
  *
380
379
  * An emptied map arrives from the property editor as `[]` and means the same
381
- * thing `{}` does — this note authors no entries (#8) — so both read as an
380
+ * thing `{}` does — this note authors no entries — so both read as an
382
381
  * empty container rather than as a malformed one.
383
382
  *
384
383
  * @param {object} fm - The note's frontmatter.
@@ -137,11 +137,10 @@ export function sohlSystemField(fm, to, defaultValue = undefined, { legacyKey =
137
137
  * Read a frontmatter property that is authored as a **map**, returning its
138
138
  * entries — or `null` when the note authors none.
139
139
  *
140
- * Obsidian's property editor serializes an **emptied map as an empty list**, so
141
- * a note whose map property was ever touched and cleared in the editor arrives
142
- * as `[]`, not `{}`. The two spellings mean the same thing — this note authors
140
+ * An empty map may be written as `{}` or `[]`. Both spellings mean the same
141
+ * thing — this note authors
143
142
  * no entries — and a build that accepted only one of them failed on notes the
144
- * editor itself had produced (#8; 44 affiliation notes in `sohl-thalorna`).
143
+ * editor itself had produced ( 44 affiliation notes in `sohl-thalorna`).
145
144
  * Normalizing the notes would fix only today's tree: the next editor touch puts
146
145
  * the empty list back.
147
146
  *
@@ -213,8 +212,7 @@ export function resolveCharges(fm) {
213
212
  * kept — an element a sign leaves untouched still beats one another sign
214
213
  * hinders, so it carries real weight when maps merge.
215
214
  *
216
- * An absent property, an empty map, and the empty **list** Obsidian's property
217
- * editor writes for a cleared map all mean the same thing — see
215
+ * An absent property, an empty map, and an empty list all mean the same thing — see
218
216
  * {@link readMapEntries}.
219
217
  *
220
218
  * @param {object} fm - The item frontmatter.
@@ -249,8 +247,7 @@ export function resolveSkillAptitudes(fm, ctx = "item") {
249
247
  * and be dropped silently, shipping an affiliation whose authored hostility had
250
248
  * quietly become neutrality — so it is a build error instead.
251
249
  *
252
- * An absent property, an empty map, and the empty **list** Obsidian's property
253
- * editor writes for a cleared map all mean the same thing — neutral toward
250
+ * An absent property, an empty map, and an empty list all mean the same thing — neutral toward
254
251
  * everyone. See {@link readMapEntries}.
255
252
  *
256
253
  * @param {object} fm - The item frontmatter.
@@ -249,9 +249,8 @@ export function assertSuppliedCorpus(records, who) {
249
249
  * is warned about and skipped.
250
250
  *
251
251
  * Directory names in `skipDirectories` are ignored wherever they appear. The
252
- * walk itself knows nothing about what they mean: `Templates/` is an Obsidian
253
- * templater convention this repository's vault happens to use, not a property
254
- * of a content tree, so it is stated by the caller rather than hard-coded.
252
+ * walk itself knows nothing about what they mean: `Templates/` is authoring
253
+ * scaffolding rather than content, so the caller names it explicitly.
255
254
  *
256
255
  * @param {string} rootDir - Root of the tree to walk.
257
256
  * @param {object} opts
@@ -652,7 +651,7 @@ let cachedDefaultStats;
652
651
  * Each compiler used to hoist `const STATS = buildStats()` at module scope,
653
652
  * which read the shipped package manifest the moment the module was imported —
654
653
  * so importing a compiler required a manifest to exist even when nothing was
655
- * going to be compiled (#2). Deferred to first use and memoised here, the
654
+ * going to be compiled. Deferred to first use and memoised here, the
656
655
  * cost and the identity are what they always were; only the moment moved.
657
656
  *
658
657
  * @returns {object} The default `_stats` block, shared by every compiler.
package/engine/index.mjs CHANGED
@@ -184,6 +184,9 @@ export * as packagebuildIndex from "./packagebuild-index.mjs";
184
184
  /** Publishing a content tree as a website: the pass, and its integrity gates. */
185
185
  export * as siteBuild from "./site-build.mjs";
186
186
 
187
+ /** In-memory page rendering for an editor preview. */
188
+ export * as sitePreview from "./site-preview.mjs";
189
+
187
190
  /** Address rules every content tree is linted against: shape, uniqueness, alias. */
188
191
  export * as contentLint from "./content-lint.mjs";
189
192
 
@@ -112,7 +112,7 @@
112
112
  * @module
113
113
  */
114
114
 
115
- import { isAddressTuple, renderAddress } from "./address.mjs";
115
+ import { isAddressTuple, parseAddress, renderAddress } from "./address.mjs";
116
116
  import { getFrontmatter } from "./frontmatter.mjs";
117
117
  import { currentType } from "./ids.mjs";
118
118
  import { NOTE_VOCABULARY, dataFields } from "./note-vocabulary.mjs";
@@ -556,6 +556,58 @@ function rowValue(kind, raw, resolve, hint) {
556
556
  return presentValue(raw);
557
557
  }
558
558
 
559
+ /** Expand structured references using the existing linked-row shape. */
560
+ function structuredRows(field, raw, resolve, label, fm) {
561
+ if (Array.isArray(raw) && raw.some(isMapping)) {
562
+ const links = raw
563
+ .filter((entry) => isMapping(entry) && hasValue(entry.to))
564
+ .map((entry) => {
565
+ const link = linkValue(entry.to, resolve, { type: field.ref ?? "place" });
566
+ const details = Object.entries(entry)
567
+ .filter(([key, value]) => key !== "to" && hasValue(value))
568
+ .map(([key, value]) => `${humanizeFieldName(key)}: ${presentValue(value)}`);
569
+ return {
570
+ ...link,
571
+ text: details.length ? `${link.text} (${details.join(", ")})` : link.text,
572
+ };
573
+ });
574
+ return links.length ? [{ label, kind: "links", value: links }] : [];
575
+ }
576
+ if (isMapping(raw) && !isAddressTuple(raw)) {
577
+ const groups = new Map();
578
+ for (const [target, relation] of Object.entries(raw)) {
579
+ if (!hasValue(target) || !hasValue(relation) || isMapping(relation)) continue;
580
+ const term = String(relation);
581
+ if (field.terms && !field.terms.some((entry) => entry.term === term)) continue;
582
+ if (field.terms && field.keyKind === "address") {
583
+ const address = parseAddress(
584
+ target,
585
+ {
586
+ package: fm.package ?? "local",
587
+ system: "note",
588
+ types: new Set(field.accepts),
589
+ },
590
+ { declared: true },
591
+ );
592
+ if (address.reason || (field.accepts && !field.accepts.includes(address.type)))
593
+ continue;
594
+ }
595
+ const links = groups.get(term) ?? [];
596
+ links.push(linkValue(target, resolve, { type: field.ref }));
597
+ groups.set(term, links);
598
+ }
599
+ const terms = field.terms ? field.terms.map(({ term }) => term) : [...groups.keys()];
600
+ return terms
601
+ .filter((term) => groups.has(term))
602
+ .map((term) => ({
603
+ label: presentValue(term),
604
+ kind: "links",
605
+ value: groups.get(term),
606
+ }));
607
+ }
608
+ return null;
609
+ }
610
+
559
611
  /**
560
612
  * One row, with its unit on it.
561
613
  *
@@ -665,7 +717,19 @@ function noteBox(
665
717
  // The current name wins, which is the whole of the retirement window's
666
718
  // behaviour and is `readAliasedField`'s answer, not a second one.
667
719
  const own = getFrontmatter(data, field.name, undefined);
668
- const raw = hasValue(own) ? own : readAliasedField(fm, field.name, { inData: true });
720
+ const raw = own !== undefined ? own : readAliasedField(fm, field.name, { inData: true });
721
+ const structured = structuredRows(
722
+ field,
723
+ raw,
724
+ resolve,
725
+ overlay.label ?? humanizeFieldName(field.name),
726
+ fm,
727
+ );
728
+ if (structured) {
729
+ rows.push(...structured);
730
+ if (structured.length) shown.add(field.name);
731
+ continue;
732
+ }
669
733
  if (!hasValue(raw)) continue;
670
734
 
671
735
  if (overlay.group) {
@@ -57,7 +57,7 @@ import { resolveImg } from "./helpers.mjs";
57
57
  * compile.
58
58
  *
59
59
  * An accessor rather than a hoisted constant, so that importing this module
60
- * needs no configuration (#2).
60
+ * needs no configuration.
61
61
  *
62
62
  * @returns {ReadonlySet<string>} The configured item types.
63
63
  */
@@ -163,7 +163,7 @@ export function itemFields(type, system) {
163
163
  * SoHL-owned table could never hold. Art is not looked up in
164
164
  * `sohl/default-item-art.mjs` instead: a type was configurable while its
165
165
  * default art was not, so a second consumer's items compiled only if every one
166
- * of its notes set `img:` (#7).
166
+ * of its notes set `img:`.
167
167
  *
168
168
  * **Still fail-fast.** A type with neither a note-level `img:` nor paired art
169
169
  * aborts the pack build rather than shipping a mismatched icon — the contract
@@ -96,12 +96,16 @@ import {
96
96
  // package's declared present compute — the closed `data:` container's own
97
97
  // question, asked the way `checkPlace` and `checkCalendarNote` are.
98
98
  import { checkBeingAge } from "./being-age.mjs";
99
+ import { checkSocialTies } from "./social-ties.mjs";
100
+ import { SOCIAL_TIES } from "./social-tie-terms.mjs";
101
+ import { parseNoteDate } from "./note-dates.mjs";
99
102
  import { checkHeld } from "./holdings.mjs";
100
103
  import { checkCitedPopulations, checkPopulation } from "./populations.mjs";
101
104
  // What trade a settlement supports — the scale and the check that holds a
102
105
  // value to it, read rather than restated, so the reference below and the
103
106
  // finding an author meets state one list.
104
107
  import { MARKET_CLASSES, checkMarket } from "./market-class.mjs";
108
+ import { positionOfFrontmatterPath } from "./diagnostics.mjs";
105
109
 
106
110
  /**
107
111
  * One `data:` key a note type may carry.
@@ -365,6 +369,51 @@ function checkPlace(note, opts) {
365
369
  return [...checkHeld(note, opts), ...checkWorldFacts(note, opts)];
366
370
  }
367
371
 
372
+ /** Validate a being's authored date using the shared note-date grammar. */
373
+ function checkBeingDate(key) {
374
+ return (note) =>
375
+ parseNoteDate(note.fm?.data?.[key], {
376
+ field: `data.${key}`,
377
+ file: note.file,
378
+ raw: note.raw,
379
+ keyPath: ["data", key],
380
+ }).findings;
381
+ }
382
+
383
+ /** A place's purpose selects one of its own character tags. */
384
+ function checkPlacePurpose(note) {
385
+ const value = note.fm?.data?.purpose;
386
+ if (value === undefined || value === null) return [];
387
+ const at = () => ({
388
+ file: note.file,
389
+ ...positionOfFrontmatterPath(note.raw ?? "", ["data", "purpose"]),
390
+ severity: "error",
391
+ });
392
+ const subType = note.fm?.subType;
393
+ if (!["settlement", "site", "structure"].includes(subType))
394
+ return [
395
+ {
396
+ ...at(),
397
+ message: `data.purpose applies to a settlement, site, or structure; this place is ${subType ?? "untyped"}`,
398
+ },
399
+ ];
400
+ if (!DECLARED_TAGS.placeCharacter.tags.includes(value))
401
+ return [
402
+ {
403
+ ...at(),
404
+ message: `data.purpose ${JSON.stringify(value)} is not a placeCharacter tag; choose one of ${DECLARED_TAGS.placeCharacter.tags.join(", ")}`,
405
+ },
406
+ ];
407
+ if (hasTag(note.fm, value)) return [];
408
+ const tags = note.fm?.tags ?? note.fm?.tag;
409
+ return [
410
+ {
411
+ ...at(),
412
+ message: `data.purpose ${JSON.stringify(value)} must select one of this place's tags; it carries ${JSON.stringify(tags ?? [])}`,
413
+ },
414
+ ];
415
+ }
416
+
368
417
  /* --------------------------------------------------------------------- */
369
418
  /* The vocabulary */
370
419
  /* --------------------------------------------------------------------- */
@@ -485,6 +534,10 @@ export const DECLARED_TAGS = Object.freeze({
485
534
  "holy",
486
535
  "sacred",
487
536
  "free",
537
+ "ford",
538
+ "portage",
539
+ "pass",
540
+ "well",
488
541
  ]),
489
542
  }),
490
543
  /** A place's scale, where the subtype does not distinguish it. */
@@ -564,14 +617,13 @@ export function exclusiveTagGroups(type, groups = DECLARED_TAGS) {
564
617
  /**
565
618
  * Whether a note carries a given tag, however the author wrote it.
566
619
  *
567
- * `tags:` is authored by hand and Obsidian is permissive about it: a single tag
620
+ * `tags:` accepts common YAML forms: a single tag
568
621
  * may be a scalar rather than a list, a value may carry the leading `#` it is
569
622
  * written with in prose, and case and surrounding space are not significant.
570
623
  * The spelling of the tag *itself* still is — a near miss is a near miss, and
571
624
  * the frontmatter lint is what reports it; nothing here guesses.
572
625
  *
573
- * Reads `tags` and, as Dataview does, `tag` — the singular spelling Obsidian
574
- * also accepts.
626
+ * Reads `tags` and the singular `tag` spelling.
575
627
  *
576
628
  * @param {object|null|undefined} fm - Parsed frontmatter.
577
629
  * @param {string} tag - The tag to look for, in its declared spelling.
@@ -663,6 +715,16 @@ export const NOTE_VOCABULARY = Object.freeze({
663
715
  accepts: ["affiliation"],
664
716
  describe: "Affiliations the being belongs to — traditions, polities, and the rest.",
665
717
  },
718
+ {
719
+ name: "socialTies",
720
+ kind: "map",
721
+ keyKind: "address",
722
+ accepts: ["being", "affiliation"],
723
+ terms: SOCIAL_TIES,
724
+ shape: "a map keyed by Address",
725
+ check: checkSocialTies,
726
+ describe: `Defining ties directed from this being to others: ${SOCIAL_TIES.map(({ term, meaning }) => `\`${term}\` (${meaning})`).join("; ")}`,
727
+ },
666
728
  { name: "gender", ...TEXT, describe: "`male`, `female` or `other`." },
667
729
  {
668
730
  name: "species",
@@ -674,6 +736,7 @@ export const NOTE_VOCABULARY = Object.freeze({
674
736
  {
675
737
  name: "born",
676
738
  ...TEXT,
739
+ check: checkBeingDate("born"),
677
740
  describe:
678
741
  "When the being was born — a date, or `unknown` where the birth is " +
679
742
  "unrecorded. Absent, the being was never born.",
@@ -681,6 +744,7 @@ export const NOTE_VOCABULARY = Object.freeze({
681
744
  {
682
745
  name: "died",
683
746
  ...TEXT,
747
+ check: checkBeingDate("died"),
684
748
  describe:
685
749
  "When the being died — a date, or `unknown` where the death is " +
686
750
  "unrecorded. Absent, the being is alive.",
@@ -1097,13 +1161,20 @@ export const NOTE_VOCABULARY = Object.freeze({
1097
1161
 
1098
1162
  doc: Object.freeze({
1099
1163
  stubbable: true,
1100
- // Five genres, and a genre is all this field carries: what kind of page
1164
+ // A genre is all this field carries: what kind of page
1101
1165
  // it is, never who reads it. An audience term alongside them would give
1102
1166
  // a developer how-to two valid values and no rule for choosing.
1103
1167
  //
1104
1168
  // `userguide` and `howto`, not `user-guide` and `how-to`: a subType is
1105
1169
  // held to the address charset, and a segment carries no hyphen.
1106
- subTypes: Object.freeze(["rules", "userguide", "reference", "howto", "concept"]),
1170
+ subTypes: Object.freeze([
1171
+ "rules",
1172
+ "userguide",
1173
+ "reference",
1174
+ "howto",
1175
+ "concept",
1176
+ "settingguide",
1177
+ ]),
1107
1178
  check: checkCitedPopulations,
1108
1179
  data: Object.freeze([]),
1109
1180
  }),
@@ -1218,6 +1289,13 @@ export const NOTE_VOCABULARY = Object.freeze({
1218
1289
  ...TEXT,
1219
1290
  describe: "What a person from this place is called — a Vylarian.",
1220
1291
  },
1292
+ {
1293
+ name: "purpose",
1294
+ ...TEXT,
1295
+ check: checkPlacePurpose,
1296
+ describe:
1297
+ "The reason this settlement, site, or structure exists, selected from its placeCharacter tags.",
1298
+ },
1221
1299
  {
1222
1300
  name: "lore",
1223
1301
  ...LINKS,
@@ -37,7 +37,7 @@
37
37
  * only the derivations differ, and they differ because a code file can do its
38
38
  * own I/O while `defineConfig` deliberately does none.
39
39
  *
40
- * **Resolved on first read, never at import (#2).** {@link loadPackConfig}
40
+ * **Resolved on first read, never at import.** {@link loadPackConfig}
41
41
  * is a function rather than a module-level constant, so importing this module —
42
42
  * or the `engine` barrel, or a leaf module that happens to sit downstream of it
43
43
  * — costs nothing and requires nothing. A repository with no configuration can
@@ -705,7 +705,7 @@ let loadedFrom;
705
705
  *
706
706
  * Every engine module that needs a configured value calls this, rather than
707
707
  * hoisting one at import: that is what keeps the library importable without a
708
- * configuration (#2). The result is memoised, so calling it in a default
708
+ * configuration. The result is memoised, so calling it in a default
709
709
  * parameter — the usual spelling here — costs one property read per call.
710
710
  *
711
711
  * @returns {import("../content-config.mjs").ContentBuildConfig} The frozen configuration.
@@ -43,7 +43,7 @@
43
43
  * - A declared name that no pack answers to — or that names a pack holding
44
44
  * another document type, or a companion pack, which no note may address —
45
45
  * fails the build, naming the note and what it asked for. A silent fall-back
46
- * to the default would be #1502 in a new costume.
46
+ * to the default would publish an address into the wrong compendium.
47
47
  * - A note's **derived** documents are routed by the default of *their* type,
48
48
  * not by the note's declaration: an item note's prose compiles into a
49
49
  * JournalEntry, and `pack:` names where the *item* goes.
@@ -501,7 +501,7 @@ export function routerFor(config) {
501
501
  * asks where a note's document lives.
502
502
  *
503
503
  * An accessor rather than a hoisted constant, so that importing this module
504
- * needs no configuration (#2). {@link routerFor} keeps one router per
504
+ * needs no configuration. {@link routerFor} keeps one router per
505
505
  * configuration object, so repeated calls return the same instance.
506
506
  *
507
507
  * @returns {ReturnType<typeof createPackRouter>} This repository's router.
@@ -66,6 +66,16 @@ import MarkdownIt from "markdown-it";
66
66
  import { bookDraftNoticePreamble } from "./draft-notice.mjs";
67
67
  import { iconPlugin, ICON_PATTERN } from "./content-icons.mjs";
68
68
  import { IMAGE_CLASSES, IMAGE_FLOATS, imagePlugin } from "./content-images.mjs";
69
+
70
+ /** Requested print width for every named image size. */
71
+ export const BOOK_IMAGE_WIDTHS = Object.freeze({
72
+ auto: "auto",
73
+ small: "1.6cm",
74
+ medium: "3.2cm",
75
+ large: "5.6cm",
76
+ xlarge: "8cm",
77
+ "full-width": '"full-width"',
78
+ });
69
79
  import { slugify } from "./content-slug.mjs";
70
80
 
71
81
  /**
@@ -706,17 +716,19 @@ function renderLink(href, inner, ctx) {
706
716
  */
707
717
  function renderImage(token, ctx) {
708
718
  const alt = token.content || token.attrGet?.("alt") || "";
709
- const caption =
710
- alt ? `\n #text(size: 7.6pt, style: "italic", fill: luma(45%))[${escapeTypst(alt)}]` : "";
719
+ const caption = alt ? `[${escapeTypst(alt)}]` : "none";
711
720
  const staged = ctx.images.get(token.attrGet?.("src") ?? "");
712
- if (!staged) return caption ? `\n#block(below: 0.6em)[${caption}\n]\n\n` : "";
721
+ if (!staged)
722
+ return alt ?
723
+ `\n#block(below: 0.6em)[#text(size: 7.6pt, style: "italic", fill: luma(45%))${caption}]\n\n`
724
+ : "";
713
725
 
714
- const figure =
715
- `#block(width: 100%, below: 0.6em)[\n` +
716
- ` #image("${escapeTypstString(staged)}", width: 100%)${caption}\n]`;
726
+ const size = BOOK_IMAGE_WIDTHS[token.meta?.size] ?? BOOK_IMAGE_WIDTHS.auto;
727
+ const figure = `#book-image("${escapeTypstString(staged)}", requested: ${size}, caption: ${caption})`;
717
728
 
718
729
  const width = token.meta?.classes?.[0];
719
- const scope = IMAGE_CLASSES[width]?.scope ?? "column";
730
+ const scope =
731
+ token.meta?.size === "full-width" ? "parent" : (IMAGE_CLASSES[width]?.scope ?? "column");
720
732
  const float = IMAGE_FLOATS[token.meta?.float];
721
733
  // In the flow where it was written: no class asking for the page, and no
722
734
  // position asking for the top or the bottom of the column.
@@ -804,6 +816,17 @@ export function bookTypstPreamble() {
804
816
  "#let book-page-height = 11in",
805
817
  "#let book-text-width = book-page-width - 2 * book-margin",
806
818
  "#let book-text-height = book-page-height - 2 * book-margin",
819
+ // Use the file's natural measure for auto. Every requested width is
820
+ // bounded by the available measure and by the height of a page.
821
+ "#let book-image(path, requested: auto, caption: none) = layout(size => {\n" +
822
+ " let natural = measure(image(path))\n" +
823
+ ' let target = if requested == auto { natural.width } else if requested == "full-width" { size.width } else { requested }\n' +
824
+ " let width = calc.min(target, size.width, book-text-height * 0.8 * (natural.width / natural.height))\n" +
825
+ " block(width: width, below: 0.6em)[\n" +
826
+ ' #image(path, width: 100%, fit: "contain")\n' +
827
+ ' #if caption != none { text(size: 7.6pt, style: "italic", fill: luma(45%))[#caption] }\n' +
828
+ " ]\n" +
829
+ "})",
807
830
  // The title sets short of the measure, so a plate's last line does not
808
831
  // run to the trimmed edge of the paper.
809
832
  "#let book-title-measure = book-text-width - 2.5cm",
@@ -956,7 +979,7 @@ export function bookTypstPreamble() {
956
979
  /**
957
980
  * The whole book, as one Typst document.
958
981
  *
959
- * **Pure, and that is the point.** Everything a reviewer of #316 has to check
982
+ * **Pure, and that is the point.** The book's structural properties
960
983
  * about structure — the outline's shape, the anchors, which links went inward,
961
984
  * the order entries print in — is decided here from a plan and a map of bodies,
962
985
  * with no filesystem and no compiler. {@link module:engine/pdf-build} supplies
@@ -12,7 +12,7 @@
12
12
  */
13
13
 
14
14
  /**
15
- * The document tree a PDF is built from, and the plan it resolves to (#316).
15
+ * The document tree a PDF is built from, and the plan it resolves to.
16
16
  *
17
17
  * The packs and the website both render the *whole* content tree: every note
18
18
  * becomes a document and a page, and the three surfaces agreeing about what the
@@ -39,7 +39,7 @@
39
39
  * handed back as `selections`, the way `expandContentTables` is handed its
40
40
  * `sqlTables` because DuckDB is async and the pass is not. That is what lets the
41
41
  * whole structure of a 2,500-entry book be asserted in a unit test with no
42
- * renderer present, which is most of what #316 asks for.
42
+ * renderer present, covering the structure before presentation is applied.
43
43
  *
44
44
  * **Every gate reports; none exits.** A filter that will not parse, a `file:`
45
45
  * that resolves nowhere and a filter reaching another package's schema are
@@ -415,7 +415,7 @@ export async function runTreeFilters(nodes, db, { keep = () => true, text } = {}
415
415
  * The plan is an ordered list of entries — `section`, `prose`, `note` — and it
416
416
  * is the artifact worth having. Order, depth, the outline, the table of
417
417
  * contents, anchor uniqueness and every link destination are all readable from
418
- * it, so nearly the whole of what #316 asks for can be asserted here, on data,
418
+ * it, so the book's structure can be asserted here, on data,
419
419
  * without a renderer or a PDF. Only how the result *looks* needs eyes.
420
420
  *
421
421
  * **Notes are sorted, prose is not.** A section's entries come out in
@@ -84,7 +84,7 @@
84
84
  * {@link RETIRED_FIELD_ALIASES}.
85
85
  *
86
86
  * **The third step is deletion, and it needs no code.** `image`, a map's
87
- * background art, is the first rename to have run all three: reported (#142),
87
+ * background art, illustrates all three states: reported,
88
88
  * swept, then dropped from the
89
89
  * table. Removing the entry is the whole of it — with no alias, the spelling is
90
90
  * an ordinary unknown key in the `sohl:` block, which the frontmatter lint
@@ -43,7 +43,7 @@
43
43
  * Plain ESM with no Foundry, so the rule itself is a pure function over records
44
44
  * and is unit-tested directly.
45
45
  *
46
- * **What a report has to say (#9).** The likeliest way to meet any of this is a
46
+ * **What a report has to say.** The likeliest way to meet any of this is a
47
47
  * compendium CLI older than {@link LEVEL_SPLITTING_CLI}, which does not split
48
48
  * Scene Levels into the sublevel at all — so the guard fires on every scene at
49
49
  * once, and each `levels` entry is an inline Level *object* rather than an id.
@@ -71,7 +71,7 @@ const ADVENTURE_PREFIX = "!adventures!";
71
71
  /**
72
72
  * The compendium CLI release that began splitting Scene Levels into the
73
73
  * `!scenes.levels!` sublevel. Anything older writes them inline on the Scene
74
- * document, which is the shape the reports below name (#9).
74
+ * document, which is the shape the reports below name.
75
75
  */
76
76
  const LEVEL_SPLITTING_CLI = "3.0.3";
77
77
 
@@ -184,7 +184,7 @@ function predatesLevelSplitting(version) {
184
184
 
185
185
  /**
186
186
  * What to do about it, decided from the version actually resolved — so the
187
- * report ends in an instruction rather than a lead to follow (#9).
187
+ * report ends in an instruction rather than a lead to follow.
188
188
  *
189
189
  * @param {string | undefined} cliVersion - The resolved compendium CLI
190
190
  * version, when it is known.
@@ -222,7 +222,7 @@ function describeRemedy(cliVersion) {
222
222
  * it on that record's write path; a pack that has none never split them, which
223
223
  * is what a compendium CLI older than {@link LEVEL_SPLITTING_CLI} does — two
224
224
  * different diagnoses, so the message names whichever shape it found rather
225
- * than guessing (#9).
225
+ * than guessing.
226
226
  *
227
227
  * @param {Array<[string, object]>} declaring - `[sceneId, scene]` for every
228
228
  * scene declaring at least one level.
@@ -26,7 +26,7 @@
26
26
  * of `sohl-kethira-basic`'s deities, is not defined at the version that module
27
27
  * targets, so the divine/arcane split evaporates on load.
28
28
  * - **Declared, not emitted.** The mirror image, fixed by hand in
29
- * content-build#3.
29
+ * the shared build.
30
30
  *
31
31
  * Neither was found by tooling. Both were found by set-subtracting compiled
32
32
  * documents' `system` keys against `defineSchema()` **by hand**, which is how