@heroiclands/package-build 23.13.0 → 24.0.1

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 (236) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +2 -1
  3. package/assets/preview/gitea.ini +12 -7
  4. package/assets/styles/content-base.css +150 -1
  5. package/bin/content-commands.mjs +356 -210
  6. package/bin/package-build.mjs +3 -2
  7. package/ci/check-banners.mjs +7 -7
  8. package/config.mjs +21 -6
  9. package/content-config.mjs +199 -80
  10. package/docs/api.md +93 -72
  11. package/docs/authoring/links-and-markup.md +316 -78
  12. package/docs/commands.md +248 -80
  13. package/docs/configuration.md +210 -102
  14. package/docs/content-format.md +2 -2
  15. package/docs/diagnostics.md +2 -2
  16. package/docs/getting-started.md +1 -1
  17. package/docs/guides/icons.md +1 -1
  18. package/docs/guides/module-project.md +5 -8
  19. package/docs/guides/release-and-deploy.md +2 -2
  20. package/docs/guides/site.md +155 -23
  21. package/docs/guides/system-project.md +1 -1
  22. package/docs/guides/writing-in-invented-scripts.md +1247 -0
  23. package/docs/index.md +1 -0
  24. package/docs/project-setup.md +28 -30
  25. package/docs/reference/format-details.md +589 -308
  26. package/docs/reference/note-types.md +40 -10
  27. package/docs/reference/theme-pages.md +127 -108
  28. package/docs/source-preview.md +15 -1
  29. package/engine/asset-shipping.mjs +22 -10
  30. package/engine/base-compiler.mjs +62 -104
  31. package/engine/changelog-lint.mjs +1 -1
  32. package/engine/content-address.mjs +1 -1
  33. package/engine/content-alerts.mjs +8 -4
  34. package/engine/content-blocks.mjs +26 -11
  35. package/engine/content-embeds.mjs +1 -1
  36. package/engine/content-expand.mjs +376 -0
  37. package/engine/content-figures.mjs +13 -6
  38. package/engine/content-format.yaml +8 -0
  39. package/engine/content-glyphs.mjs +213 -0
  40. package/engine/content-images.mjs +6 -7
  41. package/engine/content-links.mjs +3 -3
  42. package/engine/content-lint.mjs +1 -1
  43. package/engine/content-render.mjs +453 -0
  44. package/engine/content-scripts.mjs +326 -27
  45. package/engine/content-spans.mjs +4 -1
  46. package/engine/content-styles.mjs +91 -0
  47. package/engine/content-symbols.mjs +186 -0
  48. package/engine/content-verse.mjs +6 -18
  49. package/engine/core-report.mjs +169 -0
  50. package/engine/core-vocabulary.mjs +208 -0
  51. package/engine/dictionary-html.mjs +119 -0
  52. package/engine/field-reference.mjs +1 -1
  53. package/engine/frontmatter-lint.mjs +1 -1
  54. package/engine/heading-attributes.mjs +89 -0
  55. package/engine/helpers.mjs +61 -101
  56. package/engine/homepage.mjs +17 -29
  57. package/engine/html-minify.mjs +70 -0
  58. package/engine/index.mjs +8 -5
  59. package/engine/language-rows.mjs +8 -2
  60. package/engine/language-terms.mjs +115 -14
  61. package/engine/language-words.mjs +12 -0
  62. package/engine/logographic.mjs +255 -0
  63. package/engine/map-lexicon.mjs +221 -25
  64. package/engine/map-places.mjs +5 -4
  65. package/engine/markdown-expressions.mjs +0 -2
  66. package/engine/markdownlint-run.mjs +9 -3
  67. package/engine/markup-validation.mjs +21 -0
  68. package/engine/metre-declaration.mjs +134 -0
  69. package/engine/note-matter.mjs +2 -2
  70. package/engine/note-vocabulary.mjs +187 -3
  71. package/engine/pdf-build.mjs +68 -133
  72. package/engine/pdf-render.mjs +102 -44
  73. package/engine/post-fields.mjs +157 -0
  74. package/engine/preview-index.mjs +130 -0
  75. package/engine/preview-source.mjs +450 -0
  76. package/engine/project-init.mjs +5 -12
  77. package/engine/prose-config.mjs +3 -3
  78. package/engine/run-words.mjs +3 -2
  79. package/engine/script-emphasis-terms.mjs +38 -0
  80. package/engine/script-emphasis.mjs +295 -0
  81. package/engine/script-font-delivery.mjs +4 -4
  82. package/engine/site-build.mjs +265 -313
  83. package/engine/site-config.mjs +26 -238
  84. package/engine/site-files.mjs +175 -0
  85. package/engine/site-index.mjs +9 -4
  86. package/engine/site-preview.mjs +50 -523
  87. package/engine/site-reach.mjs +213 -0
  88. package/engine/site-render.mjs +668 -0
  89. package/engine/site-root.mjs +90 -39
  90. package/engine/site-search.mjs +161 -0
  91. package/engine/site-serve.mjs +274 -0
  92. package/engine/site-theme-banner.mjs +154 -0
  93. package/engine/site-theme-urls.mjs +125 -0
  94. package/engine/site-theme.mjs +939 -0
  95. package/engine/sql-tables.mjs +9 -6
  96. package/engine/text-maps.mjs +57 -10
  97. package/engine/typographer.mjs +248 -0
  98. package/engine/verse-metre.mjs +357 -0
  99. package/engine/wikilink-syntax.mjs +29 -47
  100. package/engine/wikilinks.mjs +758 -279
  101. package/engine/word-forms.mjs +39 -24
  102. package/engine/word-rows.mjs +12 -2
  103. package/engine/word-terms.mjs +29 -4
  104. package/manifest.mjs +3 -4
  105. package/package.json +10 -7
  106. package/{hugo-theme → site-theme}/data/banners.yaml +6 -10
  107. package/site-theme/layouts/base.hbs +31 -0
  108. package/site-theme/layouts/blog-list.hbs +30 -0
  109. package/site-theme/layouts/blog-post.hbs +40 -0
  110. package/site-theme/layouts/home.hbs +35 -0
  111. package/site-theme/layouts/list.hbs +20 -0
  112. package/site-theme/layouts/not-found.hbs +31 -0
  113. package/site-theme/layouts/page.hbs +7 -0
  114. package/site-theme/partials/copy-buttons.hbs +37 -0
  115. package/site-theme/partials/draft-notice.hbs +5 -0
  116. package/site-theme/partials/exturl.hbs +2 -0
  117. package/{hugo-theme/layouts/partials/footer.html → site-theme/partials/footer.hbs} +10 -7
  118. package/site-theme/partials/header.hbs +44 -0
  119. package/site-theme/partials/hero-banner.hbs +11 -0
  120. package/site-theme/partials/infobox-entry.hbs +2 -0
  121. package/site-theme/partials/infobox-section.hbs +31 -0
  122. package/site-theme/partials/infobox-value.hbs +2 -0
  123. package/site-theme/partials/infobox.hbs +18 -0
  124. package/site-theme/partials/list-members.hbs +9 -0
  125. package/site-theme/partials/nav-links.hbs +5 -0
  126. package/site-theme/partials/page-body.hbs +48 -0
  127. package/site-theme/partials/page-list.hbs +14 -0
  128. package/site-theme/partials/related.hbs +24 -0
  129. package/site-theme/partials/search-meta.hbs +6 -0
  130. package/site-theme/partials/search-names.hbs +2 -0
  131. package/site-theme/partials/search.hbs +52 -0
  132. package/site-theme/partials/tags.hbs +4 -0
  133. package/site-theme/partials/toc.hbs +7 -0
  134. package/{hugo-theme → site-theme}/static/css/style.css +14 -0
  135. package/stage.mjs +1 -2
  136. package/types/config.d.mts +8 -4
  137. package/types/content-config.d.mts +15 -15
  138. package/types/engine/asset-shipping.d.mts +5 -2
  139. package/types/engine/base-compiler.d.mts +20 -2
  140. package/types/engine/content-address.d.mts +1 -1
  141. package/types/engine/content-alerts.d.mts +8 -2
  142. package/types/engine/content-blocks.d.mts +11 -4
  143. package/types/engine/content-expand.d.mts +288 -0
  144. package/types/engine/content-figures.d.mts +11 -3
  145. package/types/engine/content-glyphs.d.mts +85 -0
  146. package/types/engine/content-images.d.mts +6 -7
  147. package/types/engine/content-links.d.mts +1 -1
  148. package/types/engine/content-render.d.mts +199 -0
  149. package/types/engine/content-scripts.d.mts +62 -0
  150. package/types/engine/content-spans.d.mts +1 -1
  151. package/types/engine/content-styles.d.mts +33 -0
  152. package/types/engine/content-symbols.d.mts +35 -0
  153. package/types/engine/core-report.d.mts +45 -0
  154. package/types/engine/core-vocabulary.d.mts +53 -0
  155. package/types/engine/dictionary-html.d.mts +8 -0
  156. package/types/engine/field-reference.d.mts +1 -1
  157. package/types/engine/heading-attributes.d.mts +20 -0
  158. package/types/engine/helpers.d.mts +3 -10
  159. package/types/engine/homepage.d.mts +11 -24
  160. package/types/engine/html-minify.d.mts +9 -0
  161. package/types/engine/index.d.mts +2 -1
  162. package/types/engine/language-terms.d.mts +24 -2
  163. package/types/engine/logographic.d.mts +45 -0
  164. package/types/engine/map-lexicon.d.mts +48 -8
  165. package/types/engine/map-places.d.mts +8 -0
  166. package/types/engine/markdown-expressions.d.mts +0 -2
  167. package/types/engine/metre-declaration.d.mts +39 -0
  168. package/types/engine/pdf-render.d.mts +6 -2
  169. package/types/engine/post-fields.d.mts +44 -0
  170. package/types/engine/preview-index.d.mts +72 -0
  171. package/types/engine/preview-source.d.mts +51 -0
  172. package/types/engine/script-emphasis-terms.d.mts +21 -0
  173. package/types/engine/script-emphasis.d.mts +84 -0
  174. package/types/engine/script-font-delivery.d.mts +4 -4
  175. package/types/engine/site-build.d.mts +71 -113
  176. package/types/engine/site-config.d.mts +14 -134
  177. package/types/engine/site-files.d.mts +73 -0
  178. package/types/engine/site-index.d.mts +11 -3
  179. package/types/engine/site-preview.d.mts +15 -19
  180. package/types/engine/site-reach.d.mts +76 -0
  181. package/types/engine/site-render.d.mts +173 -0
  182. package/types/engine/site-root.d.mts +14 -4
  183. package/types/engine/site-search.d.mts +54 -0
  184. package/types/engine/site-serve.d.mts +44 -0
  185. package/types/engine/site-theme-banner.d.mts +51 -0
  186. package/types/engine/site-theme-urls.d.mts +60 -0
  187. package/types/engine/site-theme.d.mts +127 -0
  188. package/types/engine/text-maps.d.mts +17 -3
  189. package/types/engine/typographer.d.mts +11 -0
  190. package/types/engine/verse-metre.d.mts +131 -0
  191. package/types/engine/wikilink-syntax.d.mts +30 -48
  192. package/types/engine/wikilinks.d.mts +330 -14
  193. package/types/engine/word-forms.d.mts +3 -2
  194. package/types/manifest.d.mts +3 -4
  195. package/types/stage.d.mts +1 -2
  196. package/types/vendor/markdownlint.d.mts +8 -0
  197. package/vendor/markdownlint.mjs +20388 -0
  198. package/engine/web-wikilinks.mjs +0 -594
  199. package/hugo-theme/layouts/404.html +0 -85
  200. package/hugo-theme/layouts/_default/baseof.html +0 -67
  201. package/hugo-theme/layouts/_default/list.html +0 -139
  202. package/hugo-theme/layouts/_default/single.html +0 -4
  203. package/hugo-theme/layouts/blog/list.html +0 -54
  204. package/hugo-theme/layouts/blog-post/single.html +0 -36
  205. package/hugo-theme/layouts/index.html +0 -75
  206. package/hugo-theme/layouts/partials/banner-url.html +0 -141
  207. package/hugo-theme/layouts/partials/cdn-url.html +0 -36
  208. package/hugo-theme/layouts/partials/dictionary-lists.html +0 -58
  209. package/hugo-theme/layouts/partials/draft-notice.html +0 -41
  210. package/hugo-theme/layouts/partials/draft-state.html +0 -26
  211. package/hugo-theme/layouts/partials/header.html +0 -35
  212. package/hugo-theme/layouts/partials/hero-banner.html +0 -85
  213. package/hugo-theme/layouts/partials/infobox/entry.html +0 -18
  214. package/hugo-theme/layouts/partials/infobox/items.html +0 -26
  215. package/hugo-theme/layouts/partials/infobox/section.html +0 -56
  216. package/hugo-theme/layouts/partials/infobox/value.html +0 -43
  217. package/hugo-theme/layouts/partials/infobox.html +0 -57
  218. package/hugo-theme/layouts/partials/page-body.html +0 -152
  219. package/hugo-theme/layouts/partials/related.html +0 -138
  220. package/hugo-theme/layouts/partials/search.html +0 -84
  221. package/hugo-theme/layouts/partials/site-url.html +0 -32
  222. package/hugo-theme/layouts/partials/tags.html +0 -30
  223. package/hugo-theme/layouts/partials/toc.html +0 -28
  224. package/hugo-theme/layouts/partials/warn-missing-banner.html +0 -3
  225. package/hugo-theme/layouts/shortcodes/cartouche.html +0 -64
  226. package/hugo-theme/layouts/shortcodes/exturl.html +0 -12
  227. package/hugo-theme/layouts/shortcodes/glyph.html +0 -43
  228. package/hugo-theme/layouts/shortcodes/pagelist.html +0 -63
  229. package/hugo-theme/theme.toml +0 -11
  230. package/types/engine/web-wikilinks.d.mts +0 -107
  231. /package/{hugo-theme → site-theme}/data/banner-kinds.yaml +0 -0
  232. /package/{hugo-theme → site-theme}/static/css/sohl-icons.css +0 -0
  233. /package/{hugo-theme → site-theme}/static/fonts/game-icons-kb.woff2 +0 -0
  234. /package/{hugo-theme → site-theme}/static/img/banners/takheperu-hieroglyphs.svg +0 -0
  235. /package/{hugo-theme → site-theme}/static/img/cartouches/takheperu.svg +0 -0
  236. /package/{hugo-theme → site-theme}/static/js/inline-spoilers.js +0 -0
