@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
@@ -67,7 +67,14 @@ import deflistPlugin from "markdown-it-deflist";
67
67
 
68
68
  import { bookDraftNoticePreamble } from "./draft-notice.mjs";
69
69
  import { iconPlugin, ICON_PATTERN, ICON_SIZES } from "./content-icons.mjs";
70
- import { IMAGE_CLASSES, IMAGE_FLOATS, imagePlugin } from "./content-images.mjs";
70
+ import {
71
+ IMAGE_CLASSES,
72
+ IMAGE_FLOATS,
73
+ IMAGE_PATTERN,
74
+ checkImages,
75
+ imagePlugin,
76
+ } from "./content-images.mjs";
77
+ import { bookImageWidthIn } from "./pdf-images.mjs";
71
78
 
72
79
  /** Requested print width for every named image size. */
73
80
  export const BOOK_IMAGE_WIDTHS = Object.freeze({
@@ -79,9 +86,38 @@ export const BOOK_IMAGE_WIDTHS = Object.freeze({
79
86
  "full-width": '"full-width"',
80
87
  });
81
88
  import { slugify } from "./content-slug.mjs";
82
- import { scanCaptions } from "./content-captions.mjs";
83
- import { scanAdmonitions } from "./content-admonitions.mjs";
84
- import { separateFootnotes } from "./content-footnotes.mjs";
89
+ import { scanFigures } from "./content-figures.mjs";
90
+ import { BLOCK_CONTAINERS, scanBlocks, BLOCK_NAMES } from "./content-blocks.mjs";
91
+ import { matchAllOutsideCode, replaceOutsideCode } from "./code-fences.mjs";
92
+ import { HTML_TAG, htmlMessage } from "./content-html.mjs";
93
+ import { EXPRESSION } from "./markdown-expressions.mjs";
94
+ import { WIKILINK } from "./wikilink-syntax.mjs";
95
+ import { EMBED_PATTERN } from "./content-embeds.mjs";
96
+ import { positionInBody } from "./diagnostics.mjs";
97
+ import {
98
+ WITHHELD_CLASS,
99
+ scanHeadingAttributes,
100
+ splitHeadingAttributes,
101
+ withheldSections,
102
+ } from "./heading-attributes.mjs";
103
+
104
+ /**
105
+ * How each named block prints. Typst has no stylesheet to defer to, so the
106
+ * colours live here rather than in the emitted markup; the HTML surfaces carry
107
+ * a class and let CSS decide.
108
+ */
109
+ const BLOCK_PRINT = Object.freeze({
110
+ info: Object.freeze({ color: "#2f6f9f", background: "#eef6fb", symbol: "i" }),
111
+ secret: Object.freeze({ color: "#5c4b8a", background: "#f2eefb", symbol: "!" }),
112
+ warn: Object.freeze({ color: "#9a6700", background: "#fff5db", symbol: "!" }),
113
+ });
114
+
115
+ import {
116
+ misplacedFootnoteDefinitions,
117
+ separateFootnotes,
118
+ unresolvedFootnoteReferences,
119
+ unusedFootnoteDefinitions,
120
+ } from "./content-footnotes.mjs";
85
121
 
86
122
  /**
87
123
  * Characters that mean something to Typst's markup parser.
@@ -168,6 +204,210 @@ export function createParser(registry) {
168
204
  return md;
169
205
  }
170
206
 
207
+ /**
208
+ * A closed HTML comment, however many lines it spans.
209
+ *
210
+ * Closed only: an unclosed `<!--` is text by CommonMark's reading, and a
211
+ * pattern that ran to the end of the file for want of a closer would delete
212
+ * every line after the mistake. {@link reportUnrenderable} names it instead.
213
+ *
214
+ * @type {RegExp}
215
+ */
216
+ const HTML_COMMENT = /<!--[\s\S]*?-->/g;
217
+
218
+ /**
219
+ * A markdown body with its HTML comments taken out.
220
+ *
221
+ * A comment is an author's aside to the next author — a `markdownlint` pragma, a
222
+ * note recording why a notice is quoted verbatim — and no surface shows one: the
223
+ * packs and the website emit it into HTML, where it is a comment still. Typst
224
+ * has nowhere to put it, so markdown-it hands the delimiters over as prose and
225
+ * the aside is typeset. It comes out here instead, and a comment inside a fence
226
+ * or a code span stays, because there it is an example of a comment.
227
+ *
228
+ * **The line count is preserved.** Every finding this module reports is located
229
+ * in the markdown it was handed, so a pass that removed a line would move every
230
+ * position after it.
231
+ *
232
+ * @param {string} markdown - The body, as authored.
233
+ * @returns {string} The same body, its comments replaced by their own newlines.
234
+ */
235
+ export function stripHtmlComments(markdown) {
236
+ return replaceOutsideCode(String(markdown ?? ""), HTML_COMMENT, (comment) =>
237
+ "\n".repeat((comment.match(/\n/g) ?? []).length),
238
+ );
239
+ }
240
+
241
+ /**
242
+ * Where a position in the markdown handed in falls in the file that holds it.
243
+ *
244
+ * The three corrections {@link module:engine/diagnostics.positionInBody} makes,
245
+ * applied to a line and column rather than to an offset: the frontmatter above
246
+ * the body, the indentation a trimmed first line lost, and a line that a
247
+ * content table generated and no author wrote — which has no column to point at.
248
+ *
249
+ * @param {number} line - 1-based line within the markdown.
250
+ * @param {number} column - 1-based column within that line.
251
+ * @param {object} [opts] - As {@link markdownToTypst} takes.
252
+ * @returns {{line: number, column?: number}} The position, in the file.
253
+ */
254
+ function linePosition(line, column, { bodyLine = 1, bodyColumn = 1, lineMap } = {}) {
255
+ const mapped = lineMap?.[line - 1];
256
+ const sourceLine = mapped ? mapped.line : line - 1;
257
+ if (mapped?.generated) return { line: bodyLine + sourceLine };
258
+ return {
259
+ line: bodyLine + sourceLine,
260
+ column: sourceLine === 0 ? bodyColumn + column - 1 : column,
261
+ };
262
+ }
263
+
264
+ /**
265
+ * Where a character offset within the markdown handed in falls in its file.
266
+ *
267
+ * @param {string} source - The markdown the offset indexes into.
268
+ * @param {number} offset - 0-based character offset.
269
+ * @param {object} [opts] - As {@link markdownToTypst} takes.
270
+ * @returns {{line: number, column?: number}} The position, in the file.
271
+ */
272
+ function offsetPosition(source, offset, opts = {}) {
273
+ const { line, column, generated } = positionInBody(source, offset, opts);
274
+ return generated ? { line } : { line, column };
275
+ }
276
+
277
+ /**
278
+ * Everything in one body that the book cannot set, reported.
279
+ *
280
+ * **The renderer owes a finding for every construct it meets and cannot
281
+ * render.** It emits text and reads nothing, so there is no state it can refuse
282
+ * from; what it can do is say what it met, at the line it met it, and let the
283
+ * caller decide. Without that a body carrying a construct this pass does not
284
+ * handle is typeset as its own markup — delimiters, braces and all — and the
285
+ * build exits 0 having printed the mistake into the book.
286
+ *
287
+ * Two families are named here, and they fail for opposite reasons:
288
+ *
289
+ * - **A construct no book renderer reads.** Raw HTML, and an HTML comment that
290
+ * is never closed. Both reach Typst as prose, and the first is reported in
291
+ * {@link module:engine/content-html}'s own words so one authored tag does not
292
+ * get two explanations.
293
+ * - **A pass's output that never arrived.** A wikilink, an embed and an inline
294
+ * `{{…}}` expression are each resolved before the book sees them; one that is
295
+ * still in the markdown means the pass that owns it did not run over this
296
+ * body, and the markup is about to be typeset verbatim. Reported only where
297
+ * those passes did not run — `opts.prepared` — because each of them leaves the
298
+ * markup as written when it fails and has already said so in its own words.
299
+ *
300
+ * A `:::` block and a `:::figure` are the pair this pass does handle, and their
301
+ * own scanners decide what is well formed; their findings are reported here so
302
+ * that a front-matter file, a prose file and a note body all get them.
303
+ *
304
+ * @param {string} source - The body, comments already stripped.
305
+ * @param {string} definitions - The footnote definitions separated from it.
306
+ * @param {object} opts - As {@link markdownToTypst} takes.
307
+ * @param {string} original - `source` before its definitions were blanked —
308
+ * {@link unusedFootnoteDefinitions} locates a definition by searching for
309
+ * its own text, which `source` no longer carries.
310
+ * @returns {void}
311
+ */
312
+ function reportUnrenderable(source, definitions, opts, original) {
313
+ const findings = opts.findings;
314
+ if (!findings) return;
315
+ const file = opts.file ? { file: opts.file } : {};
316
+ /**
317
+ * @param {{line: number, column?: number}} position - Where it is.
318
+ * @param {"warning"|"error"} severity - Which level.
319
+ * @param {string} message - What is wrong.
320
+ * @returns {void}
321
+ */
322
+ const report = (position, severity, message) => {
323
+ findings.push({ ...file, ...position, severity, message });
324
+ };
325
+
326
+ const blocks = scanBlocks(source);
327
+ for (const error of blocks.errors)
328
+ report(linePosition(error.line, error.column, opts), "error", error.message);
329
+ // A container's body, read for faults of its own — an empty box, an
330
+ // attribute that does not parse — which the scan above passes over, because
331
+ // there an inner opener is a counted line rather than a block.
332
+ //
333
+ // Only where that reading was sound. One mistake draws several findings from
334
+ // a single scan already, and a body read from a misread outer block draws
335
+ // more that say the same thing in other words. One level down and no
336
+ // further: a box inside a GM-only section is the only nesting the format
337
+ // admits.
338
+ const lines = source.split("\n");
339
+ if (!blocks.errors.length) {
340
+ for (const block of blocks.blocks) {
341
+ if (!BLOCK_CONTAINERS.includes(block.name)) continue;
342
+ const body = lines.slice(block.start + 1, block.end).join("\n");
343
+ for (const error of scanBlocks(body).errors)
344
+ report(
345
+ linePosition(block.start + 1 + error.line, error.column, opts),
346
+ "error",
347
+ error.message,
348
+ );
349
+ }
350
+ }
351
+ for (const error of scanFigures(source).errors)
352
+ report(linePosition(error.line, error.column, opts), "error", error.message);
353
+ // An image sharing its paragraph with other text, or an address or title
354
+ // the website's own lint already refuses — asked here too, so the book
355
+ // fails on the same input rather than typesetting the directive as
356
+ // though it were absent. `checkImages` resolves `opts.bodyLine` and
357
+ // `opts.bodyColumn` itself.
358
+ for (const error of checkImages(source, opts.file ?? "", opts))
359
+ report({ line: error.line, column: error.column }, error.severity, error.message);
360
+ for (const error of scanHeadingAttributes(source).errors)
361
+ report(linePosition(error.line, error.column, opts), "error", error.message);
362
+ for (const error of withheldSections(source).errors)
363
+ report(linePosition(error.line, error.column, opts), "error", error.message);
364
+
365
+ // Every closed comment is gone by the time this runs, so an opener still
366
+ // here is one that was never closed — on the website that swallows the rest
367
+ // of the page, and here it is typeset.
368
+ for (const match of matchAllOutsideCode(source, /<!--/g))
369
+ report(
370
+ offsetPosition(source, match.index, opts),
371
+ "error",
372
+ "an HTML comment is opened and never closed — close it with `-->`",
373
+ );
374
+ for (const match of matchAllOutsideCode(source, HTML_TAG))
375
+ report(offsetPosition(source, match.index, opts), "warning", htmlMessage(match[0]));
376
+ if (!opts.prepared) {
377
+ for (const match of matchAllOutsideCode(source, EMBED_PATTERN))
378
+ report(
379
+ offsetPosition(source, match.index, opts),
380
+ "error",
381
+ `\`${match[0]}\` reaches the book as written — an embed is resolved to a ` +
382
+ "picture before the book is set, and nothing resolved this one",
383
+ );
384
+ for (const match of matchAllOutsideCode(source, WIKILINK))
385
+ report(
386
+ offsetPosition(source, match.index, opts),
387
+ "error",
388
+ `\`${match[0]}\` reaches the book as written — a link is resolved to an ` +
389
+ "address before the book is set, and nothing resolved this one",
390
+ );
391
+ for (const match of matchAllOutsideCode(source, EXPRESSION))
392
+ report(
393
+ offsetPosition(source, match.index, opts),
394
+ "error",
395
+ `\`${match[0]}\` reaches the book as written — an expression is evaluated ` +
396
+ "before the book is set, and nothing evaluated this one",
397
+ );
398
+ }
399
+ // Derived from the one place that already finds definitions, rather than
400
+ // a second scanner that could drift from {@link separateFootnotes} — the
401
+ // web and Foundry report the identical three findings from the identical
402
+ // functions.
403
+ for (const error of misplacedFootnoteDefinitions(source))
404
+ report(linePosition(error.line, error.column, opts), "error", error.message);
405
+ for (const error of unresolvedFootnoteReferences(source, definitions))
406
+ report(linePosition(error.line, error.column, opts), "error", error.message);
407
+ for (const error of unusedFootnoteDefinitions(original))
408
+ report(linePosition(error.line, error.column, opts), "error", error.message);
409
+ }
410
+
171
411
  /**
172
412
  * Render markdown as Typst content.
173
413
  *
@@ -182,12 +422,36 @@ export function createParser(registry) {
182
422
  * the staged file's path, relative to the `.typ`. An address this does not
183
423
  * carry has no file the compiler can open, so the figure prints its caption
184
424
  * alone — see {@link renderImage}.
425
+ * @param {Map<string, {type: string, role?: string, width: number|"", height: number|""}>}
426
+ * [opts.assets] - An image's address as authored → what
427
+ * {@link module:engine/art-fields.assetImageInfoByPathname} records for it,
428
+ * which is what sizes a picture carrying no named `size=` — see
429
+ * {@link module:engine/pdf-images.bookImageWidthIn}. An address missing from
430
+ * this map draws at the medium's ordinary size, the same as today.
185
431
  * @param {number} [opts.headingOffset] - Added to every heading level, so a
186
432
  * note's own `##` nests beneath the entry heading the book gave it.
187
433
  * @param {string} [opts.anchorPrefix] - The entry's anchor, which namespaces
188
434
  * 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.
435
+ * @param {object[]} [opts.findings] - Collected here rather than thrown. Every
436
+ * construct this pass cannot render is reported into it — see
437
+ * {@link reportUnrenderable} — and a caller that passes none renders the same
438
+ * Typst and is told nothing.
439
+ * @param {string} [opts.file] - The file the markdown came from, named in every
440
+ * finding.
441
+ * @param {number} [opts.bodyLine] - The 1-based file line the body starts on, so
442
+ * a finding's line is the file's rather than the body's.
443
+ * @param {number} [opts.bodyColumn] - The 1-based column the body starts at.
444
+ * @param {Array<{line: number, generated: boolean}>} [opts.lineMap] - From
445
+ * table expansion, mapping each line handed in back to the line an author
446
+ * wrote.
447
+ * @param {boolean} [opts.prepared] - Whether this body has been through the
448
+ * passes that resolve a wikilink, an embed and an inline expression. A note's
449
+ * has; a front-matter file's and a prose file's have not, and markup of theirs
450
+ * left in one is reported rather than typeset in silence.
451
+ * @param {string} [opts.url] - The absolute address this book's pages are served
452
+ * at. Every link the book sets as a URL is resolved against it, because a
453
+ * reader holding a PDF has no page to resolve a path against — see
454
+ * {@link renderLink}.
191
455
  * @returns {string} Typst markup.
192
456
  */
193
457
  export function markdownToTypst(markdown, opts = {}) {
@@ -196,17 +460,73 @@ export function markdownToTypst(markdown, opts = {}) {
196
460
  links = new Map(),
197
461
  glyphs = new Map(),
198
462
  images = new Map(),
463
+ assets = new Map(),
199
464
  headingOffset = 0,
200
465
  anchorPrefix = "",
201
466
  } = opts;
202
- const separated =
203
- opts.footnoteDefinitions === undefined ? separateFootnotes(String(markdown ?? "")) : null;
204
- const source = separated?.markdown ?? String(markdown ?? "");
467
+ const given = opts.nested ? String(markdown ?? "") : stripHtmlComments(markdown);
468
+ const separated = opts.footnoteDefinitions === undefined ? separateFootnotes(given) : null;
469
+ const source = separated?.markdown ?? given;
205
470
  const definitions = opts.footnoteDefinitions ?? separated?.definitions ?? "";
206
471
  const footnoteState = opts.footnoteState ?? { seen: new Set() };
207
- const sharedOptions = { ...opts, footnoteDefinitions: definitions, footnoteState };
472
+ const sharedOptions = {
473
+ ...opts,
474
+ footnoteDefinitions: definitions,
475
+ footnoteState,
476
+ nested: true,
477
+ };
478
+ // Read once, over the body as it arrived: every position a finding carries
479
+ // is an offset into *this* string, and the renderer below walks slices of it.
480
+ if (!opts.nested) reportUnrenderable(source, definitions, opts, given);
481
+ // One map for the whole body, not one per block: a heading inside a
482
+ // blockquote or a list item shares the entry's anchor namespace with every
483
+ // other heading in the same body, because `sectionLabel` scopes by entry
484
+ // rather than by container.
485
+ const ctx = {
486
+ md,
487
+ links,
488
+ glyphs,
489
+ images,
490
+ assets,
491
+ headingOffset,
492
+ anchorPrefix,
493
+ url: opts.url,
494
+ seen: new Map(),
495
+ footnoteState,
496
+ footnotePrefix: opts.footnotePrefix ?? `footnote-${slugify(anchorPrefix || "body")}`,
497
+ };
498
+ // A withheld section is set in the box a `:::secret` is set in, and its
499
+ // heading is set inside it: a printed page honours no reader permission, so
500
+ // the labelled box is what tells a referee the section is for them. Read
501
+ // outermost, before the named blocks, because a section holds blocks of its
502
+ // own and they are rendered by the recursion into its body.
503
+ if (!opts.insideAdmonition && !opts.insideWithheld) {
504
+ const { sections } = withheldSections(source);
505
+ if (sections.length) {
506
+ const lines = source.split("\n");
507
+ const output = [];
508
+ let cursor = 0;
509
+ for (const section of sections) {
510
+ output.push(
511
+ markdownToTypst(lines.slice(cursor, section.start).join("\n"), sharedOptions),
512
+ );
513
+ output.push(
514
+ withheldBox(
515
+ markdownToTypst(lines.slice(section.start, section.end).join("\n"), {
516
+ ...sharedOptions,
517
+ insideWithheld: true,
518
+ }),
519
+ ctx,
520
+ ),
521
+ );
522
+ cursor = section.end;
523
+ }
524
+ output.push(markdownToTypst(lines.slice(cursor).join("\n"), sharedOptions));
525
+ return output.join("\n");
526
+ }
527
+ }
208
528
  if (!opts.insideAdmonition) {
209
- const { blocks } = scanAdmonitions(source);
529
+ const { blocks } = scanBlocks(source);
210
530
  if (blocks.length) {
211
531
  const lines = source.split("\n");
212
532
  const output = [];
@@ -218,13 +538,18 @@ export function markdownToTypst(markdown, opts = {}) {
218
538
  insideAdmonition: true,
219
539
  }),
220
540
  );
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";
541
+ const { color, background, symbol } = BLOCK_PRINT[block.name];
542
+ // The title is inline markdown, rendered the way every other
543
+ // phrase in the book is. Set as it was written it would be read
544
+ // by Typst instead: `*word*` is bold there and emphasis here, a
545
+ // `#` opens a function call, and one unbalanced `]` ends the box
546
+ // and takes the rest of the document with it.
547
+ const label = inlineMarkup(block.title, ctx);
548
+ // A GM-only section holds a box, so its body is read for blocks
549
+ // of its own; a box holds none, and the scan has already said so.
225
550
  const content = markdownToTypst(block.body, {
226
551
  ...sharedOptions,
227
- insideAdmonition: true,
552
+ insideAdmonition: !BLOCK_CONTAINERS.includes(block.name),
228
553
  });
229
554
  output.push(
230
555
  `\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 +565,20 @@ export function markdownToTypst(markdown, opts = {}) {
240
565
  return output.join("\n");
241
566
  }
242
567
  }
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
568
  const lines = source.split("\n");
259
- 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);
569
+ const localFigures = scanFigures(source, { resolveRole: opts.resolveRole }).figures;
570
+ // Numbered by the caller, which counts across the whole book, and placed by
571
+ // the scan above, which is the only reading of *this* body. Paired by
572
+ // position: two figures may share an id — a finding, and the book is built
573
+ // anyway — and keying on one would give the second figure's lines to the
574
+ // first, printing its contents twice and losing its label.
575
+ const provided = opts.captions ?? localFigures;
576
+ const numbered = new Map(provided.map((figure) => [figure.id, figure]));
577
+ const captions = localFigures.map((figure, at) => {
578
+ const counted =
579
+ provided[at]?.id === figure.id ? provided[at] : (numbered.get(figure.id) ?? figure);
580
+ return { ...figure, label: counted.label, number: counted.number };
581
+ });
264
582
  if (!captions.length) return renderMarkdownSegment(source, md, ctx, definitions);
265
583
  const out = [];
266
584
  let cursor = 0;
@@ -273,14 +591,29 @@ export function markdownToTypst(markdown, opts = {}) {
273
591
  definitions,
274
592
  ),
275
593
  );
276
- const block = lines.slice(caption.blockStart, caption.blockEnd).join("\n");
277
- out.push(renderMarkdownSegment(block, md, { ...ctx, caption }, definitions));
278
- if (caption.kind !== "table" && caption.kind !== "figure") {
279
- out.push(
280
- `\n#block(below: 0.6em)[#text(size: 7.6pt, style: "italic")[${captionMarkup(caption, ctx)}]] <${sectionLabel(anchorPrefix, slugify(caption.id))}>\n\n`,
594
+ const block = lines.slice(caption.bodyStart, caption.bodyEnd).join("\n");
595
+ // A grouped figure's images are rendered through the same call that
596
+ // reads `ctx.caption`, so `renderImage` needs to know which one is
597
+ // last — see the counter it decrements, set only for a `figure`-kind
598
+ // fence, where more than one image can share the one caption.
599
+ const captioned =
600
+ caption.kind === "figure" || caption.kind === "map" ?
601
+ { ...caption, imagesRemaining: { n: countImages(block) } }
602
+ : caption;
603
+ const segments = [
604
+ renderMarkdownSegment(block, md, { ...ctx, caption: captioned }, definitions),
605
+ ];
606
+ if (caption.kind !== "table" && caption.kind !== "figure" && caption.kind !== "map") {
607
+ segments.push(
608
+ `\n#block(below: 0.6em)[#text(size: 7.6pt, style: "italic")[${captionMarkup(caption, ctx)}]]${typstAnchor(anchorPrefix, caption)}\n\n`,
281
609
  );
282
610
  }
283
- cursor = caption.blockEnd;
611
+ out.push(
612
+ caption.classes.includes("border") ?
613
+ figureBorder(segments.join(""))
614
+ : segments.join(""),
615
+ );
616
+ cursor = caption.close + 1;
284
617
  }
285
618
  out.push(renderMarkdownSegment(lines.slice(cursor).join("\n"), md, ctx, definitions));
286
619
  return out.filter(Boolean).join("\n\n");
@@ -428,12 +761,29 @@ function renderBlock(tokens, i, out, ctx) {
428
761
  }
429
762
 
430
763
  /**
431
- * A heading's text, and the `{#slug}` it may end with.
764
+ * A withheld section, set in the box a `secret` block is set in.
765
+ *
766
+ * @param {string} content - The section's rendered Typst, heading included.
767
+ * @param {object} ctx - Render context, for the label's inline markup.
768
+ * @returns {string} Typst markup.
769
+ */
770
+ function withheldBox(content, ctx) {
771
+ const { color, background, symbol } = BLOCK_PRINT[WITHHELD_CLASS];
772
+ const label = inlineMarkup(BLOCK_NAMES[WITHHELD_CLASS], ctx);
773
+ 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`;
774
+ }
775
+
776
+ /**
777
+ * A heading's text, and the attribute block it may end with.
432
778
  *
433
779
  * The suffix is removed from the *rendered* children rather than from the raw
434
780
  * source, so an anchor written inside emphasis or after a link still comes off
435
781
  * cleanly and the text either side of it survives.
436
782
  *
783
+ * A class and an attribute have nowhere to go in Typst, which has no
784
+ * stylesheet, so the id is what the book takes and the rest is dropped — the
785
+ * reference says so per surface.
786
+ *
437
787
  * @param {object} inline - The heading's `inline` token.
438
788
  * @param {object} ctx - Render context.
439
789
  * @returns {{text: string, anchor: string}} The heading, and its anchor or "".
@@ -441,31 +791,36 @@ function renderBlock(tokens, i, out, ctx) {
441
791
  function splitHeadingAnchor(inline, ctx) {
442
792
  const last = inline?.children?.[(inline.children?.length ?? 0) - 1];
443
793
  const raw = last?.type === "text" ? String(last.content ?? "") : "";
444
- const match = /^(.*?)\s*\{#([^}]+)\}\s*$/.exec(raw);
445
- if (!match) return { text: renderInline(inline, ctx), anchor: "" };
794
+ const parsed = splitHeadingAttributes(raw);
795
+ if (!parsed.braces || parsed.problems.length) {
796
+ return { text: renderInline(inline, ctx), anchor: "" };
797
+ }
446
798
  // Rendered with the suffix removed from a copy, so the token stream the
447
799
  // caller owns is not mutated — the same tokens are walked again by the
448
800
  // journals and the index.
449
801
  const children = [...inline.children];
450
- children[children.length - 1] = { ...last, content: match[1] };
451
- return { text: renderInline({ ...inline, children }, ctx), anchor: match[2] };
802
+ children[children.length - 1] = { ...last, content: parsed.text };
803
+ return { text: renderInline({ ...inline, children }, ctx), anchor: parsed.id };
452
804
  }
453
805
 
454
806
  /**
455
- * A heading's text, unescaped and with any `{#anchor}` suffix still attached.
807
+ * A heading's words, unescaped and without its attribute block.
456
808
  *
457
809
  * 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.
810
+ * words as typed rather than the Typst-escaped text {@link splitHeadingAnchor}
811
+ * renders — {@link module:engine/content-slug.slugify} normalises punctuation
812
+ * and case itself and has no use for an escape backslash. The block comes off
813
+ * because a heading that declares classes alone would otherwise be addressed by
814
+ * an anchor naming them.
462
815
  *
463
816
  * @param {object} inline - The heading's `inline` token.
464
817
  * @returns {string} The heading's raw text.
465
818
  */
466
819
  function plainHeadingText(inline) {
467
820
  const children = inline?.children ?? [];
468
- return children.map((child) => child.content ?? "").join("");
821
+ const raw = children.map((child) => child.content ?? "").join("");
822
+ const parsed = splitHeadingAttributes(raw);
823
+ return parsed.problems.length ? raw : parsed.text;
469
824
  }
470
825
 
471
826
  /**
@@ -550,10 +905,80 @@ function listItems(tokens, start, end, ctx) {
550
905
  return items;
551
906
  }
552
907
 
553
- /** Caption text with the same inline Markdown handling as the surrounding prose. */
908
+ /**
909
+ * A phrase of inline Markdown, set the way the prose around it is.
910
+ *
911
+ * A caption, a named block's title: text an author writes outside a paragraph
912
+ * and still expects emphasis in. Rendering it through the same walk is also what
913
+ * makes it inert — Typst's own markup characters are escaped on the way, which
914
+ * handing the string over raw does not do.
915
+ *
916
+ * @param {string} text - The phrase, as authored.
917
+ * @param {object} ctx - Render context.
918
+ * @returns {string} Typst markup.
919
+ */
920
+ function inlineMarkup(text, ctx) {
921
+ const inline = ctx.md.parseInline(String(text ?? ""), {})[0];
922
+ return inline ? renderInline(inline, ctx) : "";
923
+ }
924
+
925
+ /**
926
+ * A figure's label, with its caption when it carries one.
927
+ *
928
+ * The caption's inline Markdown is handled the way the surrounding prose is. A
929
+ * figure with no caption draws its label alone.
930
+ *
931
+ * @param {object} caption - A figure record.
932
+ * @param {object} ctx - Render context.
933
+ * @returns {string} Typst markup.
934
+ */
554
935
  function captionMarkup(caption, ctx) {
555
- const inline = ctx.md.parseInline(caption.text, {})[0];
556
- return `${escapeTypst(caption.label)}: ${renderInline(inline, ctx)}`;
936
+ const label = escapeTypst(caption.label);
937
+ return caption.hasCaption ? `${label}: ${inlineMarkup(caption.caption, ctx)}` : label;
938
+ }
939
+
940
+ /**
941
+ * How many markdown images a figure's contents carry.
942
+ *
943
+ * A grouped figure's `///` caption describes the whole plate, not any one
944
+ * picture in it, so {@link renderImage} needs to know which image is the
945
+ * last — the one that carries the group's single label and anchor — and this
946
+ * is the count it counts down from.
947
+ *
948
+ * @param {string} contents - The figure's captioned contents, as authored.
949
+ * @returns {number} How many images the contents hold.
950
+ */
951
+ function countImages(contents) {
952
+ return (String(contents ?? "").match(IMAGE_PATTERN) ?? []).length;
953
+ }
954
+
955
+ /**
956
+ * `.border` draws the whole figure — its content and its label together —
957
+ * inside a hairline box. A thin stroke is print's equivalent of a border and
958
+ * padding on the web and in a Foundry journal: a figure already sits inside
959
+ * its own column or float, so the book's version of a border is the lightest
960
+ * mark that still reads as one rather than a second, heavier frame around a
961
+ * frame.
962
+ *
963
+ * @param {string} body - The figure's own rendered Typst, content and label.
964
+ * @returns {string} Typst markup.
965
+ */
966
+ function figureBorder(body) {
967
+ return `\n#block(width: 100%, stroke: 0.4pt + luma(60%), inset: 8pt, above: 0.6em, below: 0.6em)[\n${body}\n]\n\n`;
968
+ }
969
+
970
+ /**
971
+ * The Typst label a figure is referenced by, or `""` when it declares no id.
972
+ *
973
+ * A figure with no id is numbered and drawn and nothing addresses it, so it is
974
+ * given no label: an empty one would collide with every other idless figure.
975
+ *
976
+ * @param {string} prefix - The entry's anchor prefix.
977
+ * @param {object} caption - A figure record.
978
+ * @returns {string} A ` <label>` to append, or `""`.
979
+ */
980
+ function typstAnchor(prefix, caption) {
981
+ return caption.id ? ` <${sectionLabel(prefix, slugify(caption.id))}>` : "";
557
982
  }
558
983
 
559
984
  /**
@@ -632,7 +1057,7 @@ function renderTable(tokens, ctx) {
632
1057
  const caption = ctx.caption;
633
1058
  const labelled =
634
1059
  caption ?
635
- `#text(size: 7.6pt, style: "italic")[${captionMarkup(caption, ctx)}] <${sectionLabel(ctx.anchorPrefix, slugify(caption.id))}>\n${drawn}`
1060
+ `#text(size: 7.6pt, style: "italic")[${captionMarkup(caption, ctx)}]${typstAnchor(ctx.anchorPrefix, caption)}\n${drawn}`
636
1061
  : drawn;
637
1062
  // Wide content is given an explicit span rather than left to overflow the
638
1063
  // measure: past three columns a table is set across the page, and
@@ -783,6 +1208,39 @@ function inlineRaw(content) {
783
1208
  return `#raw("${escapeTypstString(content)}", block: false)`;
784
1209
  }
785
1210
 
1211
+ /**
1212
+ * A page address as a reader of the book can follow it.
1213
+ *
1214
+ * **Every link the book sets as a URL is absolute.** A page's address is written
1215
+ * from the root of the site that serves it — `/sohl/skill-guil/` — because on the
1216
+ * website that is the shortest form that is right wherever the page is read
1217
+ * from. A PDF is read from nowhere: a viewer handed a path has no document to
1218
+ * resolve it against, so it either does nothing or looks for a file on the
1219
+ * reader's own disk, and a book of a thousand pages carries a thousand links
1220
+ * that do neither.
1221
+ *
1222
+ * So a root-relative address is resolved against the site's own, which is the
1223
+ * one absolute address a build already holds. A **dependency's** page keeps its
1224
+ * own package prefix, from the registry in
1225
+ * {@link module:engine/content-address.PACKAGE_BASE}, so it resolves to the host
1226
+ * serving that package — and where the registry names a host of its own the
1227
+ * address is already absolute and is left exactly as it is.
1228
+ *
1229
+ * @param {string} url - The address, as the link pass resolved it.
1230
+ * @param {string} [site] - The absolute address this book's pages are served at.
1231
+ * @returns {string} An absolute URL, or the address unchanged when there is no
1232
+ * site to resolve it against or it is already absolute.
1233
+ */
1234
+ export function absolutePageUrl(url, site) {
1235
+ const address = String(url ?? "");
1236
+ if (!site || !address.startsWith("/") || address.startsWith("//")) return address;
1237
+ try {
1238
+ return new URL(address, site).href;
1239
+ } catch {
1240
+ return address;
1241
+ }
1242
+ }
1243
+
786
1244
  /**
787
1245
  * A link, internal when the book prints its destination and external otherwise.
788
1246
  *
@@ -816,7 +1274,17 @@ function renderLink(href, inner, ctx) {
816
1274
  return `#link(<${target}>)[${inner}]`;
817
1275
  }
818
1276
  if (!url) return inner;
819
- return `#link("${escapeTypstString(url)}")[${inner}]`;
1277
+ return `#link("${escapeTypstString(absolutePageUrl(url, ctx.url))}")[${inner}]`;
1278
+ }
1279
+
1280
+ /**
1281
+ * A width in inches, as a Typst length literal.
1282
+ *
1283
+ * @param {number} inches - The width.
1284
+ * @returns {string} A Typst length, rounded to a thousandth of an inch.
1285
+ */
1286
+ function typstInches(inches) {
1287
+ return `${Math.round(inches * 1000) / 1000}in`;
820
1288
  }
821
1289
 
822
1290
  /**
@@ -854,26 +1322,55 @@ function renderLink(href, inner, ctx) {
854
1322
  * `#image` on a path Typst cannot open is a compile error, and a compile error
855
1323
  * in a 2,500-entry book is fatal at the very end of a run that otherwise
856
1324
  * succeeded — over an illustration, which is the least important thing on the
857
- * page. An address the build could not stage prints its caption alone instead,
858
- * and the build reports the address it could not find.
1325
+ * page. An address the build could not stage prints its authored caption alone
1326
+ * instead, if it has one, and nothing at all if it does not; the build reports
1327
+ * the address it could not find either way.
1328
+ *
1329
+ * ## A grouped figure's one label belongs to the last picture
1330
+ *
1331
+ * Only a figure-kind fence labels an image inline — a fence holding anything
1332
+ * besides pictures is prose, and gets its label from the generic block-level
1333
+ * print in {@link markdownToTypst} instead, the same as a code or a table
1334
+ * fence. Within a figure-kind fence, `ctx.caption.imagesRemaining` counts down
1335
+ * one picture at a time; the picture that brings it to zero is the last one in
1336
+ * document order, and only that one carries the caption and the anchor. Every
1337
+ * other picture in the group draws with no caption at all. Attaching the label
1338
+ * to each of them would print the same text under every picture and emit the
1339
+ * same Typst label more than once, which Typst refuses to compile.
859
1340
  *
860
1341
  * @param {object} token - An `image` token.
861
1342
  * @param {object} ctx - Render context.
862
1343
  * @returns {string} Typst markup.
863
1344
  */
864
1345
  function renderImage(token, ctx) {
865
- const alt = token.content || token.attrGet?.("alt") || "";
866
- const captionText = ctx.caption ? captionMarkup(ctx.caption, ctx) : escapeTypst(alt);
1346
+ const figureCaption =
1347
+ ctx.caption?.kind === "figure" || ctx.caption?.kind === "map" ? ctx.caption : null;
1348
+ const remaining = figureCaption?.imagesRemaining;
1349
+ const isLastImage = !remaining || --remaining.n <= 0;
1350
+ const labelled = Boolean(figureCaption) && isLastImage;
1351
+ const captionText = labelled ? captionMarkup(figureCaption, ctx) : "";
867
1352
  const caption = captionText ? `[${captionText}]` : "none";
868
- const anchor =
869
- ctx.caption ? ` <${sectionLabel(ctx.anchorPrefix, slugify(ctx.caption.id))}>` : "";
870
- const staged = ctx.images.get(token.attrGet?.("src") ?? "");
1353
+ const anchor = labelled ? typstAnchor(ctx.anchorPrefix, figureCaption) : "";
1354
+ const src = token.attrGet?.("src") ?? "";
1355
+ const staged = ctx.images.get(src);
871
1356
  if (!staged)
872
1357
  return captionText ?
873
1358
  `\n#block(below: 0.6em)[#text(size: 7.6pt, style: "italic", fill: luma(45%))${caption}]${anchor}\n\n`
874
1359
  : "";
875
1360
 
876
- const size = BOOK_IMAGE_WIDTHS[token.meta?.size] ?? BOOK_IMAGE_WIDTHS.auto;
1361
+ // A named `size=` is the author's own statement and wins outright. Left
1362
+ // unstated — `auto`, written or implied — the picture's role or its icon
1363
+ // type decides instead, and only a picture declaring neither falls back to
1364
+ // the natural-pixel `auto` Typst resolves for itself.
1365
+ const requestedSize = token.meta?.size;
1366
+ const roleWidthIn =
1367
+ requestedSize && requestedSize !== "auto" ?
1368
+ undefined
1369
+ : bookImageWidthIn(ctx.assets?.get(src));
1370
+ const size =
1371
+ roleWidthIn !== undefined ?
1372
+ typstInches(roleWidthIn)
1373
+ : (BOOK_IMAGE_WIDTHS[requestedSize] ?? BOOK_IMAGE_WIDTHS.auto);
877
1374
  const figure = `#book-image("${escapeTypstString(staged)}", requested: ${size}, caption: ${caption})${anchor}`;
878
1375
 
879
1376
  const width = token.meta?.classes?.[0];