@heroiclands/package-build 22.15.0 → 22.16.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 (223) hide show
  1. package/CHANGELOG.md +103 -0
  2. package/README.md +39 -678
  3. package/bin/{content-build.mjs → content-commands.mjs} +197 -118
  4. package/bin/package-build.mjs +303 -42
  5. package/bin/report.mjs +1 -1
  6. package/ci/ci-docker.mjs +6 -3
  7. package/ci/ci-steps.mjs +6 -4
  8. package/ci/content-format-reference.mjs +106 -0
  9. package/config.mjs +22 -2
  10. package/content-config.mjs +74 -134
  11. package/docs/api.md +294 -234
  12. package/docs/authoring/assets.md +31 -0
  13. package/docs/authoring/dates-and-calendars.md +33 -0
  14. package/docs/authoring/first-note.md +48 -0
  15. package/docs/authoring/frontmatter.md +40 -0
  16. package/docs/authoring/links-and-markup.md +137 -0
  17. package/docs/commands.md +631 -162
  18. package/docs/configuration.md +176 -140
  19. package/docs/content-format.md +50 -4229
  20. package/docs/design.md +32 -0
  21. package/docs/diagnostics.md +29 -22
  22. package/docs/getting-started.md +21 -877
  23. package/docs/guides/book.md +153 -0
  24. package/docs/guides/e2e.md +143 -0
  25. package/docs/guides/icons.md +126 -0
  26. package/docs/guides/localization.md +120 -0
  27. package/docs/guides/module-project.md +915 -0
  28. package/docs/guides/packs.md +167 -0
  29. package/docs/guides/reference-pages.md +148 -0
  30. package/docs/guides/release-and-deploy.md +114 -0
  31. package/docs/guides/site.md +152 -0
  32. package/docs/guides/system-project.md +45 -0
  33. package/docs/index.md +16 -0
  34. package/docs/project-setup.md +54 -35
  35. package/docs/reference/format-details.md +4420 -0
  36. package/docs/reference/note-types.md +531 -0
  37. package/engine/address-render.mjs +26 -0
  38. package/engine/address.mjs +3 -20
  39. package/engine/anchors.mjs +15 -6
  40. package/engine/asset-bindings.mjs +6 -2
  41. package/engine/base-compiler.mjs +47 -11
  42. package/engine/being-measurements.mjs +70 -0
  43. package/engine/calendar-artifacts.mjs +1 -1
  44. package/engine/calendar-choice.mjs +32 -0
  45. package/engine/calendar-human.mjs +71 -0
  46. package/engine/calendar-notes.mjs +41 -3
  47. package/engine/calendars.mjs +8 -1
  48. package/engine/changelog-lint.mjs +2 -2
  49. package/engine/code-fences.mjs +23 -97
  50. package/engine/compile-corpus.mjs +6 -7
  51. package/engine/content-admonitions.mjs +114 -0
  52. package/engine/content-captions.mjs +181 -0
  53. package/engine/content-embeds.mjs +2 -2
  54. package/engine/content-footnotes.mjs +34 -0
  55. package/engine/content-format-check.mjs +2 -2
  56. package/engine/content-format.mjs +127 -73
  57. package/engine/content-format.yaml +745 -0
  58. package/engine/content-html.mjs +1 -1
  59. package/engine/content-icons.mjs +45 -92
  60. package/engine/content-images.mjs +22 -74
  61. package/engine/content-index.mjs +12 -3
  62. package/engine/content-links.mjs +21 -26
  63. package/engine/content-lint.mjs +1 -5
  64. package/engine/content-secrets.mjs +3 -1
  65. package/engine/content-tables.mjs +47 -1257
  66. package/engine/culture-choice.mjs +32 -0
  67. package/engine/date-conversion.mjs +47 -0
  68. package/engine/declaration-check.mjs +87 -0
  69. package/engine/docs-checks.mjs +140 -0
  70. package/engine/documentation-links.mjs +31 -0
  71. package/engine/exported-scene.mjs +1 -1
  72. package/engine/extension-attributes.mjs +74 -0
  73. package/engine/field-reference.mjs +1 -1
  74. package/engine/field-spec.mjs +4 -0
  75. package/engine/foreign-catalog.mjs +5 -5
  76. package/engine/format-generated.mjs +15 -0
  77. package/engine/foundry-entries.mjs +3 -1
  78. package/engine/frontmatter-lint.mjs +5 -1
  79. package/engine/frontmatter.mjs +10 -0
  80. package/engine/generate.mjs +2 -2
  81. package/engine/helpers.mjs +71 -117
  82. package/engine/homepage.mjs +2 -3
  83. package/engine/index-records.mjs +1 -0
  84. package/engine/index.mjs +18 -3
  85. package/engine/infobox-registry.mjs +3 -1
  86. package/engine/infobox.mjs +38 -20
  87. package/engine/item-compiler.mjs +10 -8
  88. package/engine/item-docs.mjs +7 -6
  89. package/engine/itinerary-scenes.mjs +4 -2
  90. package/engine/journals.mjs +51 -10
  91. package/engine/macros.mjs +1 -1
  92. package/engine/map-build.mjs +1 -1
  93. package/engine/map-dot.mjs +1 -1
  94. package/engine/map-raster.mjs +4 -2
  95. package/engine/markdown-expressions.mjs +117 -0
  96. package/engine/metadata-index.mjs +7 -3
  97. package/engine/note-addresses.mjs +1 -0
  98. package/engine/note-dates.mjs +283 -61
  99. package/engine/note-package.mjs +5 -25
  100. package/engine/note-state.mjs +6 -2
  101. package/engine/note-vocabulary.mjs +74 -19
  102. package/engine/number-words.mjs +75 -0
  103. package/engine/office-holders.mjs +188 -0
  104. package/engine/pack-config.mjs +2 -15
  105. package/engine/pack-router.mjs +24 -5
  106. package/engine/packages.mjs +3 -2
  107. package/engine/pathnames.mjs +21 -24
  108. package/engine/pdf-build.mjs +217 -55
  109. package/engine/pdf-fonts.mjs +2 -2
  110. package/engine/pdf-images.mjs +82 -0
  111. package/engine/pdf-render.mjs +174 -22
  112. package/engine/populations.mjs +3 -2
  113. package/engine/project-init.mjs +499 -0
  114. package/engine/prose-config.mjs +1 -1
  115. package/engine/prose-lint.mjs +7 -6
  116. package/engine/readability-lint.mjs +86 -0
  117. package/engine/reckoning-markers.mjs +205 -93
  118. package/engine/scenes.mjs +5 -2
  119. package/engine/schema-check.mjs +3 -3
  120. package/engine/site-build.mjs +87 -37
  121. package/engine/site-config.mjs +8 -8
  122. package/engine/site-index.mjs +4 -0
  123. package/engine/site-maps.mjs +1 -1
  124. package/engine/site-preview.mjs +19 -8
  125. package/engine/social-ties.mjs +5 -1
  126. package/engine/sql-tables.mjs +92 -22
  127. package/engine/web-wikilinks.mjs +15 -31
  128. package/engine/wikilink-syntax.mjs +4 -1
  129. package/engine/wikilinks.mjs +12 -14
  130. package/hm3/item-fields.mjs +1 -1
  131. package/hm3/items.mjs +12 -45
  132. package/index.mjs +2 -14
  133. package/manifest.mjs +31 -4
  134. package/markdownlint-config.mjs +1 -1
  135. package/package.json +32 -11
  136. package/prettier-config.mjs +1 -1
  137. package/sohl/being-info.mjs +3 -145
  138. package/sohl/index.mjs +1 -1
  139. package/sohl/infobox.mjs +2 -1
  140. package/sohl/item-builders.mjs +3 -27
  141. package/sohl/item-fields.mjs +61 -22
  142. package/sohl/kb-passes.mjs +2 -2
  143. package/sohl/note-schemas.mjs +0 -8
  144. package/stage.mjs +19 -0
  145. package/types/config.d.mts +12 -3
  146. package/types/content-config.d.mts +15 -81
  147. package/types/engine/address-render.d.mts +13 -0
  148. package/types/engine/address.d.mts +2 -11
  149. package/types/engine/anchors.d.mts +3 -4
  150. package/types/engine/asset-bindings.d.mts +9 -2
  151. package/types/engine/base-compiler.d.mts +1 -2
  152. package/types/engine/being-measurements.d.mts +16 -0
  153. package/types/engine/calendar-choice.d.mts +14 -0
  154. package/types/engine/calendar-human.d.mts +4 -0
  155. package/types/engine/calendar-notes.d.mts +2 -0
  156. package/types/engine/calendars.d.mts +5 -3
  157. package/types/engine/code-fences.d.mts +6 -72
  158. package/types/engine/compile-corpus.d.mts +1 -3
  159. package/types/engine/content-admonitions.d.mts +24 -0
  160. package/types/engine/content-captions.d.mts +45 -0
  161. package/types/engine/content-footnotes.d.mts +10 -0
  162. package/types/engine/content-format-check.d.mts +17 -4
  163. package/types/engine/content-format.d.mts +24 -11
  164. package/types/engine/content-icons.d.mts +14 -23
  165. package/types/engine/content-images.d.mts +2 -2
  166. package/types/engine/content-index.d.mts +9 -2
  167. package/types/engine/content-links.d.mts +3 -5
  168. package/types/engine/content-tables.d.mts +11 -140
  169. package/types/engine/culture-choice.d.mts +14 -0
  170. package/types/engine/date-conversion.d.mts +4 -0
  171. package/types/engine/declaration-check.d.mts +24 -0
  172. package/types/engine/docs-checks.d.mts +28 -0
  173. package/types/engine/documentation-links.d.mts +7 -0
  174. package/types/engine/exported-scene.d.mts +6 -2
  175. package/types/engine/extension-attributes.d.mts +14 -0
  176. package/types/engine/field-spec.d.mts +9 -0
  177. package/types/engine/format-generated.d.mts +7 -0
  178. package/types/engine/frontmatter.d.mts +14 -4
  179. package/types/engine/generate.d.mts +1 -1
  180. package/types/engine/helpers.d.mts +37 -92
  181. package/types/engine/homepage.d.mts +2 -3
  182. package/types/engine/index.d.mts +5 -0
  183. package/types/engine/infobox-registry.d.mts +3 -1
  184. package/types/engine/infobox.d.mts +17 -6
  185. package/types/engine/item-compiler.d.mts +4 -4
  186. package/types/engine/item-docs.d.mts +4 -3
  187. package/types/engine/journals.d.mts +10 -7
  188. package/types/engine/map-graphviz.d.mts +1 -1
  189. package/types/engine/markdown-expressions.d.mts +17 -0
  190. package/types/engine/note-dates.d.mts +47 -1
  191. package/types/engine/note-package.d.mts +3 -19
  192. package/types/engine/note-state.d.mts +2 -1
  193. package/types/engine/note-vocabulary.d.mts +9 -0
  194. package/types/engine/number-words.d.mts +4 -0
  195. package/types/engine/office-holders.d.mts +14 -0
  196. package/types/engine/pack-config.d.mts +1 -1
  197. package/types/engine/packages.d.mts +3 -2
  198. package/types/engine/pdf-images.d.mts +38 -0
  199. package/types/engine/populations.d.mts +2 -2
  200. package/types/engine/project-init.d.mts +17 -0
  201. package/types/engine/prose-config.d.mts +1 -1
  202. package/types/engine/prose-lint.d.mts +3 -2
  203. package/types/engine/readability-lint.d.mts +22 -0
  204. package/types/engine/reckoning-markers.d.mts +4 -2
  205. package/types/engine/schema-check.d.mts +3 -3
  206. package/types/engine/site-build.d.mts +38 -28
  207. package/types/engine/site-config.d.mts +2 -2
  208. package/types/engine/site-index.d.mts +4 -0
  209. package/types/engine/site-preview.d.mts +1 -1
  210. package/types/engine/social-ties.d.mts +8 -2
  211. package/types/engine/sql-tables.d.mts +13 -2
  212. package/types/engine/web-wikilinks.d.mts +2 -1
  213. package/types/engine/wikilinks.d.mts +7 -1
  214. package/types/hm3/items.d.mts +5 -6
  215. package/types/labels.d.mts +40 -0
  216. package/types/manifest.d.mts +3 -3
  217. package/types/sohl/actors.d.mts +2 -2
  218. package/types/sohl/being-info.d.mts +3 -43
  219. package/types/sohl/index.d.mts +1 -1
  220. package/types/sohl/infobox.d.mts +5 -2
  221. package/types/sohl/item-fields.d.mts +4 -13
  222. package/types/stage.d.mts +9 -0
  223. package/CONTENT.md +0 -3239
