@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,468 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `:::figure` — the fence that declares what is numbered and captioned.
|
|
7
|
+
*
|
|
8
|
+
* ```
|
|
9
|
+
* :::figure {#thorn .border}
|
|
10
|
+
* ![[being-foobar|The Great Beast]]
|
|
11
|
+
* ///
|
|
12
|
+
* The great beast, as drawn by [[person-havard]].
|
|
13
|
+
* :::
|
|
14
|
+
* ```
|
|
15
|
+
*
|
|
16
|
+
* The fence states the unit, so nothing is inferred about scope and a group —
|
|
17
|
+
* two portraits as one plate, a table with its key beneath it — is one figure
|
|
18
|
+
* with one number.
|
|
19
|
+
*
|
|
20
|
+
* **The caption is optional and the fence is what numbers a thing.** A fence
|
|
21
|
+
* with no `///` draws its label alone. So the rule is "no fence, no number":
|
|
22
|
+
* a bare embed takes no number and nothing is drawn beneath it.
|
|
23
|
+
*
|
|
24
|
+
* **`{#id}` is optional too.** A fence that declares none is numbered and drawn
|
|
25
|
+
* and carries no anchor, so nothing can reference it.
|
|
26
|
+
*
|
|
27
|
+
* ## The split is lexical
|
|
28
|
+
*
|
|
29
|
+
* The first `///` at the top level of the body divides the contents from the
|
|
30
|
+
* caption, and it is found **on the raw lines, before any markdown parsing**.
|
|
31
|
+
* The alternative delimiters are claimed by the grammar: `---` or `===` on the
|
|
32
|
+
* line after a paragraph is a setext heading, so an image tight against one
|
|
33
|
+
* becomes an `<h2>` with nothing said. `///` has no CommonMark meaning, which
|
|
34
|
+
* leaves a genuine thematic break available inside a grouped figure.
|
|
35
|
+
*
|
|
36
|
+
* A delimiter inside a nested code fence is content: a captioned listing
|
|
37
|
+
* carries `///` as a doc comment, so the scan for both the delimiter and the
|
|
38
|
+
* closing `:::` tracks nested fences and reads neither inside one.
|
|
39
|
+
*
|
|
40
|
+
* ## The kind is derived, never authored
|
|
41
|
+
*
|
|
42
|
+
* A table is self-evidently a table and a lone picture a figure, so the counter
|
|
43
|
+
* comes from the fence's contents: a table is `table`, an `sql` fence is
|
|
44
|
+
* `table`, any other fence or code block is `code`, pictures alone are
|
|
45
|
+
* `figure`, and everything else is `prose`. A fence around prose, with
|
|
46
|
+
* `.border`, is a numbered, referable boxed aside, which is what `Prose` names.
|
|
47
|
+
*
|
|
48
|
+
* **A picture counts as `map` instead of `figure` when every picture the fence
|
|
49
|
+
* holds is one.** The fence's own markup cannot say so — a picture of a map
|
|
50
|
+
* looks like any other picture — so the fact travels through a `resolveRole`
|
|
51
|
+
* function the caller supplies, from the address each picture draws (an
|
|
52
|
+
* embed's address, an image's path, or an `<img>` tag's `src`) to the role its
|
|
53
|
+
* asset declares. This pass resolves no address of its own; `resolveRole` is
|
|
54
|
+
* the one seam a caller reaches through with whatever lookup it already holds.
|
|
55
|
+
* A caller that supplies none, or whose lookup answers nothing for a picture,
|
|
56
|
+
* gets `figure` — the same reading as a picture with a role other than `map`.
|
|
57
|
+
*
|
|
58
|
+
* ## The class vocabulary is closed
|
|
59
|
+
*
|
|
60
|
+
* {@link FIGURE_CLASSES} holds what an author may write, and a class outside it
|
|
61
|
+
* is a finding naming the class — so `.border` does not become the first of
|
|
62
|
+
* twenty presentational classes. `key=value` is refused: the construct takes an
|
|
63
|
+
* id and classes.
|
|
64
|
+
*
|
|
65
|
+
* @module
|
|
66
|
+
*/
|
|
67
|
+
|
|
68
|
+
import MarkdownIt from "markdown-it";
|
|
69
|
+
import footnotePlugin from "markdown-it-footnote";
|
|
70
|
+
import deflistPlugin from "markdown-it-deflist";
|
|
71
|
+
|
|
72
|
+
import { slugify } from "./content-slug.mjs";
|
|
73
|
+
import { parseExtensionAttributes } from "./extension-attributes.mjs";
|
|
74
|
+
import { HEADING_LINE, parseHeadingLine, splitHeadingAttributes } from "./heading-attributes.mjs";
|
|
75
|
+
import { IMAGE_PATTERN } from "./content-images.mjs";
|
|
76
|
+
|
|
77
|
+
const parser = new MarkdownIt({ html: true }).use(footnotePlugin).use(deflistPlugin);
|
|
78
|
+
const OPEN = /^:::figure(?:[ \t]+(\{[^}\n]*\}))?[ \t]*$/;
|
|
79
|
+
const FIGURE_LINE = /^:::figure\b/;
|
|
80
|
+
const CLOSE = /^:::\s*$/;
|
|
81
|
+
const SPLIT = /^\/\/\/\s*$/;
|
|
82
|
+
const FENCE = /^ {0,3}(`{3,}|~{3,})/;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* An asset embed as authored, with the directive it may carry.
|
|
86
|
+
*
|
|
87
|
+
* Written here rather than imported so this module stays a leaf: it is read by
|
|
88
|
+
* {@link module:engine/anchors}, which is asked by the link checker, the
|
|
89
|
+
* content index and every build, and none of them can afford the address
|
|
90
|
+
* machinery an embed reader pulls in. Only the spelling is needed — this pass
|
|
91
|
+
* resolves no address.
|
|
92
|
+
*/
|
|
93
|
+
const EMBED = /!\[\[[^\]\n]+\]\](?:\{[^}\n]*\})?/;
|
|
94
|
+
|
|
95
|
+
/** An embed, capturing its interior — reused to read the address it draws. */
|
|
96
|
+
const EMBED_INNER = /!\[\[([^\]\n]+)\]\]/g;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* An `<img>` tag's `src`, as the website hands this pass once its own image
|
|
100
|
+
* pass has already turned a block picture into HTML.
|
|
101
|
+
*/
|
|
102
|
+
const HTML_IMG_SRC = /<img\b[^>]*\bsrc="([^"]*)"/g;
|
|
103
|
+
|
|
104
|
+
/** A figure's automatically determined kind, and the word that labels it. */
|
|
105
|
+
export const FIGURE_NAMES = Object.freeze({
|
|
106
|
+
code: "Code",
|
|
107
|
+
table: "Table",
|
|
108
|
+
figure: "Figure",
|
|
109
|
+
map: "Map",
|
|
110
|
+
prose: "Prose",
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The classes a figure takes.
|
|
115
|
+
*
|
|
116
|
+
* `.border` states that the captioned thing is drawn inside a border, and each
|
|
117
|
+
* medium owns what that means. The vocabulary is closed: a class absent from it
|
|
118
|
+
* is a finding naming the class.
|
|
119
|
+
*
|
|
120
|
+
* @type {readonly string[]}
|
|
121
|
+
*/
|
|
122
|
+
export const FIGURE_CLASSES = Object.freeze(["border"]);
|
|
123
|
+
|
|
124
|
+
/** Number a sequence of figures with counters shared across documents. */
|
|
125
|
+
export function numberFigures(figures, counts) {
|
|
126
|
+
return figures.map((figure) => {
|
|
127
|
+
const number = (counts[figure.kind] ?? 0) + 1;
|
|
128
|
+
counts[figure.kind] = number;
|
|
129
|
+
return { ...figure, number, label: `${FIGURE_NAMES[figure.kind]} ${number}` };
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Text safe inside a double-quoted attribute, and as element text. */
|
|
134
|
+
function escapeHtml(value) {
|
|
135
|
+
return String(value)
|
|
136
|
+
.replace(/&/g, "&")
|
|
137
|
+
.replace(/</g, "<")
|
|
138
|
+
.replace(/>/g, ">")
|
|
139
|
+
.replace(/"/g, """);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Whether a fence's contents are pictures and nothing else.
|
|
144
|
+
*
|
|
145
|
+
* Read line by line on the raw contents, because the contents of a fence are
|
|
146
|
+
* what the author grouped: a plate of two embeds is as much a figure as one is,
|
|
147
|
+
* and a trailing sentence or a footnote reference beside a picture makes the
|
|
148
|
+
* passage prose with a picture in it.
|
|
149
|
+
*
|
|
150
|
+
* @param {string} contents - The captioned contents, as authored.
|
|
151
|
+
* @returns {boolean} Whether every line that carries anything is a picture.
|
|
152
|
+
*/
|
|
153
|
+
function picturesOnly(contents) {
|
|
154
|
+
const lines = contents.split("\n").filter((line) => line.trim());
|
|
155
|
+
if (!lines.length) return false;
|
|
156
|
+
return lines.every((line) => {
|
|
157
|
+
const stripped = line
|
|
158
|
+
.replace(new RegExp(EMBED.source, "g"), "")
|
|
159
|
+
.replace(new RegExp(IMAGE_PATTERN.source, "g"), "");
|
|
160
|
+
return stripped !== line && !stripped.trim();
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Every address a fence's pictures draw, across the three forms a picture
|
|
166
|
+
* reaches this pass in: an embed's inner text before its `|`, a markdown
|
|
167
|
+
* image's path, or an HTML `<img>`'s `src`.
|
|
168
|
+
*
|
|
169
|
+
* @param {string} contents - The fence's contents.
|
|
170
|
+
* @returns {string[]} One address per picture, in source order.
|
|
171
|
+
*/
|
|
172
|
+
function pictureAddresses(contents) {
|
|
173
|
+
const addresses = [];
|
|
174
|
+
for (const match of contents.matchAll(EMBED_INNER)) {
|
|
175
|
+
addresses.push(match[1].split("|")[0].trim());
|
|
176
|
+
}
|
|
177
|
+
for (const match of contents.matchAll(IMAGE_PATTERN)) {
|
|
178
|
+
if (match[2]) addresses.push(match[2]);
|
|
179
|
+
}
|
|
180
|
+
for (const match of contents.matchAll(HTML_IMG_SRC)) {
|
|
181
|
+
addresses.push(match[1]);
|
|
182
|
+
}
|
|
183
|
+
return addresses.filter(Boolean);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Whether every picture a fence holds declares the `map` role.
|
|
188
|
+
*
|
|
189
|
+
* @param {string} contents - The fence's contents.
|
|
190
|
+
* @param {(address: string) => string|undefined} resolveRole - The caller's
|
|
191
|
+
* lookup from an address to the role its asset declares.
|
|
192
|
+
* @returns {boolean} Whether the fence draws at least one picture and every
|
|
193
|
+
* one of them resolves to `map`.
|
|
194
|
+
*/
|
|
195
|
+
function picturesAreMap(contents, resolveRole) {
|
|
196
|
+
const addresses = pictureAddresses(contents);
|
|
197
|
+
return addresses.length > 0 && addresses.every((address) => resolveRole(address) === "map");
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Which counter a fence's contents belong to.
|
|
202
|
+
*
|
|
203
|
+
* @param {object|undefined} first - The first top-level token of the contents.
|
|
204
|
+
* @param {string} contents - The contents, trimmed.
|
|
205
|
+
* @param {(address: string) => string|undefined} resolveRole - The caller's
|
|
206
|
+
* lookup from a picture's address to the role its asset declares.
|
|
207
|
+
* @returns {"code"|"table"|"figure"|"map"|"prose"} The kind.
|
|
208
|
+
*/
|
|
209
|
+
function figureKind(first, contents, resolveRole) {
|
|
210
|
+
if (first?.type === "table_open") return "table";
|
|
211
|
+
if (first?.type === "fence" && /^\s*sql\b/i.test(first.info ?? "")) return "table";
|
|
212
|
+
if (first?.type === "fence" || first?.type === "code_block") return "code";
|
|
213
|
+
// `<figure>` and `<img>` reach this pass from the website, where the image
|
|
214
|
+
// pass runs first and hands over HTML rather than markdown.
|
|
215
|
+
if (picturesOnly(contents) || /^(?:<figure\b|<img\b)/.test(contents)) {
|
|
216
|
+
return picturesAreMap(contents, resolveRole) ? "map" : "figure";
|
|
217
|
+
}
|
|
218
|
+
return "prose";
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Read every `:::figure` fence in a body.
|
|
223
|
+
*
|
|
224
|
+
* SQL fences are expanded before this pass, so their output is a table.
|
|
225
|
+
*
|
|
226
|
+
* @param {string} source - A note body with expanded tables.
|
|
227
|
+
* @param {object} [options] - Options.
|
|
228
|
+
* @param {(address: string) => string|undefined} [options.resolveRole] - From
|
|
229
|
+
* a picture's address to the role its asset declares, so a picture whose
|
|
230
|
+
* asset declares `role: map` counts as `map` rather than `figure`. Omitted,
|
|
231
|
+
* every picture counts as `figure` — the same reading a role other than
|
|
232
|
+
* `map` gets.
|
|
233
|
+
* @returns {{figures: Array<{id: string, slug: string, caption: string,
|
|
234
|
+
* hasCaption: boolean, classes: string[], kind: string, number: number,
|
|
235
|
+
* label: string, line: number, bodyStart: number, bodyEnd: number,
|
|
236
|
+
* captionStart: number, captionEnd: number, close: number}>,
|
|
237
|
+
* errors: Array<{line: number, column: number, message: string}>}}
|
|
238
|
+
*/
|
|
239
|
+
export function scanFigures(source, { resolveRole = () => undefined } = {}) {
|
|
240
|
+
const lines = String(source ?? "").split("\n");
|
|
241
|
+
const figures = [];
|
|
242
|
+
const errors = [];
|
|
243
|
+
const counts = { code: 0, table: 0, figure: 0, map: 0, prose: 0 };
|
|
244
|
+
const ids = new Set();
|
|
245
|
+
const headingIds = [];
|
|
246
|
+
let codeFence = null;
|
|
247
|
+
|
|
248
|
+
for (let i = 0; i < lines.length; i++) {
|
|
249
|
+
const line = lines[i];
|
|
250
|
+
const fence = FENCE.exec(line);
|
|
251
|
+
if (codeFence) {
|
|
252
|
+
if (fence && fence[1][0] === codeFence[0] && fence[1].length >= codeFence.length)
|
|
253
|
+
codeFence = null;
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
if (fence) {
|
|
257
|
+
codeFence = fence[1];
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
const heading = HEADING_LINE.exec(line);
|
|
261
|
+
const headingId = heading ? splitHeadingAttributes(heading[2]).id : "";
|
|
262
|
+
if (headingId) headingIds.push({ id: slugify(headingId), line: i + 1 });
|
|
263
|
+
if (!FIGURE_LINE.test(line)) continue;
|
|
264
|
+
const open = OPEN.exec(line);
|
|
265
|
+
if (!open) {
|
|
266
|
+
errors.push({
|
|
267
|
+
line: i + 1,
|
|
268
|
+
column: 1,
|
|
269
|
+
message: "a figure's attributes are written as {#id .border}",
|
|
270
|
+
});
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
const start = i;
|
|
274
|
+
const attributes = parseExtensionAttributes((open[1] ?? "{}").slice(1, -1));
|
|
275
|
+
const faults = [...attributes.problems];
|
|
276
|
+
for (const name of attributes.classes) {
|
|
277
|
+
if (FIGURE_CLASSES.includes(name)) continue;
|
|
278
|
+
faults.push(
|
|
279
|
+
`a figure takes no .${name} class — the classes a figure takes are ` +
|
|
280
|
+
FIGURE_CLASSES.map((known) => `.${known}`).join(", "),
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
for (const key of Object.keys(attributes.values)) {
|
|
284
|
+
faults.push(
|
|
285
|
+
`${key}= is not an attribute a figure takes — a figure takes an id and classes`,
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
for (const message of faults) errors.push({ line: start + 1, column: 1, message });
|
|
289
|
+
|
|
290
|
+
// The body, read line by line: the first top-level `///` splits it, the
|
|
291
|
+
// closing `:::` ends it, and a nested fence holds neither.
|
|
292
|
+
let close = start + 1;
|
|
293
|
+
let split = -1;
|
|
294
|
+
let nested = null;
|
|
295
|
+
const fenced = new Set();
|
|
296
|
+
while (close < lines.length) {
|
|
297
|
+
const body = lines[close];
|
|
298
|
+
const bodyFence = FENCE.exec(body);
|
|
299
|
+
if (nested) {
|
|
300
|
+
fenced.add(close);
|
|
301
|
+
if (
|
|
302
|
+
bodyFence &&
|
|
303
|
+
bodyFence[1][0] === nested[0] &&
|
|
304
|
+
bodyFence[1].length >= nested.length
|
|
305
|
+
)
|
|
306
|
+
nested = null;
|
|
307
|
+
close++;
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
if (bodyFence) {
|
|
311
|
+
nested = bodyFence[1];
|
|
312
|
+
fenced.add(close);
|
|
313
|
+
close++;
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
if (CLOSE.test(body)) break;
|
|
317
|
+
if (SPLIT.test(body)) {
|
|
318
|
+
if (split < 0) split = close;
|
|
319
|
+
else
|
|
320
|
+
errors.push({
|
|
321
|
+
line: close + 1,
|
|
322
|
+
column: 1,
|
|
323
|
+
message: "a figure holds one caption, so it carries one /// line",
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
close++;
|
|
327
|
+
}
|
|
328
|
+
if (close === lines.length) {
|
|
329
|
+
errors.push({ line: start + 1, column: 1, message: "a figure needs a closing :::" });
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const bodyStart = start + 1;
|
|
334
|
+
const bodyEnd = split < 0 ? close : split;
|
|
335
|
+
const captionStart = split < 0 ? -1 : split + 1;
|
|
336
|
+
const captionEnd = split < 0 ? -1 : close;
|
|
337
|
+
const contents = lines.slice(bodyStart, bodyEnd).join("\n");
|
|
338
|
+
const caption = split < 0 ? "" : lines.slice(captionStart, captionEnd).join("\n").trim();
|
|
339
|
+
if (!contents.trim())
|
|
340
|
+
errors.push({ line: start + 1, column: 1, message: "a figure has no contents" });
|
|
341
|
+
if (split >= 0 && !caption)
|
|
342
|
+
errors.push({
|
|
343
|
+
line: split + 1,
|
|
344
|
+
column: 1,
|
|
345
|
+
message:
|
|
346
|
+
"a figure's /// section carries no caption — write no /// to leave the " +
|
|
347
|
+
"figure uncaptioned",
|
|
348
|
+
});
|
|
349
|
+
// An H1, or an anchored heading at any level, starts a Foundry
|
|
350
|
+
// journal page — see `engine/content-blocks.mjs`'s identical refusal
|
|
351
|
+
// for a named block.
|
|
352
|
+
for (let at = bodyStart; at < close; at++) {
|
|
353
|
+
if (fenced.has(at)) continue;
|
|
354
|
+
if (!parseHeadingLine(lines[at])?.startsPage) continue;
|
|
355
|
+
errors.push({
|
|
356
|
+
line: at + 1,
|
|
357
|
+
column: 1,
|
|
358
|
+
message:
|
|
359
|
+
"a heading that starts a page cannot be written inside a figure — " +
|
|
360
|
+
"keep an H1 or an anchored heading at the top level, or drop the " +
|
|
361
|
+
"anchor and the level to stay inside it",
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
const id = attributes.id;
|
|
365
|
+
if (id) {
|
|
366
|
+
if (ids.has(slugify(id)))
|
|
367
|
+
errors.push({ line: start + 1, column: 1, message: `duplicate figure id "${id}"` });
|
|
368
|
+
ids.add(slugify(id));
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const trimmed = contents.trim();
|
|
372
|
+
const tokens = parser.parse(contents, {});
|
|
373
|
+
const first = tokens.find((token) => token.level === 0 && token.map);
|
|
374
|
+
const kind = figureKind(first, trimmed, resolveRole);
|
|
375
|
+
const number = ++counts[kind];
|
|
376
|
+
figures.push({
|
|
377
|
+
id,
|
|
378
|
+
slug: id ? slugify(id) : "",
|
|
379
|
+
caption,
|
|
380
|
+
hasCaption: Boolean(caption),
|
|
381
|
+
classes: attributes.classes,
|
|
382
|
+
kind,
|
|
383
|
+
number,
|
|
384
|
+
label: `${FIGURE_NAMES[kind]} ${number}`,
|
|
385
|
+
line: start + 1,
|
|
386
|
+
bodyStart,
|
|
387
|
+
bodyEnd,
|
|
388
|
+
captionStart,
|
|
389
|
+
captionEnd,
|
|
390
|
+
close,
|
|
391
|
+
});
|
|
392
|
+
i = close;
|
|
393
|
+
}
|
|
394
|
+
for (const heading of headingIds) {
|
|
395
|
+
if (ids.has(heading.id))
|
|
396
|
+
errors.push({
|
|
397
|
+
line: heading.line,
|
|
398
|
+
column: 1,
|
|
399
|
+
message: `heading and figure declare the same anchor "${heading.id}"`,
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
return { figures, errors };
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Render figure fences as HTML, leaving other Markdown untouched.
|
|
407
|
+
*
|
|
408
|
+
* A code, table or prose figure's contents are left as Markdown inside the
|
|
409
|
+
* wrapper, blank-line separated from its tags exactly as
|
|
410
|
+
* {@link module:engine/content-blocks.renderBlocks} leaves a named block's
|
|
411
|
+
* body — so the surrounding render sees it as part of its own document and a
|
|
412
|
+
* footnote reference inside it resolves against the note's own definitions.
|
|
413
|
+
*
|
|
414
|
+
* A figure's contents are rendered here rather than left for a later pass,
|
|
415
|
+
* because a picture-only fence has to become the surface's own `<figure>`
|
|
416
|
+
* markup directly inside the wrapper this function writes; the surrounding
|
|
417
|
+
* render never sees it as Markdown, so a footnote reference inside one is not
|
|
418
|
+
* resolved, the same gap a block's own title carries.
|
|
419
|
+
*
|
|
420
|
+
* @param {string} source - A note body with expanded tables.
|
|
421
|
+
* @param {(markdown: string) => string} [renderMarkdown] - How to render a
|
|
422
|
+
* figure's own contents.
|
|
423
|
+
* @param {Array<{id: string, label: string}>} [numbers] - Labels assigned by a
|
|
424
|
+
* surface that counts across documents, matched by id.
|
|
425
|
+
* @param {object} [options] - Options.
|
|
426
|
+
* @param {(address: string) => string|undefined} [options.resolveRole] - See
|
|
427
|
+
* {@link scanFigures}.
|
|
428
|
+
* @returns {{markdown: string, errors: Array<{line: number, column: number, message: string}>}}
|
|
429
|
+
*/
|
|
430
|
+
export function renderFigureBlocks(
|
|
431
|
+
source,
|
|
432
|
+
renderMarkdown = parser.render.bind(parser),
|
|
433
|
+
numbers,
|
|
434
|
+
{ resolveRole } = {},
|
|
435
|
+
) {
|
|
436
|
+
const { figures, errors } = scanFigures(source, { resolveRole });
|
|
437
|
+
if (errors.length) return { markdown: source, errors };
|
|
438
|
+
const byId =
|
|
439
|
+
numbers ?
|
|
440
|
+
new Map(numbers.filter((entry) => entry.id).map((entry) => [entry.id, entry]))
|
|
441
|
+
: new Map();
|
|
442
|
+
const lines = String(source ?? "").split("\n");
|
|
443
|
+
const output = [];
|
|
444
|
+
let cursor = 0;
|
|
445
|
+
for (const figure of figures) {
|
|
446
|
+
output.push(...lines.slice(cursor, figure.line - 1));
|
|
447
|
+
const numbered = (figure.id ? byId.get(figure.id) : null) ?? figure;
|
|
448
|
+
const contents = lines.slice(figure.bodyStart, figure.bodyEnd).join("\n");
|
|
449
|
+
const classes = ["content-figure", `content-figure-${figure.kind}`, ...figure.classes];
|
|
450
|
+
const id = figure.slug ? ` id="${escapeHtml(figure.slug)}"` : "";
|
|
451
|
+
output.push(`<div${id} class="${escapeHtml(classes.join(" "))}">`);
|
|
452
|
+
if (figure.kind === "figure" || figure.kind === "map") {
|
|
453
|
+
output.push(renderMarkdown(contents).trim());
|
|
454
|
+
} else {
|
|
455
|
+
output.push("", contents, "");
|
|
456
|
+
}
|
|
457
|
+
const label = escapeHtml(numbered.label);
|
|
458
|
+
output.push(
|
|
459
|
+
`<p class="content-figure-label">${
|
|
460
|
+
figure.hasCaption ? `${label}: ${parser.renderInline(figure.caption)}` : label
|
|
461
|
+
}</p>`,
|
|
462
|
+
"</div>",
|
|
463
|
+
);
|
|
464
|
+
cursor = figure.close + 1;
|
|
465
|
+
}
|
|
466
|
+
output.push(...lines.slice(cursor));
|
|
467
|
+
return { markdown: output.join("\n"), errors: [] };
|
|
468
|
+
}
|
|
@@ -68,6 +68,14 @@ export function separateFootnotes(source) {
|
|
|
68
68
|
/** A top-level footnote definition's opening line. */
|
|
69
69
|
const TOP_LEVEL_DEF = /^\[\^([^\]\s]+)\]:/;
|
|
70
70
|
|
|
71
|
+
/**
|
|
72
|
+
* A footnote reference's shape, wherever it is written — excluding a
|
|
73
|
+
* definition's own `[^id]:` opener, which this never matches.
|
|
74
|
+
*
|
|
75
|
+
* @type {RegExp}
|
|
76
|
+
*/
|
|
77
|
+
export const FOOTNOTE_REFERENCE = /\[\^([^\]\s]+)\](?!:)/;
|
|
78
|
+
|
|
71
79
|
/**
|
|
72
80
|
* A list marker or a block quote marker, stripped as many times as it
|
|
73
81
|
* repeats — a block quote can hold a list, and either can nest.
|
|
@@ -154,7 +162,7 @@ export function unresolvedFootnoteReferences(source, definitions) {
|
|
|
154
162
|
const resolved = new Set([...resolvedAgainst.matchAll(/\[\^([^\]\s]+)\]:/g)].map((m) => m[1]));
|
|
155
163
|
const misplaced = new Set(misplacedFootnoteDefinitions(src).map((e) => e.label));
|
|
156
164
|
const errors = [];
|
|
157
|
-
for (const match of matchAllOutsideCode(src,
|
|
165
|
+
for (const match of matchAllOutsideCode(src, new RegExp(FOOTNOTE_REFERENCE.source, "g"))) {
|
|
158
166
|
const label = match[1];
|
|
159
167
|
if (resolved.has(label) || misplaced.has(label)) continue;
|
|
160
168
|
errors.push({
|
|
@@ -186,7 +194,7 @@ export function unusedFootnoteDefinitions(source) {
|
|
|
186
194
|
const src = String(source ?? "");
|
|
187
195
|
const { definitions } = separateFootnotes(src);
|
|
188
196
|
const referenced = new Set(
|
|
189
|
-
[...matchAllOutsideCode(src,
|
|
197
|
+
[...matchAllOutsideCode(src, new RegExp(FOOTNOTE_REFERENCE.source, "g"))].map((m) => m[1]),
|
|
190
198
|
);
|
|
191
199
|
const errors = [];
|
|
192
200
|
for (const match of definitions.matchAll(/^\[\^([^\]\s]+)\]:/gm)) {
|