@heroiclands/package-build 21.0.0 → 21.2.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 (112) hide show
  1. package/CHANGELOG.md +228 -0
  2. package/CONTENT.md +109 -7
  3. package/assets/fonts/libertinus/LibertinusMono-Regular.otf +0 -0
  4. package/assets/fonts/libertinus/LibertinusSans-Bold.otf +0 -0
  5. package/assets/fonts/libertinus/LibertinusSans-Italic.otf +0 -0
  6. package/assets/fonts/libertinus/LibertinusSans-Regular.otf +0 -0
  7. package/assets/fonts/libertinus/OFL.txt +94 -0
  8. package/assets/fonts/libertinus/provenance.yaml +11 -0
  9. package/assets/images/banners/afflictionbnr.webp +0 -0
  10. package/assets/images/banners/armorclothingbnr.webp +0 -0
  11. package/assets/images/banners/attributebnr.webp +0 -0
  12. package/assets/images/banners/containerbnr.webp +0 -0
  13. package/assets/images/banners/devdocsbnr.webp +0 -0
  14. package/assets/images/banners/miscgearbnr.webp +0 -0
  15. package/assets/images/banners/mysticalabilitybnr.webp +0 -0
  16. package/assets/images/banners/projectilebnr.webp +0 -0
  17. package/assets/images/banners/rulesbnr.webp +0 -0
  18. package/assets/images/banners/skillbnr.webp +0 -0
  19. package/assets/images/banners/traumabnr.webp +0 -0
  20. package/assets/images/banners/userguidebnr.webp +0 -0
  21. package/assets/images/banners/weapongearbnr.webp +0 -0
  22. package/assets/images/provenance.yaml +4 -0
  23. package/bin/content-build.mjs +19 -5
  24. package/ci/emit-asset-index.mjs +32 -0
  25. package/content-config.mjs +30 -1
  26. package/docs/api.md +100 -26
  27. package/docs/configuration.md +16 -7
  28. package/docs/content-format.md +512 -109
  29. package/engine/actor-compiler.mjs +58 -9
  30. package/engine/art-fields.mjs +292 -0
  31. package/engine/asset-index.mjs +397 -0
  32. package/engine/asset-types.mjs +192 -0
  33. package/engine/base-compiler.mjs +69 -1
  34. package/engine/bundles.mjs +5 -5
  35. package/engine/compile-corpus.mjs +4 -0
  36. package/engine/content-address.mjs +14 -2
  37. package/engine/content-charset.mjs +5 -1
  38. package/engine/content-embeds.mjs +314 -0
  39. package/engine/content-html.mjs +5 -1
  40. package/engine/content-icons.mjs +5 -1
  41. package/engine/content-images.mjs +26 -5
  42. package/engine/content-index.mjs +68 -33
  43. package/engine/content-links.mjs +164 -24
  44. package/engine/frontmatter-lint.mjs +36 -23
  45. package/engine/helpers.mjs +83 -31
  46. package/engine/index-records.mjs +63 -4
  47. package/engine/index.mjs +15 -0
  48. package/engine/infobox-registry.mjs +50 -3
  49. package/engine/infobox-render.mjs +4 -3
  50. package/engine/infobox.mjs +12 -8
  51. package/engine/item-compiler.mjs +10 -10
  52. package/engine/journals.mjs +3 -0
  53. package/engine/macros.mjs +16 -13
  54. package/engine/map-notes.mjs +29 -22
  55. package/engine/metadata-index.mjs +44 -36
  56. package/engine/note-vocabulary.mjs +71 -29
  57. package/engine/pack-router.mjs +14 -0
  58. package/engine/packagebuild-index.mjs +124 -0
  59. package/engine/packages.mjs +83 -0
  60. package/engine/pathnames.mjs +74 -0
  61. package/engine/pdf-build.mjs +133 -10
  62. package/engine/pdf-render.mjs +41 -41
  63. package/engine/scenes.mjs +12 -9
  64. package/engine/site-build.mjs +43 -16
  65. package/engine/site-index.mjs +10 -1
  66. package/engine/subtype-registry.mjs +21 -0
  67. package/engine/web-wikilinks.mjs +46 -12
  68. package/engine/wikilink-syntax.mjs +20 -2
  69. package/engine/wikilinks.mjs +15 -2
  70. package/hm3/actors.mjs +11 -38
  71. package/hm3/infobox.mjs +1 -1
  72. package/package.json +5 -2
  73. package/packagebuild-metadata.jsonl +13 -0
  74. package/sohl/actors.mjs +12 -86
  75. package/sohl/infobox.mjs +1 -1
  76. package/sohl/note-schemas.mjs +4 -16
  77. package/types/content-config.d.mts +8 -0
  78. package/types/engine/actor-compiler.d.mts +29 -0
  79. package/types/engine/art-fields.d.mts +192 -0
  80. package/types/engine/asset-index.d.mts +92 -0
  81. package/types/engine/asset-types.d.mts +110 -0
  82. package/types/engine/base-compiler.d.mts +42 -1
  83. package/types/engine/compile-corpus.d.mts +4 -1
  84. package/types/engine/content-charset.d.mts +4 -0
  85. package/types/engine/content-embeds.d.mts +218 -0
  86. package/types/engine/content-html.d.mts +4 -0
  87. package/types/engine/content-icons.d.mts +4 -0
  88. package/types/engine/content-images.d.mts +15 -2
  89. package/types/engine/content-index.d.mts +27 -15
  90. package/types/engine/frontmatter-lint.d.mts +11 -14
  91. package/types/engine/helpers.d.mts +7 -2
  92. package/types/engine/index-records.d.mts +44 -3
  93. package/types/engine/index.d.mts +5 -0
  94. package/types/engine/infobox-registry.d.mts +36 -1
  95. package/types/engine/infobox.d.mts +6 -4
  96. package/types/engine/macros.d.mts +9 -5
  97. package/types/engine/map-notes.d.mts +10 -6
  98. package/types/engine/metadata-index.d.mts +2 -17
  99. package/types/engine/note-vocabulary.d.mts +30 -0
  100. package/types/engine/packagebuild-index.d.mts +56 -0
  101. package/types/engine/packages.d.mts +62 -0
  102. package/types/engine/pathnames.d.mts +41 -81
  103. package/types/engine/pdf-build.d.mts +58 -1
  104. package/types/engine/pdf-render.d.mts +3 -3
  105. package/types/engine/scenes.d.mts +2 -1
  106. package/types/engine/site-build.d.mts +5 -1
  107. package/types/engine/site-index.d.mts +4 -1
  108. package/types/engine/subtype-registry.d.mts +20 -0
  109. package/types/engine/web-wikilinks.d.mts +14 -4
  110. package/types/engine/wikilink-syntax.d.mts +13 -1
  111. package/types/engine/wikilinks.d.mts +7 -1
  112. package/types/sohl/actors.d.mts +0 -1
