@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.
Files changed (110) hide show
  1. package/CHANGELOG.md +191 -0
  2. package/assets/styles/content-base.css +162 -0
  3. package/bin/content-commands.mjs +26 -3
  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 +73 -4
  8. package/docs/api.md +36 -18
  9. package/docs/authoring/dates-and-calendars.md +4 -2
  10. package/docs/authoring/frontmatter.md +2 -2
  11. package/docs/authoring/links-and-markup.md +237 -28
  12. package/docs/configuration.md +27 -1
  13. package/docs/content-format.md +3 -1
  14. package/docs/guides/reference-pages.md +15 -0
  15. package/docs/reference/format-details.md +224 -46
  16. package/docs/reference/note-types.md +21 -4
  17. package/engine/actor-compiler.mjs +23 -0
  18. package/engine/anchored-sections.mjs +35 -4
  19. package/engine/anchors.mjs +10 -20
  20. package/engine/base-compiler.mjs +86 -21
  21. package/engine/base-styles.mjs +149 -0
  22. package/engine/bundles.mjs +0 -1
  23. package/engine/compendiums.mjs +8 -1
  24. package/engine/content-blocks.mjs +410 -0
  25. package/engine/content-captions.mjs +57 -8
  26. package/engine/content-footnotes.mjs +189 -0
  27. package/engine/content-format-check.mjs +15 -12
  28. package/engine/content-index.mjs +2 -2
  29. package/engine/content-links.mjs +14 -6
  30. package/engine/content-tables.mjs +45 -4
  31. package/engine/content-tasklists.mjs +189 -0
  32. package/engine/docs-checks.mjs +4 -4
  33. package/engine/extension-attributes.mjs +48 -2
  34. package/engine/frontmatter-lint.mjs +47 -64
  35. package/engine/frontmatter.mjs +3 -3
  36. package/engine/heading-attributes.mjs +308 -0
  37. package/engine/helpers.mjs +14 -13
  38. package/engine/homepage.mjs +8 -8
  39. package/engine/index.mjs +3 -0
  40. package/engine/infobox-render.mjs +15 -3
  41. package/engine/infobox.mjs +83 -8
  42. package/engine/journals.mjs +36 -17
  43. package/engine/macros.mjs +1 -1
  44. package/engine/markdown-expressions.mjs +178 -53
  45. package/engine/note-dates.mjs +49 -0
  46. package/engine/note-frontmatter.mjs +7 -16
  47. package/engine/note-vocabulary.mjs +172 -37
  48. package/engine/page-lists.mjs +358 -0
  49. package/engine/pdf-build.mjs +76 -42
  50. package/engine/pdf-render.mjs +422 -49
  51. package/engine/rank-ladder.mjs +198 -0
  52. package/engine/reckoning-markers.mjs +1 -1
  53. package/engine/retired-fields.mjs +8 -13
  54. package/engine/scenes.mjs +10 -3
  55. package/engine/site-build.mjs +41 -12
  56. package/engine/site-config.mjs +10 -1
  57. package/engine/site-index.mjs +18 -2
  58. package/engine/site-preview.mjs +18 -7
  59. package/engine/social-ties.mjs +52 -14
  60. package/engine/sql-tables.mjs +66 -9
  61. package/engine/standings.mjs +89 -6
  62. package/engine/system-block.mjs +12 -14
  63. package/engine/web-wikilinks.mjs +48 -1
  64. package/hm3/actors.mjs +6 -3
  65. package/manifest.mjs +13 -0
  66. package/package.json +1 -1
  67. package/sohl/actors.mjs +22 -99
  68. package/sohl/item-fields.mjs +4 -5
  69. package/sohl/items.mjs +1 -1
  70. package/sohl/note-schemas.mjs +11 -21
  71. package/types/config.d.mts +9 -0
  72. package/types/engine/actor-compiler.d.mts +11 -0
  73. package/types/engine/anchored-sections.d.mts +24 -0
  74. package/types/engine/base-compiler.d.mts +19 -0
  75. package/types/engine/base-styles.d.mts +93 -0
  76. package/types/engine/content-blocks.d.mts +87 -0
  77. package/types/engine/content-captions.d.mts +14 -1
  78. package/types/engine/content-footnotes.d.mts +73 -0
  79. package/types/engine/content-format-check.d.mts +8 -6
  80. package/types/engine/content-tables.d.mts +12 -4
  81. package/types/engine/content-tasklists.d.mts +72 -0
  82. package/types/engine/extension-attributes.d.mts +22 -1
  83. package/types/engine/frontmatter.d.mts +1 -1
  84. package/types/engine/heading-attributes.d.mts +174 -0
  85. package/types/engine/helpers.d.mts +7 -4
  86. package/types/engine/homepage.d.mts +7 -7
  87. package/types/engine/index.d.mts +1 -0
  88. package/types/engine/infobox-render.d.mts +6 -1
  89. package/types/engine/journals.d.mts +14 -7
  90. package/types/engine/markdown-expressions.d.mts +23 -0
  91. package/types/engine/note-frontmatter.d.mts +1 -4
  92. package/types/engine/note-vocabulary.d.mts +82 -6
  93. package/types/engine/page-lists.d.mts +126 -0
  94. package/types/engine/pdf-render.d.mts +72 -2
  95. package/types/engine/rank-ladder.d.mts +32 -0
  96. package/types/engine/retired-fields.d.mts +4 -6
  97. package/types/engine/site-build.d.mts +13 -1
  98. package/types/engine/site-config.d.mts +9 -0
  99. package/types/engine/site-index.d.mts +13 -1
  100. package/types/engine/site-preview.d.mts +1 -1
  101. package/types/engine/sql-tables.d.mts +31 -7
  102. package/types/engine/standings.d.mts +21 -3
  103. package/types/engine/system-block.d.mts +11 -12
  104. package/types/engine/web-wikilinks.d.mts +3 -1
  105. package/types/hm3/actors.d.mts +3 -3
  106. package/types/sohl/actors.d.mts +6 -7
  107. package/engine/content-admonitions.mjs +0 -114
  108. package/engine/content-secrets.mjs +0 -112
  109. package/types/engine/content-admonitions.d.mts +0 -24
  110. package/types/engine/content-secrets.d.mts +0 -17
