@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,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 — `:::figure` — 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 `:::figure` is read by the figure 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 figure out of the note.
|
|
95
|
+
*/
|
|
96
|
+
const FOREIGN = Object.freeze(["figure"]);
|
|
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
|
+
}
|
|
@@ -173,6 +173,25 @@ export function resolveEmbed(index, embed) {
|
|
|
173
173
|
return { reason: read.reason, target: embed.written, type: read.type };
|
|
174
174
|
}
|
|
175
175
|
|
|
176
|
+
/**
|
|
177
|
+
* The role an address's asset declares, read through the same resolution an
|
|
178
|
+
* embed's address takes.
|
|
179
|
+
*
|
|
180
|
+
* This is the lookup a `:::figure` fence's `map` counter reaches through —
|
|
181
|
+
* see {@link module:engine/content-figures.scanFigures}'s `resolveRole` — for
|
|
182
|
+
* an address still in its authored form, before {@link resolveEmbeds} has
|
|
183
|
+
* rewritten it into the ordinary image every surface renders.
|
|
184
|
+
*
|
|
185
|
+
* @param {object} index - From {@link module:engine/wikilinks.buildWikilinkIndex},
|
|
186
|
+
* or the equivalent the site and the book build.
|
|
187
|
+
* @param {string} address - The address as authored, inside `![[...]]`.
|
|
188
|
+
* @returns {string|undefined} The role, or `undefined` for a note's address,
|
|
189
|
+
* one that resolves nowhere, or a picture that declares none.
|
|
190
|
+
*/
|
|
191
|
+
export function embedRole(index, address) {
|
|
192
|
+
return readAssetAddress(index, address, EMBED_DEFAULT_TYPE).record?.asset?.role || undefined;
|
|
193
|
+
}
|
|
194
|
+
|
|
176
195
|
/**
|
|
177
196
|
* What is wrong with one embed's directive or placement, if anything.
|
|
178
197
|
*
|