@heroiclands/package-build 22.22.0 → 22.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +1 -0
  3. package/bin/content-commands.mjs +51 -14
  4. package/ci/content-format-reference.mjs +1 -1
  5. package/content-config.mjs +80 -2
  6. package/docs/api.md +85 -72
  7. package/docs/authoring/assets.md +29 -0
  8. package/docs/authoring/dates-and-calendars.md +37 -1
  9. package/docs/authoring/links-and-markup.md +112 -31
  10. package/docs/commands.md +30 -29
  11. package/docs/configuration.md +92 -19
  12. package/docs/content-format.md +1 -1
  13. package/docs/guides/book.md +5 -4
  14. package/docs/guides/module-project.md +2 -2
  15. package/docs/guides/site.md +41 -0
  16. package/docs/project-setup.md +4 -4
  17. package/docs/reference/format-details.md +323 -60
  18. package/docs/reference/note-types.md +9 -9
  19. package/docs/reference/theme-pages.md +496 -0
  20. package/engine/anchors.mjs +16 -10
  21. package/engine/art-fields.mjs +118 -0
  22. package/engine/art-slots.mjs +9 -2
  23. package/engine/asset-index.mjs +160 -10
  24. package/engine/asset-replacement.mjs +93 -0
  25. package/engine/asset-types.mjs +240 -0
  26. package/engine/base-compiler.mjs +54 -2
  27. package/engine/bundles.mjs +13 -1
  28. package/engine/content-blocks.mjs +4 -4
  29. package/engine/content-embeds.mjs +19 -0
  30. package/engine/content-figures.mjs +468 -0
  31. package/engine/content-footnotes.mjs +10 -2
  32. package/engine/content-format.yaml +1 -1
  33. package/engine/content-images.mjs +126 -25
  34. package/engine/content-index.mjs +36 -10
  35. package/engine/field-reference.mjs +31 -10
  36. package/engine/field-spec.mjs +10 -8
  37. package/engine/foreign-catalog.mjs +145 -19
  38. package/engine/frontmatter-lint.mjs +73 -1
  39. package/engine/heading-attributes.mjs +31 -18
  40. package/engine/helpers.mjs +102 -33
  41. package/engine/infobox-registry.mjs +4 -1
  42. package/engine/infobox.mjs +47 -8
  43. package/engine/itinerary-scenes.mjs +4 -10
  44. package/engine/journals.mjs +264 -39
  45. package/engine/lore-events.mjs +197 -0
  46. package/engine/markdown-expressions.mjs +257 -7
  47. package/engine/note-dates.mjs +305 -12
  48. package/engine/note-vocabulary.mjs +11 -5
  49. package/engine/pathnames.mjs +36 -0
  50. package/engine/pdf-build.mjs +357 -34
  51. package/engine/pdf-images.mjs +64 -0
  52. package/engine/pdf-render.mjs +160 -36
  53. package/engine/scenes.mjs +16 -4
  54. package/engine/site-build.mjs +127 -13
  55. package/engine/site-config.mjs +19 -24
  56. package/engine/site-index.mjs +72 -1
  57. package/engine/site-preview.mjs +2 -0
  58. package/engine/system-block.mjs +44 -16
  59. package/hm3/actors.mjs +2 -2
  60. package/hugo-theme/data/banners.yaml +69 -0
  61. package/hugo-theme/layouts/404.html +85 -0
  62. package/hugo-theme/layouts/_default/baseof.html +64 -0
  63. package/hugo-theme/layouts/_default/list.html +139 -0
  64. package/hugo-theme/layouts/_default/single.html +4 -0
  65. package/hugo-theme/layouts/blog/list.html +54 -0
  66. package/hugo-theme/layouts/blog-post/single.html +36 -0
  67. package/hugo-theme/layouts/index.html +75 -0
  68. package/hugo-theme/layouts/partials/banner-url.html +117 -0
  69. package/hugo-theme/layouts/partials/cdn-url.html +36 -0
  70. package/hugo-theme/layouts/partials/draft-notice.html +41 -0
  71. package/hugo-theme/layouts/partials/draft-state.html +26 -0
  72. package/hugo-theme/layouts/partials/footer.html +29 -0
  73. package/hugo-theme/layouts/partials/header.html +35 -0
  74. package/hugo-theme/layouts/partials/hero-banner.html +86 -0
  75. package/hugo-theme/layouts/partials/holdings/entry.html +21 -0
  76. package/hugo-theme/layouts/partials/holdings/kind-groups.html +71 -0
  77. package/hugo-theme/layouts/partials/holdings.html +61 -0
  78. package/hugo-theme/layouts/partials/infobox/entry.html +15 -0
  79. package/hugo-theme/layouts/partials/infobox/items.html +26 -0
  80. package/hugo-theme/layouts/partials/infobox/section.html +56 -0
  81. package/hugo-theme/layouts/partials/infobox/value.html +43 -0
  82. package/hugo-theme/layouts/partials/infobox.html +57 -0
  83. package/hugo-theme/layouts/partials/page-body.html +154 -0
  84. package/hugo-theme/layouts/partials/place-map.html +9 -0
  85. package/hugo-theme/layouts/partials/related.html +121 -0
  86. package/hugo-theme/layouts/partials/search.html +84 -0
  87. package/hugo-theme/layouts/partials/site-url.html +32 -0
  88. package/hugo-theme/layouts/partials/tags.html +30 -0
  89. package/hugo-theme/layouts/partials/toc.html +28 -0
  90. package/hugo-theme/layouts/partials/warn-missing-banner.html +3 -0
  91. package/hugo-theme/layouts/shortcodes/cartouche.html +64 -0
  92. package/hugo-theme/layouts/shortcodes/exturl.html +12 -0
  93. package/hugo-theme/layouts/shortcodes/glyph.html +43 -0
  94. package/hugo-theme/layouts/shortcodes/pagelist.html +63 -0
  95. package/hugo-theme/static/css/sohl-icons.css +57 -0
  96. package/hugo-theme/static/css/style.css +2953 -0
  97. package/hugo-theme/static/fonts/game-icons-kb.woff2 +0 -0
  98. package/hugo-theme/static/img/banners/takheperu-hieroglyphs.svg +1 -0
  99. package/hugo-theme/static/img/cartouches/takheperu.svg +1 -0
  100. package/hugo-theme/theme.toml +11 -0
  101. package/manifest.mjs +30 -24
  102. package/package.json +2 -1
  103. package/packagebuild-metadata.jsonl +13 -13
  104. package/sohl/item-fields.mjs +4 -3
  105. package/types/content-config.d.mts +48 -1
  106. package/types/engine/anchors.d.mts +9 -4
  107. package/types/engine/art-fields.d.mts +64 -0
  108. package/types/engine/art-slots.d.mts +2 -1
  109. package/types/engine/asset-index.d.mts +43 -0
  110. package/types/engine/asset-replacement.d.mts +22 -0
  111. package/types/engine/asset-types.d.mts +18 -0
  112. package/types/engine/base-compiler.d.mts +0 -17
  113. package/types/engine/content-embeds.d.mts +16 -0
  114. package/types/engine/content-figures.d.mts +101 -0
  115. package/types/engine/content-footnotes.d.mts +7 -0
  116. package/types/engine/content-images.d.mts +78 -12
  117. package/types/engine/content-index.d.mts +8 -2
  118. package/types/engine/field-reference.d.mts +21 -0
  119. package/types/engine/field-spec.d.mts +20 -16
  120. package/types/engine/foreign-catalog.d.mts +39 -0
  121. package/types/engine/heading-attributes.d.mts +13 -8
  122. package/types/engine/helpers.d.mts +28 -10
  123. package/types/engine/infobox-registry.d.mts +4 -1
  124. package/types/engine/infobox.d.mts +6 -0
  125. package/types/engine/journals.d.mts +31 -9
  126. package/types/engine/lore-events.d.mts +14 -0
  127. package/types/engine/markdown-expressions.d.mts +23 -2
  128. package/types/engine/note-dates.d.mts +64 -1
  129. package/types/engine/pathnames.d.mts +26 -0
  130. package/types/engine/pdf-build.d.mts +28 -6
  131. package/types/engine/pdf-images.d.mts +52 -0
  132. package/types/engine/pdf-render.d.mts +12 -0
  133. package/types/engine/site-build.d.mts +14 -3
  134. package/types/engine/site-config.d.mts +9 -9
  135. package/types/engine/site-index.d.mts +54 -0
  136. package/types/engine/system-block.d.mts +26 -2
  137. package/types/hm3/actors.d.mts +18 -0
  138. package/types/manifest.d.mts +19 -23
  139. package/engine/content-captions.mjs +0 -230
  140. package/types/engine/content-captions.d.mts +0 -58
