@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.
Files changed (152) hide show
  1. package/CHANGELOG.md +223 -0
  2. package/assets/styles/content-base.css +162 -0
  3. package/bin/content-commands.mjs +77 -17
  4. package/bin/package-build.mjs +15 -2
  5. package/ci/content-format-reference.mjs +68 -1
  6. package/config.mjs +26 -0
  7. package/content-config.mjs +153 -6
  8. package/docs/api.md +120 -89
  9. package/docs/authoring/assets.md +14 -0
  10. package/docs/authoring/dates-and-calendars.md +41 -3
  11. package/docs/authoring/frontmatter.md +2 -2
  12. package/docs/authoring/links-and-markup.md +325 -35
  13. package/docs/commands.md +12 -10
  14. package/docs/configuration.md +117 -14
  15. package/docs/content-format.md +4 -2
  16. package/docs/guides/book.md +5 -4
  17. package/docs/guides/reference-pages.md +15 -0
  18. package/docs/reference/format-details.md +545 -104
  19. package/docs/reference/note-types.md +30 -13
  20. package/engine/actor-compiler.mjs +23 -0
  21. package/engine/anchored-sections.mjs +35 -4
  22. package/engine/anchors.mjs +26 -30
  23. package/engine/art-fields.mjs +118 -0
  24. package/engine/art-slots.mjs +7 -1
  25. package/engine/asset-index.mjs +160 -10
  26. package/engine/asset-replacement.mjs +93 -0
  27. package/engine/asset-types.mjs +240 -0
  28. package/engine/base-compiler.mjs +139 -22
  29. package/engine/base-styles.mjs +149 -0
  30. package/engine/bundles.mjs +13 -2
  31. package/engine/compendiums.mjs +8 -1
  32. package/engine/content-blocks.mjs +410 -0
  33. package/engine/content-embeds.mjs +19 -0
  34. package/engine/content-figures.mjs +468 -0
  35. package/engine/content-footnotes.mjs +197 -0
  36. package/engine/content-format-check.mjs +15 -12
  37. package/engine/content-format.yaml +1 -1
  38. package/engine/content-images.mjs +126 -25
  39. package/engine/content-index.mjs +38 -12
  40. package/engine/content-links.mjs +14 -6
  41. package/engine/content-tables.mjs +45 -4
  42. package/engine/content-tasklists.mjs +189 -0
  43. package/engine/docs-checks.mjs +4 -4
  44. package/engine/extension-attributes.mjs +48 -2
  45. package/engine/field-reference.mjs +31 -10
  46. package/engine/field-spec.mjs +10 -8
  47. package/engine/foreign-catalog.mjs +143 -18
  48. package/engine/frontmatter-lint.mjs +120 -65
  49. package/engine/frontmatter.mjs +3 -3
  50. package/engine/heading-attributes.mjs +321 -0
  51. package/engine/helpers.mjs +109 -41
  52. package/engine/homepage.mjs +8 -8
  53. package/engine/index.mjs +3 -0
  54. package/engine/infobox-registry.mjs +4 -1
  55. package/engine/infobox-render.mjs +15 -3
  56. package/engine/infobox.mjs +129 -15
  57. package/engine/itinerary-scenes.mjs +4 -10
  58. package/engine/journals.mjs +294 -50
  59. package/engine/lore-events.mjs +197 -0
  60. package/engine/macros.mjs +1 -1
  61. package/engine/markdown-expressions.mjs +434 -59
  62. package/engine/note-dates.mjs +354 -12
  63. package/engine/note-frontmatter.mjs +7 -16
  64. package/engine/note-vocabulary.mjs +183 -42
  65. package/engine/page-lists.mjs +358 -0
  66. package/engine/pathnames.mjs +36 -0
  67. package/engine/pdf-build.mjs +431 -74
  68. package/engine/pdf-images.mjs +64 -0
  69. package/engine/pdf-render.mjs +563 -66
  70. package/engine/rank-ladder.mjs +198 -0
  71. package/engine/reckoning-markers.mjs +1 -1
  72. package/engine/retired-fields.mjs +8 -13
  73. package/engine/scenes.mjs +26 -7
  74. package/engine/site-build.mjs +166 -23
  75. package/engine/site-config.mjs +10 -1
  76. package/engine/site-index.mjs +90 -3
  77. package/engine/site-preview.mjs +20 -7
  78. package/engine/social-ties.mjs +52 -14
  79. package/engine/sql-tables.mjs +66 -9
  80. package/engine/standings.mjs +89 -6
  81. package/engine/system-block.mjs +56 -30
  82. package/engine/web-wikilinks.mjs +48 -1
  83. package/hm3/actors.mjs +8 -5
  84. package/manifest.mjs +43 -24
  85. package/package.json +1 -1
  86. package/packagebuild-metadata.jsonl +13 -13
  87. package/sohl/actors.mjs +22 -99
  88. package/sohl/item-fields.mjs +5 -5
  89. package/sohl/items.mjs +1 -1
  90. package/sohl/note-schemas.mjs +11 -21
  91. package/types/config.d.mts +9 -0
  92. package/types/content-config.d.mts +48 -1
  93. package/types/engine/actor-compiler.d.mts +11 -0
  94. package/types/engine/anchored-sections.d.mts +24 -0
  95. package/types/engine/anchors.d.mts +9 -4
  96. package/types/engine/art-fields.d.mts +64 -0
  97. package/types/engine/asset-index.d.mts +43 -0
  98. package/types/engine/asset-replacement.d.mts +22 -0
  99. package/types/engine/asset-types.d.mts +18 -0
  100. package/types/engine/base-compiler.d.mts +19 -17
  101. package/types/engine/base-styles.d.mts +93 -0
  102. package/types/engine/content-blocks.d.mts +87 -0
  103. package/types/engine/content-embeds.d.mts +16 -0
  104. package/types/engine/content-figures.d.mts +101 -0
  105. package/types/engine/content-footnotes.d.mts +80 -0
  106. package/types/engine/content-format-check.d.mts +8 -6
  107. package/types/engine/content-images.d.mts +78 -12
  108. package/types/engine/content-index.d.mts +8 -2
  109. package/types/engine/content-tables.d.mts +12 -4
  110. package/types/engine/content-tasklists.d.mts +72 -0
  111. package/types/engine/extension-attributes.d.mts +22 -1
  112. package/types/engine/field-reference.d.mts +21 -0
  113. package/types/engine/field-spec.d.mts +20 -16
  114. package/types/engine/foreign-catalog.d.mts +39 -0
  115. package/types/engine/frontmatter.d.mts +1 -1
  116. package/types/engine/heading-attributes.d.mts +179 -0
  117. package/types/engine/helpers.d.mts +29 -10
  118. package/types/engine/homepage.d.mts +7 -7
  119. package/types/engine/index.d.mts +1 -0
  120. package/types/engine/infobox-registry.d.mts +4 -1
  121. package/types/engine/infobox-render.d.mts +6 -1
  122. package/types/engine/infobox.d.mts +6 -0
  123. package/types/engine/journals.d.mts +41 -12
  124. package/types/engine/lore-events.d.mts +14 -0
  125. package/types/engine/markdown-expressions.d.mts +46 -2
  126. package/types/engine/note-dates.d.mts +64 -1
  127. package/types/engine/note-frontmatter.d.mts +1 -4
  128. package/types/engine/note-vocabulary.d.mts +82 -6
  129. package/types/engine/page-lists.d.mts +126 -0
  130. package/types/engine/pathnames.d.mts +26 -0
  131. package/types/engine/pdf-build.d.mts +28 -6
  132. package/types/engine/pdf-images.d.mts +52 -0
  133. package/types/engine/pdf-render.d.mts +84 -2
  134. package/types/engine/rank-ladder.d.mts +32 -0
  135. package/types/engine/retired-fields.d.mts +4 -6
  136. package/types/engine/site-build.d.mts +27 -4
  137. package/types/engine/site-config.d.mts +9 -0
  138. package/types/engine/site-index.d.mts +67 -1
  139. package/types/engine/site-preview.d.mts +1 -1
  140. package/types/engine/sql-tables.d.mts +31 -7
  141. package/types/engine/standings.d.mts +21 -3
  142. package/types/engine/system-block.d.mts +37 -14
  143. package/types/engine/web-wikilinks.d.mts +3 -1
  144. package/types/hm3/actors.d.mts +21 -3
  145. package/types/manifest.d.mts +19 -23
  146. package/types/sohl/actors.d.mts +6 -7
  147. package/engine/content-admonitions.mjs +0 -114
  148. package/engine/content-captions.mjs +0 -181
  149. package/engine/content-secrets.mjs +0 -112
  150. package/types/engine/content-admonitions.d.mts +0 -24
  151. package/types/engine/content-captions.d.mts +0 -45
  152. 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, "&amp;")
107
+ .replace(/</g, "&lt;")
108
+ .replace(/>/g, "&gt;")
109
+ .replace(/"/g, "&quot;");
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
  *