@heroiclands/package-build 22.21.0 → 22.23.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 (152) hide show
  1. package/CHANGELOG.md +223 -0
  2. package/assets/styles/content-base.css +162 -0
  3. package/bin/content-commands.mjs +77 -17
  4. package/bin/package-build.mjs +15 -2
  5. package/ci/content-format-reference.mjs +68 -1
  6. package/config.mjs +26 -0
  7. package/content-config.mjs +153 -6
  8. package/docs/api.md +120 -89
  9. package/docs/authoring/assets.md +14 -0
  10. package/docs/authoring/dates-and-calendars.md +41 -3
  11. package/docs/authoring/frontmatter.md +2 -2
  12. package/docs/authoring/links-and-markup.md +325 -35
  13. package/docs/commands.md +12 -10
  14. package/docs/configuration.md +117 -14
  15. package/docs/content-format.md +4 -2
  16. package/docs/guides/book.md +5 -4
  17. package/docs/guides/reference-pages.md +15 -0
  18. package/docs/reference/format-details.md +545 -104
  19. package/docs/reference/note-types.md +30 -13
  20. package/engine/actor-compiler.mjs +23 -0
  21. package/engine/anchored-sections.mjs +35 -4
  22. package/engine/anchors.mjs +26 -30
  23. package/engine/art-fields.mjs +118 -0
  24. package/engine/art-slots.mjs +7 -1
  25. package/engine/asset-index.mjs +160 -10
  26. package/engine/asset-replacement.mjs +93 -0
  27. package/engine/asset-types.mjs +240 -0
  28. package/engine/base-compiler.mjs +139 -22
  29. package/engine/base-styles.mjs +149 -0
  30. package/engine/bundles.mjs +13 -2
  31. package/engine/compendiums.mjs +8 -1
  32. package/engine/content-blocks.mjs +410 -0
  33. package/engine/content-embeds.mjs +19 -0
  34. package/engine/content-figures.mjs +468 -0
  35. package/engine/content-footnotes.mjs +197 -0
  36. package/engine/content-format-check.mjs +15 -12
  37. package/engine/content-format.yaml +1 -1
  38. package/engine/content-images.mjs +126 -25
  39. package/engine/content-index.mjs +38 -12
  40. package/engine/content-links.mjs +14 -6
  41. package/engine/content-tables.mjs +45 -4
  42. package/engine/content-tasklists.mjs +189 -0
  43. package/engine/docs-checks.mjs +4 -4
  44. package/engine/extension-attributes.mjs +48 -2
  45. package/engine/field-reference.mjs +31 -10
  46. package/engine/field-spec.mjs +10 -8
  47. package/engine/foreign-catalog.mjs +143 -18
  48. package/engine/frontmatter-lint.mjs +120 -65
  49. package/engine/frontmatter.mjs +3 -3
  50. package/engine/heading-attributes.mjs +321 -0
  51. package/engine/helpers.mjs +109 -41
  52. package/engine/homepage.mjs +8 -8
  53. package/engine/index.mjs +3 -0
  54. package/engine/infobox-registry.mjs +4 -1
  55. package/engine/infobox-render.mjs +15 -3
  56. package/engine/infobox.mjs +129 -15
  57. package/engine/itinerary-scenes.mjs +4 -10
  58. package/engine/journals.mjs +294 -50
  59. package/engine/lore-events.mjs +197 -0
  60. package/engine/macros.mjs +1 -1
  61. package/engine/markdown-expressions.mjs +434 -59
  62. package/engine/note-dates.mjs +354 -12
  63. package/engine/note-frontmatter.mjs +7 -16
  64. package/engine/note-vocabulary.mjs +183 -42
  65. package/engine/page-lists.mjs +358 -0
  66. package/engine/pathnames.mjs +36 -0
  67. package/engine/pdf-build.mjs +431 -74
  68. package/engine/pdf-images.mjs +64 -0
  69. package/engine/pdf-render.mjs +563 -66
  70. package/engine/rank-ladder.mjs +198 -0
  71. package/engine/reckoning-markers.mjs +1 -1
  72. package/engine/retired-fields.mjs +8 -13
  73. package/engine/scenes.mjs +26 -7
  74. package/engine/site-build.mjs +166 -23
  75. package/engine/site-config.mjs +10 -1
  76. package/engine/site-index.mjs +90 -3
  77. package/engine/site-preview.mjs +20 -7
  78. package/engine/social-ties.mjs +52 -14
  79. package/engine/sql-tables.mjs +66 -9
  80. package/engine/standings.mjs +89 -6
  81. package/engine/system-block.mjs +56 -30
  82. package/engine/web-wikilinks.mjs +48 -1
  83. package/hm3/actors.mjs +8 -5
  84. package/manifest.mjs +43 -24
  85. package/package.json +1 -1
  86. package/packagebuild-metadata.jsonl +13 -13
  87. package/sohl/actors.mjs +22 -99
  88. package/sohl/item-fields.mjs +5 -5
  89. package/sohl/items.mjs +1 -1
  90. package/sohl/note-schemas.mjs +11 -21
  91. package/types/config.d.mts +9 -0
  92. package/types/content-config.d.mts +48 -1
  93. package/types/engine/actor-compiler.d.mts +11 -0
  94. package/types/engine/anchored-sections.d.mts +24 -0
  95. package/types/engine/anchors.d.mts +9 -4
  96. package/types/engine/art-fields.d.mts +64 -0
  97. package/types/engine/asset-index.d.mts +43 -0
  98. package/types/engine/asset-replacement.d.mts +22 -0
  99. package/types/engine/asset-types.d.mts +18 -0
  100. package/types/engine/base-compiler.d.mts +19 -17
  101. package/types/engine/base-styles.d.mts +93 -0
  102. package/types/engine/content-blocks.d.mts +87 -0
  103. package/types/engine/content-embeds.d.mts +16 -0
  104. package/types/engine/content-figures.d.mts +101 -0
  105. package/types/engine/content-footnotes.d.mts +80 -0
  106. package/types/engine/content-format-check.d.mts +8 -6
  107. package/types/engine/content-images.d.mts +78 -12
  108. package/types/engine/content-index.d.mts +8 -2
  109. package/types/engine/content-tables.d.mts +12 -4
  110. package/types/engine/content-tasklists.d.mts +72 -0
  111. package/types/engine/extension-attributes.d.mts +22 -1
  112. package/types/engine/field-reference.d.mts +21 -0
  113. package/types/engine/field-spec.d.mts +20 -16
  114. package/types/engine/foreign-catalog.d.mts +39 -0
  115. package/types/engine/frontmatter.d.mts +1 -1
  116. package/types/engine/heading-attributes.d.mts +179 -0
  117. package/types/engine/helpers.d.mts +29 -10
  118. package/types/engine/homepage.d.mts +7 -7
  119. package/types/engine/index.d.mts +1 -0
  120. package/types/engine/infobox-registry.d.mts +4 -1
  121. package/types/engine/infobox-render.d.mts +6 -1
  122. package/types/engine/infobox.d.mts +6 -0
  123. package/types/engine/journals.d.mts +41 -12
  124. package/types/engine/lore-events.d.mts +14 -0
  125. package/types/engine/markdown-expressions.d.mts +46 -2
  126. package/types/engine/note-dates.d.mts +64 -1
  127. package/types/engine/note-frontmatter.d.mts +1 -4
  128. package/types/engine/note-vocabulary.d.mts +82 -6
  129. package/types/engine/page-lists.d.mts +126 -0
  130. package/types/engine/pathnames.d.mts +26 -0
  131. package/types/engine/pdf-build.d.mts +28 -6
  132. package/types/engine/pdf-images.d.mts +52 -0
  133. package/types/engine/pdf-render.d.mts +84 -2
  134. package/types/engine/rank-ladder.d.mts +32 -0
  135. package/types/engine/retired-fields.d.mts +4 -6
  136. package/types/engine/site-build.d.mts +27 -4
  137. package/types/engine/site-config.d.mts +9 -0
  138. package/types/engine/site-index.d.mts +67 -1
  139. package/types/engine/site-preview.d.mts +1 -1
  140. package/types/engine/sql-tables.d.mts +31 -7
  141. package/types/engine/standings.d.mts +21 -3
  142. package/types/engine/system-block.d.mts +37 -14
  143. package/types/engine/web-wikilinks.d.mts +3 -1
  144. package/types/hm3/actors.d.mts +21 -3
  145. package/types/manifest.d.mts +19 -23
  146. package/types/sohl/actors.d.mts +6 -7
  147. package/engine/content-admonitions.mjs +0 -114
  148. package/engine/content-captions.mjs +0 -181
  149. package/engine/content-secrets.mjs +0 -112
  150. package/types/engine/content-admonitions.d.mts +0 -24
  151. package/types/engine/content-captions.d.mts +0 -45
  152. package/types/engine/content-secrets.d.mts +0 -17