@@ -0,0 +1,197 @@
1
+ /*
2
+ * This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
3
+ * Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
4
+ *
5
+ * This work is licensed under the GNU General Public License v3.0 (GPLv3).
6
+ * You may copy, modify, and distribute it under the terms of that license.
7
+ *
8
+ * For full terms, see the LICENSE.md file in the project root or visit:
9
+ * https://www.gnu.org/licenses/gpl-3.0.html
10
+ *
11
+ * SPDX-License-Identifier: GPL-3.0-or-later
12
+ */
13
+
14
+ /**
15
+ * Validate a `lore` note's `data.events` list, entry by entry.
16
+ *
17
+ * `data.events` is declared `kind: "list"` with a prose `shape`, the way
18
+ * `data.eras` already is (`engine/calendar-notes.mjs`), and checked by its own
19
+ * walker rather than by the generic `data:` container check, which only knows
20
+ * that the value is an array. Every finding here is positioned at
21
+ * `["data", "events", position, ...]`, so it lands on the entry that carries
22
+ * the fault rather than on the note.
23
+ *
24
+ * @module
25
+ */
26
+
27
+ import { positionOfFrontmatterPath } from "./diagnostics.mjs";
28
+ import { parseNoteDate } from "./note-dates.mjs";
29
+ import { reckoningContext } from "./reckoning-markers.mjs";
30
+
31
+ /** Whether a value is a plain map — not `null`, not an array. */
32
+ function mapping(value) {
33
+ return value !== null && typeof value === "object" && !Array.isArray(value);
34
+ }
35
+
36
+ /**
37
+ * Validate one `lore` note's `data.events`.
38
+ *
39
+ * Registered as the `events` field's own `check` in `note-vocabulary.mjs`, so
40
+ * it runs on every `lore` note regardless of `subType` — an occasion is not a
41
+ * calendar fact, and nothing here is scoped to `subType: calendar`.
42
+ *
43
+ * @param {object} note - The note, as the link index hands it over.
44
+ * @param {{index?: object}} [options]
45
+ * @returns {object[]} Findings, each positioned at the entry that earned it.
46
+ */
47
+ export function checkLoreEvents(note, { index } = {}) {
48
+ const events = note.fm?.data?.events;
49
+ if (!Array.isArray(events)) return [];
50
+
51
+ const findings = [];
52
+ const dates = reckoningContext(index);
53
+ const at = (path, message) => ({
54
+ file: note.file,
55
+ ...positionOfFrontmatterPath(note.raw ?? "", path),
56
+ severity: "error",
57
+ message,
58
+ });
59
+
60
+ events.forEach((entry, position) => {
61
+ const row = mapping(entry) ? entry : {};
62
+ const base = ["data", "events", position];
63
+
64
+ const when = parseNoteDate(row.when, {
65
+ ...dates,
66
+ allowUnknown: true,
67
+ allowZeroYear: true,
68
+ field: "when",
69
+ file: note.file,
70
+ raw: note.raw,
71
+ keyPath: [...base, "when"],
72
+ });
73
+ findings.push(...when.findings);
74
+
75
+ const until = parseNoteDate(row.until, {
76
+ ...dates,
77
+ allowUnknown: false,
78
+ field: "until",
79
+ file: note.file,
80
+ raw: note.raw,
81
+ keyPath: [...base, "until"],
82
+ });
83
+ findings.push(...until.findings);
84
+
85
+ const recurs = row.recurs;
86
+ if (recurs === undefined || recurs === null) return;
87
+
88
+ if (row.when === undefined || row.when === null) {
89
+ findings.push(
90
+ at(
91
+ [...base, "recurs"],
92
+ "`recurs` needs a `when` of its own — a recurrence counts from the anchor it names",
93
+ ),
94
+ );
95
+ return;
96
+ }
97
+ if (when.date?.known && when.date.year === 0) {
98
+ findings.push(
99
+ at(
100
+ [...base, "recurs"],
101
+ "`recurs` is refused beside a `when` of year 0 — that date already recurs " +
102
+ "on that day every year, and `every` above 1 cannot be counted without " +
103
+ "an anchor year",
104
+ ),
105
+ );
106
+ return;
107
+ }
108
+ if (!mapping(recurs)) {
109
+ findings.push(at([...base, "recurs"], "`recurs` is a map of `every` or `on`"));
110
+ return;
111
+ }
112
+
113
+ const hasEvery = Object.hasOwn(recurs, "every");
114
+ const hasOn = Object.hasOwn(recurs, "on");
115
+ if (hasEvery && hasOn) {
116
+ findings.push(
117
+ at(
118
+ [...base, "recurs"],
119
+ "`recurs` declares exactly one of `every` or `on`, not both — a period " +
120
+ "with exceptions is written as an enumeration",
121
+ ),
122
+ );
123
+ return;
124
+ }
125
+ if (!hasEvery && !hasOn) {
126
+ findings.push(
127
+ at(
128
+ [...base, "recurs"],
129
+ "`recurs` needs `every` or `on` — its absence is what says the occasion " +
130
+ "happened once",
131
+ ),
132
+ );
133
+ return;
134
+ }
135
+
136
+ if (hasEvery) {
137
+ const { every } = recurs;
138
+ if (!(Number.isInteger(every) && every >= 1)) {
139
+ findings.push(
140
+ at(
141
+ [...base, "recurs", "every"],
142
+ `\`recurs.every\` needs a whole number of years, 1 or more, but reads ` +
143
+ `${JSON.stringify(every)}`,
144
+ ),
145
+ );
146
+ }
147
+ return;
148
+ }
149
+
150
+ if (row.until !== undefined && row.until !== null) {
151
+ findings.push(
152
+ at(
153
+ [...base, "until"],
154
+ "`until` is refused beside `recurs.on` — the enumeration states its own " +
155
+ "last entry",
156
+ ),
157
+ );
158
+ }
159
+
160
+ const onList = recurs.on;
161
+ if (!Array.isArray(onList) || onList.length === 0) {
162
+ findings.push(at([...base, "recurs", "on"], "`recurs.on` needs a list of dates"));
163
+ return;
164
+ }
165
+
166
+ let previous = when.date?.known ? when.date : null;
167
+ onList.forEach((onRaw, onPosition) => {
168
+ const onPath = [...base, "recurs", "on", onPosition];
169
+ const onParsed = parseNoteDate(onRaw, {
170
+ ...dates,
171
+ allowUnknown: false,
172
+ field: "recurs.on",
173
+ file: note.file,
174
+ raw: note.raw,
175
+ keyPath: onPath,
176
+ });
177
+ findings.push(...onParsed.findings);
178
+ const onDate = onParsed.date;
179
+ if (onDate?.known && previous?.known) {
180
+ if (onDate.sort <= previous.sort) {
181
+ findings.push(
182
+ at(
183
+ onPath,
184
+ onPosition === 0 ?
185
+ "`recurs.on` entry is at or before `when`, so it is not a " +
186
+ "later occurrence"
187
+ : "`recurs.on` entries are not strictly increasing",
188
+ ),
189
+ );
190
+ }
191
+ }
192
+ if (onDate?.known) previous = onDate;
193
+ });
194
+ });
195
+
196
+ return findings;
197
+ }
@@ -6,6 +6,8 @@ import { isAddressTuple, renderAddress } from "./address.mjs";
6
6
  import { matchAllOutsideCode, replaceOutsideCode } from "./code-fences.mjs";
