@heroiclands/package-build 22.21.0 → 22.22.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 +191 -0
- package/assets/styles/content-base.css +162 -0
- package/bin/content-commands.mjs +26 -3
- package/bin/package-build.mjs +15 -2
- package/ci/content-format-reference.mjs +68 -1
- package/config.mjs +26 -0
- package/content-config.mjs +73 -4
- package/docs/api.md +36 -18
- package/docs/authoring/dates-and-calendars.md +4 -2
- package/docs/authoring/frontmatter.md +2 -2
- package/docs/authoring/links-and-markup.md +237 -28
- package/docs/configuration.md +27 -1
- package/docs/content-format.md +3 -1
- package/docs/guides/reference-pages.md +15 -0
- package/docs/reference/format-details.md +224 -46
- package/docs/reference/note-types.md +21 -4
- package/engine/actor-compiler.mjs +23 -0
- package/engine/anchored-sections.mjs +35 -4
- package/engine/anchors.mjs +10 -20
- package/engine/base-compiler.mjs +86 -21
- package/engine/base-styles.mjs +149 -0
- package/engine/bundles.mjs +0 -1
- package/engine/compendiums.mjs +8 -1
- package/engine/content-blocks.mjs +410 -0
- package/engine/content-captions.mjs +57 -8
- package/engine/content-footnotes.mjs +189 -0
- package/engine/content-format-check.mjs +15 -12
- package/engine/content-index.mjs +2 -2
- 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/frontmatter-lint.mjs +47 -64
- package/engine/frontmatter.mjs +3 -3
- package/engine/heading-attributes.mjs +308 -0
- package/engine/helpers.mjs +14 -13
- package/engine/homepage.mjs +8 -8
- package/engine/index.mjs +3 -0
- package/engine/infobox-render.mjs +15 -3
- package/engine/infobox.mjs +83 -8
- package/engine/journals.mjs +36 -17
- package/engine/macros.mjs +1 -1
- package/engine/markdown-expressions.mjs +178 -53
- package/engine/note-dates.mjs +49 -0
- package/engine/note-frontmatter.mjs +7 -16
- package/engine/note-vocabulary.mjs +172 -37
- package/engine/page-lists.mjs +358 -0
- package/engine/pdf-build.mjs +76 -42
- package/engine/pdf-render.mjs +422 -49
- 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 +10 -3
- package/engine/site-build.mjs +41 -12
- package/engine/site-config.mjs +10 -1
- package/engine/site-index.mjs +18 -2
- package/engine/site-preview.mjs +18 -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 +12 -14
- package/engine/web-wikilinks.mjs +48 -1
- package/hm3/actors.mjs +6 -3
- package/manifest.mjs +13 -0
- package/package.json +1 -1
- package/sohl/actors.mjs +22 -99
- package/sohl/item-fields.mjs +4 -5
- package/sohl/items.mjs +1 -1
- package/sohl/note-schemas.mjs +11 -21
- package/types/config.d.mts +9 -0
- package/types/engine/actor-compiler.d.mts +11 -0
- package/types/engine/anchored-sections.d.mts +24 -0
- package/types/engine/base-compiler.d.mts +19 -0
- package/types/engine/base-styles.d.mts +93 -0
- package/types/engine/content-blocks.d.mts +87 -0
- package/types/engine/content-captions.d.mts +14 -1
- package/types/engine/content-footnotes.d.mts +73 -0
- package/types/engine/content-format-check.d.mts +8 -6
- 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/frontmatter.d.mts +1 -1
- package/types/engine/heading-attributes.d.mts +174 -0
- package/types/engine/helpers.d.mts +7 -4
- package/types/engine/homepage.d.mts +7 -7
- package/types/engine/index.d.mts +1 -0
- package/types/engine/infobox-render.d.mts +6 -1
- package/types/engine/journals.d.mts +14 -7
- package/types/engine/markdown-expressions.d.mts +23 -0
- 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/pdf-render.d.mts +72 -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 +13 -1
- package/types/engine/site-config.d.mts +9 -0
- package/types/engine/site-index.d.mts +13 -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 +11 -12
- package/types/engine/web-wikilinks.d.mts +3 -1
- package/types/hm3/actors.d.mts +3 -3
- package/types/sohl/actors.d.mts +6 -7
- package/engine/content-admonitions.mjs +0 -114
- package/engine/content-secrets.mjs +0 -112
- package/types/engine/content-admonitions.d.mts +0 -24
- package/types/engine/content-secrets.d.mts +0 -17
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
/* SPDX-License-Identifier: GPL-3.0-or-later */
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Named body blocks — `:::secret`, `:::info` and `:::warn` — as one construct.
|
|
5
|
+
*
|
|
6
|
+
* ```
|
|
7
|
+
* :::info {#route title="The road north" .wide}
|
|
8
|
+
* The ford floods in Azlet.
|
|
9
|
+
* :::
|
|
10
|
+
* ```
|
|
11
|
+
*
|
|
12
|
+
* The block's name becomes its first class, the author's classes follow it, and
|
|
13
|
+
* every other attribute is written onto the element. `title` is reserved: it
|
|
14
|
+
* supplies the heading, and a block that states none takes the capitalised name
|
|
15
|
+
* — `Secret`, `Info`, `Warn`.
|
|
16
|
+
*
|
|
17
|
+
* The three names differ in the class they carry and in nothing else, so a
|
|
18
|
+
* stylesheet owns their appearance. None is emitted with an inline `style`.
|
|
19
|
+
*
|
|
20
|
+
* Each block stands on its own: a malformed one is a finding at its own line
|
|
21
|
+
* and the blocks around it still render.
|
|
22
|
+
*
|
|
23
|
+
* **An H1, or an anchored heading at any level, is refused inside a block.**
|
|
24
|
+
* Either starts a Foundry journal page, which would tear the block's own
|
|
25
|
+
* page in two and publish the rest with no wrapper around it at all.
|
|
26
|
+
*
|
|
27
|
+
|
|
28
|
+
* **One pass reads all three names.** Reading them in two passes made the first
|
|
29
|
+
* one meet the second's closers with nothing open, and report a block the author
|
|
30
|
+
* had not written. A construct this pass does not own — `:::caption` — is
|
|
31
|
+
* counted rather than claimed, so its closer is attributed to it and the pass
|
|
32
|
+
* that owns it still finds it in the markdown this one passes through.
|
|
33
|
+
*
|
|
34
|
+
* **A block's body is left as Markdown, not pre-rendered.** The wrapper is
|
|
35
|
+
* written with a blank line after the opening tag and before the closing one,
|
|
36
|
+
* which is what lets the surrounding renderer — Foundry's single parse of the
|
|
37
|
+
* whole page, or Hugo's of the whole site page — read the body as part of its
|
|
38
|
+
* own document rather than as an isolated fragment. A fragment rendered on its
|
|
39
|
+
* own has no access to the note's footnote definitions, which live at the top
|
|
40
|
+
* level outside every block, so a reference inside one used to fall through as
|
|
41
|
+
* literal `[^id]` text; reached by the one parse that also sees the
|
|
42
|
+
* definitions, it resolves exactly as a reference inside a list item or a
|
|
43
|
+
* block quote already does.
|
|
44
|
+
*
|
|
45
|
+
* @module
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
import crypto from "node:crypto";
|
|
49
|
+
import MarkdownIt from "markdown-it";
|
|
50
|
+
import footnotePlugin from "markdown-it-footnote";
|
|
51
|
+
import deflistPlugin from "markdown-it-deflist";
|
|
52
|
+
import { parseExtensionAttributes, refusedAttributes } from "./extension-attributes.mjs";
|
|
53
|
+
import { WITHHELD_CLASS, parseHeadingLine, withheldSections } from "./heading-attributes.mjs";
|
|
54
|
+
|
|
55
|
+
const parser = new MarkdownIt({ html: true }).use(footnotePlugin).use(deflistPlugin);
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Titles carry emphasis and nothing else. `html: false` escapes any tag an
|
|
59
|
+
* author writes there, because a title is written into an element's first child
|
|
60
|
+
* and a stray `<section>` would close the block early.
|
|
61
|
+
*/
|
|
62
|
+
const titleParser = new MarkdownIt({ html: false });
|
|
63
|
+
|
|
64
|
+
const OPEN = /^:::([A-Za-z][A-Za-z0-9-]*)(?:[ \t]+(.*?))?[ \t]*$/;
|
|
65
|
+
const CLOSE = /^:::[ \t]*$/;
|
|
66
|
+
|
|
67
|
+
/** The blocks an author may open, with the heading each takes by default. */
|
|
68
|
+
export const BLOCK_NAMES = Object.freeze({
|
|
69
|
+
info: "Info",
|
|
70
|
+
secret: "Secret",
|
|
71
|
+
warn: "Warn",
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Blocks that may hold another named block.
|
|
76
|
+
*
|
|
77
|
+
* A box inside a GM-only section is a real thing to write — the section is a
|
|
78
|
+
* container for whatever the GM reads, boxes included — so a `secret` holds one
|
|
79
|
+
* and the box stays inside it. A box inside a box is noise, so `info` and `warn`
|
|
80
|
+
* hold no named block and a second opener inside one is a finding.
|
|
81
|
+
*
|
|
82
|
+
* Exported because a renderer has to know it: the body of a container is read
|
|
83
|
+
* for blocks of its own, and the body of a box is not.
|
|
84
|
+
*
|
|
85
|
+
* @type {readonly string[]}
|
|
86
|
+
*/
|
|
87
|
+
export const BLOCK_CONTAINERS = Object.freeze(["secret"]);
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Constructs another pass owns. A `:::caption` is read by the caption pass, so
|
|
91
|
+
* this one **counts it and does not claim it**: the opener and its closer pass
|
|
92
|
+
* through untouched, and the count is what lets a closer be attributed to the
|
|
93
|
+
* innermost block actually open. Claiming one would report a block that does not
|
|
94
|
+
* exist and take the caption out of the note.
|
|
95
|
+
*/
|
|
96
|
+
const FOREIGN = Object.freeze(["caption"]);
|
|
97
|
+
|
|
98
|
+
/** `title` is the heading. Everything else an author writes becomes an attribute. */
|
|
99
|
+
const TITLE = "title";
|
|
100
|
+
|
|
101
|
+
const names = () => Object.keys(BLOCK_NAMES).join(", ");
|
|
102
|
+
|
|
103
|
+
/** Text safe inside a double-quoted attribute. */
|
|
104
|
+
function escapeAttribute(value) {
|
|
105
|
+
return String(value)
|
|
106
|
+
.replace(/&/g, "&")
|
|
107
|
+
.replace(/</g, "<")
|
|
108
|
+
.replace(/>/g, ">")
|
|
109
|
+
.replace(/"/g, """);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Find named blocks, leaving code examples alone.
|
|
114
|
+
*
|
|
115
|
+
* A block reaches `blocks` only when it is well formed and closed; everything
|
|
116
|
+
* else is a finding, and the surrounding blocks are unaffected.
|
|
117
|
+
*
|
|
118
|
+
* @param {string} source - Markdown that may contain named blocks.
|
|
119
|
+
* @returns {{blocks: Array<{name: string, title: string, id: string, classes: string[], attributes: Record<string, string>, start: number, end: number, body: string}>, errors: Array<{line: number, column: number, message: string}>}}
|
|
120
|
+
*/
|
|
121
|
+
export function scanBlocks(source) {
|
|
122
|
+
const lines = String(source ?? "").split("\n");
|
|
123
|
+
const blocks = [];
|
|
124
|
+
const errors = [];
|
|
125
|
+
let opening = null;
|
|
126
|
+
let codeFence = null;
|
|
127
|
+
// Blocks open inside this pass's own block, and blocks of another
|
|
128
|
+
// construct open outside it, counted so a `:::` closes the innermost thing
|
|
129
|
+
// rather than whatever this pass happens to have open.
|
|
130
|
+
let innerOpen = 0;
|
|
131
|
+
let foreignOutside = 0;
|
|
132
|
+
|
|
133
|
+
for (let i = 0; i < lines.length; i++) {
|
|
134
|
+
const line = lines[i];
|
|
135
|
+
const fence = /^ {0,3}(`{3,}|~{3,})/.exec(line);
|
|
136
|
+
if (codeFence) {
|
|
137
|
+
if (fence && fence[1][0] === codeFence[0] && fence[1].length >= codeFence.length) {
|
|
138
|
+
codeFence = null;
|
|
139
|
+
}
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (fence) {
|
|
143
|
+
codeFence = fence[1];
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (CLOSE.test(line)) {
|
|
148
|
+
// A closer belongs to the innermost block still open, whichever
|
|
149
|
+
// pass owns it.
|
|
150
|
+
if (opening && innerOpen > 0) {
|
|
151
|
+
innerOpen -= 1;
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (!opening) {
|
|
155
|
+
if (foreignOutside > 0) {
|
|
156
|
+
foreignOutside -= 1;
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
errors.push({ line: i + 1, column: 1, message: "a ::: line closes no block" });
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
if (opening.rejected) {
|
|
163
|
+
opening = null;
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
const body = lines
|
|
167
|
+
.slice(opening.start + 1, i)
|
|
168
|
+
.join("\n")
|
|
169
|
+
.trim();
|
|
170
|
+
if (!body) {
|
|
171
|
+
errors.push({
|
|
172
|
+
line: opening.start + 1,
|
|
173
|
+
column: 1,
|
|
174
|
+
message: `${opening.name} block is empty`,
|
|
175
|
+
});
|
|
176
|
+
opening = null;
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
// An H1, or an anchored heading at any level, starts a Foundry
|
|
180
|
+
// journal page — splitting the block's own page in two and
|
|
181
|
+
// publishing the rest with no wrapper around it, a GM-only
|
|
182
|
+
// section included. Refused rather than split: a lower heading
|
|
183
|
+
// with no anchor is still the ordinary way to structure a box.
|
|
184
|
+
for (let at = opening.start + 1; at < i; at++) {
|
|
185
|
+
if (!parseHeadingLine(lines[at])?.startsPage) continue;
|
|
186
|
+
const article = /^[aeiou]/i.test(opening.name) ? "an" : "a";
|
|
187
|
+
errors.push({
|
|
188
|
+
line: at + 1,
|
|
189
|
+
column: 1,
|
|
190
|
+
message:
|
|
191
|
+
`a heading that starts a page cannot be written inside ${article} ` +
|
|
192
|
+
`${opening.name} block — keep an H1 or an anchored heading ` +
|
|
193
|
+
"at the top level, or drop the anchor and the level to stay inside it",
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
blocks.push({ ...opening, end: i, body });
|
|
197
|
+
opening = null;
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const open = OPEN.exec(line);
|
|
202
|
+
if (!open) continue;
|
|
203
|
+
const [, name, raw = ""] = open;
|
|
204
|
+
const at = { line: i + 1, column: 1 };
|
|
205
|
+
if (FOREIGN.includes(name)) {
|
|
206
|
+
if (opening) innerOpen += 1;
|
|
207
|
+
else foreignOutside += 1;
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
if (!Object.hasOwn(BLOCK_NAMES, name)) {
|
|
211
|
+
errors.push({ ...at, message: `there is no ${name} block; the blocks are ${names()}` });
|
|
212
|
+
opening = { start: i, rejected: true };
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
if (opening) {
|
|
216
|
+
if (BLOCK_CONTAINERS.includes(opening.name) && !BLOCK_CONTAINERS.includes(name)) {
|
|
217
|
+
// Held by the block it is written in, and counted so the
|
|
218
|
+
// closers are attributed in the order they were opened. The
|
|
219
|
+
// body is rendered for its own blocks before it is rendered as
|
|
220
|
+
// markdown, so the inner block comes out as a block.
|
|
221
|
+
innerOpen += 1;
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
// Not counted, unlike a block the outer one holds: the nesting is
|
|
225
|
+
// already reported, and counting it would leave the outer block
|
|
226
|
+
// looking unclosed as well — two findings for one mistake.
|
|
227
|
+
errors.push({ ...at, message: `nested ${name} blocks are not supported` });
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
let id = "";
|
|
232
|
+
let classes = [];
|
|
233
|
+
const attributes = {};
|
|
234
|
+
let title = BLOCK_NAMES[name];
|
|
235
|
+
const text = raw.trim();
|
|
236
|
+
if (text) {
|
|
237
|
+
if (!/^\{[^}\n]*\}$/.test(text)) {
|
|
238
|
+
errors.push({ ...at, message: "block attributes need {…} braces" });
|
|
239
|
+
opening = { start: i, rejected: true };
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
const parsed = parseExtensionAttributes(text.slice(1, -1));
|
|
243
|
+
if (parsed.problems.length) {
|
|
244
|
+
for (const message of parsed.problems) errors.push({ ...at, message });
|
|
245
|
+
opening = { start: i, rejected: true };
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
id = parsed.id;
|
|
249
|
+
classes = parsed.classes;
|
|
250
|
+
const refused = refusedAttributes(parsed.values);
|
|
251
|
+
for (const message of refused) errors.push({ ...at, message });
|
|
252
|
+
if (refused.length) {
|
|
253
|
+
opening = { start: i, rejected: true };
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
for (const [key, value] of Object.entries(parsed.values)) {
|
|
257
|
+
if (key === TITLE) title = value;
|
|
258
|
+
else attributes[key] = value;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
opening = { start: i, name, title, id, classes, attributes };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (opening && !opening.rejected) {
|
|
265
|
+
errors.push({
|
|
266
|
+
line: opening.start + 1,
|
|
267
|
+
column: 1,
|
|
268
|
+
message: `${opening.name} block needs a closing ::: line`,
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
return { blocks, errors };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* The id an element carries.
|
|
276
|
+
*
|
|
277
|
+
* A `secret` with no id of its own is given one derived from its body, because
|
|
278
|
+
* Foundry tracks a revealed section by id and a section without one cannot be
|
|
279
|
+
* remembered.
|
|
280
|
+
*/
|
|
281
|
+
function elementId(block, target) {
|
|
282
|
+
if (block.id) return block.id;
|
|
283
|
+
if (target !== "foundry" || block.name !== "secret") return "";
|
|
284
|
+
const digest = crypto
|
|
285
|
+
.createHash("sha256")
|
|
286
|
+
.update(`${block.start}:${block.body}`)
|
|
287
|
+
.digest("hex")
|
|
288
|
+
.slice(0, 12);
|
|
289
|
+
return `secret-${digest}`;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** The attribute text shared by both surfaces, id and classes first. */
|
|
293
|
+
function attributeText(block, target, classes) {
|
|
294
|
+
const id = elementId(block, target);
|
|
295
|
+
const parts = [`class="${escapeAttribute(classes.join(" "))}"`];
|
|
296
|
+
if (id) parts.push(`id="${escapeAttribute(id)}"`);
|
|
297
|
+
for (const [key, value] of Object.entries(block.attributes)) {
|
|
298
|
+
parts.push(`${key}="${escapeAttribute(value)}"`);
|
|
299
|
+
}
|
|
300
|
+
return parts.join(" ");
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Render named blocks for one publishing surface.
|
|
305
|
+
*
|
|
306
|
+
* `foundry` emits a `<section>` whose heading is its first line; `web` emits a
|
|
307
|
+
* `<details>` the reader opens. Neither carries an inline `style`.
|
|
308
|
+
*
|
|
309
|
+
* The body is left as Markdown between the wrapper's tags, each separated from
|
|
310
|
+
* its tag by a blank line — see the module docs for why that is what lets the
|
|
311
|
+
* surrounding render treat the body as real content rather than an opaque
|
|
312
|
+
* fragment.
|
|
313
|
+
*
|
|
314
|
+
* A finding does not stop the well-formed blocks around it from rendering.
|
|
315
|
+
*
|
|
316
|
+
* @param {string} source - Markdown containing named blocks.
|
|
317
|
+
* @param {"foundry"|"web"} target - Publishing surface.
|
|
318
|
+
* @returns {{markdown: string, errors: Array<{line: number, column: number, message: string}>}}
|
|
319
|
+
*/
|
|
320
|
+
export function renderBlocks(source, target) {
|
|
321
|
+
const { blocks, errors } = scanBlocks(source);
|
|
322
|
+
if (!blocks.length) return { markdown: String(source ?? ""), errors };
|
|
323
|
+
const lines = String(source ?? "").split("\n");
|
|
324
|
+
const output = [];
|
|
325
|
+
let cursor = 0;
|
|
326
|
+
for (const block of blocks) {
|
|
327
|
+
output.push(...lines.slice(cursor, block.start));
|
|
328
|
+
const classes = [block.name, ...block.classes];
|
|
329
|
+
const attributes = attributeText(block, target, classes);
|
|
330
|
+
const title = titleParser.renderInline(block.title);
|
|
331
|
+
// A container's body may hold a block of its own, rendered first so
|
|
332
|
+
// its own wrapper is already written when the outer one wraps it in
|
|
333
|
+
// turn — nesting composes because each level is markdown-with-raw-HTML
|
|
334
|
+
// at every depth, never a rendered fragment.
|
|
335
|
+
const held =
|
|
336
|
+
BLOCK_CONTAINERS.includes(block.name) ?
|
|
337
|
+
renderBlocks(block.body, target).markdown
|
|
338
|
+
: block.body;
|
|
339
|
+
if (target === "foundry") {
|
|
340
|
+
output.push(
|
|
341
|
+
"",
|
|
342
|
+
`<section ${attributes}>`,
|
|
343
|
+
`<strong>${title}</strong>:<br/>`,
|
|
344
|
+
"",
|
|
345
|
+
held,
|
|
346
|
+
"",
|
|
347
|
+
"</section>",
|
|
348
|
+
"",
|
|
349
|
+
);
|
|
350
|
+
} else {
|
|
351
|
+
output.push(
|
|
352
|
+
"",
|
|
353
|
+
`<details ${attributes}>`,
|
|
354
|
+
`<summary class="${escapeAttribute(block.name)}">${title}</summary>`,
|
|
355
|
+
"",
|
|
356
|
+
held,
|
|
357
|
+
"",
|
|
358
|
+
"</details>",
|
|
359
|
+
"",
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
cursor = block.end + 1;
|
|
363
|
+
}
|
|
364
|
+
output.push(...lines.slice(cursor));
|
|
365
|
+
return { markdown: output.join("\n"), errors };
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Wrap each withheld section in the disclosure a `secret` block renders as.
|
|
370
|
+
*
|
|
371
|
+
* The section's own Markdown is left as Markdown, with a blank line either side
|
|
372
|
+
* of it, so the page's renderer still reads it: the heading keeps its id, the
|
|
373
|
+
* prose keeps its links, and only the disclosure around them is written as HTML.
|
|
374
|
+
*
|
|
375
|
+
* **A spoiler, not access control.** The section is in the published page, so a
|
|
376
|
+
* reader who opens the element or reads the source reads it. Foundry is the one
|
|
377
|
+
* surface that withholds anything.
|
|
378
|
+
*
|
|
379
|
+
* @param {string} source - A page's Markdown, headings intact.
|
|
380
|
+
* @returns {string} The same Markdown, each withheld section inside a
|
|
381
|
+
* `<details>`.
|
|
382
|
+
*/
|
|
383
|
+
export function renderWithheldSections(source) {
|
|
384
|
+
const text = String(source ?? "");
|
|
385
|
+
const { sections } = withheldSections(text);
|
|
386
|
+
if (!sections.length) return text;
|
|
387
|
+
const lines = text.split("\n");
|
|
388
|
+
const output = [];
|
|
389
|
+
let cursor = 0;
|
|
390
|
+
for (const section of sections) {
|
|
391
|
+
output.push(...lines.slice(cursor, section.start));
|
|
392
|
+
const classes = [
|
|
393
|
+
WITHHELD_CLASS,
|
|
394
|
+
...section.heading.classes.filter((name) => name !== WITHHELD_CLASS),
|
|
395
|
+
];
|
|
396
|
+
output.push(
|
|
397
|
+
"",
|
|
398
|
+
`<details class="${escapeAttribute(classes.join(" "))}">`,
|
|
399
|
+
`<summary class="${escapeAttribute(WITHHELD_CLASS)}">${BLOCK_NAMES[WITHHELD_CLASS]}</summary>`,
|
|
400
|
+
"",
|
|
401
|
+
...lines.slice(section.start, section.end),
|
|
402
|
+
"",
|
|
403
|
+
"</details>",
|
|
404
|
+
"",
|
|
405
|
+
);
|
|
406
|
+
cursor = section.end;
|
|
407
|
+
}
|
|
408
|
+
output.push(...lines.slice(cursor));
|
|
409
|
+
return output.join("\n");
|
|
410
|
+
}
|
|
@@ -8,6 +8,8 @@ import deflistPlugin from "markdown-it-deflist";
|
|
|
8
8
|
|
|
9
9
|
import { slugify } from "./content-slug.mjs";
|
|
10
10
|
import { parseExtensionAttributes } from "./extension-attributes.mjs";
|
|
11
|
+
import { HEADING_LINE, parseHeadingLine, splitHeadingAttributes } from "./heading-attributes.mjs";
|
|
12
|
+
import { imagesIn } from "./content-images.mjs";
|
|
11
13
|
|
|
12
14
|
const parser = new MarkdownIt({ html: true }).use(footnotePlugin).use(deflistPlugin);
|
|
13
15
|
const OPEN = /^:::caption\s+(\{[^}\n]*\})\s*$/;
|
|
@@ -61,8 +63,9 @@ export function scanCaptions(source) {
|
|
|
61
63
|
codeFence = fence[1];
|
|
62
64
|
continue;
|
|
63
65
|
}
|
|
64
|
-
const heading =
|
|
65
|
-
|
|
66
|
+
const heading = HEADING_LINE.exec(line);
|
|
67
|
+
const headingId = heading ? splitHeadingAttributes(heading[2]).id : "";
|
|
68
|
+
if (headingId) headingIds.push({ id: slugify(headingId), line: i + 1 });
|
|
66
69
|
if (!CAPTION_LINE.test(line)) continue;
|
|
67
70
|
const open = OPEN.exec(line);
|
|
68
71
|
if (!open) {
|
|
@@ -96,6 +99,20 @@ export function scanCaptions(source) {
|
|
|
96
99
|
.join("\n")
|
|
97
100
|
.trim();
|
|
98
101
|
if (!caption) errors.push({ line: start + 1, column: 1, message: "caption text is empty" });
|
|
102
|
+
// An H1, or an anchored heading at any level, starts a Foundry
|
|
103
|
+
// journal page — see `engine/content-blocks.mjs`'s identical refusal
|
|
104
|
+
// for a named block.
|
|
105
|
+
for (let at = i + 1; at < close; at++) {
|
|
106
|
+
if (!parseHeadingLine(lines[at])?.startsPage) continue;
|
|
107
|
+
errors.push({
|
|
108
|
+
line: at + 1,
|
|
109
|
+
column: 1,
|
|
110
|
+
message:
|
|
111
|
+
"a heading that starts a page cannot be written inside a caption — " +
|
|
112
|
+
"keep an H1 or an anchored heading at the top level, or drop the " +
|
|
113
|
+
"anchor and the level to stay inside it",
|
|
114
|
+
});
|
|
115
|
+
}
|
|
99
116
|
if (ids.has(slugify(id)))
|
|
100
117
|
errors.push({ line: start + 1, column: 1, message: `duplicate caption id "${id}"` });
|
|
101
118
|
ids.add(slugify(id));
|
|
@@ -116,11 +133,22 @@ export function scanCaptions(source) {
|
|
|
116
133
|
}
|
|
117
134
|
const blockEnd = next + first.map[1];
|
|
118
135
|
const block = lines.slice(next, blockEnd).join("\n");
|
|
136
|
+
const trimmedBlock = block.trim();
|
|
137
|
+
// A markdown image is only a figure when it stands alone — nothing
|
|
138
|
+
// else in the block, which is also what decides whether the image
|
|
139
|
+
// plugin wraps it in a `<figure>` at all. A trailing footnote
|
|
140
|
+
// reference, or any other trailing text, disqualifies it exactly as
|
|
141
|
+
// it disqualifies `standsAlone`; such a block is prose with an inline
|
|
142
|
+
// image in it, left as Markdown so a reference inside resolves
|
|
143
|
+
// rather than classified as a figure whose own render strips nothing.
|
|
144
|
+
const aloneImage =
|
|
145
|
+
/^(?:!\[|!\[\[)/.test(trimmedBlock) &&
|
|
146
|
+
imagesIn(trimmedBlock).some((image) => image.block);
|
|
119
147
|
const kind =
|
|
120
148
|
first.type === "table_open" ? "table"
|
|
121
149
|
: first.type === "fence" && /^\s*sql\b/i.test(first.info ?? "") ? "table"
|
|
122
150
|
: first.type === "fence" || first.type === "code_block" ? "code"
|
|
123
|
-
: /^(
|
|
151
|
+
: aloneImage || /^(?:<figure\b|<img\b)/.test(trimmedBlock) ? "figure"
|
|
124
152
|
: "prose";
|
|
125
153
|
const number = ++counts[kind];
|
|
126
154
|
captions.push({
|
|
@@ -147,7 +175,20 @@ export function scanCaptions(source) {
|
|
|
147
175
|
return { captions, errors };
|
|
148
176
|
}
|
|
149
177
|
|
|
150
|
-
/**
|
|
178
|
+
/**
|
|
179
|
+
* Render captioned blocks as HTML, leaving other Markdown untouched.
|
|
180
|
+
*
|
|
181
|
+
* A code, table or prose caption's block is left as Markdown inside the
|
|
182
|
+
* wrapper, blank-line separated from its tags exactly as
|
|
183
|
+
* {@link module:engine/content-blocks.renderBlocks} leaves a named block's
|
|
184
|
+
* body — so the surrounding render sees it as part of its own document and a
|
|
185
|
+
* footnote reference inside it resolves against the note's own definitions.
|
|
186
|
+
*
|
|
187
|
+
* A figure's block is still rendered here, because its `<figcaption>` has to
|
|
188
|
+
* be stripped from HTML this pass controls directly; the surrounding render
|
|
189
|
+
* never sees it as Markdown, so a footnote reference inside a figure caption
|
|
190
|
+
* is not resolved, the same gap a block's own title carries.
|
|
191
|
+
*/
|
|
151
192
|
export function renderCaptionBlocks(source, renderMarkdown = parser.render.bind(parser), numbers) {
|
|
152
193
|
const { captions, errors } = scanCaptions(source);
|
|
153
194
|
if (errors.length) return { markdown: source, errors };
|
|
@@ -164,13 +205,21 @@ export function renderCaptionBlocks(source, renderMarkdown = parser.render.bind(
|
|
|
164
205
|
for (const caption of captions) {
|
|
165
206
|
output.push(...lines.slice(cursor, caption.line - 1));
|
|
166
207
|
const numbered = byId.get(caption.id) ?? caption;
|
|
167
|
-
|
|
168
|
-
if (caption.kind === "figure")
|
|
169
|
-
html = html.replace(/<figcaption\b[^>]*>[\s\S]*?<\/figcaption>/i, "");
|
|
208
|
+
const block = lines.slice(caption.blockStart, caption.blockEnd).join("\n");
|
|
170
209
|
const captionHtml = parser.renderInline(caption.text);
|
|
171
210
|
output.push(
|
|
172
211
|
`<div id="${escape(slugify(caption.id))}" class="content-caption content-caption-${caption.kind}">`,
|
|
173
|
-
|
|
212
|
+
);
|
|
213
|
+
if (caption.kind === "figure") {
|
|
214
|
+
const html = renderMarkdown(block).replace(
|
|
215
|
+
/<figcaption\b[^>]*>[\s\S]*?<\/figcaption>/i,
|
|
216
|
+
"",
|
|
217
|
+
);
|
|
218
|
+
output.push(html.trim());
|
|
219
|
+
} else {
|
|
220
|
+
output.push("", block, "");
|
|
221
|
+
}
|
|
222
|
+
output.push(
|
|
174
223
|
`<p class="content-caption-label">${escape(numbered.label)}: ${captionHtml}</p>`,
|
|
175
224
|
"</div>",
|
|
176
225
|
);
|