@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
@@ -35,6 +35,13 @@ and this tutorial links to them rather than repeating them:
35
35
  | [`project-setup.md`](project-setup.md) | The files a repository carries beyond the configuration, and the script wiring. |
36
36
  | [`api.md`](api.md) | The programmatic surface, for a repository with a build script of its own. |
37
37
 
38
+ An editor can render one note from its current buffer through the
39
+ [`engine.sitePreview` API](api.md#enginesitepreview). The context reads saved
40
+ notes and dependency indexes once, while each render overlays the active note's
41
+ text and frontmatter. It returns generated Markdown and frontmatter for an HTML
42
+ preview, or located findings if the page cannot be rendered. The editor keeps
43
+ its own preview artifact and calls `refresh()` after a save.
44
+
38
45
  ## The shape of what you are building
39
46
 
40
47
  Nine files, and a directory the build writes:
@@ -270,9 +277,9 @@ requires it. It compiles to a page and to no Foundry document.
270
277
 
271
278
  ```markdown
272
279
  ---
273
- type: homepage
274
280
  shortcode: root
275
- title: The Acme Bestiary
281
+ name: { full: The Acme Bestiary }
282
+ type: homepage
276
283
  description: Creatures of the reed flats, their habits and their hides.
277
284
  ---
278
285
 
@@ -286,11 +293,10 @@ who hunt it.
286
293
 
287
294
  ```markdown
288
295
  ---
296
+ shortcode: marshdrake
297
+ name: { full: Marsh Drake }
289
298
  type: lore
290
299
  subType: bestiary
291
- name:
292
- full: Marsh Drake
293
- shortcode: marshdrake
294
300
  description: A wingless drake of the reed flats, hunted for its hide and feared for its patience.
295
301
  ---
296
302
 
@@ -317,20 +323,45 @@ directory has no bearing on which pack it lands in. `subType:` narrows it —
317
323
  `(type, subType)` is what each game system maps onto its own document type.
318
324
 
319
325
  **`shortcode:` is the note's address.** Cross-references are written
320
- `[[lore-marshdrake]]` — the type and the shortcode — and resolve to whatever
326
+ `[[lore-marshdrake|Marsh Drake]]` — the type, shortcode, and readable label — and resolve to whatever
321
327
  that note compiles into, in whatever system is being compiled. A shortcode is
322
328
  identity: renaming one breaks every link into it.
323
329
 
324
330
  **`name.full` is the document name and the published URL**, derived by one
325
331
  shared rule. There is no authored slug anywhere in this toolchain.
326
332
 
327
- **Frontmatter has three regions and only one of them is open.** Top-level keys
328
- (`type`, `shortcode`, `description`, `tags`, and anything else) are copied into
329
- the generated web page, so an unrecognised one is a theme parameter rather than
330
- an error. A `data:` block and a `sohl:` / `hm3:` block are closed: a misspelled
331
- key there is a finding that names the key you meant.
333
+ **Frontmatter has three closed regions.** Top-level keys identify the note and
334
+ describe its publication; `data:` holds shared facts; `sohl:` and `hm3:` hold
335
+ system-specific facts. An unrecognised key is an error rather than a site
336
+ parameter. The formatter keeps the allowed top-level keys in their required
337
+ order.
332
338
  [`content-format.md`](content-format.md) is the specification.
333
339
 
340
+ **A setting guide is a `doc` that orients a reader to a place or setting.** It
341
+ gives a usable overview of areas, peoples, institutions, and daily life, then
342
+ links to the detailed notes. Use `subType: settingguide` for that entry point;
343
+ `concept` explains one subject, `reference` serves lookup, and `userguide`
344
+ explains the Foundry controls. For example, a guide at
345
+ `assets/content/Guides/Reed_Flats.md` can begin:
346
+
347
+ ```markdown
348
+ ---
349
+ shortcode: reedflatsguide
350
+ name: { full: The Reed Flats Guide }
351
+ type: doc
352
+ subType: settingguide
353
+ description: A starting point for adventures in the Reed Flats.
354
+ ---
355
+
356
+ The river trade links the settlements of the Reed Flats. Their routes and
357
+ customs give visitors a way into the region. See [[lore-reedfolk|Reed Folk]]
358
+ for the people who guide those journeys.
359
+ ```
360
+
361
+ The guide can include SQL tables for lists drawn from indexed notes and
362
+ `:::secret` blocks for GM passages. Its address is
363
+ `doc-reedflatsguide`, following the same rule as every `doc` note.
364
+
334
365
  An image stands in its own paragraph. Its directive accepts a named size and a
335
366
  position together:
336
367
 
@@ -339,9 +370,10 @@ position together:
339
370
  ```
340
371
 
341
372
  The accepted sizes are `auto`, `small`, `medium`, `large`, `xlarge`, and
342
- `full-width`. Omitting `size:` means `auto`. The build checks the name, but
343
- `size:` does not change the displayed dimensions on the website, in Foundry, or
344
- in the book. See [Images](content-format.md#images) for the rendering rules.
373
+ `full-width`. Omitting `size:` means `auto`, which uses the image's natural
374
+ dimensions within the available measure. The other names set maximum display
375
+ widths for the website, Foundry, and book. See
376
+ [Images](content-format.md#images) for the surface sizes and layout rules.
345
377
 
346
378
  ## Step 6 — check the tree
347
379
 
@@ -27,7 +27,7 @@ acme-bestiary/
27
27
  │ ├── ISSUE_REPORTING.md # §3 lists the same labels
28
28
  │ └── workflows/
29
29
  ├── assets/
30
- │ └── content/ # authored notes — an Obsidian vault
30
+ │ └── content/ # authored Markdown notes
31
31
  ├── lang/
32
32
  │ └── en.json
33
33
  ├── src/ # module JavaScript, if the package has any
@@ -55,11 +55,10 @@ The two that matter when something is wrong are `build/packs-json/`, which is
55
55
  the compiled document before Foundry ever sees it, and `build/stage/`, which is
56
56
  exactly what ships.
57
57
 
58
- **`assets/content/` is opened as an Obsidian vault.** There is no export step
59
- and no vault environment variable: the notes in the repository are the source,
60
- and a plain checkout builds. Dataview renders the content tables live while
61
- authoring, which is why the tree is arranged for humans even though `type:`
62
- rather than location is what routes a note.
58
+ **`assets/content/` holds the source notes.** Edit the Markdown and YAML
59
+ frontmatter directly; a plain checkout builds without an export step. The
60
+ tree is arranged for readers and authors, while `type:` determines how each
61
+ note compiles.
63
62
 
64
63
  ## `package.json`
65
64
 
@@ -491,11 +491,9 @@ export function readCanonicalKey(key) {
491
491
  * confidently they mark a value as qualified:
492
492
  *
493
493
  * - **`type-shortcode`** and its qualified forms — the canonical spelling.
494
- * Obsidian reads `/` inside a wikilink as a *path* and resolves it against the
495
- * vault's folders, so a slash-qualified link is a broken link in the editor
496
- * where the content is authored.
497
- * - **`type/shortcode`** — resolved so that a link written before the vault
498
- * migrated does not silently die. A slash is *unconditionally* a qualifier:
494
+ * The hyphenated form is portable across Markdown editors and renderers.
495
+ * - **`type/shortcode`** — an accepted qualified spelling. A slash is
496
+ * *unconditionally* a qualifier:
499
497
  * nothing else uses one, so an unknown type before it is reported rather than
500
498
  * guessed at. The split is at the **last** slash.
501
499
  *
@@ -0,0 +1,66 @@
1
+ /* SPDX-License-Identifier: GPL-3.0-or-later */
2
+
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+
6
+ import { isAddressSegment } from "./address-charset.mjs";
7
+ import { canonicalKey } from "./content-address.mjs";
8
+ import { loadForeignIndexes, metadataRelationships } from "./metadata-index.mjs";
9
+
10
+ /** Files under a configured staging root, without provenance sidecars. */
11
+ function assetFiles(root) {
12
+ if (!fs.existsSync(root)) return [];
13
+ const files = [];
14
+ const visit = (dir) => {
15
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
16
+ const file = path.join(dir, entry.name);
17
+ if (entry.isDirectory()) visit(file);
18
+ else if (entry.isFile() && !entry.name.endsWith(".yaml")) files.push(file);
19
+ }
20
+ };
21
+ visit(root);
22
+ return files;
23
+ }
24
+
25
+ /** Check configured foreign Address bindings against fetched indexes. */
26
+ export function checkForeignAssetBindings(config, { foreignIndex, assets } = {}) {
27
+ const copies = (assets ?? config.packageBuild?.assets ?? []).filter((asset) => asset.bindsTo);
28
+ if (!copies.length) return [];
29
+ const relationships = metadataRelationships(config);
30
+ const active = copies.filter(({ bindsTo }) =>
31
+ relationships.some((rel) => rel.id === bindsTo.package),
32
+ );
33
+ if (!active.length) return [];
34
+ const fetched = foreignIndex ? null : loadForeignIndexes(config, [config.contentPackage]);
35
+ const foreign = foreignIndex ?? fetched.index;
36
+ for (const { bindsTo } of active) {
37
+ if (fetched && !fetched.packages.has(bindsTo.package))
38
+ return [
39
+ {
40
+ file: "package-build.config.yaml",
41
+ severity: "error",
42
+ message: `asset binding index for ${bindsTo.package} is unavailable; run content-build deps fetch`,
43
+ },
44
+ ];
45
+ }
46
+ const findings = [];
47
+ for (const { from, bindsTo } of active) {
48
+ const root = path.resolve(config.rootDir, from);
49
+ if (path.relative(config.rootDir, root).startsWith(".."))
50
+ throw new Error(`packageBuild.assets source ${from} escapes the repository`);
51
+ for (const file of assetFiles(root)) {
52
+ const shortcode = path.parse(file).name;
53
+ const key =
54
+ isAddressSegment(shortcode) ?
55
+ canonicalKey(bindsTo.package, bindsTo.system ?? "note", bindsTo.type, shortcode)
56
+ : null;
57
+ if (!key || !foreign.has(key))
58
+ findings.push({
59
+ file: path.relative(config.rootDir, file),
60
+ severity: "error",
61
+ message: `asset binding ${key ?? JSON.stringify(shortcode)} resolves to no ${bindsTo.type} in ${bindsTo.package}`,
62
+ });
63
+ }
64
+ }
65
+ return findings;
66
+ }
@@ -82,6 +82,27 @@ export const CALENDAR_SUBTYPE = "calendar";
82
82
  */
83
83
  export const INVARIANT_SUBTYPES = Object.freeze(["world", "celestial"]);
84
84
 
85
+ /** Calendaria's display locations that a calendar note can format. */
86
+ export const CALENDAR_DATE_FORMAT_KEYS = Object.freeze([
87
+ "short",
88
+ "long",
89
+ "full",
90
+ "time",
91
+ "weekHeader",
92
+ "yearHeader",
93
+ "yearLabel",
94
+ "crossCalendar",
95
+ ]);
96
+
97
+ /** Bare date tokens Calendaria substitutes inside authored format strings. */
98
+ export const CALENDAR_FORMAT_TOKENS = Object.freeze(
99
+ new Set(
100
+ "YYYY YY Y MMMM MMM MM Mo M EEEEE EEEE EEE EE E dddd ddd dd Do DDD DD D d e GGGG GGG GG G QQQQ QQQ QQ Q zzzz z ww w W HH H hh h mm m ss s A a".split(
101
+ " ",
102
+ ),
103
+ ),
104
+ );
105
+
85
106
  /**
86
107
  * The `data:` keys a calendar note may write — the closed list, in authored
87
108
  * order.
@@ -129,6 +150,12 @@ export const CALENDAR_FIELDS = Object.freeze([
129
150
  "The year-counts kept in this calendar. A date names one by writing " +
130
151
  "`<calendar shortcode>.<era shortcode>`.",
131
152
  },
153
+ {
154
+ name: "dateFormats",
155
+ shape: "map of short, long, full, time, weekHeader, yearHeader, yearLabel, crossCalendar strings",
156
+ kind: "map",
157
+ describe: "How this calendar writes dates and time in each display context.",
158
+ },
132
159
  ]);
133
160
 
134
161
  /**
@@ -336,6 +363,51 @@ function atData(note, keyPath, severity, message) {
336
363
  };
337
364
  }
338
365
 
366
+ /** Check each authored format slot and every unescaped letter run in it. */
367
+ export function checkCalendarDateFormats(note) {
368
+ const formats = dataOf(note).dateFormats;
369
+ if (formats === undefined || formats === null) return [];
370
+ if (typeof formats !== "object" || Array.isArray(formats)) return [];
371
+ const findings = [];
372
+ for (const [slot, value] of Object.entries(formats)) {
373
+ if (!CALENDAR_DATE_FORMAT_KEYS.includes(slot)) {
374
+ findings.push(
375
+ atData(
376
+ note,
377
+ ["dateFormats", slot],
378
+ "error",
379
+ `data.dateFormats.${slot} is not a calendar display format; choose ${CALENDAR_DATE_FORMAT_KEYS.join(", ")}`,
380
+ ),
381
+ );
382
+ continue;
383
+ }
384
+ if (typeof value !== "string") {
385
+ findings.push(
386
+ atData(
387
+ note,
388
+ ["dateFormats", slot],
389
+ "error",
390
+ `data.dateFormats.${slot} must be a string`,
391
+ ),
392
+ );
393
+ continue;
394
+ }
395
+ const bare = value.replace(/\[[^\]]*\]|\{[^}]*\}/g, " ");
396
+ for (const run of bare.match(/\p{L}+/gu) ?? []) {
397
+ if (CALENDAR_FORMAT_TOKENS.has(run)) continue;
398
+ findings.push(
399
+ atData(
400
+ note,
401
+ ["dateFormats", slot],
402
+ "error",
403
+ `data.dateFormats.${slot} has unescaped letter run ${JSON.stringify(run)}; write literal words in brackets, or use a Calendaria date token`,
404
+ ),
405
+ );
406
+ }
407
+ }
408
+ return findings;
409
+ }
410
+
339
411
  /**
340
412
  * Check a `place` note's world facts.
341
413
  *
@@ -488,6 +560,7 @@ export function checkCalendarNote(note, { index } = {}) {
488
560
 
489
561
  findings.push(...checkMonthSum(note, index));
490
562
  findings.push(...checkEraShortcodes(note));
563
+ findings.push(...checkCalendarDateFormats(note));
491
564
  return findings;
492
565
  }
493
566
 
@@ -747,6 +820,7 @@ export function compileCalendar({ note, invariants, contentPackage }) {
747
820
  epochDayOffset:
748
821
  epoch && months.length ? dayOfYear(months, epoch.month ?? 1, epoch.day ?? 1) - 1 : 0,
749
822
  eras: eraEntries(data.eras),
823
+ dateFormats: data.dateFormats,
750
824
  moons: moons ?? undefined,
751
825
  metadata: written({
752
826
  id: shortcode || undefined,
@@ -56,10 +56,10 @@ const LABEL_LINE_RE = /^\*\*([^*]+)\*\*/;
56
56
  const COMMIT_HASH_RE = /^-\s+([0-9a-fA-F]{7,40})(?=[:\s]|$)/;
57
57
 
58
58
  /**
59
- * An issue or pull-request reference: `#123`, `owner/repo#123`.
59
+ * An issue or pull-request reference, with or without an owner and repository.
60
60
  *
61
61
  * No leading `\b` — `#` is not a word character, so a boundary assertion
62
- * immediately before it never matches the ordinary case of a bare `#123`
62
+ * immediately before it never matches a bare issue reference
63
63
  * preceded by whitespace or punctuation.
64
64
  */
65
65
  const ISSUE_REF_RE = /(?:[\w.-]+\/[\w.-]+)?#\d+\b/g;
@@ -25,7 +25,8 @@
25
25
  * simple case needs no spelling. {@link IMAGE_CLASSES} holds the one class
26
26
  * there is. **Position is `float:`**, and {@link IMAGE_FLOATS} holds the five
27
27
  * values it takes. **Named size is `size:`**, and {@link IMAGE_SIZES} holds its
28
- * values. Size is parsed but does not change the figure's current presentation.
28
+ * values. The HTML surfaces receive a size class; the book receives a print
29
+ * measure.
29
30
  *
30
31
  * All three are closed, and an unrecognised value is **refused with a located
31
32
  * diagnostic** rather than ignored. Ignoring is the failure worth preventing:
@@ -105,7 +106,7 @@ export const IMAGE_CLASSES = Object.freeze({
105
106
  },
106
107
  });