@@ -6,9 +6,341 @@ 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
- // Hugo shortcodes begin `{{<` or `{{%` and stay in the source for Hugo.
11
- const EXPRESSION = /(?<![\\{])\{\{(?![{<%])([^{}\n]+)\}\}(?!\})/g;
12
+ /**
13
+ * An inline expression, as an author writes one.
14
+ *
15
+ * Hugo shortcodes begin `{{<` or `{{%` and stay in the source for Hugo.
16
+ *
17
+ * Exported because a renderer that meets one has met a body this pass never ran
18
+ * over, and has to be able to say so.
19
+ *
20
+ * @type {RegExp}
21
+ */
22
+ export const EXPRESSION = /(?<![\\{])\{\{(?![{<%])([^{}\n]+)\}\}(?!\})/g;
23
+
24
+ /**
25
+ * Every helper an expression may call, with the parameters it takes.
26
+ *
27
+ * **This is the one list.** The engine registers from it and the authoring
28
+ * document is checked against it, so a helper added here is a helper the
29
+ * document must describe, and a helper the document describes but the engine
30
+ * does not register fails the same test.
31
+ *
32
+ * `make` receives the render's context — the prepared SQL results and the date
33
+ * rules — and returns the function Handlebars calls. A helper that needs
34
+ * neither ignores it.
35
+ */
36
+ const HELPERS = Object.freeze([
37
+ Object.freeze({
38
+ name: "eq",
39
+ params: "a b",
40
+ summary: 'True when both are the same value. Compares exactly, so 1 and "1" differ.',
41
+ make: () => (left, right) => left === right,
42
+ }),
43
+ Object.freeze({
44
+ name: "gt",
45
+ params: "a b",
46
+ summary: "True when a is greater than b. Both must be finite numbers.",
47
+ make: () => numericCompare("gt", (a, b) => a > b),
48
+ }),
49
+ Object.freeze({
50
+ name: "gte",
51
+ params: "a b",
52
+ summary: "True when a is greater than or equal to b. Both must be finite numbers.",
53
+ make: () => numericCompare("gte", (a, b) => a >= b),
54
+ }),
55
+ Object.freeze({
56
+ name: "lt",
57
+ params: "a b",
58
+ summary: "True when a is less than b. Both must be finite numbers.",
59
+ make: () => numericCompare("lt", (a, b) => a < b),
60
+ }),
61
+ Object.freeze({
62
+ name: "lte",
63
+ params: "a b",
64
+ summary: "True when a is less than or equal to b. Both must be finite numbers.",
65
+ make: () => numericCompare("lte", (a, b) => a <= b),
66
+ }),
67
+ Object.freeze({
68
+ name: "not",
69
+ params: "value",
70
+ summary: "True when the value is false, zero, empty or absent.",
71
+ make: () => (value) => !value,
72
+ }),
73
+ Object.freeze({
74
+ name: "and",
75
+ params: "value …",
76
+ summary: "True when every value given is true. Takes two or more.",
77
+ make:
78
+ () =>
79
+ (...args) =>
80
+ args.slice(0, -1).every(Boolean),
81
+ }),
82
+ Object.freeze({
83
+ name: "or",
84
+ params: "value …",
85
+ summary: "True when any value given is true. Takes two or more.",
86
+ make:
87
+ () =>
88
+ (...args) =>
89
+ args.slice(0, -1).some(Boolean),
90
+ }),
91
+ Object.freeze({
92
+ name: "words",
93
+ params: "number",
94
+ summary:
95
+ "The number spelled out for running prose — `1200` becomes one thousand two hundred. " +
96
+ "Whole numbers only; a decimal is a finding, and `digits` takes one.",
97
+ make: () => (value) => numberWords(value),
98
+ }),
99
+ Object.freeze({
100
+ name: "digits",
101
+ params: "number",
102
+ summary:
103
+ "The number as a grouped numeral — `1200` becomes 1,200, and a fractional part is kept.",
104
+ make: () => (value) => numberDigits(value),
105
+ }),
106
+ Object.freeze({
107
+ name: "sql",
108
+ params: '"query"',
109
+ summary:
110
+ "The single value a query returns, for use inside a sentence. The query is a quoted " +
111
+ "string, the same SQL a table fence takes.",
112
+ make:
113
+ ({ sqlResults }) =>
114
+ (query) => {
115
+ if (typeof query !== "string" || !query.trim())
116
+ throw new TypeError("sql needs a nonempty query string");
117
+ const result = sqlResults?.get(query);
118
+ if (!result)
119
+ throw new RangeError(`SQL expression has no prepared result: ${query}`);
120
+ if (result.error) throw new RangeError(`SQL expression failed: ${result.error}`);
121
+ return result.value;
122
+ },
123
+ }),
124
+ Object.freeze({
125
+ name: "dateformat",
126
+ params: 'calendar date ["format"]',
127
+ summary:
128
+ "A date written in a calendar. The calendar is an Address or its shortcode, the date " +
129
+ "is the value a note carries, and the optional third argument names an output pattern.",
130
+ make:
131
+ ({ dates }) =>
132
+ (calendar, authored, formatOrOptions) => {
133
+ if (
134
+ !(typeof calendar === "string" || isAddressTuple(calendar)) ||
135
+ !["string", "number"].includes(typeof authored)
136
+ )
137
+ throw new TypeError("dateformat needs a calendar Address and a date");
138
+ const parsed = parseNoteDate(authored, { ...dates, allowUnknown: false });
139
+ const error = parsed.findings.find((item) => item.severity === "error");
140
+ if (error || !parsed.date)
141
+ throw new RangeError(error?.message ?? "date does not resolve");
142
+ const printable = formatDateInCalendar(
143
+ parsed.date,
144
+ isAddressTuple(calendar) ? renderAddress(calendar) : calendar,
145
+ dates,
146
+ typeof formatOrOptions === "string" ? formatOrOptions : undefined,
147
+ );
148
+ if (!printable)
149
+ throw new RangeError(`date cannot be printed in calendar ${calendar}`);
150
+ return printable.prose ?? printable.text;
151
+ },
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
+ }),
181
+ ]);
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
+
311
+ /**
312
+ * A comparison of two numbers, which refuses anything else.
313
+ *
314
+ * Four helpers differ only in the comparison, and a value that is not a finite
315
+ * number is a finding rather than a silent `false`.
316
+ *
317
+ * @param {string} name - The helper's name, for the message.
318
+ * @param {(a: number, b: number) => boolean} compare - The comparison.
319
+ * @returns {(left: unknown, right: unknown) => boolean} The helper.
320
+ */
321
+ function numericCompare(name, compare) {
322
+ return (left, right) => {
323
+ const finite = (value) =>
324
+ typeof value === "bigint" || (typeof value === "number" && Number.isFinite(value));
325
+ if (!finite(left) || !finite(right))
326
+ throw new TypeError(`${name} needs two finite numbers`);
327
+ return compare(left, right);
328
+ };
329
+ }
330
+
331
+ /**
332
+ * Each helper's parameters and what it does, keyed by name.
333
+ *
334
+ * The authoring document describes this surface, and a test holds the two
335
+ * together. A consumer reads it to list what an expression may call.
336
+ *
337
+ * @type {Readonly<Record<string, Readonly<{params: string, summary: string}>>>}
338
+ */
339
+ export const EXPRESSION_HELPERS = Object.freeze(
340
+ Object.fromEntries(
341
+ HELPERS.map(({ name, params, summary }) => [name, Object.freeze({ params, summary })]),
342
+ ),
343
+ );
12
344
 
13
345
  /** Find SQL helper calls, including calls nested inside Boolean expressions. */
14
346
  export function sqlQueriesInMarkdown(body, fm = {}) {
@@ -36,78 +368,121 @@ export function sqlQueriesInMarkdown(body, fm = {}) {
36
368
  return queries;
37
369
  }
38
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
+
39
442
  /**
40
443
  * Expand scalar frontmatter references and registered functions in Markdown prose.
41
444
  * @param {string} body
42
- * @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.
43
455
  */
44
456
  export function renderMarkdownExpressions(
45
457
  body,
46
- { fm, dates, sqlResults, file, bodyLine = 1 } = {},
458
+ { fm, dates, sqlResults, figures, file, bodyLine = 1 } = {},
47
459
  ) {
48
460
  const findings = [];
49
461
  const engine = Handlebars.create();
50
- const numeric = (name, left, right) => {
51
- if (
52
- !["number", "bigint"].includes(typeof left) ||
53
- !["number", "bigint"].includes(typeof right) ||
54
- (typeof left === "number" && !Number.isFinite(left)) ||
55
- (typeof right === "number" && !Number.isFinite(right))
56
- )
57
- throw new TypeError(`${name} needs two finite numbers`);
58
- };
59
- for (const [name, compare] of Object.entries({
60
- gt: (a, b) => a > b,
61
- gte: (a, b) => a >= b,
62
- lt: (a, b) => a < b,
63
- lte: (a, b) => a <= b,
64
- }))
65
- engine.registerHelper(name, (left, right) => {
66
- numeric(name, left, right);
67
- return compare(left, right);
68
- });
69
- engine.registerHelper("eq", (left, right) => left === right);
70
- engine.registerHelper("not", (value) => !value);
71
- engine.registerHelper("and", (...args) => args.slice(0, -1).every(Boolean));
72
- engine.registerHelper("or", (...args) => args.slice(0, -1).some(Boolean));
73
- engine.registerHelper("words", (value) => numberWords(value));
74
- engine.registerHelper("digits", (value) => numberDigits(value));
75
- engine.registerHelper("sql", (query) => {
76
- if (typeof query !== "string" || !query.trim())
77
- throw new TypeError("sql needs a nonempty query string");
78
- const result = sqlResults?.get(query);
79
- if (!result) throw new RangeError(`SQL expression has no prepared result: ${query}`);
80
- if (result.error) throw new RangeError(`SQL expression failed: ${result.error}`);
81
- return result.value;
82
- });
83
- engine.registerHelper("dateformat", (calendar, authored, formatOrOptions) => {
84
- if (
85
- !(typeof calendar === "string" || isAddressTuple(calendar)) ||
86
- !["string", "number"].includes(typeof authored)
87
- )
88
- throw new TypeError("dateformat needs a calendar Address and a date");
89
- const parsed = parseNoteDate(authored, { ...dates, allowUnknown: false });
90
- const error = parsed.findings.find((item) => item.severity === "error");
91
- if (error || !parsed.date) throw new RangeError(error?.message ?? "date does not resolve");
92
- const printable = formatDateInCalendar(
93
- parsed.date,
94
- isAddressTuple(calendar) ? renderAddress(calendar) : calendar,
95
- dates,
96
- typeof formatOrOptions === "string" ? formatOrOptions : undefined,
97
- );
98
- if (!printable) throw new RangeError(`date cannot be printed in calendar ${calendar}`);
99
- return printable.prose ?? printable.text;
100
- });
462
+ for (const helper of HELPERS)
463
+ engine.registerHelper(helper.name, helper.make({ sqlResults, dates, figures }));
464
+ const staticFaults = new Map(refFaults(body, figures).map((f) => [f.offset, f.message]));
101
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
+ }
102
478
  try {
103
479
  return engine.compile(source, { strict: true })(fm ?? {});
104
480
  } catch (error) {
105
- const before = String(body).slice(0, offset);
106
- const lines = before.split("\n");
481
+ const { line, column } = positionAt(body, offset, bodyLine);
107
482
  findings.push({
108
483
  ...(file ? { file } : {}),
109
- line: bodyLine + lines.length - 1,
110
- column: lines.at(-1).length + 1,
484
+ line,
485
+ column,
111
486
  severity: "error",
112
487
  message: `Markdown expression ${source} failed: ${error.message}`,
113
488
  });