7
7
  import { formatDateInCalendar, parseNoteDate } from "./note-dates.mjs";
8
8
  import { numberWords, numberDigits } from "./number-words.mjs";
9
+ import { slugify } from "./content-slug.mjs";
10
+ import { WIKILINK, authoredLabel, isSamePage, parseWikilink } from "./wikilink-syntax.mjs";
9
11
 
10
12
  /**
11
13
  * An inline expression, as an author writes one.
@@ -148,8 +150,164 @@ const HELPERS = Object.freeze([
148
150
  return printable.prose ?? printable.text;
149
151
  },
150
152
  }),
153
+ Object.freeze({
154
+ name: "ref",
155
+ params: 'address [form="number"|"full"|"title"]',
156
+ summary:
157
+ 'A link to a `:::figure` fence, by its anchor — `"#thorn"` on this note, ' +
158
+ '`"note-address#thorn"` on another. `form` is `number` (the default, and ' +
159
+ 'admitted explicitly), rendering "Figure 13"; `full`, the number and the ' +
160
+ "caption; or `title`, the caption alone. Always renders as a link to the " +
161
+ "figure, and a link inside the caption contributes only its label text. " +
162
+ "The number rendered is the one the figure carries on the surface doing " +
163
+ "the rendering, whichever note names it.",
164
+ make:
165
+ ({ figures }) =>
166
+ (address, options) => {
167
+ const hash = options?.hash ?? {};
168
+ const form = Object.hasOwn(hash, "form") ? hash.form : "number";
169
+ const formFault = refFormFault(form);
170
+ if (formFault) throw new RangeError(formFault);
171
+ if (typeof address !== "string" || !address.trim())
172
+ throw new TypeError("ref needs an address naming a figure's anchor");
173
+ const { parsed, figure, crossNote, noteFound, url } = refFigure(address, figures);
174
+ if (!figure)
175
+ throw new RangeError(refNotFoundFault(address, parsed, crossNote, noteFound));
176
+ if (form !== "number" && !figure.hasCaption)
177
+ throw new RangeError(refNoCaptionFault(form, parsed));
178
+ return renderRef(figure, parsed, form, { url, link: figures?.link });
179
+ },
180
+ }),
151
181
  ]);
152
182
 
183
+ /** The closed set of forms a figure reference may render as; `number` is the default. */
184
+ const REF_FORMS = Object.freeze(["number", "full", "title"]);
185
+
186
+ /**
187
+ * Whether a `ref` helper's `form` is one of {@link REF_FORMS}.
188
+ * @param {unknown} form - The resolved value, quoted or not.
189
+ * @returns {string|null} A finding message, or `null` when the form is valid.
190
+ */
191
+ function refFormFault(form) {
192
+ return REF_FORMS.includes(form) ? null : `ref's form must be one of ${REF_FORMS.join(", ")}`;
193
+ }
194
+
195
+ /**
196
+ * The figure a `ref` address names, read through the wikilink grammar's own
197
+ * address parsing rather than a second parser.
198
+ *
199
+ * A same-page address reads `figures.get`. A cross-note address reads
200
+ * `figures.note`, which resolves the written address exactly as a wikilink
201
+ * does and hands back the target's own figures and its own address — a build
202
+ * that resolves no cross-note reference simply omits `note`, and every
203
+ * cross-note address is then refused alike.
204
+ *
205
+ * @param {string} address - `"#thorn"` or `"note-address#thorn"`.
206
+ * @param {{get: (id: string) => object|undefined,
207
+ * note?: (address: string) => {url: string|null,
208
+ * figures: Map<string, object>}|undefined, link?: Function}} [figures] -
209
+ * This note's figures, and how to reach another note's.
210
+ * @returns {{parsed: object, figure: object|null, crossNote: boolean,
211
+ * noteFound: boolean, url: string|null}} The parsed address, the figure it
212
+ * names (`null` when none matches), whether the address names another
213
+ * note, whether that note was found at all, and the target's own address
214
+ * (`null` for a same-page reference).
215
+ */
216
+ function refFigure(address, figures) {
217
+ const parsed = parseWikilink(address);
218
+ if (isSamePage(parsed)) {
219
+ return {
220
+ parsed,
221
+ figure: figures?.get(parsed.anchor) ?? null,
222
+ crossNote: false,
223
+ noteFound: true,
224
+ url: null,
225
+ };
226
+ }
227
+ const note = figures?.note?.(parsed.target);
228
+ if (!note) return { parsed, figure: null, crossNote: true, noteFound: false, url: null };
229
+ return {
230
+ parsed,
231
+ figure: note.figures.get(parsed.anchor) ?? null,
232
+ crossNote: true,
233
+ noteFound: true,
234
+ url: note.url ?? null,
235
+ };
236
+ }
237
+
238
+ /** The message for a `ref` address naming no figure, same-note or across notes. */
239
+ function refNotFoundFault(address, parsed, crossNote, noteFound) {
240
+ if (!crossNote) return `ref "${address}" names no figure for anchor "#${parsed.anchor}"`;
241
+ if (!noteFound)
242
+ return `ref "${address}" addresses "${parsed.target}", which names no note this build resolves`;
243
+ return `ref "${address}" names no figure for anchor "#${parsed.anchor}" in "${parsed.target}"`;
244
+ }
245
+
246
+ /** The message for a `full` or `title` reference to a figure with no caption. */
247
+ function refNoCaptionFault(form, parsed) {
248
+ return `ref form="${form}" needs a caption, and figure "#${parsed.anchor}" has none`;
249
+ }
250
+
251
+ /**
252
+ * A figure caption's markdown, with every link flattened to its label text.
253
+ *
254
+ * `ref` renders as a link, so a link nested inside the label it wraps would be
255
+ * a second anchor — invalid HTML, and unreadable in Typst. Emphasis and an
256
+ * icon embed nest inside an anchor legally, and are left as written.
257
+ *
258
+ * @param {string} caption - The figure's raw caption markdown.
259
+ * @returns {string} The caption, every link reduced to its label.
260
+ */
261
+ function flattenCaptionLinks(caption) {
262
+ return String(caption ?? "")
263
+ .replace(WIKILINK, (_all, inner) => {
264
+ const link = parseWikilink(inner);
265
+ return authoredLabel(link) ?? link.inner;
266
+ })
267
+ .replace(/\[([^\]\n]*)\]\([^)\n]*\)/g, "$1");
268
+ }
269
+
270
+ /** A `ref` call's label text, for the form it was asked to render. */
271
+ function refLabel(figure, form) {
272
+ if (form === "number") return figure.label;
273
+ const title = flattenCaptionLinks(figure.caption);
274
+ return form === "title" ? title : `${figure.label}: ${title}`;
275
+ }
276
+
277
+ /**
278
+ * The default rendering of a `ref` call — a Markdown link to the figure's
279
+ * anchor, on the target's own address when the reference crosses notes.
280
+ *
281
+ * @param {{anchor: string, url: string|null}} target - The anchor, and the
282
+ * target note's own address (`null` for a same-page reference).
283
+ * @param {string} label - The link's text.
284
+ * @returns {string} `[label](url#anchor)`, `url` empty for a same-page link.
285
+ */
286
+ function defaultRefLink({ anchor, url }, label) {
287
+ return `[${label}](${url ?? ""}#${slugify(anchor)})`;
288
+ }
289
+
290
+ /**
291
+ * A `ref` call's rendered Markdown, always a link to the figure's anchor.
292
+ *
293
+ * `link`, when the surface supplies one, replaces the default Markdown link
294
+ * — the one override this build needs is Foundry, which addresses a figure
295
+ * through its own wikilink conversion rather than through an anchor fragment
296
+ * a journal page cannot resolve.
297
+ *
298
+ * @param {object} figure - The figure record.
299
+ * @param {object} parsed - The parsed `ref` address.
300
+ * @param {string} form - One of {@link REF_FORMS}.
301
+ * @param {{url: string|null, link?: (target: {anchor: string, url: string|null},
302
+ * label: string) => string}} context - The target's own address, and the
303
+ * surface's own link renderer.
304
+ */
305
+ function renderRef(figure, parsed, form, { url, link } = {}) {
306
+ const label = refLabel(figure, form);
307
+ const render = link ?? defaultRefLink;
308
+ return render({ anchor: parsed.anchor, url }, label);
309
+ }
310
+
153
311
  /**
154
312
  * A comparison of two numbers, which refuses anything else.
155
313
  *
@@ -210,29 +368,121 @@ export function sqlQueriesInMarkdown(body, fm = {}) {
210
368
  return queries;
211
369
  }
212
370
 
371
+ /**
372
+ * A `ref` call's fault, told from its AST node rather than its rendered
373
+ * result. **Only the AST tells a quoted `"full"` from an unquoted `full`** —
374
+ * Handlebars resolves the unquoted form against the render context before a
375
+ * helper ever sees it, so by the time `ref` runs the two are the same value
376
+ * and the distinction is gone.
377
+ *
378
+ * @param {object} node - A `MustacheStatement`/`SubExpression` AST node.
379
+ * @param {object} [figures] - This note's figures, and how to reach another
380
+ * note's — see {@link refFigure}.
381
+ * @returns {string|null} A finding message, or `null` when the call is sound.
382
+ */
383
+ function refCallFault(node, figures) {
384
+ const pair = node.hash?.pairs?.find((entry) => entry.key === "form");
385
+ if (pair && pair.value.type !== "StringLiteral")
386
+ return `ref's form must be a quoted string — one of ${REF_FORMS.join(", ")}`;
387
+ const form = pair ? pair.value.value : "number";
388
+ const formFault = refFormFault(form);
389
+ if (formFault) return formFault;
390
+ const addressParam = node.params?.[0];
391
+ const address = addressParam?.type === "StringLiteral" ? addressParam.value : null;
392
+ // Not a literal address: left to the render pass, which reports whatever
393
+ // the resolved value turns out to be.
394
+ if (typeof address !== "string" || !address.trim()) return null;
395
+ const { parsed, figure, crossNote, noteFound } = refFigure(address, figures);
396
+ if (!figure) return refNotFoundFault(address, parsed, crossNote, noteFound);
397
+ if (form !== "number" && !figure.hasCaption) return refNoCaptionFault(form, parsed);
398
+ return null;
399
+ }
400
+
401
+ /**
402
+ * Every `ref` call's fault in a body, reachable without rendering a thing —
403
+ * walked from the same parsed AST {@link sqlQueriesInMarkdown} walks, since a
404
+ * `ref` call can sit nested inside a Boolean helper exactly as a `sql` one
405
+ * can.
406
+ *
407
+ * @param {string} body
408
+ * @param {object} [figures] - This note's figures, and how to reach another
409
+ * note's — see {@link refFigure}.
410
+ * @returns {Array<{offset: number, message: string}>} One entry per faulty
411
+ * `{{...}}` expression, `offset` into `body`.
412
+ */
413
+ function refFaults(body, figures) {
414
+ const faults = [];
415
+ for (const match of matchAllOutsideCode(body, EXPRESSION)) {
416
+ let program;
417
+ try {
418
+ program = Handlebars.parse(match[0]).body;
419
+ } catch {
420
+ continue; // The render pass reports the malformed expression itself.
421
+ }
422
+ const visit = (node) => {
423
+ if (!node || typeof node !== "object") return;
424
+ if (node.path?.original === "ref") {
425
+ const message = refCallFault(node, figures);
426
+ if (message) faults.push({ offset: match.index, message });
427
+ }
428
+ for (const parameter of node.params ?? []) visit(parameter);
429
+ };
430
+ for (const node of program) visit(node);
431
+ }
432
+ return faults;
433
+ }
434
+
435
+ /** Where an offset into `body` sits, in the file's own line and column. */
436
+ function positionAt(body, offset, bodyLine) {
437
+ const before = String(body).slice(0, offset);
438
+ const lines = before.split("\n");
439
+ return { line: bodyLine + lines.length - 1, column: lines.at(-1).length + 1 };
440
+ }
441
+
213
442
  /**
214
443
  * Expand scalar frontmatter references and registered functions in Markdown prose.
215
444
  * @param {string} body
216
- * @param {{fm?: object, dates?: object, sqlResults?: Map<string, object>, file?: string, bodyLine?: number}} [options]
445
+ * @param {{fm?: object, dates?: object, sqlResults?: Map<string, object>,
446
+ * figures?: {get: (id: string) => {label: string, caption: string,
447
+ * hasCaption: boolean}|undefined, note?: (address: string) =>
448
+ * {url: string|null, figures: Map<string, object>}|undefined,
449
+ * link?: Function}, file?: string, bodyLine?: number}} [options] -
450
+ * `figures` is the `ref` helper's own view of the corpus: `get` reads this
451
+ * note's `:::figure` fences by id, and `note` resolves another note's the
452
+ * way a wikilink would — omitted, a cross-note address is refused rather
453
+ * than looked up. `link` overrides how a resolved reference renders; the
454
+ * default is a Markdown link to the target's own anchor.
217
455
  */
