@heroiclands/package-build 23.12.8 → 24.0.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.
- package/CHANGELOG.md +84 -0
- package/README.md +3 -2
- package/assets/preview/gitea.ini +12 -7
- package/assets/styles/content-base.css +293 -24
- package/bin/content-commands.mjs +370 -213
- package/bin/package-build-preview.mjs +2 -2
- package/bin/package-build.mjs +3 -2
- package/ci/check-banners.mjs +7 -7
- package/config.mjs +21 -6
- package/content-config.mjs +199 -80
- package/docs/api.md +110 -89
- package/docs/authoring/links-and-markup.md +469 -97
- package/docs/commands.md +259 -78
- package/docs/configuration.md +211 -103
- package/docs/content-format.md +4 -4
- package/docs/diagnostics.md +44 -2
- package/docs/getting-started.md +1 -1
- package/docs/guides/icons.md +1 -1
- package/docs/guides/module-project.md +5 -8
- package/docs/guides/release-and-deploy.md +2 -2
- package/docs/guides/site.md +112 -23
- package/docs/guides/system-project.md +1 -1
- package/docs/guides/writing-in-invented-scripts.md +1247 -0
- package/docs/index.md +1 -0
- package/docs/project-setup.md +28 -30
- package/docs/reference/format-details.md +677 -319
- package/docs/reference/note-types.md +47 -10
- package/docs/reference/theme-pages.md +178 -108
- package/docs/source-preview.md +16 -2
- package/engine/anchors.mjs +2 -2
- package/engine/asset-shipping.mjs +22 -10
- package/engine/base-compiler.mjs +62 -104
- package/engine/changelog-lint.mjs +1 -1
- package/engine/code-fences.mjs +3 -3
- package/engine/content-address.mjs +1 -1
- package/engine/content-alerts.mjs +8 -4
- package/engine/content-blocks.mjs +29 -14
- package/engine/content-embeds.mjs +1 -1
- package/engine/content-expand.mjs +376 -0
- package/engine/content-figures.mjs +18 -11
- package/engine/content-format.yaml +8 -0
- package/engine/content-glyphs.mjs +213 -0
- package/engine/content-images.mjs +6 -7
- package/engine/content-index.mjs +1 -1
- package/engine/content-links.mjs +3 -3
- package/engine/content-lint.mjs +1 -1
- package/engine/content-render.mjs +453 -0
- package/engine/content-scripts.mjs +376 -43
- package/engine/content-spans.mjs +6 -3
- package/engine/content-styles.mjs +91 -0
- package/engine/content-symbols.mjs +186 -0
- package/engine/content-verse.mjs +738 -0
- package/engine/core-report.mjs +169 -0
- package/engine/core-vocabulary.mjs +208 -0
- package/engine/dictionary-html.mjs +119 -0
- package/engine/dictionary-rows.mjs +15 -4
- package/engine/field-reference.mjs +5 -5
- package/engine/first-use.mjs +17 -27
- package/engine/frontmatter-lint.mjs +1 -1
- package/engine/heading-attributes.mjs +89 -0
- package/engine/helpers.mjs +61 -101
- package/engine/homepage.mjs +17 -29
- package/engine/html-minify.mjs +70 -0
- package/engine/index.mjs +8 -5
- package/engine/key-case-collisions.mjs +136 -0
- package/engine/language-data.mjs +203 -6
- package/engine/language-rows.mjs +8 -2
- package/engine/language-terms.mjs +186 -16
- package/engine/language-words.mjs +12 -0
- package/engine/logographic.mjs +255 -0
- package/engine/map-lexicon.mjs +262 -47
- package/engine/map-places.mjs +5 -4
- package/engine/markdown-expressions.mjs +0 -2
- package/engine/markdownlint-run.mjs +9 -3
- package/engine/markup-validation.mjs +21 -0
- package/engine/metre-declaration.mjs +134 -0
- package/engine/names.mjs +36 -9
- package/engine/note-matter.mjs +66 -0
- package/engine/note-vocabulary.mjs +187 -3
- package/engine/pdf-build.mjs +69 -133
- package/engine/pdf-render.mjs +205 -58
- package/engine/post-fields.mjs +157 -0
- package/engine/preview-index.mjs +130 -0
- package/engine/preview-source.mjs +450 -0
- package/engine/project-init.mjs +5 -12
- package/engine/prose-config.mjs +3 -3
- package/engine/read-stdin.mjs +29 -0
- package/engine/run-rows.mjs +2 -2
- package/engine/run-words.mjs +6 -112
- package/engine/script-emphasis-terms.mjs +38 -0
- package/engine/script-emphasis.mjs +295 -0
- package/engine/script-font-delivery.mjs +4 -4
- package/engine/site-build.mjs +260 -315
- package/engine/site-config.mjs +26 -238
- package/engine/site-files.mjs +175 -0
- package/engine/site-index.mjs +9 -4
- package/engine/site-preview.mjs +52 -525
- package/engine/site-render.mjs +593 -0
- package/engine/site-root.mjs +22 -13
- package/engine/site-search.mjs +161 -0
- package/engine/site-serve.mjs +273 -0
- package/engine/site-theme-banner.mjs +154 -0
- package/engine/site-theme-urls.mjs +125 -0
- package/engine/site-theme.mjs +926 -0
- package/engine/sql-tables.mjs +20 -3
- package/engine/text-maps.mjs +57 -10
- package/engine/typographer.mjs +248 -0
- package/engine/verse-metre.mjs +357 -0
- package/engine/wikilink-syntax.mjs +29 -47
- package/engine/wikilinks.mjs +758 -279
- package/engine/word-forms.mjs +364 -0
- package/engine/word-rows.mjs +12 -2
- package/engine/word-terms.mjs +38 -4
- package/manifest.mjs +3 -4
- package/package.json +11 -8
- package/{hugo-theme → site-theme}/data/banners.yaml +6 -10
- package/site-theme/layouts/base.hbs +31 -0
- package/site-theme/layouts/blog-list.hbs +30 -0
- package/site-theme/layouts/blog-post.hbs +40 -0
- package/site-theme/layouts/home.hbs +35 -0
- package/site-theme/layouts/list.hbs +20 -0
- package/site-theme/layouts/not-found.hbs +31 -0
- package/site-theme/layouts/page.hbs +7 -0
- package/site-theme/partials/copy-buttons.hbs +37 -0
- package/site-theme/partials/draft-notice.hbs +5 -0
- package/site-theme/partials/exturl.hbs +2 -0
- package/{hugo-theme/layouts/partials/footer.html → site-theme/partials/footer.hbs} +10 -7
- package/site-theme/partials/header.hbs +44 -0
- package/site-theme/partials/hero-banner.hbs +11 -0
- package/site-theme/partials/infobox-entry.hbs +2 -0
- package/site-theme/partials/infobox-section.hbs +31 -0
- package/site-theme/partials/infobox-value.hbs +2 -0
- package/site-theme/partials/infobox.hbs +18 -0
- package/site-theme/partials/list-members.hbs +9 -0
- package/site-theme/partials/nav-links.hbs +5 -0
- package/site-theme/partials/page-body.hbs +48 -0
- package/site-theme/partials/page-list.hbs +14 -0
- package/site-theme/partials/related.hbs +24 -0
- package/site-theme/partials/search-meta.hbs +6 -0
- package/site-theme/partials/search-names.hbs +2 -0
- package/site-theme/partials/search.hbs +52 -0
- package/site-theme/partials/tags.hbs +4 -0
- package/site-theme/partials/toc.hbs +7 -0
- package/{hugo-theme → site-theme}/static/css/style.css +106 -14
- package/stage.mjs +1 -2
- package/types/config.d.mts +8 -4
- package/types/content-config.d.mts +15 -15
- package/types/engine/asset-shipping.d.mts +5 -2
- package/types/engine/base-compiler.d.mts +20 -2
- package/types/engine/code-fences.d.mts +3 -3
- package/types/engine/content-address.d.mts +1 -1
- package/types/engine/content-alerts.d.mts +8 -2
- package/types/engine/content-blocks.d.mts +11 -4
- package/types/engine/content-expand.d.mts +288 -0
- package/types/engine/content-figures.d.mts +11 -3
- package/types/engine/content-glyphs.d.mts +85 -0
- package/types/engine/content-images.d.mts +6 -7
- package/types/engine/content-links.d.mts +1 -1
- package/types/engine/content-render.d.mts +199 -0
- package/types/engine/content-scripts.d.mts +70 -4
- package/types/engine/content-spans.d.mts +1 -1
- package/types/engine/content-styles.d.mts +33 -0
- package/types/engine/content-symbols.d.mts +35 -0
- package/types/engine/content-verse.d.mts +240 -0
- package/types/engine/core-report.d.mts +45 -0
- package/types/engine/core-vocabulary.d.mts +53 -0
- package/types/engine/dictionary-html.d.mts +8 -0
- package/types/engine/dictionary-rows.d.mts +10 -0
- package/types/engine/field-reference.d.mts +2 -2
- package/types/engine/heading-attributes.d.mts +20 -0
- package/types/engine/helpers.d.mts +3 -10
- package/types/engine/homepage.d.mts +11 -24
- package/types/engine/html-minify.d.mts +9 -0
- package/types/engine/index.d.mts +2 -1
- package/types/engine/key-case-collisions.d.mts +28 -0
- package/types/engine/language-data.d.mts +0 -2
- package/types/engine/language-terms.d.mts +24 -2
- package/types/engine/logographic.d.mts +45 -0
- package/types/engine/map-lexicon.d.mts +66 -13
- package/types/engine/map-places.d.mts +8 -0
- package/types/engine/markdown-expressions.d.mts +0 -2
- package/types/engine/metre-declaration.d.mts +39 -0
- package/types/engine/names.d.mts +34 -3
- package/types/engine/note-matter.d.mts +48 -0
- package/types/engine/pdf-render.d.mts +6 -2
- package/types/engine/post-fields.d.mts +44 -0
- package/types/engine/preview-index.d.mts +72 -0
- package/types/engine/preview-source.d.mts +51 -0
- package/types/engine/read-stdin.d.mts +11 -0
- package/types/engine/run-rows.d.mts +2 -2
- package/types/engine/run-words.d.mts +0 -15
- package/types/engine/script-emphasis-terms.d.mts +21 -0
- package/types/engine/script-emphasis.d.mts +84 -0
- package/types/engine/script-font-delivery.d.mts +4 -4
- package/types/engine/site-build.d.mts +71 -113
- package/types/engine/site-config.d.mts +14 -134
- package/types/engine/site-files.d.mts +73 -0
- package/types/engine/site-index.d.mts +11 -3
- package/types/engine/site-preview.d.mts +15 -19
- package/types/engine/site-render.d.mts +171 -0
- package/types/engine/site-root.d.mts +12 -4
- package/types/engine/site-search.d.mts +54 -0
- package/types/engine/site-serve.d.mts +44 -0
- package/types/engine/site-theme-banner.d.mts +51 -0
- package/types/engine/site-theme-urls.d.mts +60 -0
- package/types/engine/site-theme.d.mts +127 -0
- package/types/engine/sql-tables.d.mts +4 -1
- package/types/engine/text-maps.d.mts +17 -3
- package/types/engine/typographer.d.mts +11 -0
- package/types/engine/verse-metre.d.mts +131 -0
- package/types/engine/wikilink-syntax.d.mts +30 -48
- package/types/engine/wikilinks.d.mts +330 -14
- package/types/engine/word-forms.d.mts +83 -0
- package/types/manifest.d.mts +3 -4
- package/types/stage.d.mts +1 -2
- package/types/vendor/markdownlint.d.mts +8 -0
- package/vendor/markdownlint.mjs +20388 -0
- package/engine/content-poetry.mjs +0 -240
- package/engine/web-wikilinks.mjs +0 -594
- package/hugo-theme/layouts/404.html +0 -85
- package/hugo-theme/layouts/_default/baseof.html +0 -67
- package/hugo-theme/layouts/_default/list.html +0 -139
- package/hugo-theme/layouts/_default/single.html +0 -4
- package/hugo-theme/layouts/blog/list.html +0 -54
- package/hugo-theme/layouts/blog-post/single.html +0 -36
- package/hugo-theme/layouts/index.html +0 -75
- package/hugo-theme/layouts/partials/banner-url.html +0 -141
- package/hugo-theme/layouts/partials/cdn-url.html +0 -36
- package/hugo-theme/layouts/partials/dictionary-lists.html +0 -58
- package/hugo-theme/layouts/partials/draft-notice.html +0 -41
- package/hugo-theme/layouts/partials/draft-state.html +0 -26
- package/hugo-theme/layouts/partials/header.html +0 -35
- package/hugo-theme/layouts/partials/hero-banner.html +0 -85
- package/hugo-theme/layouts/partials/infobox/entry.html +0 -18
- package/hugo-theme/layouts/partials/infobox/items.html +0 -26
- package/hugo-theme/layouts/partials/infobox/section.html +0 -56
- package/hugo-theme/layouts/partials/infobox/value.html +0 -43
- package/hugo-theme/layouts/partials/infobox.html +0 -57
- package/hugo-theme/layouts/partials/page-body.html +0 -152
- package/hugo-theme/layouts/partials/related.html +0 -138
- package/hugo-theme/layouts/partials/search.html +0 -84
- package/hugo-theme/layouts/partials/site-url.html +0 -32
- package/hugo-theme/layouts/partials/tags.html +0 -30
- package/hugo-theme/layouts/partials/toc.html +0 -28
- package/hugo-theme/layouts/partials/warn-missing-banner.html +0 -3
- package/hugo-theme/layouts/shortcodes/cartouche.html +0 -64
- package/hugo-theme/layouts/shortcodes/exturl.html +0 -12
- package/hugo-theme/layouts/shortcodes/glyph.html +0 -43
- package/hugo-theme/layouts/shortcodes/pagelist.html +0 -63
- package/hugo-theme/theme.toml +0 -11
- package/types/engine/content-poetry.d.mts +0 -58
- package/types/engine/web-wikilinks.d.mts +0 -107
- /package/{hugo-theme → site-theme}/data/banner-kinds.yaml +0 -0
- /package/{hugo-theme → site-theme}/static/css/sohl-icons.css +0 -0
- /package/{hugo-theme → site-theme}/static/fonts/game-icons-kb.woff2 +0 -0
- /package/{hugo-theme → site-theme}/static/img/banners/takheperu-hieroglyphs.svg +0 -0
- /package/{hugo-theme → site-theme}/static/img/cartouches/takheperu.svg +0 -0
- /package/{hugo-theme → site-theme}/static/js/inline-spoilers.js +0 -0
package/engine/wikilinks.mjs
CHANGED
|
@@ -12,26 +12,35 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
|
-
*
|
|
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
|
-
*
|
|
20
|
-
*
|
|
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`,
|
|
54
|
-
//
|
|
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
|
-
|
|
66
|
+
LINK_FINDING_REASONS,
|
|
57
67
|
WIKILINK,
|
|
68
|
+
authoredLabel,
|
|
58
69
|
isEventAnchor,
|
|
59
|
-
|
|
60
|
-
|
|
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
|
|
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
|
-
*
|
|
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
|
|
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
|
|
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
|
-
*
|
|
354
|
-
*
|
|
355
|
-
*
|
|
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
|
|
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
|
-
*
|
|
417
|
+
* How each target writes a {@link ResolvedLink}.
|
|
369
418
|
*
|
|
370
|
-
*
|
|
371
|
-
*
|
|
372
|
-
* a
|
|
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
|
-
*
|
|
378
|
-
*
|
|
379
|
-
*
|
|
380
|
-
*
|
|
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:
|
|
396
|
-
* offset: number, reason: string, packages?:
|
|
397
|
-
* Each `reason` is one of
|
|
398
|
-
*
|
|
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
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
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
|
-
|
|
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
|
+
}
|