@@ -32,24 +32,15 @@
32
32
  * the site publishes every page, and the book publishes what its document tree
33
33
  * asked for.
34
34
  *
35
- * ## `publish.site` is the switch, and it is the only switch
36
- *
37
- * `homepage` mode fences the content surfaces off: the tree is not walked for
38
- * pages, whatever else the configuration declares. A
39
- * PDF of the content tree is a content surface by any reading — arguably the
40
- * most portable one there is — so it is fenced on exactly the same terms, by
41
- * asking the same {@link module:content-config.publishesContentPages} the
42
- * site build asks. Four of the six packages that would adopt this run
43
- * `homepage`, and a PDF that appeared there would breach the fence silently:
44
- * nothing in those packages' configuration would say so. So the fence is
45
- * checked **before** the tree is read, and the command says why it built
46
- * nothing rather than emitting an empty document.
35
+ * The book builds when the authored tree contains content pages. The same
36
+ * {@link module:content-config.publishesContentPages} decision governs the
37
+ * site and the content index.
47
38
  *
48
39
  * ## Every gate reports; none exits
49
40
  *
50
41
  * A filter that selects nothing, a `file:` that resolves nowhere, a table that
51
42
  * will not run and a missing Typst binary are all findings handed back to the
52
- * caller. `bin/content-build.mjs` decides what to do about them — which is the
43
+ * caller. `bin/package-build.mjs` decides what to do about them — which is the
53
44
  * rule the whole engine is built on, and what lets one pass serve a lint, a