107
108
 
108
- /** The named sizes an image directive accepts without applying a display size. */
109
+ /** The named sizes an image directive accepts. */
109
110
  export const IMAGE_SIZES = Object.freeze([
110
111
  "auto",
111
112
  "small",
@@ -326,16 +327,17 @@ export function parseImageDirective(raw) {
326
327
  /**
327
328
  * The classes a figure carries, from a parsed directive.
328
329
  *
329
- * @param {{classes?: string[], float?: string}} [directive] - As parsed.
330
+ * @param {{classes?: string[], size?: string, float?: string}} [directive] - As parsed.
330
331
  * @returns {string} A space-separated class list, always naming
331
332
  * {@link IMAGE_FIGURE_CLASS} first.
332
333
  */
333
- export function figureClasses({ classes = [], float = "" } = {}) {
334
+ export function figureClasses({ classes = [], size = "auto", float = "" } = {}) {
334
335
  const names = [IMAGE_FIGURE_CLASS];
335
336
  for (const name of classes) {
336
337
  const spec = IMAGE_CLASSES[/** @type {keyof typeof IMAGE_CLASSES} */ (name)];
337
338
  if (spec) names.push(spec.class);
338
339
  }
340
+ if (size !== "auto" && IMAGE_SIZES.includes(size)) names.push(`note-image-size-${size}`);
339
341
  const position = IMAGE_FLOATS[/** @type {keyof typeof IMAGE_FLOATS} */ (float)];
340
342
  if (position) names.push(position.class);
341
343
  return names.join(" ");
@@ -367,13 +369,14 @@ export function escapeHtml(text) {
367
369
  * @param {string} image.src - The address, resolved for the surface.
368
370
  * @param {string} [image.alt] - The alt text, which is also the caption.
369
371
  * @param {string[]} [image.classes] - Width classes, from the directive.
372
+ * @param {string} [image.size] - Named display size, from the directive.
370
373
  * @param {string} [image.float] - The float position, from the directive.
371
374
  * @returns {string} The figure, as one HTML block.
372
375
  */
373
- export function imageFigureHtml({ src, alt = "", classes = [], float = "" }) {
376
+ export function imageFigureHtml({ src, alt = "", classes = [], size = "auto", float = "" }) {
374
377
  const caption = alt ? `\n<figcaption>${escapeHtml(alt)}</figcaption>` : "";
375
378
  return (
376
- `<figure class="${figureClasses({ classes, float })}">\n` +
379
+ `<figure class="${figureClasses({ classes, size, float })}">\n` +
377
380
  `<img src="${escapeHtml(src)}" alt="${escapeHtml(alt)}">${caption}\n` +
378
381
  `</figure>`
379
382
  );
@@ -644,10 +647,16 @@ export function renderImageFigures(body, resolveSrc = (src) => src) {
644
647
  let last = 0;
645
648
  for (const image of imagesIn(text)) {
646
649
  if (!image.block || imageSourceProblem(image.src) || image.title) continue;
647
- const { classes, float, problems } = parseImageDirective(image.directive);
650
+ const { classes, size, float, problems } = parseImageDirective(image.directive);
648
651
  if (problems.length) continue;
649
652
  out += text.slice(last, image.index);
650
- out += imageFigureHtml({ src: resolveSrc(image.src), alt: image.alt, classes, float });
653
+ out += imageFigureHtml({
654
+ src: resolveSrc(image.src),
655
+ alt: image.alt,
656
+ classes,
657
+ size,
658
+ float,
659
+ });
651
660
  last = image.index + image.length;
652
661
  }
653
662
  return out + text.slice(last);
@@ -696,6 +705,7 @@ export function imagePlugin(resolveSrc = (src) => src) {
696
705
  src: resolveSrc(token.attrGet("src") ?? ""),
697
706
  alt: token.content ?? "",
698
707
  classes: token.meta.classes,
708
+ size: token.meta.size,
699
709
  float: token.meta.float,
700
710
  })}\n`;
701
711
  };
@@ -733,12 +743,12 @@ function attachImageDirectives(tokens) {
733
743
  directive = brace[0];
734
744
  }
735
745
 
736
- const { classes, float, problems } = parseImageDirective(directive);
746
+ const { classes, size, float, problems } = parseImageDirective(directive);
737
747
  // Not ours to consume: leaving the braces in place is what makes an
738
748
  // unrecognised value visible on the page instead of passing as ordinary.
739
749
  if (problems.length) continue;
740
750
 
741
- image.meta = { ...(image.meta ?? {}), block: true, classes, float };
751
+ image.meta = { ...(image.meta ?? {}), block: true, classes, size, float };
742
752
  if (trailing) trailing.content = trailing.content.slice(directive.length);
743
753
  // The figure is a block, so the paragraph that held it renders nothing.
744
754
  tokens[i].hidden = true;
@@ -87,7 +87,7 @@
87
87
  */
88
88
 
89
89
  import { isAddressSegment } from "./address-charset.mjs";
90
- import { positionOfYamlPath } from "./diagnostics.mjs";
90
+ import { formatDiagnostic, positionOfYamlPath } from "./diagnostics.mjs";
91
91
  import {
92
92
  decodeNoteAddresses,
93
93
  decodeIndexAddresses,
@@ -101,6 +101,7 @@ import unidecode from "unidecode";
101
101
 
102
102
  import { metadataFileName } from "./metadata-index.mjs";
103
103
  import { collectAssetRecords } from "./asset-index.mjs";
104
+ import { checkForeignAssetBindings } from "./asset-bindings.mjs";
104
105
  import { addressSlug, canonicalKey } from "./content-address.mjs";
105
106
  import { ownDocumentSystem } from "./address.mjs";
106
107
  import { NOTE_SYSTEM } from "./systems.mjs";
@@ -360,6 +361,13 @@ function foundryEntries({ frontmatter, address, body, manifest }) {
360
361
 
361
362
  function foundryBlock(entry, system) {
362
363
  if (!entry) return null;
364
+ if (entry.systemUuids && Object.keys(entry.systemUuids).length)
365
+ return Object.fromEntries(
366
+ Object.entries(entry.systemUuids).map(([targetSystem, uuid]) => [
367
+ targetSystem,
368
+ { uuid },
369
+ ]),
370
+ );
363
371
  const block = {};
364
372
  if (entry.uuid) block.uuid = entry.uuid;
365
373
  if (entry.anchors) block.anchors = entry.anchors;
@@ -590,6 +598,46 @@ function buildDocRecord({ frontmatter, address, entry, file, contentPackage, anc
590
598
  );
591
599
  }
592
600
 
601
+ /** Derive every index record belonging to one parsed note. */
602
+ export function indexRecordsForNote({
603
+ frontmatter,
604
+ body,
605
+ bodyLine,
606
+ relPath,
607
+ absPath,
608
+ contentPackage,
609
+ manifest,
610
+ addressContext,
611
+ }) {
612
+ resolveNoteId(frontmatter, { pkg: contentPackage });
613
+ const record = buildIndexRecord({
614
+ frontmatter,
615
+ body,
616
+ bodyLine,
617
+ relPath,
618
+ absPath,
619
+ contentPackage,
620
+ manifest,
621
+ addressContext,
622
+ });
623
+ const records = [decodeIndexAddresses(record, addressContext ?? { package: contentPackage })];
624
+ const address = noteAddress(frontmatter, contentPackage);
625
+ const doc = foundryEntries({ frontmatter, address, body, manifest })?.doc;
626
+ if (doc?.key && address) {
627
+ records.push(
628
+ buildDocRecord({
629
+ frontmatter,
630
+ address,
631
+ entry: doc,
632
+ file: record.file,
633
+ contentPackage,
634
+ anchors: record.anchors ?? [],
635
+ }),
636
+ );
637
+ }
638
+ return records;
639
+ }
640
+
593
641
  /**
594
642
  * Read a content tree into index records, in the order they will be written.
595
643
  *
@@ -648,23 +696,20 @@ export function collectContentIndex(
648
696
  for (const { frontmatter, body, bodyLine, absPath } of notes) {
649
697
  const fm = frontmatter ?? {};
650
698
  applyComputedBeingAge(fm, present);
651
- // The id the note's document is filed under, resolved before
652
- // the record is built so the index publishes the address *and* the id
653
- // that address derives.
654
- resolveNoteId(fm, { pkg: contentPackage });
655
699
  const relPath = path.relative(contentBase, absPath);
656
- let record;
657
700
  try {
658
- record = buildIndexRecord({
659
- frontmatter: fm,
660
- relPath,
661
- absPath,
662
- contentPackage,
663
- body,
664
- bodyLine,
665
- manifest,
666
- addressContext,
667
- });
701
+ records.push(
702
+ ...indexRecordsForNote({
703
+ frontmatter: fm,
704
+ relPath,
705
+ absPath,
706
+ contentPackage,
707
+ body,
708
+ bodyLine,
709
+ manifest,
710
+ addressContext,
711
+ }),
712
+ );
668
713
  } catch (err) {
669
714
  if (fm.shortcode && !isAddressSegment(fm.shortcode) && err.keyPath) {
670
715
  err.identity = { type: fm.type, shortcode: fm.shortcode };
@@ -693,26 +738,6 @@ export function collectContentIndex(
693
738
  });
694
739
  continue;
695
740
  }
696
- records.push(decodeIndexAddresses(record, addressContext ?? { package: contentPackage }));
697
-
698
- // An item note is two documents, so it is two records. Derived from
699
- // the address the note *would* hold rather than from the one its
700
- // record carries, because a stub withholds its own address; the second
701
- // record says whether the journal beside the item was made.
702
- const address = noteAddress(fm, contentPackage);
703
- const doc = foundryEntries({ frontmatter: fm, address, body, manifest })?.doc;
704
- if (doc?.key && address) {
705
- records.push(
706
- buildDocRecord({
707
- frontmatter: fm,
708
- address,
709
- entry: doc,
710
- file: record.file,
711
- contentPackage,
712
- anchors: record.anchors ?? [],
713
- }),
714
- );
715
- }
716
741
  }
717
742
 
718
743
  if (assetsBase) {
@@ -869,6 +894,8 @@ export function emitContentIndex({ contentBase, outDir, config } = {}) {
869
894
  // the index refuse to build for a configuration that is perfectly able to
870
895
  // state one.
871
896
  const records = indexRecordsFor({ contentBase: tree, config: resolved });
897
+ const assetBindings = checkForeignAssetBindings(resolved);
898
+ if (assetBindings.length) throw new Error(assetBindings.map(formatDiagnostic).join("\n"));
872
899
  if (records.length === 0) {
873
900
  throw new Error(
874
901
  `${tree} yielded no notes, so the index would state that this ` +
@@ -48,8 +48,7 @@
48
48
  *
49
49
  * **There is deliberately no third rule** requiring every note to repeat its
50
50
  * own `type-shortcode` address in a top-level `aliases:` list. It would serve
51
- * exactly one reader — **Obsidian**, so `[[type-shortcode]]` resolves in the
52
- * editor — and nothing in the build reads it: both resolvers parse the hyphen
51
+ * no build reader: both resolvers parse the hyphen
53
52
  * qualifier themselves. The field is retired, refused from
54
53
  * `retired-fields.mjs`.
55
54
  *
@@ -46,7 +46,7 @@ import { loadPackConfig } from "./pack-config.mjs";
46
46
  * package below would differ.
47
47
  *
48
48
  * An accessor rather than a hoisted constant, so that importing this module
49
- * needs no configuration (#2).
49
+ * needs no configuration.
50
50
  *
51
51
  * @returns {string} The configured `contentPackage`.
52
52
  */
@@ -71,7 +71,7 @@ export function contentPackage() {
71
71
  * written — fails the build if this value and the manifest's `id` ever drift.
72
72
  *
73
73
  * An accessor rather than a hoisted constant, so that importing this module
74
- * needs no configuration (#2).
74
+ * needs no configuration.
75
75
  *
76
76
  * @returns {string} The configured `foundryPackage`.
77
77
  */
@@ -15,7 +15,7 @@ import { completeAddress, renderAddress, isAddressTuple } from "./address.mjs";
15
15
  import { contentPackage } from "./content-package.mjs";
16
16
 
17
17
  /**
18
- * Generated content tables — Obsidian **Dataview** `TABLE` queries.
18
+ * Generated content tables from fenced `dataview` `TABLE` queries.
19
19
  *
20
20
  * A catalog table (every cloth armour, every animal's abilities) is data that
21
21
  * already lives in the frontmatter of the notes it describes. Authoring such a
@@ -30,13 +30,9 @@ import { contentPackage } from "./content-package.mjs";
30
30
  * SORT name.full ASC
31
31
  * ```
32
32
  *
33
- * The content notes live in an **Obsidian vault**, where that block is rendered
34
- * live by the Dataview plugin. At build time this module renders the same query
35
- * against the same frontmatter and replaces the block with a markdown table, so
36
- * an author sees in Obsidian what the build ships. Both content builds run it —
37
- * the pack compilers (Foundry journals/items/actors) and the knowledgebase — so
38
- * one authored query yields the same table in the vault, in Foundry, and on the
39
- * knowledgebase.
33
+ * The build evaluates the query against note frontmatter and replaces the
34
+ * block with a Markdown table. Pack journals and site pages read the same
35
+ * generated table from the authored query.
40
36
  *
41
37
  * The emitted table is **markdown**, not HTML, and it is expanded *before*
42
38
  * wikilink resolution: a cell may therefore carry `[[type/shortcode|Text]]`,
@@ -174,8 +174,7 @@ function folderFields(fm) {
174
174
  * An authored `parent` with any wikilink brackets and label stripped.
175
175
  *
176
176
  * The specification types `parent` as an `Address`, written bare — but
177
- * `[[address]]` is what an author reaches for,
178
- * and Obsidian wrote that form for years. Accepting both costs one regex and
177
+ * `[[address]]` is also a recognizable link form. Accepting both costs one regex and
179
178
  * removes a failure whose message would have to explain the difference.
180
179
  *
181
180
  * @param {string|null} value - As authored.