@@ -12,26 +12,35 @@
12
12
  */
13
13
 
14
14
  /**
15
- * Resolve body wikilinks for Foundry packs.
15
+ * **The link resolver.** One authored `[[…]]` is read the same way for every
16
+ * target, and only the form it is written in differs: a Foundry journal gets
17
+ * an `@UUID[…]{label}` content link, a web page a relative URL, the book a
18
+ * cross-reference.
19
+ *
20
+ * Resolution is {@link resolveWikilinks}. It reads the link with the syntax
21
+ * module, applies the rules every target shares — a link carries a label, a
22
+ * same-page link names a section the page declares, a target is an address,
23
+ * an address names one note — and hands back a {@link ResolvedLink}: what the
24
+ * link names, independent of any target. The index a link is read against is a
25
+ * {@link LinkView}, of the pack compile's index ({@link packLinkView}) or of
26
+ * the site's ({@link siteLinkView}), and each target writes the resolution
27
+ * through its {@link LINK_WRITERS} entry. {@link convertWikilinks} is the pack
28
+ * compile's entry, {@link resolveWebWikilinks} the website's and the book's.
16
29
  *
17
30
  * `[[type-shortcode|Text]]` defaults to the readable `note` Address. A stated
18
31
  * game system names an Actor or Item; `none` names a systemless document.
19
- * Journal links can carry a heading anchor. Actor, Item and Macro links open
20
- * their sheets, so their anchors do not select a page.
21
- *
22
- * `doc<type>` is accepted as an input alias for the readable note Address.
23
- * A link with an empty label displays the target's current name.
32
+ * `doc<type>` names an item's readable note. A link with an empty label
33
+ * displays the target's current name.
24
34
  *
25
35
  * @module
26
36
  */
27
37
 
28
38
  import { resolveShortcodeReference } from "./shortcode-references.mjs";
29
39
 
30
- import { presentWikilink, readWikilink } from "./wikilink-syntax.mjs";
31
40
  import crypto from "crypto";
32
41
 
33
42
  import { compendiumUuid, ITEM_PACK, packForType, pageUuid, PACK_BY_TYPE } from "./ids.mjs";
34
- import { readCanonicalKey } from "./content-address.mjs";
43
+ import { expandAddress, readCanonicalKey } from "./content-address.mjs";
35
44
  // The address grammar. A wikilink *contains* an Address, so what counts as one
36
45
  // is stated there and read here — the anchor and the label are this module's,
37
46
  // and the tuple inside them is not.
@@ -40,30 +49,36 @@ import {
40
49
  resolveItemDocType,
41
50
  parseAddress,
42
51
  renderAddress,
43
- isAddressTuple,
44
52
  completeAddress,
45
53
  ownDocumentSystem,
46
54
  } from "./address.mjs";
55
+ import { collectAnchors } from "./anchors.mjs";
47
56
  import { ASSET_TYPE_NAMES } from "./asset-types.mjs";