54
45
  * build step and a unit test.
55
46
  *
@@ -96,15 +87,32 @@ import { indexRecordsFor } from "./content-index.mjs";
96
87
  import { isNoteRecord, isStub, noteFile } from "./index-records.mjs";
97
88
  import { openNotesDatabase, prepareTreeSqlTables } from "./sql-tables.mjs";
98
89
  import { parseDocumentTree, runTreeFilters, planDocument } from "./pdf-toc.mjs";
99
- import { collectContentPages, siteGates, tableUniverse, gatesFailed } from "./site-build.mjs";
90
+ import {
91
+ collectContentPages,
92
+ siteGates,
93
+ tableUniverse,
94
+ gatesFailed,
95
+ resolveSitePass,
96
+ } from "./site-build.mjs";
100
97
  import { resolveInfoboxRef, wikiContext } from "./site-index.mjs";
101
98
  import { resolveWebWikilinks } from "./web-wikilinks.mjs";
102
99
  import { linkFindingMessage } from "./wikilink-syntax.mjs";
103
100
  import { artPathname, assetAddressIndex } from "./art-fields.mjs";
104
101
  import { expandContentTables } from "./content-tables.mjs";
102
+ import { renderMarkdownExpressions } from "./markdown-expressions.mjs";
105
103
  import { renderSecretBlocks } from "./content-secrets.mjs";