@@ -80,8 +80,37 @@ export const BOOK_IMAGE_WIDTHS = Object.freeze({
80
80
  });
81
81
  import { slugify } from "./content-slug.mjs";
82
82
  import { scanCaptions } from "./content-captions.mjs";
83
- import { scanAdmonitions } from "./content-admonitions.mjs";
84
- import { separateFootnotes } from "./content-footnotes.mjs";
83
+ import { BLOCK_CONTAINERS, scanBlocks, BLOCK_NAMES } from "./content-blocks.mjs";
84
+ import { matchAllOutsideCode, replaceOutsideCode } from "./code-fences.mjs";
85
+ import { HTML_TAG, htmlMessage } from "./content-html.mjs";
86
+ import { EXPRESSION } from "./markdown-expressions.mjs";
87
+ import { WIKILINK } from "./wikilink-syntax.mjs";
88
+ import { EMBED_PATTERN } from "./content-embeds.mjs";
89
+ import { positionInBody } from "./diagnostics.mjs";
90
+ import {
91
+ WITHHELD_CLASS,
92
+ scanHeadingAttributes,
93
+ splitHeadingAttributes,
94
+ withheldSections,
95
+ } from "./heading-attributes.mjs";
96
+
97
+ /**
98
+ * How each named block prints. Typst has no stylesheet to defer to, so the
99
+ * colours live here rather than in the emitted markup; the HTML surfaces carry
100
+ * a class and let CSS decide.
101
+ */
102
+ const BLOCK_PRINT = Object.freeze({
103
+ info: Object.freeze({ color: "#2f6f9f", background: "#eef6fb", symbol: "i" }),
104
+ secret: Object.freeze({ color: "#5c4b8a", background: "#f2eefb", symbol: "!" }),
105
+ warn: Object.freeze({ color: "#9a6700", background: "#fff5db", symbol: "!" }),
106
+ });
107
+
108
+ import {
109
+ misplacedFootnoteDefinitions,
110
+ separateFootnotes,
111
+ unresolvedFootnoteReferences,
112
+ unusedFootnoteDefinitions,
113
+ } from "./content-footnotes.mjs";
85
114
 
