@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.
- package/CHANGELOG.md +223 -0
- package/assets/styles/content-base.css +162 -0
- package/bin/content-commands.mjs +77 -17
- package/bin/package-build.mjs +15 -2
- package/ci/content-format-reference.mjs +68 -1
- package/config.mjs +26 -0
- package/content-config.mjs +153 -6
- package/docs/api.md +120 -89
- package/docs/authoring/assets.md +14 -0
- package/docs/authoring/dates-and-calendars.md +41 -3
- package/docs/authoring/frontmatter.md +2 -2
- package/docs/authoring/links-and-markup.md +325 -35
- package/docs/commands.md +12 -10
- package/docs/configuration.md +117 -14
- package/docs/content-format.md +4 -2
- package/docs/guides/book.md +5 -4
- package/docs/guides/reference-pages.md +15 -0
- package/docs/reference/format-details.md +545 -104
- package/docs/reference/note-types.md +30 -13
- package/engine/actor-compiler.mjs +23 -0
- package/engine/anchored-sections.mjs +35 -4
- package/engine/anchors.mjs +26 -30
- package/engine/art-fields.mjs +118 -0
- package/engine/art-slots.mjs +7 -1
- package/engine/asset-index.mjs +160 -10
- package/engine/asset-replacement.mjs +93 -0
- package/engine/asset-types.mjs +240 -0
- package/engine/base-compiler.mjs +139 -22
- package/engine/base-styles.mjs +149 -0
- package/engine/bundles.mjs +13 -2
- package/engine/compendiums.mjs +8 -1
- package/engine/content-blocks.mjs +410 -0
- package/engine/content-embeds.mjs +19 -0
- package/engine/content-figures.mjs +468 -0
- package/engine/content-footnotes.mjs +197 -0
- package/engine/content-format-check.mjs +15 -12
- package/engine/content-format.yaml +1 -1
- package/engine/content-images.mjs +126 -25
- package/engine/content-index.mjs +38 -12
- package/engine/content-links.mjs +14 -6
- package/engine/content-tables.mjs +45 -4
- package/engine/content-tasklists.mjs +189 -0
- package/engine/docs-checks.mjs +4 -4
- package/engine/extension-attributes.mjs +48 -2
- package/engine/field-reference.mjs +31 -10
- package/engine/field-spec.mjs +10 -8
- package/engine/foreign-catalog.mjs +143 -18
- package/engine/frontmatter-lint.mjs +120 -65
- package/engine/frontmatter.mjs +3 -3
- package/engine/heading-attributes.mjs +321 -0
- package/engine/helpers.mjs +109 -41
- package/engine/homepage.mjs +8 -8
- package/engine/index.mjs +3 -0
- package/engine/infobox-registry.mjs +4 -1
- package/engine/infobox-render.mjs +15 -3
- package/engine/infobox.mjs +129 -15
- package/engine/itinerary-scenes.mjs +4 -10
- package/engine/journals.mjs +294 -50
- package/engine/lore-events.mjs +197 -0
- package/engine/macros.mjs +1 -1
- package/engine/markdown-expressions.mjs +434 -59
- package/engine/note-dates.mjs +354 -12
- package/engine/note-frontmatter.mjs +7 -16
- package/engine/note-vocabulary.mjs +183 -42
- package/engine/page-lists.mjs +358 -0
- package/engine/pathnames.mjs +36 -0
- package/engine/pdf-build.mjs +431 -74
- package/engine/pdf-images.mjs +64 -0
- package/engine/pdf-render.mjs +563 -66
- package/engine/rank-ladder.mjs +198 -0
- package/engine/reckoning-markers.mjs +1 -1
- package/engine/retired-fields.mjs +8 -13
- package/engine/scenes.mjs +26 -7
- package/engine/site-build.mjs +166 -23
- package/engine/site-config.mjs +10 -1
- package/engine/site-index.mjs +90 -3
- package/engine/site-preview.mjs +20 -7
- package/engine/social-ties.mjs +52 -14
- package/engine/sql-tables.mjs +66 -9
- package/engine/standings.mjs +89 -6
- package/engine/system-block.mjs +56 -30
- package/engine/web-wikilinks.mjs +48 -1
- package/hm3/actors.mjs +8 -5
- package/manifest.mjs +43 -24
- package/package.json +1 -1
- package/packagebuild-metadata.jsonl +13 -13
- package/sohl/actors.mjs +22 -99
- package/sohl/item-fields.mjs +5 -5
- package/sohl/items.mjs +1 -1
- package/sohl/note-schemas.mjs +11 -21
- package/types/config.d.mts +9 -0
- package/types/content-config.d.mts +48 -1
- package/types/engine/actor-compiler.d.mts +11 -0
- package/types/engine/anchored-sections.d.mts +24 -0
- package/types/engine/anchors.d.mts +9 -4
- package/types/engine/art-fields.d.mts +64 -0
- package/types/engine/asset-index.d.mts +43 -0
- package/types/engine/asset-replacement.d.mts +22 -0
- package/types/engine/asset-types.d.mts +18 -0
- package/types/engine/base-compiler.d.mts +19 -17
- package/types/engine/base-styles.d.mts +93 -0
- package/types/engine/content-blocks.d.mts +87 -0
- package/types/engine/content-embeds.d.mts +16 -0
- package/types/engine/content-figures.d.mts +101 -0
- package/types/engine/content-footnotes.d.mts +80 -0
- package/types/engine/content-format-check.d.mts +8 -6
- package/types/engine/content-images.d.mts +78 -12
- package/types/engine/content-index.d.mts +8 -2
- package/types/engine/content-tables.d.mts +12 -4
- package/types/engine/content-tasklists.d.mts +72 -0
- package/types/engine/extension-attributes.d.mts +22 -1
- package/types/engine/field-reference.d.mts +21 -0
- package/types/engine/field-spec.d.mts +20 -16
- package/types/engine/foreign-catalog.d.mts +39 -0
- package/types/engine/frontmatter.d.mts +1 -1
- package/types/engine/heading-attributes.d.mts +179 -0
- package/types/engine/helpers.d.mts +29 -10
- package/types/engine/homepage.d.mts +7 -7
- package/types/engine/index.d.mts +1 -0
- package/types/engine/infobox-registry.d.mts +4 -1
- package/types/engine/infobox-render.d.mts +6 -1
- package/types/engine/infobox.d.mts +6 -0
- package/types/engine/journals.d.mts +41 -12
- package/types/engine/lore-events.d.mts +14 -0
- package/types/engine/markdown-expressions.d.mts +46 -2
- package/types/engine/note-dates.d.mts +64 -1
- package/types/engine/note-frontmatter.d.mts +1 -4
- package/types/engine/note-vocabulary.d.mts +82 -6
- package/types/engine/page-lists.d.mts +126 -0
- package/types/engine/pathnames.d.mts +26 -0
- package/types/engine/pdf-build.d.mts +28 -6
- package/types/engine/pdf-images.d.mts +52 -0
- package/types/engine/pdf-render.d.mts +84 -2
- package/types/engine/rank-ladder.d.mts +32 -0
- package/types/engine/retired-fields.d.mts +4 -6
- package/types/engine/site-build.d.mts +27 -4
- package/types/engine/site-config.d.mts +9 -0
- package/types/engine/site-index.d.mts +67 -1
- package/types/engine/site-preview.d.mts +1 -1
- package/types/engine/sql-tables.d.mts +31 -7
- package/types/engine/standings.d.mts +21 -3
- package/types/engine/system-block.d.mts +37 -14
- package/types/engine/web-wikilinks.d.mts +3 -1
- package/types/hm3/actors.d.mts +21 -3
- package/types/manifest.d.mts +19 -23
- package/types/sohl/actors.d.mts +6 -7
- package/engine/content-admonitions.mjs +0 -114
- package/engine/content-captions.mjs +0 -181
- package/engine/content-secrets.mjs +0 -112
- package/types/engine/content-admonitions.d.mts +0 -24
- package/types/engine/content-captions.d.mts +0 -45
- 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
|
-
|
|
11
|
-
|
|
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>,
|
|
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
|
|
51
|
-
|
|
52
|
-
|
|
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
|
|
106
|
-
const lines = before.split("\n");
|
|
481
|
+
const { line, column } = positionAt(body, offset, bodyLine);
|
|
107
482
|
findings.push({
|
|
108
483
|
...(file ? { file } : {}),
|
|
109
|
-
line
|
|
110
|
-
column
|
|
484
|
+
line,
|
|
485
|
+
column,
|
|
111
486
|
severity: "error",
|
|
112
487
|
message: `Markdown expression ${source} failed: ${error.message}`,
|
|
113
488
|
});
|