104
+ import { scanAdmonitions } from "./content-admonitions.mjs";
105
+ import { numberCaptions, scanCaptions } from "./content-captions.mjs";
106
106
  import { protectCode } from "./code-fences.mjs";
107
- import { imageSourcesIn } from "./content-images.mjs";
107
+ import { imagesIn, parseImageDirective } from "./content-images.mjs";
108
+ import {
109
+ PDF_IMAGE_INCHES,
110
+ PDF_PAGE,
111
+ pdfColumnWidth,
112
+ pdfTextHeight,
113
+ pdfTextWidth,
114
+ resamplePdfImages,
115
+ } from "./pdf-images.mjs";
108
116
  import { pathnameProblem, resolvePathname } from "./pathnames.mjs";
109
117
  import {
110
118
  createParser,
@@ -114,7 +122,7 @@ import {
114
122
  resolveDanglingLabels,
115
123
  } from "./pdf-render.mjs";
116
124
  import { draftNoticeTypst } from "./draft-notice.mjs";
117
- import { isDraftNote } from "./note-vocabulary.mjs";
125
+ import { isDraftNote, isGmNote } from "./note-vocabulary.mjs";
118
126
  import { infoboxTypstPreamble, infoboxesToTypst, linkToTypst } from "./infobox-render.mjs";
119
127
  import { noteInfoboxes } from "./infobox-registry.mjs";
120
128
  import { resolveIconGlyphs } from "./pdf-fonts.mjs";
@@ -304,8 +312,9 @@ export async function buildPdf({ config, out, version = "", compile = true } = {
304
312
  return {
305
313
  built: false,
306
314
  reason:
307
- "`publish.site` is `homepage`, which fences the content surfaces off — " +
308
- "the tree is not walked and no book is built. Publish content to build one.",
315
+ fs.existsSync(resolved.paths.content) ?
316
+ "The content tree contains only a homepage, so there is no book to build."
317
+ : "this package has no content tree, so there is nothing to print",
309
318
  findings,
310
319
  typ: null,
311
320
  pdf: null,
@@ -354,11 +363,11 @@ export async function buildPdf({ config, out, version = "", compile = true } = {
354
363
  });
355
364
 
356
365
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), "content-pdf-"));
357
- const db = await openNotesDatabase(records, { dir });
366
+ const db = await openNotesDatabase(records, { dir, audience: "public" });
358
367
  const ran = await runTreeFilters(parsed.nodes, db, {
359
368
  // A stub is a note and is deliberately in the corpus, but it has no
360
369
  // body — selecting one would print a blank page under its name.
361
- keep: (record) => isNoteRecord(record) && !isStub(record),
370
+ keep: (record) => isNoteRecord(record) && !isStub(record) && !isGmNote(record),
362
371
  text: tree.text,
363
372
  });
364
373
  findings.push(...ran.findings.map((f) => ({ file: resolved.pdf.document, ...f })));
@@ -403,6 +412,7 @@ export async function buildPdf({ config, out, version = "", compile = true } = {
403
412
  config: resolved,
404
413
  skipDirectories: resolved.skipDirectories,
405
414
  records,
415
+ audience: "public",
406
416
  });
407
417
  // The address space an `![[…]]` embed resolves against — the same one the
408
418
  // site builds, so one authored picture reaches both surfaces or neither.
@@ -422,6 +432,8 @@ export async function buildPdf({ config, out, version = "", compile = true } = {
422
432
 
423
433
  /** @type {Map<string, string>} Authored address → the staged file's path. */
424
434
  const images = new Map();
435
+ /** @type {Map<string, {from: string, to: string, relative: string, file: string, uses: Array<{width: number, height: number}>}>} */
436
+ const imageCandidates = new Map();
425
437
  /** @type {Set<string>} Addresses already looked for, staged or not. */
426
438
  const seenImages = new Set();
427
439
 
@@ -434,11 +446,25 @@ export async function buildPdf({ config, out, version = "", compile = true } = {
434
446
  *
435
447
  * @param {string} body - The rendered markdown.
436
448
  * @param {string} file - The note, for the finding.
449
+ * @param {number} columns - The page's column count.
437
450
  * @returns {void}
438
451
  */
439
- const stageImages = (body, file) => {
440
- for (const src of imageSourcesIn(body)) {
441
- if (seenImages.has(src)) continue;
452
+ const stageImages = (body, file, columns = 2) => {
453
+ for (const image of imagesIn(body)) {
454
+ const src = image.src;
455
+ const directive = parseImageDirective(image.directive);
456
+ const wide =
457
+ directive.size === "full-width" || directive.classes.includes("full-width");
458
+ const availableWidth = wide ? pdfTextWidth : pdfColumnWidth(columns);
459
+ const use = {
460
+ width: Math.min(PDF_IMAGE_INCHES[directive.size] ?? availableWidth, availableWidth),
461
+ height: pdfTextHeight * 0.8,
462
+ };
463
+ if (seenImages.has(src)) {
464
+ const stagedTo = images.get(src);
465
+ if (stagedTo) imageCandidates.get(stagedTo)?.uses.push(use);
466
+ continue;
467
+ }
442
468
  seenImages.add(src);
443
469
  // An **error**, where a picture the book cannot carry is a warning:
444
470
  // this pathname resolves on no surface at all, and the replacement
@@ -476,6 +502,15 @@ export async function buildPdf({ config, out, version = "", compile = true } = {
476
502
  continue;
477
503
  }
478
504
  images.set(src, staged.to);
505
+ const candidate = imageCandidates.get(staged.to);
506
+ if (candidate) candidate.uses.push(use);
507
+ else
508
+ imageCandidates.set(staged.to, {
509
+ ...staged,
510
+ relative: path.relative(resolved.rootDir, staged.from),
511
+ file,
512
+ uses: [use],
513
+ });
479
514
  }
480
515
  };
481
516
 
@@ -485,12 +520,37 @@ export async function buildPdf({ config, out, version = "", compile = true } = {
485
520
  * @param {object} page - A collected page.
486
521
  * @param {number} headingOffset - Where the book put this entry.
487
522
  * @param {string} anchorPrefix - The entry's anchor, namespacing its sections.
523
+ * @param {number} columns - The entry's page columns.
488
524
  * @returns {string} Typst markup.
489
525
  */
490
- const renderPage = (page, headingOffset, anchorPrefix) => {
526
+ const captionCounts = { code: 0, table: 0, figure: 0, prose: 0 };
527
+ const frontCaptions = new Map();
528
+ for (const file of resolved.pdf.front) {
529
+ try {
530
+ const scan = scanCaptions(fs.readFileSync(file, "utf8"));
531
+ frontCaptions.set(file, numberCaptions(scan.captions, captionCounts));
532
+ for (const error of scan.errors)
533
+ findings.push({
534
+ file,
535
+ line: error.line,
536
+ column: error.column,
537
+ severity: "error",
538
+ message: error.message,
539
+ });
540
+ } catch {
541
+ // The rendering pass reports an unreadable front-matter file.
542
+ }
543
+ }
544
+ const pass = resolveSitePass(resolved.site?.pass, {
545
+ ...resolved.site?.passOptions,
546
+ repoRoot: resolved.rootDir,
547
+ config: resolved,
548
+ book: true,
549
+ });
550
+ const renderPage = (page, headingOffset, anchorPrefix, columns) => {
491
551
  const src = page.relPath ?? page.base;
492
552
  const wikiErrors = [];
493
- const { markdown, errors } = expandContentTables(page.body, {
553
+ const { markdown, errors, lineMap } = expandContentTables(page.body, {
494
554
  docs: universe.get(page.pkg) ?? [],
495
555
  linkable: (d) => Boolean(d.fm.shortcode),
496
556
  source: src,
@@ -512,10 +572,34 @@ export async function buildPdf({ config, out, version = "", compile = true } = {
512
572
  foreignIndex: gates.foreign.index,
513
573
  assets,
514
574
  });
575
+ const captionScan = scanCaptions(markdown);
576
+ const numberedCaptions = numberCaptions(captionScan.captions, captionCounts);
577
+ linkCtx.captionLabels = new Map(
578
+ numberedCaptions.map((caption) => [caption.id, caption.label]),
579
+ );
580
+ for (const error of captionScan.errors)
581
+ findings.push({
582
+ file: page.file,
583
+ line: (page.bodyLine ?? 1) + (lineMap[error.line - 1]?.line ?? error.line - 1),
584
+ column: error.column,
585
+ severity: "error",
586
+ message: error.message,
587
+ });
588
+ linkCtx.output = "book";
515
589
  // Code fences are protected for the same reason every other pass
516
590
  // protects them: a wikilink shown as an example is prose about a
517
591
  // wikilink, and resolving it would make the example impossible to write.
518
- const resolvedBody = protectCode(markdown, (text) => resolveWebWikilinks(text, linkCtx));
592
+ const expressions = renderMarkdownExpressions(markdown, {
593
+ fm: page.fm,
594
+ dates: gates.index.dateContext,
595
+ sqlResults: sqlTables?.inline?.get(page.file),
596
+ file: page.file,
597
+ bodyLine: page.bodyLine,
598
+ });
599
+ findings.push(...expressions.findings);
600
+ const resolvedBody = protectCode(expressions.markdown, (text) =>
601
+ resolveWebWikilinks(pass.beforeLinks ? pass.beforeLinks(text, page) : text, linkCtx),
602
+ );
519
603
  for (const err of wikiErrors) {
520
604
  findings.push({
521
605
  file: page.file,
@@ -536,7 +620,16 @@ export async function buildPdf({ config, out, version = "", compile = true } = {
536
620
  message: error.message,
537
621
  });
538
622
  }
539
- stageImages(secrets.markdown, page.file);
623
+ for (const error of scanAdmonitions(page.body).errors) {
624
+ findings.push({
625
+ file: page.file,
626
+ line: (page.bodyLine ?? 1) + error.line - 1,
627
+ column: error.column,
628
+ severity: "error",
629
+ message: error.message,
630
+ });
631
+ }
632
+ stageImages(secrets.markdown, page.file, columns);
540
633
  const prose = markdownToTypst(secrets.markdown, {
541
634
  md,
542
635
  links: plan.links,
@@ -544,6 +637,7 @@ export async function buildPdf({ config, out, version = "", compile = true } = {
544
637
  images,
545
638
  headingOffset,
546
639
  anchorPrefix,
640
+ captions: numberedCaptions,
547
641
  });
548
642
  // The infobox is generated content in document order — prepended,
549
643
  // before the prose. An image the note authored ahead of it still comes
@@ -551,6 +645,7 @@ export async function buildPdf({ config, out, version = "", compile = true } = {
551
645
  const boxes = noteInfoboxes(page.fm, {
552
646
  resolve: (ref, hint) => resolveInfoboxRef(gates.index, ref, hint),
553
647
  router: routerFor(resolved),
648
+ dates: gates.index.dateContext,
554
649
  });
555
650
  const panel = infoboxesToTypst(boxes, {
556
651
  link: (value) => linkToTypst(value, plan.links, labelFor),
@@ -577,7 +672,10 @@ export async function buildPdf({ config, out, version = "", compile = true } = {
577
672
  // entry. A note's body starts at `##` — its name is frontmatter, not
578
673
  // an H1 — so an offset of the entry's depth puts that `##` one level
579
674
  // below the entry heading at `depth + 1`, which is where it belongs.
580
- bodies.set(entry.anchor, renderPage(page, entry.depth, entry.anchor));
675
+ bodies.set(
676
+ entry.anchor,
677
+ renderPage(page, entry.depth, entry.anchor, entry.presentation?.page?.columns ?? 2),
678
+ );
581
679
  continue;
582
680
  }
583
681
  if (entry.kind === "prose") {
@@ -593,7 +691,7 @@ export async function buildPdf({ config, out, version = "", compile = true } = {
593
691
  });
594
692
  continue;
595
693
  }
596
- stageImages(text, file);
694
+ stageImages(text, file, entry.presentation?.page?.columns ?? 2);
597
695
  bodies.set(
598
696
  entry.anchor,
599
697
  markdownToTypst(text, {
@@ -603,6 +701,7 @@ export async function buildPdf({ config, out, version = "", compile = true } = {
603
701
  images,
604
702
  headingOffset: entry.depth,
605
703
  anchorPrefix: entry.anchor,
704
+ captions: numberCaptions(scanCaptions(text).captions, captionCounts),
606
705
  }),
607
706
  );
608
707
  }
@@ -617,15 +716,17 @@ export async function buildPdf({ config, out, version = "", compile = true } = {
617
716
  });
618
717
  }
619
718
 
620
- const front = resolved.pdf.front.map((file) => {
719
+ const front = resolved.pdf.front.map((file, index) => {
621
720
  try {
622
721
  const text = fs.readFileSync(file, "utf8");
623
- stageImages(text, file);
722
+ stageImages(text, file, 1);
624
723
  return markdownToTypst(text, {
625
724
  md,
626
725
  links: plan.links,
627
726
  glyphs,
628
727
  images,
728
+ footnotePrefix: `footnote-front-${index + 1}`,
729
+ captions: frontCaptions.get(file),
629
730
  });
630
731
  } catch {
631
732
  findings.push({
@@ -638,9 +739,53 @@ export async function buildPdf({ config, out, version = "", compile = true } = {
638
739
  });
639
740
 
640
741
  const banners = stageBanners(plan.entries, resolved, outDir, findings);
742
+ for (const [declared, to] of banners) {
743
+ const from = path.resolve(resolved.rootDir, declared);
744
+ imageCandidates.set(`banner:${declared}`, {
745
+ from,
746
+ to,
747
+ relative: path.relative(resolved.rootDir, from),
748
+ file: resolved.pdf.document,
749
+ uses: [{ width: PDF_PAGE.width, height: Infinity }],
750
+ });
751
+ }
641
752
 
642
- /** @type {Map<string, {path: string, title: string}>} Entry anchor → map page. */
753
+ /** @type {Map<string, Array<{path: string, title: string}>>} Entry anchor → map pages. */
643
754
  const maps = new Map();
755
+ const addMapPage = (anchor, page) => {
756
+ if (!maps.has(anchor)) maps.set(anchor, []);
757
+ maps.get(anchor).push(page);
758
+ };
759
+ const stageMapBackground = (entry, value, title) => {
760
+ const file = noteFile(contentBase, entry.record);
761
+ const found = artPathname(assets, value, "image", ["image", "icon"]);
762
+ const staged = stagedImagePath(found.pathname ?? value, resolved);
763
+ if (!staged || !fs.existsSync(staged.from)) {
764
+ findings.push({
765
+ file,
766
+ severity: "warning",
767
+ message: `the map background "${value}" cannot be resolved for the book`,
768
+ });
769
+ return;
770
+ }
771
+ const target = path.join(outDir, staged.to);
772
+ fs.mkdirSync(path.dirname(target), { recursive: true });
773
+ fs.copyFileSync(staged.from, target);
774
+ addMapPage(entry.anchor, { path: staged.to, title });
775
+ const candidate = imageCandidates.get(staged.to);
776
+ const use = {
777
+ width: PDF_PAGE.height - 2 * PDF_PAGE.margin,
778
+ height: pdfTextWidth - 2 / 2.54,
779
+ };
780
+ if (candidate) candidate.uses.push(use);
781
+ else
782
+ imageCandidates.set(staged.to, {
783
+ ...staged,
784
+ relative: path.relative(resolved.rootDir, staged.from),
785
+ file,
786
+ uses: [use],
787
+ });
788
+ };
644
789
  const world = mapWorld({
645
790
  records,
646
791
  foreignIndex: gates.foreign.index,
@@ -661,7 +806,7 @@ export async function buildPdf({ config, out, version = "", compile = true } = {
661
806
  const drawn = buildMaps({ world, outDir: mapDir, from: centres });
662
807
  findings.push(...drawn.findings);
663
808
  for (const entry of mappedEntries) {
664
- maps.set(entry.anchor, {
809
+ addMapPage(entry.anchor, {
665
810
  path: `maps/from-${entry.record.shortcode}.svg`,
666
811
  title: `From here: ${entry.record.name?.full ?? entry.record.shortcode}`,
667
812
  });
@@ -669,27 +814,26 @@ export async function buildPdf({ config, out, version = "", compile = true } = {
669
814
  }
670
815
  for (const entry of plan.entries) {
671
816
  if (entry.kind !== "note" || entry.record?.type !== "map") continue;
672
- if (entry.record.subType !== "regionalmap") continue;
673
- const image = entry.record.data?.bgImage;
674
- if (!image) continue;
675
- const found = artPathname(assets, image, "image", ["image", "icon"]);
676
- if (!found.pathname) {
677
- findings.push({
678
- file: noteFile(contentBase, entry.record),
679
- severity: "warning",
680
- message: `the regional map's background "${image}" cannot be resolved for the book`,
681
- });
817
+ const title = entry.record.name?.full ?? entry.record.shortcode;
818
+ if (["regionalmap", "totm"].includes(entry.record.subType)) {
819
+ if (entry.record.data?.bgImage) {
820
+ stageMapBackground(entry, entry.record.data.bgImage, title);
821
+ }
682
822
  continue;
683
823
  }
684
- const staged = stagedImagePath(found.pathname, resolved);
685
- if (!staged || !staged.from.toLowerCase().endsWith(".svg")) continue;
686
- const target = path.join(outDir, staged.to);
687
- fs.mkdirSync(path.dirname(target), { recursive: true });
688
- fs.copyFileSync(staged.from, target);
689
- maps.set(entry.anchor, {
690
- path: staged.to,
691
- title: entry.record.name?.full ?? entry.record.shortcode,
692
- });
824
+ if (!["battlemap", "localmap"].includes(entry.record.subType)) continue;
825
+ for (const [index, level] of (entry.record.data?.scene?.levels ?? []).entries()) {
826
+ const paths = [
827
+ `.levels[${index}].background.src`,
828
+ ...(level._id ? [`.levels[${level._id}].background.src`] : []),
829
+ ];
830
+ const fixup = (entry.record.data?.fixup ?? []).find((item) =>
831
+ paths.includes(item.path),
832
+ );
833
+ const value = fixup?.value ?? level.background?.src;
834
+ if (value)
835
+ stageMapBackground(entry, value, level.name ? `${title}: ${level.name}` : title);
836
+ }
693
837
  }
694
838
 
695
839
  const assembled = renderBook({
@@ -713,18 +857,36 @@ export async function buildPdf({ config, out, version = "", compile = true } = {
713
857
  const pdfPath = path.join(outDir, stem);
714
858
  fs.writeFileSync(typPath, source);
715
859
 
860
+ const imageReports = await resamplePdfImages(imageCandidates, outDir);
861
+
716
862
  const stats = { ...plan.stats, entries: plan.entries.length, bytes: source.length };
717
863
  if (!compile) {
718
- return { built: true, reason: null, findings, typ: typPath, pdf: null, stats };
864
+ return {
865
+ built: true,
866
+ reason: null,
867
+ findings,
868
+ imageReports,
869
+ typ: typPath,
870
+ pdf: null,
871
+ stats,
872
+ };
719
873
  }
720
874
 
721
875
  const compiled = compileTypst(typPath, pdfPath, resolved.pdf);
722
876
  findings.push(...compiled.findings);
723
877
  if (!compiled.ok) {
724
878
  findings.push({ file: typPath, severity: "error", message: compiled.message });
725
- return { built: false, reason: null, findings, typ: typPath, pdf: null, stats };
879
+ return {
880
+ built: false,
881
+ reason: null,
882
+ findings,
883
+ imageReports,
884
+ typ: typPath,
885
+ pdf: null,
886
+ stats,
887
+ };
726
888
  }
727
- return { built: true, reason: null, findings, typ: typPath, pdf: pdfPath, stats };
889
+ return { built: true, reason: null, findings, imageReports, typ: typPath, pdf: pdfPath, stats };
728
890
  }
729
891
 
730
892
  /**
@@ -37,7 +37,7 @@
37
37
  * ## Nothing here fails a build
38
38
  *
39
39
  * A font that cannot be read, or a name it does not carry, is a **finding** and
40
- * the icon falls back to its literal `:icon-…:` text. That is the failure mode
40
+ * the icon falls back to its literal `:icon …:` text. That is the failure mode
41
41
  * {@link module:engine/content-icons} was designed around — visible on the
42
42
  * page, where an author is looking — and it is strictly better than a tofu box
43
43
  * or a silently missing glyph.
@@ -399,7 +399,7 @@ export function resolveIconGlyphs(registry, iconFonts = {}, findings = []) {
399
399
  file: font.file,
400
400
  severity: "warning",
401
401
  message:
402
- `\`:icon-${name}:\` asks for glyph \`${glyphName}\`, which ` +
402
+ `\`:icon ${name}:\` asks for glyph \`${glyphName}\`, which ` +
403
403
  `${path0(font.file)} does not carry — it prints as its name`,
404
404
  });
405
405
  continue;
@@ -0,0 +1,82 @@
1
+ /*
2
+ * This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
3
+ * Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
4
+ *
5
+ * This work is licensed under the GNU General Public License v3.0 (GPLv3).
6
+ * You may copy, modify, and distribute it under the terms of that license.
7
+ *
8
+ * For full terms, see the LICENSE.md file in the project root or visit:
9
+ * https://www.gnu.org/licenses/gpl-3.0.html
10
+ *
11
+ * SPDX-License-Identifier: GPL-3.0-or-later
12
+ */
13
+
14
+ import fs from "node:fs/promises";
15
+ import path from "node:path";
16
+ import sharp from "sharp";
17
+
18
+ /** Print geometry in inches; Typst's default column gutter is 4% of the text width. */
19
+ export const PDF_PAGE = Object.freeze({
20
+ width: 8.5,
21
+ height: 11,
22
+ margin: 1.9 / 2.54,
23
+ dpi: 300,
24
+ quality: 82,
25
+ });
26
+
27
+ /** Named image widths in inches, matching the book renderer's Typst sizes. */
28
+ export const PDF_IMAGE_INCHES = Object.freeze({
29
+ small: 1.6 / 2.54,
30
+ medium: 3.2 / 2.54,
31
+ large: 5.6 / 2.54,
32
+ xlarge: 8 / 2.54,
33
+ });
34
+
35
+ /** Usable width of a portrait page in inches. */
36
+ export const pdfTextWidth = PDF_PAGE.width - 2 * PDF_PAGE.margin;
37
+ /** Usable height of a portrait page in inches. */
38
+ export const pdfTextHeight = PDF_PAGE.height - 2 * PDF_PAGE.margin;
39
+ /** Width of a portrait page's text column in inches. */
40
+ export const pdfColumnWidth = (columns = 2) =>
41
+ (pdfTextWidth * (1 - 0.04 * (columns - 1))) / columns;
42
+
43
+ /**
44
+ * Optimize staged raster images without changing their authored files.
45
+ *
46
+ * @param {Map<string, {from: string, to: string, relative: string, file: string,
47
+ * uses: Array<{width: number, height: number}>}>} candidates
48
+ * @param {string} outDir - PDF staging directory.
49
+ * @returns {Promise<object[]>} Image reports with the authored file location.
50
+ */
51
+ export async function resamplePdfImages(candidates, outDir) {
52
+ const reports = [];
53
+ for (const candidate of candidates.values()) {
54
+ const extension = path.extname(candidate.to).toLowerCase();
55
+ if (![".jpg", ".jpeg", ".png", ".webp", ".avif"].includes(extension)) continue;
56
+ const relative = candidate.relative.replaceAll(path.sep, "/");
57
+ const destination = path.join(outDir, candidate.to);
58
+ const original = await fs.readFile(destination);
59
+ const metadata = await sharp(original).metadata();
60
+ if (!metadata.width || !metadata.height) continue;
61
+ const ratio = metadata.width / metadata.height;
62
+ const target = Math.round(
63
+ Math.max(
64
+ ...candidate.uses.map(({ width, height }) => Math.min(width, height * ratio)),
65
+ ) * PDF_PAGE.dpi,
66
+ );
67
+ if (metadata.width <= target) continue;
68
+ let pipeline = sharp(original).resize({ width: target, withoutEnlargement: true });
69
+ if (extension === ".jpg" || extension === ".jpeg")
70
+ pipeline = pipeline.jpeg({ quality: PDF_PAGE.quality });
71
+ if (extension === ".webp") pipeline = pipeline.webp({ quality: PDF_PAGE.quality });
72
+ if (extension === ".avif") pipeline = pipeline.avif({ quality: PDF_PAGE.quality });
73
+ if (extension === ".png") pipeline = pipeline.png();
74
+ const output = await pipeline.toBuffer();
75
+ await fs.writeFile(destination, output);
76
+ reports.push({
77
+ file: candidate.file,
78
+ message: `book image ${relative}: ${metadata.width}×${metadata.height} → ${target} px wide; ${original.length} → ${output.length} bytes`,
79
+ });
80
+ }
81
+ return reports;
82
+ }