86
115
  /**
87
116
  * Characters that mean something to Typst's markup parser.
@@ -168,6 +197,203 @@ export function createParser(registry) {
168
197
  return md;
169
198
  }
170
199
 
200
+ /**
201
+ * A closed HTML comment, however many lines it spans.
202
+ *
203
+ * Closed only: an unclosed `<!--` is text by CommonMark's reading, and a
204
+ * pattern that ran to the end of the file for want of a closer would delete
205
+ * every line after the mistake. {@link reportUnrenderable} names it instead.
206
+ *
207
+ * @type {RegExp}
208
+ */
209
+ const HTML_COMMENT = /<!--[\s\S]*?-->/g;
210
+
211
+ /**
212
+ * A markdown body with its HTML comments taken out.
213
+ *
214
+ * A comment is an author's aside to the next author — a `markdownlint` pragma, a
215
+ * note recording why a notice is quoted verbatim — and no surface shows one: the
216
+ * packs and the website emit it into HTML, where it is a comment still. Typst
217
+ * has nowhere to put it, so markdown-it hands the delimiters over as prose and
218
+ * the aside is typeset. It comes out here instead, and a comment inside a fence
219
+ * or a code span stays, because there it is an example of a comment.
220
+ *
221
+ * **The line count is preserved.** Every finding this module reports is located
222
+ * in the markdown it was handed, so a pass that removed a line would move every
223
+ * position after it.
224
+ *
225
+ * @param {string} markdown - The body, as authored.
226
+ * @returns {string} The same body, its comments replaced by their own newlines.
227
+ */
228
+ export function stripHtmlComments(markdown) {
229
+ return replaceOutsideCode(String(markdown ?? ""), HTML_COMMENT, (comment) =>
230
+ "\n".repeat((comment.match(/\n/g) ?? []).length),
231
+ );
232
+ }
233
+
234
+ /**
235
+ * Where a position in the markdown handed in falls in the file that holds it.
236
+ *
237
+ * The three corrections {@link module:engine/diagnostics.positionInBody} makes,
238
+ * applied to a line and column rather than to an offset: the frontmatter above
239
+ * the body, the indentation a trimmed first line lost, and a line that a
240
+ * content table generated and no author wrote — which has no column to point at.
241
+ *
242
+ * @param {number} line - 1-based line within the markdown.
243
+ * @param {number} column - 1-based column within that line.
244
+ * @param {object} [opts] - As {@link markdownToTypst} takes.
245
+ * @returns {{line: number, column?: number}} The position, in the file.
246
+ */
247
+ function linePosition(line, column, { bodyLine = 1, bodyColumn = 1, lineMap } = {}) {
248
+ const mapped = lineMap?.[line - 1];
249
+ const sourceLine = mapped ? mapped.line : line - 1;
250
+ if (mapped?.generated) return { line: bodyLine + sourceLine };
251
+ return {
252
+ line: bodyLine + sourceLine,
253
+ column: sourceLine === 0 ? bodyColumn + column - 1 : column,
254
+ };
255
+ }
256
+
257
+ /**
258
+ * Where a character offset within the markdown handed in falls in its file.
259
+ *
260
+ * @param {string} source - The markdown the offset indexes into.
261
+ * @param {number} offset - 0-based character offset.
262
+ * @param {object} [opts] - As {@link markdownToTypst} takes.
263
+ * @returns {{line: number, column?: number}} The position, in the file.
264
+ */
265
+ function offsetPosition(source, offset, opts = {}) {
266
+ const { line, column, generated } = positionInBody(source, offset, opts);
267
+ return generated ? { line } : { line, column };
268
+ }
269
+
270
+ /**
271
+ * Everything in one body that the book cannot set, reported.
272
+ *
273
+ * **The renderer owes a finding for every construct it meets and cannot
274
+ * render.** It emits text and reads nothing, so there is no state it can refuse
275
+ * from; what it can do is say what it met, at the line it met it, and let the
276
+ * caller decide. Without that a body carrying a construct this pass does not
277
+ * handle is typeset as its own markup — delimiters, braces and all — and the
278
+ * build exits 0 having printed the mistake into the book.
279
+ *
280
+ * Two families are named here, and they fail for opposite reasons:
281
+ *
282
+ * - **A construct no book renderer reads.** Raw HTML, and an HTML comment that
283
+ * is never closed. Both reach Typst as prose, and the first is reported in
284
+ * {@link module:engine/content-html}'s own words so one authored tag does not
285
+ * get two explanations.
286
+ * - **A pass's output that never arrived.** A wikilink, an embed and an inline
287
+ * `{{…}}` expression are each resolved before the book sees them; one that is
288
+ * still in the markdown means the pass that owns it did not run over this
289
+ * body, and the markup is about to be typeset verbatim. Reported only where
290
+ * those passes did not run — `opts.prepared` — because each of them leaves the
291
+ * markup as written when it fails and has already said so in its own words.
292
+ *
293
+ * A `:::` block and a `:::caption` are the pair this pass does handle, and their
294
+ * own scanners decide what is well formed; their findings are reported here so
295
+ * that a front-matter file, a prose file and a note body all get them.
296
+ *
297
+ * @param {string} source - The body, comments already stripped.
298
+ * @param {string} definitions - The footnote definitions separated from it.
299
+ * @param {object} opts - As {@link markdownToTypst} takes.
300
+ * @param {string} original - `source` before its definitions were blanked —
301
+ * {@link unusedFootnoteDefinitions} locates a definition by searching for
302
+ * its own text, which `source` no longer carries.
303
+ * @returns {void}
304
+ */
305
+ function reportUnrenderable(source, definitions, opts, original) {
306
+ const findings = opts.findings;
307
+ if (!findings) return;
308
+ const file = opts.file ? { file: opts.file } : {};
309
+ /**
310
+ * @param {{line: number, column?: number}} position - Where it is.
311
+ * @param {"warning"|"error"} severity - Which level.
312
+ * @param {string} message - What is wrong.
313
+ * @returns {void}
314
+ */
315
+ const report = (position, severity, message) => {
316
+ findings.push({ ...file, ...position, severity, message });
317
+ };
318
+
319
+ const blocks = scanBlocks(source);
320
+ for (const error of blocks.errors)
321
+ report(linePosition(error.line, error.column, opts), "error", error.message);
322
+ // A container's body, read for faults of its own — an empty box, an
323
+ // attribute that does not parse — which the scan above passes over, because
324
+ // there an inner opener is a counted line rather than a block.
325
+ //
326
+ // Only where that reading was sound. One mistake draws several findings from
327
+ // a single scan already, and a body read from a misread outer block draws
328
+ // more that say the same thing in other words. One level down and no
329
+ // further: a box inside a GM-only section is the only nesting the format
330
+ // admits.
331
+ const lines = source.split("\n");
332
+ if (!blocks.errors.length) {
333
+ for (const block of blocks.blocks) {
334
+ if (!BLOCK_CONTAINERS.includes(block.name)) continue;
335
+ const body = lines.slice(block.start + 1, block.end).join("\n");
336
+ for (const error of scanBlocks(body).errors)
337
+ report(
338
+ linePosition(block.start + 1 + error.line, error.column, opts),
339
+ "error",
340
+ error.message,
341
+ );
342
+ }
343
+ }
344
+ for (const error of scanCaptions(source).errors)
345
+ report(linePosition(error.line, error.column, opts), "error", error.message);
346
+ for (const error of scanHeadingAttributes(source).errors)
347
+ report(linePosition(error.line, error.column, opts), "error", error.message);
348
+ for (const error of withheldSections(source).errors)
349
+ report(linePosition(error.line, error.column, opts), "error", error.message);
350
+
351
+ // Every closed comment is gone by the time this runs, so an opener still
352
+ // here is one that was never closed — on the website that swallows the rest
353
+ // of the page, and here it is typeset.
354
+ for (const match of matchAllOutsideCode(source, /<!--/g))
355
+ report(
356
+ offsetPosition(source, match.index, opts),
357
+ "error",
358
+ "an HTML comment is opened and never closed — close it with `-->`",
359
+ );
360
+ for (const match of matchAllOutsideCode(source, HTML_TAG))
361
+ report(offsetPosition(source, match.index, opts), "warning", htmlMessage(match[0]));
362
+ if (!opts.prepared) {
363
+ for (const match of matchAllOutsideCode(source, EMBED_PATTERN))
364
+ report(
365
+ offsetPosition(source, match.index, opts),
366
+ "error",
367
+ `\`${match[0]}\` reaches the book as written — an embed is resolved to a ` +
368
+ "picture before the book is set, and nothing resolved this one",
369
+ );
370
+ for (const match of matchAllOutsideCode(source, WIKILINK))
371
+ report(
372
+ offsetPosition(source, match.index, opts),
373
+ "error",
374
+ `\`${match[0]}\` reaches the book as written — a link is resolved to an ` +
375
+ "address before the book is set, and nothing resolved this one",
376
+ );
377
+ for (const match of matchAllOutsideCode(source, EXPRESSION))
378
+ report(
379
+ offsetPosition(source, match.index, opts),
380
+ "error",
381
+ `\`${match[0]}\` reaches the book as written — an expression is evaluated ` +
382
+ "before the book is set, and nothing evaluated this one",
383
+ );
384
+ }
385
+ // Derived from the one place that already finds definitions, rather than
386
+ // a second scanner that could drift from {@link separateFootnotes} — the
387
+ // web and Foundry report the identical three findings from the identical
388
+ // functions.
389
+ for (const error of misplacedFootnoteDefinitions(source))
390
+ report(linePosition(error.line, error.column, opts), "error", error.message);
391
+ for (const error of unresolvedFootnoteReferences(source, definitions))
392
+ report(linePosition(error.line, error.column, opts), "error", error.message);
393
+ for (const error of unusedFootnoteDefinitions(original))
394
+ report(linePosition(error.line, error.column, opts), "error", error.message);
395
+ }
396
+
171
397
  /**
172
398
  * Render markdown as Typst content.
173
399
  *
@@ -186,8 +412,26 @@ export function createParser(registry) {
186
412
  * note's own `##` nests beneath the entry heading the book gave it.
187
413
  * @param {string} [opts.anchorPrefix] - The entry's anchor, which namespaces
188
414
  * every `{#slug}` the body declares.
189
- * capital. Set for an entry, which begins a page; not for front matter or a
190
- * prose file, which carry headings of their own.
415
+ * @param {object[]} [opts.findings] - Collected here rather than thrown. Every
416
+ * construct this pass cannot render is reported into it — see
417
+ * {@link reportUnrenderable} — and a caller that passes none renders the same
418
+ * Typst and is told nothing.
419
+ * @param {string} [opts.file] - The file the markdown came from, named in every
420
+ * finding.
421
+ * @param {number} [opts.bodyLine] - The 1-based file line the body starts on, so
422
+ * a finding's line is the file's rather than the body's.
423
+ * @param {number} [opts.bodyColumn] - The 1-based column the body starts at.
424
+ * @param {Array<{line: number, generated: boolean}>} [opts.lineMap] - From
425
+ * table expansion, mapping each line handed in back to the line an author
426
+ * wrote.
427
+ * @param {boolean} [opts.prepared] - Whether this body has been through the
428
+ * passes that resolve a wikilink, an embed and an inline expression. A note's
429
+ * has; a front-matter file's and a prose file's have not, and markup of theirs
430
+ * left in one is reported rather than typeset in silence.
431
+ * @param {string} [opts.url] - The absolute address this book's pages are served
432
+ * at. Every link the book sets as a URL is resolved against it, because a
433
+ * reader holding a PDF has no page to resolve a path against — see
434
+ * {@link renderLink}.
191
435
  * @returns {string} Typst markup.
192
436
  */