218
456
  export function renderMarkdownExpressions(
219
457
  body,
220
- { fm, dates, sqlResults, file, bodyLine = 1 } = {},
458
+ { fm, dates, sqlResults, figures, file, bodyLine = 1 } = {},
221
459
  ) {
222
460
  const findings = [];
223
461
  const engine = Handlebars.create();
224
462
  for (const helper of HELPERS)
225
- engine.registerHelper(helper.name, helper.make({ sqlResults, dates }));
463
+ engine.registerHelper(helper.name, helper.make({ sqlResults, dates, figures }));
464
+ const staticFaults = new Map(refFaults(body, figures).map((f) => [f.offset, f.message]));
226
465
  const markdown = replaceOutsideCode(body, EXPRESSION, (source, _inside, offset) => {
466
+ const fault = staticFaults.get(offset);
467
+ if (fault) {
468
+ const { line, column } = positionAt(body, offset, bodyLine);
469
+ findings.push({
470
+ ...(file ? { file } : {}),
471
+ line,
472
+ column,
473
+ severity: "error",
474
+ message: `Markdown expression ${source} failed: ${fault}`,
475
+ });
476
+ return source;
477
+ }
227
478
  try {
228
479
  return engine.compile(source, { strict: true })(fm ?? {});
229
480
  } catch (error) {
230
- const before = String(body).slice(0, offset);
231
- const lines = before.split("\n");
481
+ const { line, column } = positionAt(body, offset, bodyLine);
232
482
  findings.push({
233
483
  ...(file ? { file } : {}),
234
- line: bodyLine + lines.length - 1,
235
- column: lines.at(-1).length + 1,
484
+ line,
485
+ column,
236
486
  severity: "error",
237
487
  message: `Markdown expression ${source} failed: ${error.message}`,
238
488
  });