@@ -68,6 +68,7 @@ import log from "loglevel";
68
68
  import {
69
69
  parseMarkdownFile,
70
70
  makeFilename,
71
+ resolveImg,
71
72
  resolveName,
72
73
  convertNoteWikilinks,
73
74
  expandNoteTables,
@@ -76,6 +77,7 @@ import {
76
77
  // The record accessors only — see `engine/index-records.mjs` for why they live
77
78
  // apart from the index that builds them.
78
79
  import { isNoteRecord, noteFile } from "./index-records.mjs";
80
+ import { artPathname, artSlot, unresolvedArtMessage } from "./art-fields.mjs";
79
81
  import { emitDiagnostic } from "./diagnostics.mjs";
80
82
  import { assertNoDeclaredPackage } from "./note-package.mjs";
81
83
  import { assertNoDeclaredFolder } from "./folder-notes.mjs";
@@ -229,6 +231,13 @@ export class BasePackCompiler {
229
231
 
230
232
  /** @type {string} */
231
233
  contentBase;
234
+
235
+ /**
236
+ * The asset roots' parent, whose files the art addresses resolve against.
237
+ *
238
+ * @type {string|undefined}
239
+ */
240
+ assetsBase;
232
241
  /** @type {string} */
233
242
  outputDir;
234
243
  /** @type {(path: string|null) => string|null} */
@@ -296,6 +305,11 @@ export class BasePackCompiler {
296
305
  /**
297
306
  * @param {object} options
298
307
  * @param {string} options.contentBase - Root of the content tree.
308
+ * @param {string} [options.assetsBase] - The asset roots' parent, whose
309
+ * files the art addresses resolve against. Stated beside `contentBase`
310
+ * because the two move independently: a caller compiling a tree it
311
+ * assembled states where that tree's files are, and one compiling the
312
+ * repository's own leaves it to the configuration.
299
313
  * @param {string} options.dest - Where this pass writes its JSON.
300
314
  * @param {readonly string[]} options.skipDirectories - Directories the walk
301
315
  * never descends into. Required: see {@link assertStatedScope}.
@@ -317,6 +331,7 @@ export class BasePackCompiler {
317
331
  */
318
332
  constructor({
319
333
  contentBase,
334
+ assetsBase,
320
335
  dest,
321
336
  skipDirectories,
322
337
  folderResolver = () => null,
@@ -344,6 +359,10 @@ export class BasePackCompiler {
344
359
  value: contentBase,
345
360
  writable: false,
346
361
  });
362
+ Object.defineProperty(this, "assetsBase", {
363
+ value: assetsBase,
364
+ writable: false,
365
+ });
347
366
  Object.defineProperty(this, "outputDir", {
348
367
  value: dest,
349
368
  writable: false,
@@ -542,6 +561,7 @@ export class BasePackCompiler {
542
561
  if (!this.corpus) {
543
562
  this.corpus = await buildCompileCorpus({
544
563
  contentBase: this.contentBase,
564
+ assetsBase: this.assetsBase,
545
565
  skipDirectories: this.skipDirectories,
546
566
  router: this.router,
547
567
  });
@@ -551,8 +571,11 @@ export class BasePackCompiler {
551
571
  // the same thing six times.
552
572
  this.reportsCorpusProblems = true;
553
573
  }
574
+ // The index is taken whatever the pass does with a body: it answers
575
+ // the art fields, which name a file rather than a document, and a pass
576
+ // that converts no prose still compiles a document that carries art.
577
+ this.linkIndex = this.corpus.linkIndex;
554
578
  if (this.constructor.convertsWikilinks) {
555
- this.linkIndex = this.corpus.linkIndex;
556
579
  this.contentDocs = this.corpus.contentDocs;
557
580
  this.sqlTables = this.corpus.sqlTables;
558
581
  }
@@ -602,6 +625,51 @@ export class BasePackCompiler {
602
625
  return markdown;
603
626
  }
604
627
 
628
+ /**
629
+ * The Foundry path one authored art address names, or `null`.
630
+ *
631
+ * **The two empties survive.** `null` and an absent key mean *no art named*,
632
+ * so the caller's own default applies with nullish coalescing; `""` means
633
+ * *ship blank on purpose* and no default may replace it. That is
634
+ * {@link module:engine/helpers.resolveImg}'s rule, reached through it rather
635
+ * than restated, which is also what puts an art address and a body image
636
+ * through one ownership rule.
637
+ *
638
+ * An address nothing answers is reported against the note and treated as
639
+ * unnamed, so the document takes its default rather than shipping a path
640
+ * that installs nowhere.
641
+ *
642
+ * @param {unknown} value - The value as authored.
643
+ * @param {string} key - The key it was authored at, for the message.
644
+ * @param {string} type - The asset type a bare value takes.
645
+ * @returns {string|null} The Foundry-relative path, `""` for a deliberate
646
+ * blank, or `null` where the note names none.
647
+ */
648
+ artPathOf(value, key, type) {
649
+ const { pathname, resolved } = artPathname(this.linkIndex, value, type);
650
+ if (!resolved) {
651
+ this.noteWarn(unresolvedArtMessage(key, value, type));
652
+ return null;
653
+ }
654
+ return resolveImg(pathname);
655
+ }
656
+
657
+ /**
658
+ * The Foundry path one art slot of a note names, or `null`.
659
+ *
660
+ * @param {object} fm - The note's frontmatter.
661
+ * @param {string} key - The slot's key under `data:`.
662
+ * @returns {string|null} As {@link BasePackCompiler#artPathOf}.
663
+ */
664
+ artPath(fm, key) {
665
+ const slot = artSlot(key);
666
+ if (!slot) throw new Error(`package-build: \`${key}\` is not an art slot`);
667
+ const data = fm?.data;
668
+ const value =
669
+ data && typeof data === "object" && !Array.isArray(data) ? data[key] : undefined;
670
+ return this.artPathOf(value, key, slot.type);
671
+ }
672
+
605
673
  /**
606
674
  * Reports a warning about the note being compiled.
607
675
  *
@@ -69,7 +69,7 @@ import { contentPackage } from "./content-package.mjs";
69
69
  import { FOLDER_TYPE } from "./folder-notes.mjs";
70
70
  import { HOMEPAGE_TYPE } from "./homepage.mjs";
71
71
  import { itemDocEntryId } from "./item-docs.mjs";
72
- import { folderField, md, resolveImg, resolveName } from "./helpers.mjs";
72
+ import { folderField, md, resolveName } from "./helpers.mjs";
73
73
  import { packForType } from "./ids.mjs";
74
74
  import { readQualifier } from "./wikilinks.mjs";
75
75
 
@@ -145,13 +145,13 @@ export class Bundles extends BasePackCompiler {
145
145
  static readsPackOutputOf = Object.freeze(["Actor", "Item", "JournalEntry", "Macro", "Scene"]);
146
146
 
147
147
  /**
148
- * An Adventure carries an `img` — what Foundry shows on the import card.
149
- * There is no default for it: a bundle naming none ships a blank tile,
148
+ * An Adventure carries one piece of art — what Foundry shows on the import
149
+ * card. There is no default for it: a bundle naming none ships a blank tile,
150
150
  * deliberately, since no stand-in artwork means "a set of documents".
151
151
  *
152
152
  * @type {readonly string[]}
153
153
  */
154
- static emitsArt = Object.freeze(["img"]);
154
+ static emitsArt = Object.freeze(["icon"]);
155
155
 
156
156
  /**
157
157
  * The JSON directories this pass reads its members from, by document type.
@@ -309,7 +309,7 @@ export class Bundles extends BasePackCompiler {
309
309
  // Foundry shows this on the import card, so a bundle without one is
310
310
  // a blank tile. `null` rather than a stand-in: there is no sensible
311
311
  // default artwork for "a set of documents".
312
- img: resolveImg(fm.img),
312
+ img: this.artPath(fm, "icon"),
313
313
  // A bundle is something you hand someone, so its prose belongs on
314
314
  // the document itself — `Adventure.description` is an `HTMLField`
315
315
  // Foundry renders on the import card. That is why a bundle earns no
@@ -48,6 +48,8 @@ import { prepareTreeSqlTables } from "./sql-tables.mjs";
48
48
  *
49
49
  * @param {object} opts - Options.
50
50
  * @param {string} opts.contentBase - Root of the content tree.
51
+ * @param {string} [opts.assetsBase] - The asset roots' parent, whose files the
52
+ * art addresses resolve against. Defaults to the configured one.
51
53
  * @param {readonly string[]} opts.skipDirectories - The scope, stated by the
52
54
  * caller as every corpus read requires.
53
55
  * @param {object} opts.router - The pack router this compile resolved. Shared
@@ -64,6 +66,7 @@ import { prepareTreeSqlTables } from "./sql-tables.mjs";
64
66
  */
65
67
  export async function buildCompileCorpus({
66
68
  contentBase,
69
+ assetsBase,
67
70
  skipDirectories,
68
71
  router,
69
72
  config,
@@ -73,6 +76,7 @@ export async function buildCompileCorpus({
73
76
  const collected = problems ?? [];
74
77
  const records = indexRecordsFor({
75
78
  contentBase,
79
+ assetsBase,
76
80
  config: resolved,
77
81
  skipDirectories,
78
82
  problems: collected,
@@ -46,6 +46,10 @@ import { DEFAULT_ADDRESS_SCHEME } from "../content-config.mjs";
46
46
  // `engine/systems.mjs` imports nothing but `engine/address-charset.mjs`, so
47
47
  // the direction is toward the leaf and cannot close a cycle.
48
48
  import { NO_SYSTEM, assertSystemSegment, isSystemSegment } from "./systems.mjs";
49
+ // The asset vocabulary, for the one thing the address grammar asks of it:
50
+ // whether a type's `<system>` segment is fixed. `engine/asset-types.mjs` is a
51
+ // leaf, so the direction cannot close a cycle.
52
+ import { isAssetType } from "./asset-types.mjs";
49
53
  import { systemOf } from "./document-subtypes.mjs";
50
54
  import { KNOWN_DOCUMENT_SUBTYPE_MAPS } from "./subtype-registry.mjs";
51
55
 
@@ -267,8 +271,16 @@ export function blockSystem(keyPath) {
267
271
  export function expandAddress(read, where) {
268
272
  const pkg = read.package ?? where.package;
269
273
  // A documentation journal is a core document, so it is `none` however it was
270
- // reached; otherwise the block's system, which body prose reports as `none`.
271
- const system = read.itemDoc ? NO_SYSTEM : (read.system ?? where.system ?? NO_SYSTEM);
274
+ // reached, and an **asset** is `none` for a stronger reason: a file belongs
275
+ // to no game system, so the segment is a property of the type rather than of
276
+ // where the reference was written. Without that, a `sohl:` block naming
277
+ // `icon-anvil` would expand to `sohl-sohl-icon-anvil` and resolve to
278
+ // nothing, which is exactly where an embedded item's art is written.
279
+ // Otherwise the block's system, which body prose reports as `none`.
280
+ const system =
281
+ read.itemDoc || isAssetType(read.type) ?
282
+ NO_SYSTEM
283
+ : (read.system ?? where.system ?? NO_SYSTEM);
272
284
  const redirected = system === NO_SYSTEM && isSystemBearing(read.type);
273
285
  const type = read.itemDoc || redirected ? `doc${read.type}` : read.type;
274
286
  return canonicalKey(pkg, system, type, read.shortcode);
@@ -398,6 +398,10 @@ export function checkText(text, file) {
398
398
  * theoretical exclusion — it was the first thing a run over `sohl-thalorna`
399
399
  * reported before the skip existed.
400
400
  *
401
+ * A finding names its file **relative to the working directory**, which is
402
+ * where a reader is standing and what `formatDiagnostic` emits. The content
403
+ * root is where the walk starts, not what a path is measured from.
404
+ *
401
405
  * @param {string} contentBase - Root of the content tree.
402
406
  * @param {object} [opts]
403
407
  * @param {readonly string[]} [opts.skipDirectories] - Directory names to ignore
@@ -438,7 +442,7 @@ export function lintContentCharset(contentBase, { skipDirectories = [], extensio
438
442
  continue;
439
443
  }
440
444
  files += 1;
441
- findings.push(...checkText(text, path.relative(contentBase, full)));
445
+ findings.push(...checkText(text, path.relative(process.cwd(), full)));
442
446
  }
443
447
  };
444
448
 
@@ -0,0 +1,314 @@
1
+ /*
2
+ * This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
3
+ * Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
4
+ *
5
+ * This work is licensed under the GNU General Public License v3.0 (GPLv3).
6
+ * You may copy, modify, and distribute it under the terms of that license.
7
+ *
8
+ * For full terms, see the LICENSE.md file in the project root or visit:
9
+ * https://www.gnu.org/licenses/gpl-3.0.html
10
+ *
11
+ * SPDX-License-Identifier: GPL-3.0-or-later
12
+ */
13
+
14
+ /**
15
+ * `![[address|label]]` — the wikilink that renders a picture where it stands.
16
+ *
17
+ * There is no image grammar here. An embed is the wikilink
18
+ * {@link module:engine/wikilink-syntax} already defines, with `!` meaning
19
+ * _render it here_ rather than _link to it_, and the syntax supplying the
20
+ * default type the way a field declaration does — `image`. The short-form
21
+ * ladder, the package and system defaults, the lowercase rule and the findings
22
+ * vocabulary all apply unchanged.
23
+ *
24
+ * ## An embed resolves to an ordinary image, and that is the whole design
25
+ *
26
+ * `![[thorn|Thorn]]{float: top-left}` becomes
27
+ * `![Thorn](thalorna/assets/images/beings/thorn.webp){float: top-left}` in the
28
+ * source text, before any surface renders it. Everything downstream is machinery
29
+ * that already exists: {@link module:engine/content-images.imagePlugin} draws the
30
+ * figure for a Foundry journal and for the book's Typst walk,
31
+ * {@link module:engine/content-images.renderImageFigures} draws it for the
32
+ * website, and {@link module:engine/content-images.imageSourcesIn} is what tells
33
+ * the book which files to stage. One authored statement, three renderers, no
34
+ * fourth image path.
35
+ *
36
+ * **That is why the rewrite is a source-text pass rather than a markdown-it
37
+ * inline rule.** Two of the four readers have no render pass to put a token in:
38
+ * the website is handed *markdown* for Hugo to render, and the link checker
39
+ * parses nothing at all. A third, the book, reads its staging list out of the
40
+ * source text before it tokenises. A token would therefore serve one reader and
41
+ * leave the other three needing the grammar a second time, which is the drift
42
+ * the wikilink pattern was consolidated here to end.
43
+ *
44
+ * ## The label is the alt text, and it stays with the referrer
45
+ *
46
+ * One image serves many documents, and only the referrer knows what it means
47
+ * where it sits. `![[anvil|]]` is deliberately decorative, `![[anvil|An anvil]]`
48
+ * carries meaning, and `![[anvil]]` is unlabelled and a finding like any other
49
+ * link — the parser distinguishes a missing label from an empty one, so the two
50
+ * differ without an exemption.
51
+ *
52
+ * ## An embed reaches asset types only
53
+ *
54
+ * The syntax invites the broader reading — transclusion of an arbitrary note —
55
+ * so the restriction is a guard rather than a convention, and a `being` named
56
+ * where a picture belongs is refused with its own reason rather than reported as
57
+ * an address that resolves to nothing.
58
+ *
59
+ * @module
60
+ */
61
+
62
+ import { matchAllOutsideCode } from "./code-fences.mjs";
63
+ import { parseImageDirective, standsAlone } from "./content-images.mjs";
64
+ import { positionInBody } from "./diagnostics.mjs";
65
+ import { authoredLabel, linkFindingMessage, parseWikilink } from "./wikilink-syntax.mjs";
66
+ import { readAssetAddress } from "./art-fields.mjs";
67
+
68
+ /**
69
+ * The type a bare shortcode takes, supplied by the syntax itself.
70
+ *
71
+ * A picture is what `!` asks for, so `image` is what an unqualified embed names.
72
+ * An icon is reached by stating it — `![[icon-anvil|An anvil]]` — because the two
73
+ * have separate shortcode namespaces and neither is derivable from the other.
74
+ *
75
+ * @type {string}
76
+ */
77
+ export const EMBED_DEFAULT_TYPE = "image";
78
+
79
+ /**
80
+ * An embed, as authored, with the directive it may carry.
81
+ *
82
+ * The interior admits no `]` or newline, exactly as a wikilink's does: an embed
83
+ * is written on one line, and an unclosed `![[` is a typo rather than licence to
84
+ * consume the rest of the document looking for a closer.
85
+ *
86
+ * The directive is `{…}` immediately after the closing `]]`, holding no newline,
87
+ * for the reason {@link module:engine/content-images.IMAGE_PATTERN} gives: a
88
+ * brace that opens and never closes on its line is prose.
89
+ *
90
+ * @type {RegExp}
91
+ */
92
+ export const EMBED_PATTERN = /!\[\[([^\]\n]+)\]\](\{[^}\n]*\})?/g;
93
+
94
+ /**
95
+ * One embed, parsed.
96
+ *
97
+ * @typedef {object} ParsedEmbed
98
+ * @property {string} all - The embed exactly as authored, directive included.
99
+ * @property {string} inner - The whole interior of the brackets.
100
+ * @property {string} written - The link part as authored, anchor included. An
101
+ * embed names a file and a file has no sections, so an anchor is carried into
102
+ * the address rather than stripped off it — where it fails the address charset
103
+ * and is reported as what it is.
104
+ * @property {string|null} display - The text after `|`, `null` when unlabelled.
105
+ * @property {boolean} labelled - Whether a `|` was present at all.
106
+ * @property {string} directive - The `{…}` as written, or `""`.
107
+ * @property {number} index - Where the embed begins in the body.
108
+ * @property {number} length - How much of the body it occupies.
109
+ * @property {boolean} block - Whether it stands alone in its own paragraph.
110
+ */
111
+
112
+ /**
113
+ * Every embed in one body, in source order.
114
+ *
115
+ * Code is skipped, because an embed shown as an example is prose *about* an
116
+ * embed — which is what makes this module's own documentation writable.
117
+ *
118
+ * @param {string} body - The note's markdown, without its frontmatter.
119
+ * @returns {ParsedEmbed[]} One entry per embed.
120
+ */
121
+ export function embedsIn(body) {
122
+ const text = String(body ?? "");
123
+ return matchAllOutsideCode(text, new RegExp(EMBED_PATTERN.source, "g")).map((match) => {
124
+ const index = /** @type {number} */ (match.index);
125
+ const parsed = parseWikilink(match[1] ?? "");
126
+ const length = match[0].length;
127
+ return {
128
+ all: match[0],
129
+ inner: parsed.inner,
130
+ written: parsed.anchor ? `${parsed.target}#${parsed.anchor}` : parsed.target,
131
+ display: parsed.display,
132
+ labelled: parsed.labelled,
133
+ directive: match[2] ?? "",
134
+ index,
135
+ length,
136
+ block: standsAlone(text, index, index + length),
137
+ };
138
+ });
139
+ }
140
+
141
+ /**
142
+ * What one embed resolves to, or why it does not.
143
+ *
144
+ * The reasons are {@link module:engine/wikilink-syntax.LINK_FINDING_REASONS},
145
+ * the vocabulary every resolver shares, so an author meets one wording for one
146
+ * mistake whichever build they ran first.
147
+ *
148
+ * @param {object} index - From {@link module:engine/wikilinks.buildWikilinkIndex},
149
+ * or the equivalent the site and the book build.
150
+ * @param {ParsedEmbed} embed - The embed.
151
+ * @returns {{pathname: string}|{reason: string, target: string, type?: string}}
152
+ * The authored pathname the picture is at, or the finding.
153
+ */
154
+ export function resolveEmbed(index, embed) {
155
+ // **Every link carries a label**, and an embed is a link. Without one there
156
+ // is nothing to show and nothing to describe the picture with, so it is
157
+ // reported before anything is looked up.
158
+ if (!embed.labelled) return { reason: "unlabelled", target: embed.inner };
159
+
160
+ const read = readAssetAddress(index, embed.written, EMBED_DEFAULT_TYPE);
161
+ if (read.record) {
162
+ return { pathname: read.pathname };
163
+ }
164
+ return { reason: read.reason, target: embed.written, type: read.type };
165
+ }
166
+
167
+ /**
168
+ * What is wrong with one embed's directive or placement, if anything.
169
+ *
170
+ * Separate from the link findings beside it because the two speak different
171
+ * vocabularies: a link finding is a `reason` from the closed set every resolver
172
+ * shares, and this is an image's own complaint about a brace or a paragraph.
173
+ * Collapsing them would put a sentence where a `reason` belongs.
174
+ *
175
+ * @param {ParsedEmbed} embed - The embed.
176
+ * @returns {Array<{link: string, offset: number, message: string}>} One entry
177
+ * per defect. `link` is the embed exactly as authored, which is what lets a
178
+ * caller with no offsets locate it by searching the note for the literal.
179
+ */
180
+ export function embedProblems(embed) {
181
+ /** @type {Array<{link: string, offset: number, message: string}>} */
182
+ const problems = [];
183
+ if (!embed.block) {
184
+ problems.push({
185
+ link: embed.all,
186
+ offset: embed.index,
187
+ message:
188
+ `\`![[${embed.inner}]]\` shares its paragraph with other text — an ` +
189
+ "embedded image is a block, standing alone with a blank line either " +
190
+ "side of it, because a width and a position mean nothing applied to a " +
191
+ "word in a sentence",
192
+ });
193
+ }
194
+ if (!embed.directive) return problems;
195
+ // Located on the brace rather than on the embed: the brace is what the
196
+ // author edits, and two problems in one directive should not report at the
197
+ // same column twice.
198
+ const offset = embed.index + embed.length - embed.directive.length;
199
+ for (const message of parseImageDirective(embed.directive).problems) {
200
+ problems.push({ link: embed.all, offset, message });
201
+ }
202
+ return problems;
203
+ }
204
+
205
+ /**
206
+ * Rewrite every embed in one body into the image each surface already renders.
207
+ *
208
+ * An embed that does not resolve is left **exactly as authored** and reported,
209
+ * so a missing picture degrades to visible literal text rather than to a broken
210
+ * `src` or a swallowed paragraph. So is one whose directive does not parse: a
211
+ * directive holding a problem is not honoured at all, and the braces reaching
212
+ * the page as their own literal text is how the mistake is visible without a
213
+ * log.
214
+ *
215
+ * Call this **before** wikilink resolution. The rewrite consumes the `!` along
216
+ * with the brackets, which is what stops the link pass reading an embed's
217
+ * interior as an ordinary link to a note that does not exist.
218
+ *
219
+ * @param {string} body - The note's markdown, tables already expanded.
220
+ * @param {object} ctx
221
+ * @param {object} ctx.index - The address index assets resolve through.
222
+ * @returns {{markdown: string, unresolved: Array<{link: string, target: string,
223
+ * offset: number, reason: string, type?: string}>,
224
+ * problems: Array<{link: string, offset: number, message: string}>,
225
+ * images: Array<{link: string, offset: number, pathname: string}>}} The body,
226
+ * the embeds that named nothing, the directives that could not be honoured,
227
+ * and the pathname each embed that did resolve now names. Every `offset` is
228
+ * 0-based in `body`, which is what lets a caller report a line and a column
229
+ * and tell two identical embeds apart.
230
+ *
231
+ * **`images` is how a surface holds an embed to its own rule.** The rewrite
232
+ * is surface-agnostic — a file the website serves and the book stages is not
233
+ * always one a Foundry install carries — so a caller that cares asks about
234
+ * the pathname while it still knows which embed produced it, rather than
235
+ * searching a rewritten body for a literal the note never wrote.
236
+ */
237
+ export function resolveEmbeds(body, { index }) {
238
+ const text = String(body ?? "");
239
+ /** @type {Array<{link: string, target: string, offset: number, reason: string, type?: string}>} */
240
+ const unresolved = [];
241
+ /** @type {Array<{link: string, offset: number, message: string}>} */
242
+ const problems = [];
243
+ /** @type {Array<{link: string, offset: number, pathname: string}>} */
244
+ const images = [];
245
+ let out = "";
246
+ let last = 0;
247
+
248
+ for (const embed of embedsIn(text)) {
249
+ problems.push(...embedProblems(embed));
250
+ const resolved = resolveEmbed(index, embed);
251
+ if (!("pathname" in resolved)) {
252
+ unresolved.push({
253
+ link: embed.all,
254
+ target: resolved.target,
255
+ offset: embed.index,
256
+ reason: resolved.reason,
257
+ ...(resolved.type ? { type: resolved.type } : {}),
258
+ });
259
+ continue;
260
+ }
261
+ if (parseImageDirective(embed.directive).problems.length) continue;
262
+
263
+ out += text.slice(last, embed.index);
264
+ // The label is the alt text, and an empty one is deliberately
265
+ // decorative — {@link authoredLabel} is where that reading lives, so an
266
+ // embed and a link cannot draw the line in two places.
267
+ out += `![${authoredLabel(embed) ?? ""}](${resolved.pathname})${embed.directive}`;
268
+ images.push({ link: embed.all, offset: embed.index, pathname: resolved.pathname });
269
+ last = embed.index + embed.length;
270
+ }
271
+ return { markdown: out + text.slice(last), unresolved, problems, images };
272
+ }
273
+
274
+ /**
275
+ * Every defect in one note's embeds, located.
276
+ *
277
+ * {@link resolveEmbeds} with its two lists turned into the one shape a finding
278
+ * takes, for a caller that wants the report and not the rewrite.
279
+ *
280
+ * **Errors, not warnings**, for the reason an image's are: `reportFindings`
281
+ * fails on an error and not on a warning, so a refusal reported as advisory
282
+ * publishes anyway, looking exactly like a directive that worked.
283
+ *
284
+ * @param {string} body - The note's markdown, without its frontmatter.
285
+ * @param {string} file - The note's path, for the finding.
286
+ * @param {object} [opts]
287
+ * @param {number} [opts.bodyLine=1] - The 1-based file line the body starts on.
288
+ * @param {number} [opts.bodyColumn=1] - The 1-based file column it starts at.
289
+ * @param {object} [opts.index] - The address index assets resolve through.
290
+ * @returns {Array<{file: string, line: number, column: number|undefined,
291
+ * severity: "error", message: string}>} One finding per defect, in source
292
+ * order.
293
+ */
294
+ export function checkEmbeds(body, file, { bodyLine = 1, bodyColumn = 1, index } = {}) {
295
+ const text = String(body ?? "");
296
+ if (!text) return [];
297
+
298
+ const { unresolved, problems } = resolveEmbeds(text, { index });
299
+ return [
300
+ ...unresolved.map((u) => ({ offset: u.offset, message: linkFindingMessage(u) })),
301
+ ...problems,
302
+ ]
303
+ .sort((a, b) => a.offset - b.offset)
304
+ .map(({ offset, message }) => {
305
+ const { line, column } = positionInBody(text, offset, { bodyLine, bodyColumn });
306
+ return {
307
+ file,
308
+ line,
309
+ column,
310
+ severity: /** @type {"error"} */ ("error"),
311
+ message,
312
+ };
313
+ });
314
+ }
@@ -125,6 +125,10 @@ export function checkHtml(body, file, { bodyLine = 1, bodyColumn = 1 } = {}) {
125
125
  * whole: it is not a note, but a stray `.md` in the tree carrying markup is the
126
126
  * same problem for the same reason.
127
127
  *
128
+ * A finding names its file **relative to the working directory**, which is
129
+ * where a reader is standing and what `formatDiagnostic` emits. The content
130
+ * root is where the walk starts, not what a path is measured from.
131
+ *
128
132
  * @param {string} contentBase - Root of the content tree.
129
133
  * @param {object} [opts]
130
134
  * @param {readonly string[]} [opts.skipDirectories] - Directory names to ignore
@@ -163,7 +167,7 @@ export function lintContentHtml(contentBase, { skipDirectories = [] } = {}) {
163
167
  continue;
164
168
  }
165
169
  files += 1;
166
- findings.push(...checkHtml(...bodyOf(content, path.relative(contentBase, full))));
170
+ findings.push(...checkHtml(...bodyOf(content, path.relative(process.cwd(), full))));
167
171
  }
168
172
  };
169
173
 
@@ -563,6 +563,10 @@ export function checkIconRegistry(registry, where = "icons") {
563
563
  * with nothing imported between them. The cost is one extra pass over the tree,
564
564
  * which is the cheaper half of a lint that already parses every note.
565
565
  *
566
+ * A finding names its file **relative to the working directory**, which is
567
+ * where a reader is standing and what `formatDiagnostic` emits. The content
568
+ * root is where the walk starts, not what a path is measured from.
569
+ *
566
570
  * @param {string} contentBase - Root of the content tree.
567
571
  * @param {object} [opts]
568
572
  * @param {readonly string[]} [opts.skipDirectories] - Directory names to ignore.
@@ -599,7 +603,7 @@ export function lintContentIcons(contentBase, { skipDirectories = [], registry }
599
603
  continue;
600
604
  }
601
605
  files += 1;
602
- findings.push(...lintIcons(text, path.relative(contentBase, full), registry));
606
+ findings.push(...lintIcons(text, path.relative(process.cwd(), full), registry));
603
607
  }
604
608
  };
605
609
 
@@ -77,7 +77,7 @@ import path from "node:path";
77
77
 
78
78
  import { matchAllOutsideCode } from "./code-fences.mjs";
79
79
  import { positionInBody } from "./diagnostics.mjs";
80
- import { pathnameProblem } from "./pathnames.mjs";
80
+ import { foundryAddressProblem, pathnameProblem, servesFoundry } from "./pathnames.mjs";
81
81
 
82
82
  /**
83
83
  * The width classes an image may carry, and what each means to a renderer.
@@ -436,11 +436,16 @@ export function imageSourcesIn(body) {
436
436
  * @param {object} [opts]
437
437
  * @param {number} [opts.bodyLine=1] - The 1-based file line the body starts on.
438
438
  * @param {number} [opts.bodyColumn=1] - The 1-based file column it starts at.
439
+ * @param {object} [opts.config] - The resolved build configuration. Supplied,
440
+ * an address is also held to the one surface a pathname can be dead on
441
+ * without any other pass noticing — see the Foundry address below. Omitted,
442
+ * the config-free checks run alone, which is what lets a caller with no
443
+ * repository to resolve still read a body.
439
444
  * @returns {Array<{file: string, line: number, column: number|undefined,
440
445
  * severity: "error", message: string}>} One finding per defect, in source
441
446
  * order.
442
447
  */
443
- export function checkImages(body, file, { bodyLine = 1, bodyColumn = 1 } = {}) {
448
+ export function checkImages(body, file, { bodyLine = 1, bodyColumn = 1, config } = {}) {
444
449
  const text = String(body ?? "");
445
450
  if (!text) return [];
446
451
 
@@ -455,8 +460,17 @@ export function checkImages(body, file, { bodyLine = 1, bodyColumn = 1 } = {}) {
455
460
  findings.push({ file, line, column, severity: /** @type {"error"} */ ("error"), message });
456
461
  };
457
462
 
463
+ // The Foundry address is the one form a pathname can lack while every other
464
+ // surface resolves it, and the renderer that hands a journal its markup has
465
+ // no channel to say so — so it is asked here, where a line and a column are
466
+ // at hand. A build that installs nothing in Foundry has no such surface and
467
+ // is not asked.
468
+ const foundry = config && servesFoundry(config);
458
469
  for (const image of imagesIn(text)) {
459
- const problem = imageSourceProblem(image.src) || pathnameProblem(image.src);
470
+ const problem =
471
+ imageSourceProblem(image.src) ||
472
+ pathnameProblem(image.src) ||
473
+ (foundry ? foundryAddressProblem(image.src, config) : "");
460
474
  if (problem) report(image.index, problem);
461
475
  if (image.title) {
462
476
  report(
@@ -521,15 +535,21 @@ function bodyOf(content, file) {
521
535
  * Its own walk, like the icon and HTML checks beside it, so all three stay
522
536
  * leaves with nothing imported between them.
523
537
  *
538
+ * A finding names its file **relative to the working directory**, which is
539
+ * where a reader is standing and what `formatDiagnostic` emits. The content
540
+ * root is where the walk starts, not what a path is measured from.
541
+ *
524
542
  * @param {string} contentBase - Root of the content tree.
525
543
  * @param {object} [opts]
526
544
  * @param {readonly string[]} [opts.skipDirectories] - Directory names to ignore
527
545
  * in addition to the dot-directories always skipped.
546
+ * @param {object} [opts.config] - The resolved build configuration, passed to
547
+ * {@link checkImages} so an address is held to the Foundry surface too.
528
548
  * @returns {{findings: Array<{file: string, line: number, column: number|undefined,
529
549
  * severity: "error", message: string}>, files: number}} The findings, and how
530
550
  * many files were read.
531
551
  */
532
- export function lintContentImages(contentBase, { skipDirectories = [] } = {}) {
552
+ export function lintContentImages(contentBase, { skipDirectories = [], config } = {}) {
533
553
  const skip = new Set(skipDirectories);
534
554
  /** @type {Array<{file: string, line: number, column: number|undefined, severity: "error", message: string}>} */
535
555
  const findings = [];
@@ -559,7 +579,8 @@ export function lintContentImages(contentBase, { skipDirectories = [] } = {}) {
559
579
  continue;
560
580
  }
561
581
  files += 1;
562
- findings.push(...checkImages(...bodyOf(content, path.relative(contentBase, full))));
582
+ const [body, rel, at] = bodyOf(content, path.relative(process.cwd(), full));
583
+ findings.push(...checkImages(body, rel, { ...at, config }));
563
584
  }
564
585
  };
565
586