@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
|
@@ -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
|
+
}
|
|
@@ -1,5 +1,37 @@
|
|
|
1
1
|
/* SPDX-License-Identifier: GPL-3.0-or-later */
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* Where a footnote's two halves belong, and the two findings that hold the
|
|
5
|
+
* line.
|
|
6
|
+
*
|
|
7
|
+
* A reference (`[^id]`) may be written anywhere prose can be written — a
|
|
8
|
+
* paragraph, a list item, a block quote, a table cell, a heading, a
|
|
9
|
+
* definition-list term. A definition (`[^id]: text`) belongs at the top
|
|
10
|
+
* level of the note, the one place {@link separateFootnotes} looks for it:
|
|
11
|
+
* the web and Foundry can resolve one nested in a list or a block quote, but
|
|
12
|
+
* the book cannot, so the three surfaces would otherwise disagree about what
|
|
13
|
+
* one note means. {@link misplacedFootnoteDefinitions} and
|
|
14
|
+
* {@link unresolvedFootnoteReferences} are what every surface reports
|
|
15
|
+
* instead of rendering the three different wrong things a misplaced or
|
|
16
|
+
* missing definition otherwise produces.
|
|
17
|
+
*
|
|
18
|
+
* @module
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { matchAllOutsideCode } from "./code-fences.mjs";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Where a character offset in `source` falls, as a 1-based line and column.
|
|
25
|
+
* @param {string} source - The text the offset indexes into.
|
|
26
|
+
* @param {number} offset - A 0-based character offset.
|
|
27
|
+
* @returns {{line: number, column: number}}
|
|
28
|
+
*/
|
|
29
|
+
function lineColumn(source, offset) {
|
|
30
|
+
const before = source.slice(0, offset);
|
|
31
|
+
const nl = before.lastIndexOf("\n");
|
|
32
|
+
return { line: before.split("\n").length, column: offset - nl };
|
|
33
|
+
}
|
|
34
|
+
|
|
3
35
|
/**
|
|
4
36
|
* Separate top-level footnote definitions from prose while retaining line numbers.
|
|
5
37
|
* Definitions belong to the note and can be used by any rendered page.
|
|
@@ -32,3 +64,168 @@ export function separateFootnotes(source) {
|
|
|
32
64
|
}
|
|
33
65
|
return { markdown: body.join("\n"), definitions: definitions.join("\n\n") };
|
|
34
66
|
}
|
|
67
|
+
|
|
68
|
+
/** A top-level footnote definition's opening line. */
|
|
69
|
+
const TOP_LEVEL_DEF = /^\[\^([^\]\s]+)\]:/;
|
|
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
|
+
|
|
79
|
+
/**
|
|
80
|
+
* A list marker or a block quote marker, stripped as many times as it
|
|
81
|
+
* repeats — a block quote can hold a list, and either can nest.
|
|
82
|
+
*/
|
|
83
|
+
const CONTAINER_PREFIX = /^(?:[ \t]{0,3}>[ \t]?|[ \t]{0,3}(?:[-*+]|\d{1,9}[.)])[ \t]+)+/;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* A footnote definition written inside a list item or a block quote, where
|
|
87
|
+
* the web and Foundry can resolve it but the book drops it silently.
|
|
88
|
+
*
|
|
89
|
+
* A definition inside a table cell is not reported here: a table cell is
|
|
90
|
+
* inline content, so `[^id]:` there is never recognised as a definition on
|
|
91
|
+
* any surface and simply prints as written — the reference it was meant to
|
|
92
|
+
* answer is what {@link unresolvedFootnoteReferences} reports.
|
|
93
|
+
*
|
|
94
|
+
* @param {string} source - A note's markdown body.
|
|
95
|
+
* @returns {Array<{line: number, column: number, label: string, message: string}>}
|
|
96
|
+
*/
|
|
97
|
+
export function misplacedFootnoteDefinitions(source) {
|
|
98
|
+
const lines = String(source ?? "").split("\n");
|
|
99
|
+
const errors = [];
|
|
100
|
+
let fence = null;
|
|
101
|
+
// Inside the continuation of a genuine top-level definition: a blank
|
|
102
|
+
// line or a four-space indent belongs to it, not to a new mistake.
|
|
103
|
+
let continuation = false;
|
|
104
|
+
for (let i = 0; i < lines.length; i++) {
|
|
105
|
+
const line = lines[i];
|
|
106
|
+
const marker = /^ {0,3}(`{3,}|~{3,})/.exec(line);
|
|
107
|
+
if (fence) {
|
|
108
|
+
if (marker && marker[1][0] === fence[0] && marker[1].length >= fence.length)
|
|
109
|
+
fence = null;
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (marker) {
|
|
113
|
+
fence = marker[1];
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (TOP_LEVEL_DEF.test(line)) {
|
|
117
|
+
continuation = true;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (continuation) {
|
|
121
|
+
if (!line.trim() || /^(?: {4}|\t)/.test(line)) continue;
|
|
122
|
+
continuation = false;
|
|
123
|
+
}
|
|
124
|
+
const prefix = CONTAINER_PREFIX.exec(line);
|
|
125
|
+
if (!prefix) continue;
|
|
126
|
+
const rest = line.slice(prefix[0].length);
|
|
127
|
+
const match = TOP_LEVEL_DEF.exec(rest);
|
|
128
|
+
if (!match) continue;
|
|
129
|
+
errors.push({
|
|
130
|
+
line: i + 1,
|
|
131
|
+
column: prefix[0].length + 1,
|
|
132
|
+
label: match[1],
|
|
133
|
+
message:
|
|
134
|
+
"a footnote definition belongs at the top level of the note " +
|
|
135
|
+
"— a reference may be written anywhere, but move this " +
|
|
136
|
+
`\`[^${match[1]}]:\` definition out of the list or block quote`,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
return errors;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* A footnote reference with no top-level definition to resolve against.
|
|
144
|
+
*
|
|
145
|
+
* Covers both commoner slips alike: a label never defined at all, and one
|
|
146
|
+
* "defined" only inside a table cell, which is inline content and never
|
|
147
|
+
* registers as a definition on any surface. A label misplaced in a list or a
|
|
148
|
+
* block quote is excluded — {@link misplacedFootnoteDefinitions} already
|
|
149
|
+
* reports that mistake at the definition's own line, and reporting it again
|
|
150
|
+
* at the reference would say the same thing twice.
|
|
151
|
+
*
|
|
152
|
+
* @param {string} source - A note's markdown body.
|
|
153
|
+
* @param {string} [definitions] - Its top-level definitions, when a caller
|
|
154
|
+
* already extracted them with {@link separateFootnotes}. Derived from
|
|
155
|
+
* `source` when omitted; a caller already holding both passes its own
|
|
156
|
+
* extraction instead, so the two cannot disagree about what is defined.
|
|
157
|
+
* @returns {Array<{line: number, column: number, message: string}>}
|
|
158
|
+
*/
|
|
159
|
+
export function unresolvedFootnoteReferences(source, definitions) {
|
|
160
|
+
const src = String(source ?? "");
|
|
161
|
+
const resolvedAgainst = definitions ?? separateFootnotes(src).definitions;
|
|
162
|
+
const resolved = new Set([...resolvedAgainst.matchAll(/\[\^([^\]\s]+)\]:/g)].map((m) => m[1]));
|
|
163
|
+
const misplaced = new Set(misplacedFootnoteDefinitions(src).map((e) => e.label));
|
|
164
|
+
const errors = [];
|
|
165
|
+
for (const match of matchAllOutsideCode(src, new RegExp(FOOTNOTE_REFERENCE.source, "g"))) {
|
|
166
|
+
const label = match[1];
|
|
167
|
+
if (resolved.has(label) || misplaced.has(label)) continue;
|
|
168
|
+
errors.push({
|
|
169
|
+
...lineColumn(src, match.index),
|
|
170
|
+
message:
|
|
171
|
+
`footnote [^${label}] has no definition, so it is set as text — write ` +
|
|
172
|
+
`\`[^${label}]: …\` at the top level of the note`,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
return errors;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* A top-level footnote definition no reference anywhere in the note uses.
|
|
180
|
+
*
|
|
181
|
+
* `separateFootnotes` lifts it out correctly, nothing resolves against it,
|
|
182
|
+
* and the paragraph it held vanishes from every surface with the build
|
|
183
|
+
* exiting 0 — a dropped paragraph is not something a reader can notice is
|
|
184
|
+
* missing, so it is an error here rather than a warning, the same severity
|
|
185
|
+
* every other silent loss this pass catches already carries. A note mid-edit,
|
|
186
|
+
* with the reference already deleted and the definition not yet followed, is
|
|
187
|
+
* exactly the case this refuses — which is the point: the alternative is
|
|
188
|
+
* shipping it.
|
|
189
|
+
*
|
|
190
|
+
* @param {string} source - A note's markdown body.
|
|
191
|
+
* @returns {Array<{line: number, column: number, message: string}>}
|
|
192
|
+
*/
|
|
193
|
+
export function unusedFootnoteDefinitions(source) {
|
|
194
|
+
const src = String(source ?? "");
|
|
195
|
+
const { definitions } = separateFootnotes(src);
|
|
196
|
+
const referenced = new Set(
|
|
197
|
+
[...matchAllOutsideCode(src, new RegExp(FOOTNOTE_REFERENCE.source, "g"))].map((m) => m[1]),
|
|
198
|
+
);
|
|
199
|
+
const errors = [];
|
|
200
|
+
for (const match of definitions.matchAll(/^\[\^([^\]\s]+)\]:/gm)) {
|
|
201
|
+
const label = match[1];
|
|
202
|
+
if (referenced.has(label)) continue;
|
|
203
|
+
const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
204
|
+
const at = new RegExp(`^\\[\\^${escaped}\\]:`, "m").exec(src);
|
|
205
|
+
if (!at) continue;
|
|
206
|
+
errors.push({
|
|
207
|
+
...lineColumn(src, at.index),
|
|
208
|
+
message:
|
|
209
|
+
`footnote [^${label}] is defined but no reference uses it — delete the ` +
|
|
210
|
+
`definition, or add a [^${label}] where it belongs`,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
return errors;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Every footnote placement finding for one note body — the union
|
|
218
|
+
* {@link misplacedFootnoteDefinitions}, {@link unresolvedFootnoteReferences}
|
|
219
|
+
* and {@link unusedFootnoteDefinitions} report, in document order.
|
|
220
|
+
*
|
|
221
|
+
* @param {string} source - A note's markdown body.
|
|
222
|
+
* @returns {Array<{line: number, column: number, message: string}>}
|
|
223
|
+
*/
|
|
224
|
+
export function footnoteFindings(source) {
|
|
225
|
+
const misplaced = misplacedFootnoteDefinitions(source).map(({ label, ...rest }) => rest);
|
|
226
|
+
const unresolved = unresolvedFootnoteReferences(source);
|
|
227
|
+
const unused = unusedFootnoteDefinitions(source);
|
|
228
|
+
return [...misplaced, ...unresolved, ...unused].sort(
|
|
229
|
+
(a, b) => a.line - b.line || a.column - b.column,
|
|
230
|
+
);
|
|
231
|
+
}
|