193
437
  export function markdownToTypst(markdown, opts = {}) {
@@ -199,14 +443,68 @@ export function markdownToTypst(markdown, opts = {}) {
199
443
  headingOffset = 0,
200
444
  anchorPrefix = "",
201
445
  } = opts;
202
- const separated =
203
- opts.footnoteDefinitions === undefined ? separateFootnotes(String(markdown ?? "")) : null;
204
- const source = separated?.markdown ?? String(markdown ?? "");
446
+ const given = opts.nested ? String(markdown ?? "") : stripHtmlComments(markdown);
447
+ const separated = opts.footnoteDefinitions === undefined ? separateFootnotes(given) : null;
448
+ const source = separated?.markdown ?? given;
205
449
  const definitions = opts.footnoteDefinitions ?? separated?.definitions ?? "";
206
450
  const footnoteState = opts.footnoteState ?? { seen: new Set() };
207
- const sharedOptions = { ...opts, footnoteDefinitions: definitions, footnoteState };
451
+ const sharedOptions = {
452
+ ...opts,
453
+ footnoteDefinitions: definitions,
454
+ footnoteState,
455
+ nested: true,
456
+ };
457
+ // Read once, over the body as it arrived: every position a finding carries
458
+ // is an offset into *this* string, and the renderer below walks slices of it.
459
+ if (!opts.nested) reportUnrenderable(source, definitions, opts, given);
460
+ // One map for the whole body, not one per block: a heading inside a
461
+ // blockquote or a list item shares the entry's anchor namespace with every
462
+ // other heading in the same body, because `sectionLabel` scopes by entry
463
+ // rather than by container.
464
+ const ctx = {
465
+ md,
466
+ links,
467
+ glyphs,
468
+ images,
469
+ headingOffset,
470
+ anchorPrefix,
471
+ url: opts.url,
472
+ seen: new Map(),
473
+ footnoteState,
474
+ footnotePrefix: opts.footnotePrefix ?? `footnote-${slugify(anchorPrefix || "body")}`,
475
+ };
476
+ // A withheld section is set in the box a `:::secret` is set in, and its
477
+ // heading is set inside it: a printed page honours no reader permission, so
478
+ // the labelled box is what tells a referee the section is for them. Read
479
+ // outermost, before the named blocks, because a section holds blocks of its
480
+ // own and they are rendered by the recursion into its body.
481
+ if (!opts.insideAdmonition && !opts.insideWithheld) {
482
+ const { sections } = withheldSections(source);
483
+ if (sections.length) {
484
+ const lines = source.split("\n");
485
+ const output = [];
486
+ let cursor = 0;
487
+ for (const section of sections) {
488
+ output.push(
489
+ markdownToTypst(lines.slice(cursor, section.start).join("\n"), sharedOptions),
490
+ );
491
+ output.push(
492
+ withheldBox(
493
+ markdownToTypst(lines.slice(section.start, section.end).join("\n"), {
494
+ ...sharedOptions,
495
+ insideWithheld: true,
496
+ }),
497
+ ctx,
498
+ ),
499
+ );
500
+ cursor = section.end;
501
+ }
502
+ output.push(markdownToTypst(lines.slice(cursor).join("\n"), sharedOptions));
503
+ return output.join("\n");
504
+ }
505
+ }
208
506
  if (!opts.insideAdmonition) {
209
- const { blocks } = scanAdmonitions(source);
507
+ const { blocks } = scanBlocks(source);
210
508
  if (blocks.length) {
211
509
  const lines = source.split("\n");
212
510
  const output = [];
@@ -218,13 +516,18 @@ export function markdownToTypst(markdown, opts = {}) {
218
516
  insideAdmonition: true,
219
517
  }),
220
518
  );
221
- const color = block.kind === "warn" ? "#9a6700" : "#2f6f9f";
222
- const background = block.kind === "warn" ? "#fff5db" : "#eef6fb";
223
- const label = block.kind === "warn" ? "Warning" : "Info";
224
- const symbol = block.kind === "warn" ? "!" : "i";
519
+ const { color, background, symbol } = BLOCK_PRINT[block.name];
520
+ // The title is inline markdown, rendered the way every other
521
+ // phrase in the book is. Set as it was written it would be read
522
+ // by Typst instead: `*word*` is bold there and emphasis here, a
523
+ // `#` opens a function call, and one unbalanced `]` ends the box
524
+ // and takes the rest of the document with it.
525
+ const label = inlineMarkup(block.title, ctx);
526
+ // A GM-only section holds a box, so its body is read for blocks
527
+ // of its own; a box holds none, and the scan has already said so.
225
528
  const content = markdownToTypst(block.body, {
226
529
  ...sharedOptions,
227
- insideAdmonition: true,
530
+ insideAdmonition: !BLOCK_CONTAINERS.includes(block.name),
228
531
  });
229
532
  output.push(
230
533
  `\n#block(width: 100%, fill: rgb("${background}"), stroke: (left: 2pt + rgb("${color}")), inset: 8pt, above: 0.7em, below: 0.7em)[#text(fill: rgb("${color}"), weight: "bold")[${symbol} ${label}]\n\n${content}]\n`,
@@ -240,27 +543,20 @@ export function markdownToTypst(markdown, opts = {}) {
240
543
  return output.join("\n");
241
544
  }
242
545
  }
243
- // One map for the whole body, not one per block: a heading inside a
244
- // blockquote or a list item shares the entry's anchor namespace with every
245
- // other heading in the same body, because `sectionLabel` scopes by entry
246
- // rather than by container.
247
- const ctx = {
248
- md,
249
- links,
250
- glyphs,
251
- images,
252
- headingOffset,
253
- anchorPrefix,
254
- seen: new Map(),
255
- footnoteState,
256
- footnotePrefix: opts.footnotePrefix ?? `footnote-${slugify(anchorPrefix || "body")}`,
257
- };
258
546
  const lines = source.split("\n");
259
547
  const localCaptions = scanCaptions(source).captions;
260
- const numbered = new Map(
261
- (opts.captions ?? localCaptions).map((caption) => [caption.id, caption]),
262
- );
263
- const captions = localCaptions.map((caption) => numbered.get(caption.id) ?? caption);
548
+ // Numbered by the caller, which counts across the whole book, and placed by
549
+ // the scan above, which is the only reading of *this* body. Paired by
550
+ // position: two captions may share an id — a finding, and the book is built
551
+ // anyway — and keying on one would give the second caption's lines to the
552
+ // first, printing its block twice and losing its label.
553
+ const provided = opts.captions ?? localCaptions;
554
+ const numbered = new Map(provided.map((caption) => [caption.id, caption]));
555
+ const captions = localCaptions.map((caption, at) => {
556
+ const counted =
557
+ provided[at]?.id === caption.id ? provided[at] : (numbered.get(caption.id) ?? caption);
558
+ return { ...caption, label: counted.label, number: counted.number };
559
+ });
264
560
  if (!captions.length) return renderMarkdownSegment(source, md, ctx, definitions);
265
561
  const out = [];
266
562
  let cursor = 0;
@@ -428,12 +724,29 @@ function renderBlock(tokens, i, out, ctx) {
428
724
  }
429
725
 
430
726
  /**
431
- * A heading's text, and the `{#slug}` it may end with.
727
+ * A withheld section, set in the box a `secret` block is set in.
728
+ *
729
+ * @param {string} content - The section's rendered Typst, heading included.
730
+ * @param {object} ctx - Render context, for the label's inline markup.
731
+ * @returns {string} Typst markup.
732
+ */
733
+ function withheldBox(content, ctx) {
734
+ const { color, background, symbol } = BLOCK_PRINT[WITHHELD_CLASS];
735
+ const label = inlineMarkup(BLOCK_NAMES[WITHHELD_CLASS], ctx);
736
+ return `\n#block(width: 100%, fill: rgb("${background}"), stroke: (left: 2pt + rgb("${color}")), inset: 8pt, above: 0.7em, below: 0.7em)[#text(fill: rgb("${color}"), weight: "bold")[${symbol} ${label}]\n\n${content}]\n`;
737
+ }
738
+
739
+ /**
740
+ * A heading's text, and the attribute block it may end with.
432
741
  *
433
742
  * The suffix is removed from the *rendered* children rather than from the raw
434
743
  * source, so an anchor written inside emphasis or after a link still comes off
435
744
  * cleanly and the text either side of it survives.
436
745
  *
746
+ * A class and an attribute have nowhere to go in Typst, which has no
747
+ * stylesheet, so the id is what the book takes and the rest is dropped — the
748
+ * reference says so per surface.
749
+ *
437
750
  * @param {object} inline - The heading's `inline` token.
438
751
  * @param {object} ctx - Render context.
439
752
  * @returns {{text: string, anchor: string}} The heading, and its anchor or "".
@@ -441,31 +754,36 @@ function renderBlock(tokens, i, out, ctx) {
441
754
  function splitHeadingAnchor(inline, ctx) {
442
755
  const last = inline?.children?.[(inline.children?.length ?? 0) - 1];
443
756
  const raw = last?.type === "text" ? String(last.content ?? "") : "";
444
- const match = /^(.*?)\s*\{#([^}]+)\}\s*$/.exec(raw);
445
- if (!match) return { text: renderInline(inline, ctx), anchor: "" };
757
+ const parsed = splitHeadingAttributes(raw);
758
+ if (!parsed.braces || parsed.problems.length) {
759
+ return { text: renderInline(inline, ctx), anchor: "" };
760
+ }
446
761
  // Rendered with the suffix removed from a copy, so the token stream the
447
762
  // caller owns is not mutated — the same tokens are walked again by the
448
763
  // journals and the index.
449
764
  const children = [...inline.children];
450
- children[children.length - 1] = { ...last, content: match[1] };
451
- return { text: renderInline({ ...inline, children }, ctx), anchor: match[2] };
765
+ children[children.length - 1] = { ...last, content: parsed.text };
766
+ return { text: renderInline({ ...inline, children }, ctx), anchor: parsed.id };
452
767
  }
453
768
 
454
769
  /**
455
- * A heading's text, unescaped and with any `{#anchor}` suffix still attached.
770
+ * A heading's words, unescaped and without its attribute block.
456
771
  *
457
772
  * Used only to derive an anchor when the author wrote none, so it wants the
458
- * words as typed rather than the Typst-escaped, suffix-stripped text
459
- * {@link splitHeadingAnchor} renders — {@link module:engine/content-slug.slugify}
460
- * normalises punctuation and case itself and has no use for an escape
461
- * backslash.
773
+ * words as typed rather than the Typst-escaped text {@link splitHeadingAnchor}
774
+ * renders — {@link module:engine/content-slug.slugify} normalises punctuation
775
+ * and case itself and has no use for an escape backslash. The block comes off
776
+ * because a heading that declares classes alone would otherwise be addressed by
777
+ * an anchor naming them.
462
778
  *
463
779
  * @param {object} inline - The heading's `inline` token.
464
780
  * @returns {string} The heading's raw text.
465
781
  */
466
782
  function plainHeadingText(inline) {
467
783
  const children = inline?.children ?? [];
468
- return children.map((child) => child.content ?? "").join("");
784
+ const raw = children.map((child) => child.content ?? "").join("");
785
+ const parsed = splitHeadingAttributes(raw);
786
+ return parsed.problems.length ? raw : parsed.text;
469
787
  }
470
788
 
471
789
  /**
@@ -550,10 +868,26 @@ function listItems(tokens, start, end, ctx) {
550
868
  return items;
551
869
  }
552
870
 
871
+ /**
872
+ * A phrase of inline Markdown, set the way the prose around it is.
873
+ *
874
+ * A caption, a named block's title: text an author writes outside a paragraph
875
+ * and still expects emphasis in. Rendering it through the same walk is also what
876
+ * makes it inert — Typst's own markup characters are escaped on the way, which
877
+ * handing the string over raw does not do.
878
+ *
879
+ * @param {string} text - The phrase, as authored.
880
+ * @param {object} ctx - Render context.
881
+ * @returns {string} Typst markup.
882
+ */
883
+ function inlineMarkup(text, ctx) {
884
+ const inline = ctx.md.parseInline(String(text ?? ""), {})[0];
885
+ return inline ? renderInline(inline, ctx) : "";
886
+ }
887
+
553
888
  /** Caption text with the same inline Markdown handling as the surrounding prose. */
554
889
  function captionMarkup(caption, ctx) {
555
- const inline = ctx.md.parseInline(caption.text, {})[0];
556
- return `${escapeTypst(caption.label)}: ${renderInline(inline, ctx)}`;
890
+ return `${escapeTypst(caption.label)}: ${inlineMarkup(caption.text, ctx)}`;
557
891
  }
558
892
 
559
893
  /**
@@ -783,6 +1117,39 @@ function inlineRaw(content) {
783
1117
  return `#raw("${escapeTypstString(content)}", block: false)`;
784
1118
  }
785
1119
 
1120
+ /**
1121
+ * A page address as a reader of the book can follow it.
1122
+ *
1123
+ * **Every link the book sets as a URL is absolute.** A page's address is written
1124
+ * from the root of the site that serves it — `/sohl/skill-guil/` — because on the
1125
+ * website that is the shortest form that is right wherever the page is read
1126
+ * from. A PDF is read from nowhere: a viewer handed a path has no document to
1127
+ * resolve it against, so it either does nothing or looks for a file on the
1128
+ * reader's own disk, and a book of a thousand pages carries a thousand links
1129
+ * that do neither.
1130
+ *
1131
+ * So a root-relative address is resolved against the site's own, which is the
1132
+ * one absolute address a build already holds. A **dependency's** page keeps its
1133
+ * own package prefix, from the registry in
1134
+ * {@link module:engine/content-address.PACKAGE_BASE}, so it resolves to the host
1135
+ * serving that package — and where the registry names a host of its own the
1136
+ * address is already absolute and is left exactly as it is.
1137
+ *
1138
+ * @param {string} url - The address, as the link pass resolved it.
1139
+ * @param {string} [site] - The absolute address this book's pages are served at.
1140
+ * @returns {string} An absolute URL, or the address unchanged when there is no
1141
+ * site to resolve it against or it is already absolute.
1142
+ */
1143
+ export function absolutePageUrl(url, site) {
1144
+ const address = String(url ?? "");
1145
+ if (!site || !address.startsWith("/") || address.startsWith("//")) return address;
1146
+ try {
1147
+ return new URL(address, site).href;
1148
+ } catch {
1149
+ return address;
1150
+ }
1151
+ }
1152
+
786
1153
  /**
787
1154
  * A link, internal when the book prints its destination and external otherwise.
788
1155
  *
@@ -816,7 +1183,7 @@ function renderLink(href, inner, ctx) {
816
1183
  return `#link(<${target}>)[${inner}]`;
817
1184
  }
818
1185
  if (!url) return inner;
819
- return `#link("${escapeTypstString(url)}")[${inner}]`;
1186
+ return `#link("${escapeTypstString(absolutePageUrl(url, ctx.url))}")[${inner}]`;
820
1187
  }
821
1188
 
822
1189
  /**
@@ -863,10 +1230,16 @@ function renderLink(href, inner, ctx) {
863
1230
  */
864
1231
  function renderImage(token, ctx) {
865
1232
  const alt = token.content || token.attrGet?.("alt") || "";
866
- const captionText = ctx.caption ? captionMarkup(ctx.caption, ctx) : escapeTypst(alt);
1233
+ // Only a genuine figure caption labels the image inline — a caption
1234
+ // reclassified as prose, because something besides the image shares its
1235
+ // block, gets its label from the generic block-level caption print in
1236
+ // {@link markdownToTypst} instead, the same as a code or prose caption
1237
+ // always has. Reading it here regardless of kind printed the label twice.
1238
+ const figureCaption = ctx.caption?.kind === "figure" ? ctx.caption : null;
1239
+ const captionText = figureCaption ? captionMarkup(figureCaption, ctx) : escapeTypst(alt);
867
1240
  const caption = captionText ? `[${captionText}]` : "none";
868
1241
  const anchor =
869
- ctx.caption ? ` <${sectionLabel(ctx.anchorPrefix, slugify(ctx.caption.id))}>` : "";
1242
+ figureCaption ? ` <${sectionLabel(ctx.anchorPrefix, slugify(figureCaption.id))}>` : "";
870
1243
  const staged = ctx.images.get(token.attrGet?.("src") ?? "");
871
1244
  if (!staged)
872
1245
  return captionText ?