57
+ import { resolveEmbeds } from "./content-embeds.mjs";
58
+ // One slug rule for the whole build — see `./content-slug.mjs`.
59
+ import { slugify } from "./content-slug.mjs";
48
60
  import { NOTE_SYSTEM } from "./systems.mjs";
49
- import { systemOf } from "./document-subtypes.mjs";
50
- import { KNOWN_DOCUMENT_SUBTYPE_MAPS } from "./subtype-registry.mjs";
51
61
  import { hasDocEntry, itemDocEntryId } from "./item-docs.mjs";
52
62
  import { replaceOutsideCode } from "./code-fences.mjs";
53
- // The syntax lives in `./wikilink-syntax.mjs`, so the web resolver and this
54
- // one cannot disagree about what counts as a link.
63
+ // The syntax lives in `./wikilink-syntax.mjs`, beside the vocabulary of link
64
+ // findings and the message for each, which the link checker reads too.
55
65
  import {
56
- authoredLabel,
66
+ LINK_FINDING_REASONS,
57
67
  WIKILINK,
68
+ authoredLabel,
58
69
  isEventAnchor,
59
- parseWikilink,
60
- unlabelledLinkMessage,
70
+ isSamePage,
71
+ presentWikilink,
72
+ readWikilink,
61
73
  } from "./wikilink-syntax.mjs";
62
74
 
75
+ /** The heading and anchor slug: the rule that names a page names a section. */
76
+ export { slugify };
77
+
63
78
  export { ITEM_PACK, PACK_BY_TYPE, packForType };
64
79
 
65
80
  /**
66
- * The address grammar, under the names the link resolvers hold it by.
81
+ * The address grammar, under the names the link readers hold it by.
67
82
  *
68
83
  * {@link readQualifier} reads a link target as the partial Address the matchers
69
84
  * consume, and {@link resolveItemDocType} is the `doc<type>` reading it applies.
@@ -311,16 +326,55 @@ function shortcodeCase(index, problem) {
311
326
  }
312
327
 
313
328
  /**
314
- * How an **unresolved** link renders.
329
+ * A wikilink once it is resolved, before any target writes it.
330
+ *
331
+ * This is the link's **neutral form**: what it names and how it reads, with no
332
+ * target's syntax in it. Each target writes it through its own
333
+ * {@link LINK_WRITERS} entry, so one authored link reaches a Foundry journal as
334
+ * an `@UUID` content link, a web page as a relative URL and the book as a
335
+ * cross-reference, from the same resolution.
336
+ *
337
+ * @typedef {object} ResolvedLink
338
+ * @property {"link"|"text"|"unresolved"} kind - `link`: the target has a
339
+ * destination the reader can open. `text`: the address is real and names a
340
+ * note with nothing to open — a note with no single Foundry document, or one
341
+ * a package publishing no pages holds — so the reader gets the words.
342
+ * `unresolved`: the link is a finding, and the words are kept, marked.
343
+ * @property {string} text - What the reader sees.
344
+ * @property {string} literal - The link exactly as authored.
345
+ * @property {string} target - The target as authored, `""` for a link to a
346
+ * section of the same page.
347
+ * @property {string} anchor - The `#section` named, `""` when none.
348
+ * @property {boolean} samePage - Whether it names a section of its own page.
349
+ * @property {string|undefined} address - The canonical address it names, the
350
+ * same on every target; `undefined` for a same-page link and for a target
351
+ * that is not an address.
352
+ * @property {string} [uuid] - The Foundry document or page it opens.
353
+ * @property {string} [href] - The URL it opens on the web and in the book,
354
+ * fragment included.
355
+ * @property {boolean} draft - Whether it names a note tagged `draft`.
356
+ * @property {object} [destination] - What it resolved to: an index entry,
357
+ * which decides whether the reference is concealed.
358
+ * @property {object} parsed - The link's parts, from {@link readWikilink}.
359
+ * @property {object} [finding] - For `unresolved`, what is wrong: a `reason`
360
+ * from {@link LINK_FINDING_REASONS}, the `target` the message names, and the
361
+ * fields that reason carries.
362
+ */
363
+
364
+ /**
365
+ * How an **unresolved** link renders on a page, in Foundry and on the web.
315
366
  *
316
367
  * The author's text is kept, so the sentence still reads — dropping it would
317
368
  * silently rewrite the prose. It is marked so a reader can tell that something
318
369
  * was meant to be a link, and a maintainer can find it: the appearance lives in
319
- * `scss/components/_unresolved-link.scss`, not here.
370
+ * `scss/components/_unresolved-link.scss` and in the site theme, not here.
371
+ *
372
+ * The web page renders raw HTML, so escaping is obligatory: this is the one
373
+ * path where *authored* text becomes markup rather than content.
320
374
  *
321
375
  * @param {string} text - The text to show, from the link's label or target.
322
376
  * @param {string} target - The address that resolved nowhere, for the tooltip.
323
- * @returns {string} An HTML span. The markdown renderer passes raw HTML through.
377
+ * @returns {string} An HTML span. The markdown renderers pass raw HTML through.
324
378
  */
