@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.
- package/CHANGELOG.md +46 -0
- package/README.md +1 -0
- package/bin/content-commands.mjs +51 -14
- package/ci/content-format-reference.mjs +1 -1
- package/content-config.mjs +80 -2
- package/docs/api.md +85 -72
- package/docs/authoring/assets.md +29 -0
- package/docs/authoring/dates-and-calendars.md +37 -1
- package/docs/authoring/links-and-markup.md +112 -31
- package/docs/commands.md +30 -29
- package/docs/configuration.md +92 -19
- package/docs/content-format.md +1 -1
- package/docs/guides/book.md +5 -4
- package/docs/guides/module-project.md +2 -2
- package/docs/guides/site.md +41 -0
- package/docs/project-setup.md +4 -4
- package/docs/reference/format-details.md +323 -60
- package/docs/reference/note-types.md +9 -9
- package/docs/reference/theme-pages.md +496 -0
- package/engine/anchors.mjs +16 -10
- package/engine/art-fields.mjs +118 -0
- package/engine/art-slots.mjs +9 -2
- 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 +54 -2
- package/engine/bundles.mjs +13 -1
- package/engine/content-blocks.mjs +4 -4
- package/engine/content-embeds.mjs +19 -0
- package/engine/content-figures.mjs +468 -0
- package/engine/content-footnotes.mjs +10 -2
- package/engine/content-format.yaml +1 -1
- package/engine/content-images.mjs +126 -25
- package/engine/content-index.mjs +36 -10
- package/engine/field-reference.mjs +31 -10
- package/engine/field-spec.mjs +10 -8
- package/engine/foreign-catalog.mjs +145 -19
- package/engine/frontmatter-lint.mjs +73 -1
- package/engine/heading-attributes.mjs +31 -18
- package/engine/helpers.mjs +102 -33
- package/engine/infobox-registry.mjs +4 -1
- package/engine/infobox.mjs +47 -8
- package/engine/itinerary-scenes.mjs +4 -10
- package/engine/journals.mjs +264 -39
- package/engine/lore-events.mjs +197 -0
- package/engine/markdown-expressions.mjs +257 -7
- package/engine/note-dates.mjs +305 -12
- package/engine/note-vocabulary.mjs +11 -5
- package/engine/pathnames.mjs +36 -0
- package/engine/pdf-build.mjs +357 -34
- package/engine/pdf-images.mjs +64 -0
- package/engine/pdf-render.mjs +160 -36
- package/engine/scenes.mjs +16 -4
- package/engine/site-build.mjs +127 -13
- package/engine/site-config.mjs +19 -24
- package/engine/site-index.mjs +72 -1
- package/engine/site-preview.mjs +2 -0
- package/engine/system-block.mjs +44 -16
- package/hm3/actors.mjs +2 -2
- package/hugo-theme/data/banners.yaml +69 -0
- package/hugo-theme/layouts/404.html +85 -0
- package/hugo-theme/layouts/_default/baseof.html +64 -0
- package/hugo-theme/layouts/_default/list.html +139 -0
- package/hugo-theme/layouts/_default/single.html +4 -0
- package/hugo-theme/layouts/blog/list.html +54 -0
- package/hugo-theme/layouts/blog-post/single.html +36 -0
- package/hugo-theme/layouts/index.html +75 -0
- package/hugo-theme/layouts/partials/banner-url.html +117 -0
- package/hugo-theme/layouts/partials/cdn-url.html +36 -0
- package/hugo-theme/layouts/partials/draft-notice.html +41 -0
- package/hugo-theme/layouts/partials/draft-state.html +26 -0
- package/hugo-theme/layouts/partials/footer.html +29 -0
- package/hugo-theme/layouts/partials/header.html +35 -0
- package/hugo-theme/layouts/partials/hero-banner.html +86 -0
- package/hugo-theme/layouts/partials/holdings/entry.html +21 -0
- package/hugo-theme/layouts/partials/holdings/kind-groups.html +71 -0
- package/hugo-theme/layouts/partials/holdings.html +61 -0
- package/hugo-theme/layouts/partials/infobox/entry.html +15 -0
- package/hugo-theme/layouts/partials/infobox/items.html +26 -0
- package/hugo-theme/layouts/partials/infobox/section.html +56 -0
- package/hugo-theme/layouts/partials/infobox/value.html +43 -0
- package/hugo-theme/layouts/partials/infobox.html +57 -0
- package/hugo-theme/layouts/partials/page-body.html +154 -0
- package/hugo-theme/layouts/partials/place-map.html +9 -0
- package/hugo-theme/layouts/partials/related.html +121 -0
- package/hugo-theme/layouts/partials/search.html +84 -0
- package/hugo-theme/layouts/partials/site-url.html +32 -0
- package/hugo-theme/layouts/partials/tags.html +30 -0
- package/hugo-theme/layouts/partials/toc.html +28 -0
- package/hugo-theme/layouts/partials/warn-missing-banner.html +3 -0
- package/hugo-theme/layouts/shortcodes/cartouche.html +64 -0
- package/hugo-theme/layouts/shortcodes/exturl.html +12 -0
- package/hugo-theme/layouts/shortcodes/glyph.html +43 -0
- package/hugo-theme/layouts/shortcodes/pagelist.html +63 -0
- package/hugo-theme/static/css/sohl-icons.css +57 -0
- package/hugo-theme/static/css/style.css +2953 -0
- package/hugo-theme/static/fonts/game-icons-kb.woff2 +0 -0
- package/hugo-theme/static/img/banners/takheperu-hieroglyphs.svg +1 -0
- package/hugo-theme/static/img/cartouches/takheperu.svg +1 -0
- package/hugo-theme/theme.toml +11 -0
- package/manifest.mjs +30 -24
- package/package.json +2 -1
- package/packagebuild-metadata.jsonl +13 -13
- package/sohl/item-fields.mjs +4 -3
- package/types/content-config.d.mts +48 -1
- package/types/engine/anchors.d.mts +9 -4
- package/types/engine/art-fields.d.mts +64 -0
- package/types/engine/art-slots.d.mts +2 -1
- 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 +0 -17
- 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 +7 -0
- package/types/engine/content-images.d.mts +78 -12
- package/types/engine/content-index.d.mts +8 -2
- 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/heading-attributes.d.mts +13 -8
- package/types/engine/helpers.d.mts +28 -10
- package/types/engine/infobox-registry.d.mts +4 -1
- package/types/engine/infobox.d.mts +6 -0
- package/types/engine/journals.d.mts +31 -9
- package/types/engine/lore-events.d.mts +14 -0
- package/types/engine/markdown-expressions.d.mts +23 -2
- package/types/engine/note-dates.d.mts +64 -1
- 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 +12 -0
- package/types/engine/site-build.d.mts +14 -3
- package/types/engine/site-config.d.mts +9 -9
- package/types/engine/site-index.d.mts +54 -0
- package/types/engine/system-block.d.mts +26 -2
- package/types/hm3/actors.d.mts +18 -0
- package/types/manifest.d.mts +19 -23
- package/engine/content-captions.mjs +0 -230
- 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>,
|
|
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
|
|
231
|
-
const lines = before.split("\n");
|
|
481
|
+
const { line, column } = positionAt(body, offset, bodyLine);
|
|
232
482
|
findings.push({
|
|
233
483
|
...(file ? { file } : {}),
|
|
234
|
-
line
|
|
235
|
-
column
|
|
484
|
+
line,
|
|
485
|
+
column,
|
|
236
486
|
severity: "error",
|
|
237
487
|
message: `Markdown expression ${source} failed: ${error.message}`,
|
|
238
488
|
});
|