325
379
  function unresolvedLink(text, target) {
326
380
  const esc = (v) =>
@@ -336,7 +390,7 @@ function unresolvedLink(text, target) {
336
390
  }
337
391
 
338
392
  /**
339
- * How a link to a **draft** note renders.
393
+ * How a link to a **draft** note renders, in Foundry and on the web.
340
394
  *
341
395
  * A note tagged `draft` exists so a link into it is not dead, and nothing more.
342
396
  * Unmarked, a reader follows a promising link into an empty page and an author
@@ -345,19 +399,14 @@ function unresolvedLink(text, target) {
345
399
  * **The wrapper carries the cue and nothing else.** The link itself is
346
400
  * untouched — Foundry enriches inside HTML, so the `@UUID` still becomes a live
347
401
  * content link, and the note is in the packs, the manifest and the index
348
- * exactly as any other.
349
- *
350
- * The appearance lives in `scss/components/_draft-link.scss`, beside the
351
- * unresolved-link partial, not here.
402
+ * exactly as any other. The appearance lives in
403
+ * `scss/components/_draft-link.scss` and in the site theme, not here.
352
404
  *
353
- * **Byte-identical with the site build's copy** in `web-wikilinks.mjs`, down to
354
- * the class name and the `title` wording — one authored link renders on two
355
- * surfaces, and the two builds have drifted before over exactly this kind of
356
- * detail. The argument is already-built markup and is deliberately not
357
- * escaped; the *authored* text inside it was escaped, or made into a link, by
358
- * whichever resolver called this.
405
+ * The argument is already-built markup and is deliberately not escaped; the
406
+ * *authored* text inside it was escaped, or made into a link, by the writer
407
+ * that called this.
359
408
  *
360
- * @param {string} inner - The resolved link, as that build emits it.
409
+ * @param {string} inner - The resolved link, as the target writes it.
361
410
  * @returns {string} An inline HTML span wrapping it.
362
411
  */
363
412
  function draftLink(inner) {
@@ -365,19 +414,397 @@ function draftLink(inner) {
365
414
  }
366
415
 
367
416
  /**
368
- * Rewrites every wikilink in a markdown body as a Foundry UUID enricher.
417
+ * How each target writes a {@link ResolvedLink}.
369
418
  *
370
- * A link that cannot be resolved is left exactly as it was and reported in
371
- * `unresolved`, so a content gap degrades to visible literal text rather than
372
- * a broken link or a failed build.
419
+ * | | `foundry` | `web` | `book` |
420
+ * | --- | --- | --- | --- |
421
+ * | a link | `@UUID[…]{text}`, a content link that survives import into a world | `[text](url)`, the page's relative URL | `[text](url)`, which the book sets as a cross-reference to the entry it prints, or as the page's address where it prints none |
422
+ * | a draft target | the link in a `sohl-draft-link` span | the same span | the link, then `(draft)` |
423
+ * | nothing to open | the text | the text, marked when the target is a draft | the text, then `(draft)` when the target is one |
424
+ * | unresolved | the text in a `sohl-unresolved-link` span | the same span | the text, then `(unresolved link)` |
425
+ *
426
+ * A link to a GM-only note, or to a secret section, is concealed by
427
+ * {@link presentWikilink} on every target alike.
428
+ *
429
+ * Foundry does not mark a link to a note another package publishes as a
430
+ * draft: its `@UUID` names the document the dependency installed, and the
431
+ * dependency's journal is what the reader opens.
432
+ *
433
+ * @type {Readonly<Record<"foundry"|"web"|"book", (link: ResolvedLink) => string>>}
434
+ */
435
+ export const LINK_WRITERS = Object.freeze({
436
+ foundry(link) {
437
+ if (link.kind === "unresolved") return unresolvedLink(link.text, link.finding.target);
438
+ if (link.kind === "text") return presentWikilink(link.text, link.parsed, link.destination);
439
+ const reference = `@UUID[${link.uuid}]{${link.text}}`;
440
+ return presentWikilink(
441
+ link.draft ? draftLink(reference) : reference,
442
+ link.parsed,
443
+ link.destination,
444
+ );
445
+ },
446
+ web(link) {
447
+ if (link.kind === "unresolved") return unresolvedLink(link.text, link.finding.target);
448
+ const reference = link.kind === "link" ? `[${link.text}](${link.href})` : link.text;
449
+ return presentWikilink(
450
+ link.draft ? draftLink(reference) : reference,
451
+ link.parsed,
452
+ link.destination,
453
+ );
454
+ },
455
+ book(link) {
456
+ if (link.kind === "unresolved") return `${link.text} (unresolved link)`;
457
+ const reference = link.kind === "link" ? `[${link.text}](${link.href})` : link.text;
458
+ return presentWikilink(
459
+ link.draft ? `${reference} (draft)` : reference,
460
+ link.parsed,
461
+ link.destination,
462
+ );
463
+ },
464
+ });
465
+
466
+ /**
467
+ * What one index answers a link with: the half of resolution that depends on
468
+ * which index is read. A pack compile reads {@link buildWikilinkIndex}'s
469
+ * index and answers with UUIDs ({@link packLinkView}); the website and the
470
+ * book read the site index and answer with URLs ({@link siteLinkView}).
471
+ * Everything else — the syntax, the label rule, which failure is which, and
472
+ * the order the checks run in — is {@link resolveWikilinks}'s, once.
473
+ *
474
+ * @typedef {object} LinkView
475
+ * @property {object} context - The Address context a target is read in:
476
+ * `{package, system, types, packages, noIndexPackages}`.
477
+ * @property {(markdown: string) => object} self - The page the link is written
478
+ * on, as a destination.
479
+ * @property {(read: object, target: string) => ({destination: object,
480
+ * itemDoc?: boolean, foreign?: boolean}|{ambiguous: true, packages?:
481
+ * string[]}|undefined)} find - What an address names: a destination, which
482
+ * may be an item's readable note or another package's note; a claim by more
483
+ * than one package; or nothing.
484
+ * @property {(read: object, target: string) => boolean} collides - Whether a
485
+ * target that is not an address is a short address two packages claim.
486
+ * @property {(read: object) => boolean} declared - Whether a lowercase
487
+ * spelling of the shortcode names a note.
488
+ * @property {(destination: object, anchor: string, how: {samePage: boolean,
489
+ * itemDoc?: boolean, foreign?: boolean}) => {reason?: string, uuid?: string, href?: string,
490
+ * draft?: boolean}} locate - Where a destination opens: a finding reason
491
+ * when the anchor does not belong to it, the UUID or URL it opens, or
492
+ * neither when there is nothing to open.
493
+ */
494
+
495
+ /**
496
+ * The reasons a target can fail to parse for, as a finding names them. A
497
+ * reason the address grammar gives that the finding vocabulary has no entry
498
+ * for means the target is not an address.
499
+ *
500
+ * @param {string|undefined} reason - The address grammar's reason.
501
+ * @returns {string} A reason from {@link LINK_FINDING_REASONS}.
502
+ */
503
+ function parseFailure(reason) {
504
+ return (
505
+ reason &&
506
+ reason !== "unresolved" &&
507
+ reason !== "ambiguous" &&
508
+ LINK_FINDING_REASONS.has(reason)
509
+ ) ?
510
+ reason
511
+ : "not-an-address";
512
+ }
513
+
514
+ /**
515
+ * Resolve one authored wikilink against a view of an index.
516
+ *
517
+ * @param {string} literal - The whole link, as authored.
518
+ * @param {string} rawInner - Its interior.
519
+ * @param {LinkView} view - The index's answers.
520
+ * @param {object} opts
521
+ * @param {string} opts.markdown - The text the link sits in.
522
+ * @param {Map<string, string>} [opts.captionLabels] - Numbered caption labels
523
+ * by anchor, which an empty-labelled link to a figure on its own page shows.
524
+ * @returns {ResolvedLink} The resolution.
525
+ */
526
+ function resolveWikilink(literal, rawInner, view, { markdown, captionLabels }) {
527
+ const parsed = readWikilink(rawInner, view.context, literal);
528
+ const target = parsed.rawTarget;
529
+ const anchor = parsed.anchor || "";
530
+ // An empty label is not a label: `[[x|]]` addresses the target and shows
531
+ // its name. One reading, from {@link authoredLabel}.
532
+ const label = authoredLabel(parsed);
533
+ const samePage = isSamePage({ target, anchor });
534
+ const read = samePage ? null : (parsed.problem ?? parsed.target);
535
+ const base = {
536
+ literal,
537
+ target,
538
+ anchor,
539
+ samePage,
540
+ address: read && !read.reason ? renderAddress(read) : undefined,
541
+ parsed,
542
+ draft: false,
543
+ };
544
+ const unresolved = (finding, text) => ({
545
+ ...base,
546
+ kind: "unresolved",
547
+ text,
548
+ finding: { target, ...finding },
549
+ });
550
+
551
+ if (parsed.attributes?.problems.length)
552
+ return unresolved(
553
+ { reason: "invalid-attributes", problems: parsed.attributes.problems },
554
+ label ?? target,
555
+ );
556
+
557
+ // **Every link carries a label**. Without one there is nothing to resolve
558
+ // against, and nothing to show either, a shortcode being an address rather
559
+ // than prose. Reported before the same-page form, because the rule is
560
+ // about how the link is *written*: `[[#slug]]` needs the pipe exactly as
561
+ // `[[skill-clmb]]` does.
562
+ if (!parsed.labelled)
563
+ return unresolved({ target: parsed.inner, reason: "unlabelled" }, parsed.inner);
564
+
565
+ let found;
566
+ if (samePage) {
567
+ found = { destination: view.self(markdown) };
568
+ } else {
569
+ // A target that does not parse as an address is a defect: there is no
570
+ // second namespace left to fall through to.
571
+ if (!read || read.reason) {
572
+ if (view.collides(read, target))
573
+ return unresolved({ reason: "ambiguous" }, label ?? target);
574
+ // A capital in the shortcode is refused, never lowercased. The
575
+ // lowercase spelling is looked up only to name it in the finding.
576
+ if (read?.reason === "shortcode-not-lowercase")
577
+ return unresolved(
578
+ {
579
+ reason: "shortcode-not-lowercase",
580
+ shortcode: read.shortcode,
581
+ ...(view.declared(read.lowercase) ?
582
+ { declared: read.lowercase.shortcode }
583
+ : {}),
584
+ },
585
+ label ?? target,
586
+ );
587
+ return unresolved({ reason: parseFailure(read?.reason) }, label ?? target);
588
+ }
589
+ found = view.find(read, target);
590
+ // Two packages publish the short address, so it names neither. Its own
591
+ // class: the fix is the package-qualified form, not a corrected
592
+ // shortcode.
593
+ if (found?.ambiguous)
594
+ return unresolved(
595
+ {
596
+ reason: "ambiguous",
597
+ ...(found.packages ? { packages: found.packages } : {}),
598
+ },
599
+ label ?? target,
600
+ );
601
+ // An address that resolves nowhere is a typo or an undeclared
602
+ // dependency: every package it could name is either built here or
603
+ // fetched, so there is no third possibility left.
604
+ if (!found) return unresolved({ reason: "unresolved" }, label ?? target);
605
+ }
606
+
607
+ const { destination, itemDoc, foreign } = found;
608
+ // An address with an *empty* label has no prose to show, a shortcode not
609
+ // being display text, so the target's **current** name stands in and a
610
+ // rename shows at every citation with no link edited. A link to a
611
+ // captioned item on its own page shows the item's number.
612
+ const named =
613
+ samePage ?
614
+ (captionLabels?.get(anchor) ?? destination.name ?? anchor)
615
+ : (destination.name ?? target);
616
+ const located = view.locate(destination, anchor, { samePage, itemDoc, foreign });
617
+ if (located.reason)
618
+ return unresolved(
619
+ { reason: located.reason, anchor },
620
+ label ?? (samePage ? anchor : (destination.name ?? target)),
621
+ );
622
+ const text = label ?? named;
623
+ const opens = located.uuid ?? located.href;
624
+ return {
625
+ ...base,
626
+ kind: opens === undefined ? "text" : "link",
627
+ text,
628
+ ...(located.uuid !== undefined ? { uuid: located.uuid } : {}),
629
+ ...(located.href !== undefined ? { href: located.href } : {}),
630
+ // A link into a note that exists but is not written renders marked.
631
+ // Presentation only — the address is unchanged — and a link to a
632
+ // section of its own page is not marked, because the reader is already
633
+ // in the note it would be telling them about.
634
+ draft: !samePage && Boolean(located.draft),
635
+ destination,
636
+ };
637
+ }
638
+
639
+ /**
640
+ * Resolve every wikilink in a markdown body, and write each in one target's
641
+ * form.
642
+ *
643
+ * The one link resolver. The two indexes a link is read against differ, and
644
+ * only that differs, so the index is a {@link LinkView} and the target a
645
+ * writer: {@link convertWikilinks} and {@link resolveWebWikilinks} are this,
646
+ * with their own view and writer.
373
647
  *
374
648
  * **Code is verbatim.** A `[[…]]` inside a fenced or indented code block, or
375
649
  * inside an inline code span, is source text an author wrote to be read as
376
- * written, so it is left alone and not reported. Without that, a
377
- * script sample containing `grid[[0]]` became a link — and only for some
378
- * array shapes, `[[1,2],[3,4]]` having an inner `]` the pattern cannot cross,
379
- * so the corruption looked arbitrary. It reaches the reader through the
380
- * *documented* copy of a macro while the executable copy stays correct.
650
+ * written, so it is left alone and not reported. Without that, a script sample
651
+ * containing `grid[[0]]` would become a link — and only for some array shapes,
652
+ * `[[1,2],[3,4]]` having an inner `]` the pattern cannot cross.
653
+ *
654
+ * **Every failure is a finding**, classified into the vocabulary
655
+ * {@link LINK_FINDING_REASONS} names for every reader of a link. A finding
656
+ * keeps the link's `offset` in `markdown` and its `occurrence` — the how-many-th
657
+ * finding on that literal — so a caller can report the line and column it sits
658
+ * on by either.
659
+ *
660
+ * @param {string} markdown - The body.
661
+ * @param {object} opts
662
+ * @param {LinkView} opts.view - The index the links resolve against.
663
+ * @param {(link: ResolvedLink) => string} opts.write - The target's writer,
664
+ * from {@link LINK_WRITERS}.
665
+ * @param {Map<string, string>} [opts.captionLabels] - Numbered caption labels.
666
+ * @returns {{markdown: string, links: ResolvedLink[], findings: Array<{link:
667
+ * string, offset: number, occurrence: number, target: string, reason:
668
+ * string}>, resolved: object[]}} The written body; every link in body order;
669
+ * the findings; and every destination a link outside its own page resolved
670
+ * to, in body order, repeats included — the page's edges.
671
+ */
672
+ export function resolveWikilinks(markdown, { view, write, captionLabels }) {
673
+ const links = [];
674
+ const findings = [];
675
+ const resolved = [];
676
+ const seen = new Map();
677
+ const source = String(markdown ?? "");
678
+ // `offset` is the third replacer argument because the pattern has exactly
679
+ // one capture group. It is what makes two identical links on one note
680
+ // tellable apart, and a position reportable at all.
681
+ const out = replaceOutsideCode(source, WIKILINK, (literal, rawInner, offset) => {
682
+ const link = resolveWikilink(literal, rawInner, view, { markdown: source, captionLabels });
683
+ links.push(link);
684
+ if (link.finding) {
685
+ const occurrence = (seen.get(literal) ?? 0) + 1;
686
+ seen.set(literal, occurrence);
687
+ findings.push({ link: literal, offset, occurrence, ...link.finding });
688
+ } else if (!link.samePage) resolved.push(link.destination);
689
+ return write(link);
690
+ });
691
+ return { markdown: out, links, findings, resolved };
692
+ }
693
+
694
+ /**
695
+ * The pack compile's view of a link: {@link buildWikilinkIndex}'s index,
696
+ * answered with compendium UUIDs.
697
+ *
698
+ * `[[type-shortcode|Text]]` defaults to the readable `note` Address. A stated
699
+ * game system names an Actor or Item; `none` names a systemless document.
700
+ * `doc<type>` names the readable note of an item. A journal link may carry a
701
+ * heading anchor, which addresses the journal's page for it; an Actor, Item or
702
+ * Macro link opens a sheet, which has no sections, so its anchor addresses
703
+ * nothing and is dropped.
704
+ *
705
+ * @param {object} index - From {@link buildWikilinkIndex}.
706
+ * @param {object} source - The note the links are written on.
707
+ * @param {string} source.type - Its type.
708
+ * @param {string} source.id - Its document id.
709
+ * @param {string} [source.pack] - The pack its own document lands in.
710
+ * @param {string} [source.docPack] - The pack its documentation entry lands in.
711
+ * @returns {LinkView} The view.
712
+ */
713
+ export function packLinkView(index, { type, id, pack, docPack }) {
714
+ return {
715
+ context: {
716
+ package: index.contentPackage,
717
+ system: NOTE_SYSTEM,
718
+ types: index.types,
719
+ packages: index.packages,
720
+ noIndexPackages: index.noIndexPackages,
721
+ },
722
+ // The one target with no index entry of its own may be the note
723
+ // itself, so its own type, id and packs stand in for one.
724
+ self: () => index.byId?.get(`${type}/${id}`) ?? { type, id, pack, docPack },
725
+ find(read) {
726
+ // Ordinary wikilinks default to readable note content.
727
+ const baseType = resolveItemDocType(read.type, index.types);
728
+ const address = completeAddress(read, {
729
+ package: index.contentPackage,
730
+ system: NOTE_SYSTEM,
731
+ });
732
+ const itemDoc = address.system === NOTE_SYSTEM && hasDocEntry(baseType ?? read.type);
733
+ const local =
734
+ read.package === index.contentPackage ?
735
+ index.byShortcode.get(`${baseType ?? read.type}/${read.shortcode}`)
736
+ : undefined;
737
+ if (local) return { destination: local, itemDoc };
738
+ // Nothing local answers. A foreign package may publish this
739
+ // address, in which case its manifest hands back a complete UUID —
740
+ // including, for a section link, the anchor's own.
741
+ const hits = foreignHits(index, read);
742
+ if (hits.length > 1)
743
+ return { ambiguous: true, packages: hits.map((h) => h.package).filter(Boolean) };
744
+ return hits.length ? { destination: hits[0], foreign: true } : undefined;
745
+ },
746
+ collides: () => false,
747
+ declared: (read) =>
748
+ Boolean(shortcodeCase(index, { shortcode: "", lowercase: read }).declared),
749
+ locate(doc, anchor, { itemDoc = false, foreign = false } = {}) {
750
+ if (foreign) {
751
+ if (anchor && isEventAnchor(doc, anchor)) return { reason: "event-anchor" };
752
+ const uuid = anchor ? doc.anchors?.[anchor] : doc.uuid;
753
+ return uuid ? { uuid } : { reason: "unknown-anchor" };
754
+ }
755
+ // Both addresses were computed when the target was indexed. An
756
+ // item's readable note lives in the journals pack under its own
757
+ // derived entry id, and its pages hash against *that* id.
758
+ const addresses = index.uuidByDoc.get(doc) ?? {
759
+ uuid: compendiumUuid(index.packageId, doc.type, doc.id, doc.pack),
760
+ docUuid: compendiumUuid(
761
+ index.packageId,
762
+ "doc",
763
+ itemDocEntryId(doc.id),
764
+ doc.docPack,
765
+ ),
766
+ };
767
+ const entryUuid = itemDoc ? addresses.docUuid : addresses.uuid;
768
+ // The target has no single Foundry document. Its address is real,
769
+ // but a compendium has nothing to open, so the reader gets the
770
+ // label.
771
+ if (entryUuid === undefined) return {};
772
+ const entryId = itemDoc ? itemDocEntryId(doc.id) : doc.id;
773
+ const isJournal = itemDoc || packForType(doc.type).docType === "JournalEntry";
774
+ const anchorUuids = itemDoc ? addresses.docAnchors : addresses.anchors;
775
+ // A wikilink names a prose anchor; an event's id is refused here,
776
+ // before any surface renders a link to it.
777
+ if (anchor && isEventAnchor(doc, anchor)) return { reason: "event-anchor" };
778
+ // A `#section` the target declares no heading for. Checked here
779
+ // because this is the build that *emits* the link:
780
+ // `anchorPageId` hashes any slug into a page id, so an undeclared
781
+ // one compiles to a `@UUID` that dead-ends for the reader.
782
+ if (
783
+ anchor &&
784
+ isJournal &&
785
+ ((doc.anchors && !doc.anchors.has(anchor)) ||
786
+ (anchorUuids && !Object.hasOwn(anchorUuids, anchor)))
787
+ )
788
+ return { reason: "unknown-anchor" };
789
+ return {
790
+ uuid:
791
+ anchor && isJournal ?
792
+ (anchorUuids?.[anchor] ??
793
+ pageUuid(entryUuid, anchorPageId(entryId, anchor)))
794
+ : entryUuid,
795
+ draft: Boolean(doc.draft),
796
+ };
797
+ },
798
+ };
799
+ }
800
+
801
+ /**
802
+ * Rewrites every wikilink in a markdown body as a Foundry UUID enricher — see
803
+ * {@link resolveWikilinks}, with the pack compile's {@link packLinkView} and
804
+ * the `foundry` {@link LINK_WRITERS} entry.
805
+ *
806
+ * A link that cannot be resolved renders marked and is reported in
807
+ * `unresolved`.
381
808
  *
382
809
  * @param {string} markdown - The note body (frontmatter already stripped).
383
810
  * @param {object} ctx
@@ -392,250 +819,25 @@ function draftLink(inner) {
392
819
  * @param {{byShortcode: Map, types: Set}} ctx.index - From
393
820
  * {@link buildWikilinkIndex}.
394
821
  * @param {Map<string, string>} [ctx.captionLabels] - Numbered caption references.
395
- * @returns {{markdown: string, unresolved: Array<{link: string, target: string,
396
- * offset: number, reason: string, packages?: string[], anchor?: string}>}}
397
- * Each `reason` is one of {@link LINK_FINDING_REASONS}, the vocabulary all
398
- * three resolvers share — `ambiguous` carries the claiming `packages`
822
+ * @returns {{markdown: string, links: ResolvedLink[], unresolved: Array<{link:
823
+ * string, target: string, offset: number, reason: string, packages?:
824
+ * string[], anchor?: string}>}} Each `reason` is one of
825
+ * {@link LINK_FINDING_REASONS} — `ambiguous` carries the claiming `packages`
399
826
  * and `unknown-anchor` the section it named. `offset` is the link's 0-based
400
827
  * position in `markdown`, which is what lets a caller report the line and
401
828
  * column it sits on.
402
829
  */
403
830
  export function convertWikilinks(markdown, { type, id, pack, docPack, index, captionLabels }) {
404
- const unresolved = [];
405
-
406
- // `offset` is the third replacer argument because the pattern has exactly
407
- // one capture group. It is what makes two identical unresolved links on
408
- // one note tellable apart, and a position reportable at all.
409
- const out = replaceOutsideCode(markdown, WIKILINK, (all, rawInner, offset) => {
410
- const parsed = readWikilink(
411
- rawInner,
412
- {
413
- package: index.contentPackage,
414
- system: "note",
415
- types: index.types,
416
- packages: index.packages,
417
- noIndexPackages: index.noIndexPackages,
418
- },
419
- all,
420
- );
421
- const target = parsed.rawTarget;
422
- const slug = parsed.anchor || null;
423
- if (parsed.attributes?.problems.length) {
424
- unresolved.push({
425
- link: all,
426
- target,
427
- offset,
428
- reason: "invalid-attributes",
429
- problems: parsed.attributes.problems,
430
- });
431
- return unresolvedLink(authoredLabel(parsed) ?? target, target);
432
- }
433
-
434
- // **Every link carries a label**. Without one there is nothing
435
- // to resolve against: a shortcode is an address rather than prose, so
436
- // the link has neither a target this build can find nor text to show.
437
- // Reported before anything else, including the same-page form, because
438
- // it is a statement about how the link is *written* — `[[#slug]]` needs
439
- // the pipe exactly as `[[skill-clmb]]` does.
440
- if (!parsed.labelled) {
441
- unresolved.push({
442
- link: all,
443
- target: target || (slug ? `#${slug}` : ""),
444
- offset,
445
- reason: "unlabelled",
446
- addressed: false,
447
- });
448
- return unresolvedLink(parsed.inner, parsed.inner);
449
- }
450
-
451
- // An *empty* label is not a label — `[[x|]]` means "show the target's
452
- // name" — and that reading comes from {@link authoredLabel} so the web
453
- // resolver cannot draw the line somewhere else.
454
- let text = authoredLabel(parsed) ?? "";
455
-
456
- // Resolve the document: the source note itself for an empty target, or
457
- // the address the target parses as.
458
- let doc;
459
- // Set when the qualifier was the virtual `doc<type>` form, so the UUID
460
- // is built against the item doc entry rather than the item itself.
461
- let itemDoc = false;
462
- // Kept for the foreign fallback below, which needs the parsed address.
463
- let qualifiedRead = null;
464
- if (target === "" && slug) {
465
- doc = index.byId?.get(`${type}/${id}`) ?? { type, id, pack, docPack };
466
- } else {
467
- const qualified = parsed.problem ?? parsed.target;
468
- qualifiedRead = qualified;
469
- // A target that does not parse as an address is a defect: there is
470
- // no second namespace left to fall through to.
471
- if (!qualified || qualified.reason) {
472
- unresolved.push({
473
- link: all,
474
- target,
475
- offset,
476
- reason: qualified?.reason ?? "not-an-address",
477
- ...(qualified?.reason === "shortcode-not-lowercase" ?
478
- shortcodeCase(index, qualified)
479
- : {}),
480
- addressed: true,
481
- });
482
- return unresolvedLink(text || target, target);
483
- }
484
- // Ordinary wikilinks default to readable note content.
485
- const baseType = resolveItemDocType(qualified.type, index.types);
486
- const address = completeAddress(qualified, {
487
- package: index.contentPackage,
488
- system: NOTE_SYSTEM,
489
- });
490
- itemDoc = address.system === NOTE_SYSTEM && hasDocEntry(baseType ?? qualified.type);
491
- if (qualified.package === index.contentPackage)
492
- doc = index.byShortcode.get(`${baseType ?? qualified.type}/${qualified.shortcode}`);
493
- }
494
- if (!doc) {
495
- // Nothing local answers. A foreign package may publish this
496
- // address, in which case the manifest hands back a complete UUID —
497
- // including, for a section link, the anchor's own — so nothing is
498
- // derived here.
499
- const hits = foreignHits(index, qualifiedRead);
500
- if (hits.length > 1) {
501
- // Two packages publish the short address, so it names neither.
502
- // Its own class: the fix is the package-qualified form, not a
503
- // corrected shortcode.
504
- unresolved.push({
505
- link: all,
506
- target,
507
- offset,
508
- reason: "ambiguous",
509
- packages: hits.map((h) => h.package).filter(Boolean),
510
- addressed: true,
511
- });
512
- return unresolvedLink(text || target, target);
513
- }
514
- const hit = hits[0] ?? null;
515
- if (hit && slug && isEventAnchor(hit, slug)) {
516
- unresolved.push({
517
- link: all,
518
- target,
519
- offset,
520
- reason: "event-anchor",
521
- anchor: slug,
522
- addressed: true,
523
- });
524
- return unresolvedLink(text || target, target);
525
- }
526
- if (hit) {
527
- const uuid = slug ? hit.anchors?.[slug] : hit.uuid;
528
- if (uuid) {
529
- return presentWikilink(
530
- `@UUID[${uuid}]{${text || hit.name || target}}`,
531
- parsed,
532
- hit,
533
- );
534
- }
535
- unresolved.push({
536
- link: all,
537
- target,
538
- offset,
539
- reason: "unknown-anchor",
540
- anchor: slug,
541
- addressed: true,
542
- });
543
- return unresolvedLink(text || target, target);
544
- }
545
- unresolved.push({
546
- link: all,
547
- target,
548
- offset,
549
- reason: "unresolved",
550
- // An address that resolves nowhere is a typo: every package it
551
- // could name is either built here or vendored, so there is no
552
- // third possibility left.
553
- addressed: true,
554
- });
555
- return unresolvedLink(text || target, target);
556
- }
557
-
558
- // An address with an *empty* label — `[[skill-clmb|]]` — has no prose
559
- // to show, a shortcode being an address rather than display text, so
560
- // the document's **current** name stands in and a rename shows at every
561
- // citation with no link edited. The knowledgebase build reads
562
- // the same authored link the same way.
563
- if (!text)
564
- text = (target === "" && slug ? captionLabels?.get(slug) : null) ?? doc.name ?? target;
565
-
566
- // Both addresses were computed when the target was indexed. An item
567
- // doc lives in the journals pack under its own derived entry id, and
568
- // its pages hash against *that* id — not the item's.
569
- //
570
- // The one target with no index entry is the note itself: a `[[#slug]]`
571
- // self-link is resolved from the source's own type and id, which the
572
- // caller supplied, so it is addressed the same way here.
573
- const addresses = index.uuidByDoc.get(doc) ?? {
574
- uuid: compendiumUuid(index.packageId, doc.type, doc.id, doc.pack),
575
- docUuid: compendiumUuid(index.packageId, "doc", itemDocEntryId(doc.id), doc.docPack),
576
- };
577
- const entryUuid = itemDoc ? addresses.docUuid : addresses.uuid;
578
- // The target has no single Foundry document. Its address is real, but
579
- // a compendium has nothing to open, so the reader gets the label.
580
- if (entryUuid === undefined) return presentWikilink(text, parsed, doc);
581
- const entryId = itemDoc ? itemDocEntryId(doc.id) : doc.id;
582
- const isJournal = itemDoc || packForType(doc.type).docType === "JournalEntry";
583
- // A JournalEntry link opens a journal — at its first page, or at the
584
- // page an anchor names. An Item or Actor link opens that document's
585
- // *sheet*, which has no sections, so the anchor has nothing to address
586
- // and is dropped. Forging a JournalEntryPage id onto a document that
587
- // can never hold one is what made such links dead-end; an
588
- // an item's pages are addressed through its `note` Address.
589
- // A `#section` the target declares no heading for. Checked here, and
590
- // not only by `package-build links`, because this is the build that
591
- // *emits* the link: `anchorPageId` will hash any slug into a page id,
592
- // so an undeclared one compiles to a `@UUID` that dead-ends for the
593
- // reader. A foreign anchor has always been checked this way —
594
- // the manifest carries the map — and a local one now is too, from the
595
- // anchor set the index carries.
596
- const anchorUuids = itemDoc ? addresses.docAnchors : addresses.anchors;
597
- // A wikilink names a prose anchor; an event's id is refused here, before
598
- // any surface renders a link to it.
599
- if (slug && isEventAnchor(doc, slug)) {
600
- unresolved.push({
601
- link: all,
602
- target,
603
- offset,
604
- reason: "event-anchor",
605
- anchor: slug,
606
- addressed: true,
607
- });
608
- return unresolvedLink(text || doc.name || target, target);
609
- }
610
- if (
611
- slug &&
612
- isJournal &&
613
- ((doc.anchors && !doc.anchors.has(slug)) ||
614
- (anchorUuids && !Object.hasOwn(anchorUuids, slug)))
615
- ) {
616
- unresolved.push({
617
- link: all,
618
- target,
619
- offset,
620
- reason: "unknown-anchor",
621
- anchor: slug,
622
- addressed: true,
623
- });
624
- return unresolvedLink(text || doc.name || target, target);
625
- }
626
- const uuid =
627
- slug && isJournal ?
628
- (anchorUuids?.[slug] ?? pageUuid(entryUuid, anchorPageId(entryId, slug)))
629
- : entryUuid;
630
- const link = `@UUID[${uuid}]{${text}}`;
631
- // A link into a note that exists but is not written renders marked.
632
- // Presentation only — the UUID above is unchanged, and a
633
- // `[[#slug]]` self-link is not marked because the reader is already in
634
- // the note it would be telling them about.
635
- return presentWikilink(doc.draft ? draftLink(link) : link, parsed, doc);
831
+ const result = resolveWikilinks(markdown, {
832
+ view: packLinkView(index, { type, id, pack, docPack }),
833
+ write: LINK_WRITERS.foundry,
834
+ captionLabels,
636
835
  });
637
-
638
- return { markdown: out, unresolved };
836
+ return {
837
+ markdown: result.markdown,
838
+ links: result.links,
839
+ unresolved: result.findings.map(({ occurrence, ...finding }) => finding),
840
+ };
639
841
  }
640
842
 
641
843
  /**
@@ -714,3 +916,280 @@ export function resolveReference(index, ref, hint) {
714
916
  }
715
917
  return undefined;
716
918
  }
919
+
920
+ /**
921
+ * Whether a page's declared anchors include the slug a link names.
922
+ *
923
+ * A local page carries a `Set` built by {@link module:engine/site-index}.
924
+ * A foreign page declares prose anchors in its note metadata, independently
925
+ * of whether it has a Foundry target map. An empty collection declares none.
926
+ * An absent collection leaves the target unchecked.
927
+ *
928
+ * @param {Set<string>|Record<string, string>|Array<{slug: string, kind: string}>|undefined} anchors - The page's
929
+ * declared anchors, or `undefined` where none were recorded.
930
+ * @param {string} slug - The anchor a link names.
931
+ * @returns {boolean} Whether the anchor is declared, or `true` when `anchors`
932
+ * is `undefined` — which reads as "nothing to check against" rather than
933
+ * "declares none".
934
+ */
935
+ function hasAnchor(anchors, slug) {
936
+ if (!anchors) return true;
937
+ if (Array.isArray(anchors))
938
+ return anchors.some((anchor) => anchor.kind === "prose" && anchor.slug === slug);
939
+ return anchors instanceof Set ? anchors.has(slug) : Object.hasOwn(anchors, slug);
940
+ }
941
+
942
+ /**
943
+ * The site index entry an address names, or `undefined`.
944
+ *
945
+ * Every omitted segment defaults from where the link is written — the package
946
+ * from the citing package, the system from the block, which on a page body is
947
+ * `note` — so the target expands to exactly one canonical address and this is
948
+ * a plain lookup: one key names one entry.
949
+ *
950
+ * A local target may also be keyed by its short form. On the web that is not a
951
+ * second answer: an item note renders as **one page** which is its own
952
+ * documentation, so `skill/climb`, `docskill/climb` and both canonical
953
+ * addresses are all the same value.
954
+ *
955
+ * @param {Map<string, object>} index - The page index to look in.
956
+ * @param {object|null} read - The parsed target.
957
+ * @param {string} contentPackage - The citing package, which an omitted package
958
+ * segment defaults from.
959
+ * @returns {object|undefined} The indexed page, or `undefined` when the target
960
+ * is not an address or nothing publishes it.
961
+ */
962
+ function lookupRead(index, read, contentPackage) {
963
+ if (!read || read.reason) return undefined;
964
+ const key = expandAddress(read, { package: contentPackage, system: NOTE_SYSTEM });
965
+ const found = index.get(key);
966
+ if (found || read.package) return found;
967
+ return (
968
+ index.get(`${read.itemDoc ? "doc" : ""}${read.type}/${read.shortcode}`.toLowerCase()) ??
969
+ undefined
970
+ );
971
+ }
972
+
973
+ /**
974
+ * The website's and the book's view of a link: the site index, answered with
975
+ * URLs.
976
+ *
977
+ * The site index is keyed case-insensitively by the canonical
978
+ * `package-system-type-shortcode` address, by `type/shortcode` and by the
979
+ * site's own `section/slug`; a fully qualified address another package
980
+ * publishes is looked up in its fetched index. Local wins: a live build is
981
+ * authoritative and a fetched index can only be staler. A short address two
982
+ * packages claim is in the index's `ambiguous` set, and is a finding of its
983
+ * own.
984
+ *
985
+ * A target that **resolved** to an entry with no page is not unresolved: a
986
+ * pack-only package publishes Foundry addresses and no web pages, so the
987
+ * author wrote a real address and there is simply nothing to link to.
988
+ *
989
+ * @param {object} ctx - What {@link module:engine/site-index.wikiContext}
990
+ * returns, with the page's own `anchors` and `eventAnchors`.
991
+ * @param {string} body - The page's text, which a link to its own section is
992
+ * concealed by when the section is secret.
993
+ * @returns {LinkView} The view.
994
+ */
995
+ export function siteLinkView(ctx, body) {
996
+ let ownAnchors = ctx.noteAnchors;
997
+ // The bucket an ambiguity is recorded under is the *short* address — the
998
+ // only form two packages can collide on, since a qualified one names its
999
+ // package.
1000
+ const collideKey = (read, target) =>
1001
+ read && !read.reason && read.package === ctx.contentPackage ?
1002
+ `${read.type}/${read.shortcode}`.toLowerCase()
1003
+ : target.toLowerCase();
1004
+ const lookup = (read) =>
1005
+ lookupRead(ctx.index, read, ctx.contentPackage) ??
1006
+ // A short form names *this* package, so it never reaches a fetched
1007
+ // index; only a fully qualified address does.
1008
+ (ctx.foreign && read?.package ?
1009
+ lookupRead(ctx.foreign, read, ctx.contentPackage)
1010
+ : undefined);
1011
+ return {
1012
+ context: {
1013
+ package: ctx.contentPackage,
1014
+ system: NOTE_SYSTEM,
1015
+ types: ctx.contentTypes ?? new Set(),
1016
+ packages: ctx.packages,
1017
+ noIndexPackages: ctx.noIndexPackages,
1018
+ },
1019
+ self: () => ({ noteAnchors: (ownAnchors ??= collectAnchors(body)) }),
1020
+ find(read, target) {
1021
+ const hit = lookup(read);
1022
+ if (hit) return { destination: hit };
1023
+ return ctx.collide?.has(collideKey(read, target)) ? { ambiguous: true } : undefined;
1024
+ },
1025
+ collides: (read, target) => Boolean(ctx.collide?.has(collideKey(read, target))),
1026
+ declared: (read) => Boolean(lookup(read)),
1027
+ locate(hit, anchor, { samePage }) {
1028
+ // Checked against this page's own anchors, exactly as a link into
1029
+ // another page is: a self-link naming a section the page does not
1030
+ // declare is as dead as one naming a section nowhere does.
1031
+ if (samePage) {
1032
+ if (isEventAnchor(ctx, anchor)) return { reason: "event-anchor" };
1033
+ if (!hasAnchor(ctx.anchors, anchor)) return { reason: "unknown-anchor" };
1034
+ return { href: `#${slugify(anchor)}` };
1035
+ }
1036
+ if (anchor && isEventAnchor(hit, anchor)) return { reason: "event-anchor" };
1037
+ // A `#section` the target declares no heading for joins onto the
1038
+ // URL unchecked otherwise, and dead-ends for the reader.
1039
+ if (anchor && !hasAnchor(hit.noteAnchors ?? hit.anchors, anchor))
1040
+ return { reason: "unknown-anchor" };
1041
+ // A pack-only package's entries carry no URL: the reader gets the
1042
+ // text and no href, rather than `[Name](undefined)`.
1043
+ return {
1044
+ ...(hit.url ? { href: anchor ? `${hit.url}#${slugify(anchor)}` : hit.url } : {}),
1045
+ draft: Boolean(hit.draft),
1046
+ };
1047
+ },
1048
+ };
1049
+ }
1050
+
1051
+ /**
1052
+ * A wikilink, as it is written, anywhere in a value that is not markdown.
1053
+ *
1054
+ * Deliberately its own pattern rather than the body resolver's: nothing here is
1055
+ * markdown, so there is no fence or code span to step around, and a frontmatter
1056
+ * value is a single line by construction.
1057
+ */
1058
+ // The same syntax the body scan reads — see `./wikilink-syntax.mjs`.
1059
+ const FRONTMATTER_WIKILINK = new RegExp(WIKILINK.source, "g");
1060
+
1061
+ /**
1062
+ * Every wikilink authored inside a frontmatter value.
1063
+ *
1064
+ * Wikilinks are resolved in a note's **body**. Frontmatter is not markdown and
1065
+ * is never walked by the resolver, so a link written in one would be copied
1066
+ * through verbatim and reach the reader as literal `[[…]]` text, in whatever the
1067
+ * theme renders that field as (an infobox row, a description, a card
1068
+ * subtitle). Nothing downstream notices: the value is a valid string, the page
1069
+ * builds, and the defect is visible only to someone who looks at it.
1070
+ *
1071
+ * So the form is refused rather than resolved. Resolving it would mean choosing
1072
+ * an output syntax for a field whose renderer is unknown to this build — a
1073
+ * markdown link is inert in a template that prints the value as text, and an
1074
+ * `<a>` is unusable in one that escapes it — and would quietly bless an
1075
+ * authoring habit that the pack build has no way to honour at all. Frontmatter
1076
+ * carries data; a link belongs in prose.
1077
+ *
1078
+ * Values are read from the *parsed* frontmatter, so a `[[` inside a YAML comment
1079
+ * is not a hit, and every hit can be named by the path a reader would look at.
1080
+ *
1081
+ * @param {unknown} fm - Parsed frontmatter, as `readMatter` returns it.
1082
+ * @returns {Array<{path: string, link: string}>} In reading order; `path` is the
1083
+ * dotted key path of the offending value (`government.summary`, `aliases.1`).
1084
+ */
1085
+ export function frontmatterWikilinks(fm) {
1086
+ const hits = [];
1087
+ const visit = (value, trail) => {
1088
+ if (typeof value === "string") {
1089
+ for (const m of value.matchAll(FRONTMATTER_WIKILINK)) {
1090
+ hits.push({ path: trail, link: m[0] });
1091
+ }
1092
+ } else if (Array.isArray(value)) {
1093
+ value.forEach((v, i) => visit(v, `${trail}.${i}`));
1094
+ } else if (isPlainMap(value)) {
1095
+ for (const [k, v] of Object.entries(value)) {
1096
+ visit(v, trail ? `${trail}.${k}` : k);
1097
+ }
1098
+ }
1099
+ };
1100
+ if (!isPlainMap(fm)) return hits;
1101
+ visit(fm, "");
1102
+ return hits;
1103
+ }
1104
+
1105
+ /**
1106
+ * Whether a value is a YAML mapping rather than a scalar the parser built into
1107
+ * an object of its own (a `Date`, which is what an unquoted date becomes).
1108
+ *
1109
+ * @param {unknown} value
1110
+ * @returns {boolean}
1111
+ */
1112
+ function isPlainMap(value) {
1113
+ return (
1114
+ typeof value === "object" &&
1115
+ value !== null &&
1116
+ !Array.isArray(value) &&
1117
+ !(value instanceof Date)
1118
+ );
1119
+ }
1120
+
1121
+ /**
1122
+ * Rewrites the embeds and wikilinks in a markdown body for the website or the
1123
+ * book — see {@link resolveWikilinks}, with {@link siteLinkView} and the `web`
1124
+ * or `book` {@link LINK_WRITERS} entry.
1125
+ *
1126
+ * `[[type-shortcode|Text]]` → `[Text](/section/slug/)`
1127
+ * `[[type-shortcode|]]` → the same, showing the target's own name
1128
+ * `[[type-shortcode#slug|Text]]` → `[Text](/section/slug/#slug)`
1129
+ * `[[#slug|Text]]` → `[Text](#slug)`
1130
+ *
1131
+ * **Every target that resolves nowhere fails the build.** Failures are
1132
+ * collected in `ctx.errors`, each carrying the authored `link` and its
1133
+ * `occurrence` so a caller can report the line and column it sits on. The
1134
+ * link still renders, marked: the mark is for whoever reads the page, the
1135
+ * failure for its author.
1136
+ *
1137
+ * ## Embeds first, and they are not links
1138
+ *
1139
+ * `![[address|label]]` names a file rather than a note, so it is resolved into
1140
+ * an ordinary markdown image before anything looks for a link — which is what
1141
+ * stops the link pass reading an embed's interior as a link to a note nobody
1142
+ * wrote. The image the rewrite leaves behind is what
1143
+ * {@link module:engine/content-images.renderImageFigures} turns into a figure
1144
+ * for the website, and what the book reads its staging list out of.
1145
+ *
1146
+ * ## Every hit is recorded, so the page's edges come out of one pass
1147
+ *
1148
+ * A caller that hands in `ctx.resolved` is given every index entry a link on
1149
+ * the page resolved to, in body order, repeats included. Nothing is recorded
1150
+ * for a same-page anchor or for a target that resolved nowhere.
1151
+ *
1152
+ * @param {string} body - The markdown body.
1153
+ * @param {object} ctx - `{ index, assets, collide, contentTypes, packages,
1154
+ * noIndexPackages, foreign, errors, src, file, resolved, output, anchors,
1155
+ * eventAnchors, captionLabels }`, as
1156
+ * {@link module:engine/site-index.wikiContext} builds it. `output` is
1157
+ * `"book"` for the book and defaults to the website. `anchors` and
1158
+ * `eventAnchors` are what *this* page declares, which a `[[#slug]]`
1159
+ * self-link is checked against; absent, a self-link is not checked. `src`
1160
+ * is the page's display path and `file` the source file a diagnostic names
1161
+ * — absent, `src` stands in.
1162
+ * @returns {string} The body with embeds and wikilinks rewritten.
1163
+ */
1164
+ export function resolveWebWikilinks(body, ctx) {
1165
+ const seen = new Map();
1166
+ const record = (literal, finding) => {
1167
+ const occurrence = (seen.get(literal) ?? 0) + 1;
1168
+ seen.set(literal, occurrence);
1169
+ ctx.errors.push({
1170
+ file: ctx.file ?? ctx.src,
1171
+ src: ctx.src,
1172
+ link: literal,
1173
+ occurrence,
1174
+ ...finding,
1175
+ });
1176
+ };
1177
+ // An embed names a file, so it resolves before anything looks for a link.
1178
+ // Its findings are recorded rather than rendered: the rewrite already
1179
+ // leaves an embed it cannot resolve exactly as authored.
1180
+ const embedded = resolveEmbeds(body, { index: ctx.assets });
1181
+ for (const u of embedded.unresolved) {
1182
+ record(u.link, { target: u.target, reason: u.reason, ...(u.type ? { type: u.type } : {}) });
1183
+ }
1184
+ for (const problem of embedded.problems) {
1185
+ record(problem.link, { target: problem.link, message: problem.message });
1186
+ }
1187
+ const result = resolveWikilinks(embedded.markdown, {
1188
+ view: siteLinkView(ctx, body),
1189
+ write: ctx.output === "book" ? LINK_WRITERS.book : LINK_WRITERS.web,
1190
+ captionLabels: ctx.captionLabels,
1191
+ });
1192
+ for (const { link, offset, occurrence, ...finding } of result.findings) record(link, finding);
1193
+ ctx.resolved?.push(...result.resolved);
1194
+ return result.markdown;
1195
+ }