@heroiclands/package-build 23.1.1 → 23.2.1

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 (247) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/bin/content-commands.mjs +68 -149
  3. package/bin/package-build.mjs +56 -10
  4. package/ci/check-banners.mjs +42 -6
  5. package/ci/confirm-npm-version.mjs +135 -0
  6. package/ci/content-format-reference.mjs +14 -5
  7. package/config.mjs +1 -0
  8. package/container.mjs +185 -4
  9. package/content-config.mjs +333 -210
  10. package/deploy.mjs +116 -10
  11. package/docs/api.md +238 -246
  12. package/docs/authoring/assets.md +11 -5
  13. package/docs/authoring/frontmatter.md +1 -1
  14. package/docs/authoring/links-and-markup.md +87 -0
  15. package/docs/commands.md +138 -99
  16. package/docs/configuration.md +277 -125
  17. package/docs/content-format.md +2 -0
  18. package/docs/diagnostics.md +48 -13
  19. package/docs/guides/book.md +341 -21
  20. package/docs/guides/e2e.md +13 -2
  21. package/docs/guides/icons.md +14 -4
  22. package/docs/guides/module-project.md +18 -8
  23. package/docs/guides/release-and-deploy.md +32 -0
  24. package/docs/guides/site.md +5 -2
  25. package/docs/project-setup.md +30 -10
  26. package/docs/reference/format-details.md +373 -170
  27. package/docs/reference/note-types.md +128 -109
  28. package/docs/reference/theme-pages.md +31 -6
  29. package/e2e.mjs +140 -28
  30. package/engine/actor-compiler.mjs +22 -31
  31. package/engine/address-charset.mjs +4 -17
  32. package/engine/address-fields.mjs +1 -1
  33. package/engine/address-render.mjs +12 -1
  34. package/engine/address-values.mjs +6 -0
  35. package/engine/address.mjs +46 -33
  36. package/engine/art-fields.mjs +30 -5
  37. package/engine/art-slots.mjs +10 -1
  38. package/engine/asset-bindings.mjs +12 -1
  39. package/engine/base-compiler.mjs +34 -65
  40. package/engine/being-age.mjs +2 -7
  41. package/engine/being-measurements.mjs +12 -1
  42. package/engine/calendar-artifacts.mjs +6 -0
  43. package/engine/calendar-choice.mjs +12 -1
  44. package/engine/calendar-format.mjs +12 -1
  45. package/engine/calendar-human.mjs +12 -1
  46. package/engine/compile-corpus.mjs +3 -1
  47. package/engine/content-address.mjs +9 -12
  48. package/engine/content-alerts.mjs +13 -1
  49. package/engine/content-blocks.mjs +14 -3
  50. package/engine/content-embeds.mjs +11 -3
  51. package/engine/content-figures.mjs +9 -0
  52. package/engine/content-footnotes.mjs +12 -1
  53. package/engine/content-format-check.mjs +11 -262
  54. package/engine/content-format.yaml +5 -2
  55. package/engine/content-images.mjs +3 -0
  56. package/engine/content-index.mjs +443 -20
  57. package/engine/content-links.mjs +29 -35
  58. package/engine/content-lint.mjs +2 -3
  59. package/engine/content-poetry.mjs +12 -1
  60. package/engine/content-slug.mjs +2 -14
  61. package/engine/content-spans.mjs +13 -1
  62. package/engine/content-tables.mjs +12 -1
  63. package/engine/culture-choice.mjs +210 -13
  64. package/engine/data-keys.mjs +19 -16
  65. package/engine/date-conversion.mjs +12 -1
  66. package/engine/declaration-check.mjs +9 -0
  67. package/engine/derived-fields.mjs +6 -7
  68. package/engine/diagnostics.mjs +34 -4
  69. package/engine/docs-checks.mjs +9 -0
  70. package/engine/document-subtypes.mjs +47 -34
  71. package/engine/event-views.mjs +139 -14
  72. package/engine/exported-scene.mjs +92 -28
  73. package/engine/extension-attributes.mjs +12 -1
  74. package/engine/field-spec.mjs +11 -102
  75. package/engine/folder-notes.mjs +0 -64
  76. package/engine/foreign-catalog.mjs +2 -1
  77. package/engine/format-generated.mjs +9 -0
  78. package/engine/foundry-entries.mjs +10 -28
  79. package/engine/frontmatter-lint.mjs +83 -392
  80. package/engine/frontmatter.mjs +20 -70
  81. package/engine/generate.mjs +10 -15
  82. package/engine/heading-attributes.mjs +12 -1
  83. package/engine/helpers.mjs +41 -102
  84. package/engine/holdings.mjs +1 -1
  85. package/engine/homepage.mjs +3 -10
  86. package/engine/ids.mjs +1 -167
  87. package/engine/index-records.mjs +6 -4
  88. package/engine/index.mjs +0 -3
  89. package/engine/infobox-render.mjs +15 -3
  90. package/engine/infobox.mjs +13 -14
  91. package/engine/item-compiler.mjs +4 -10
  92. package/engine/item-docs.mjs +3 -5
  93. package/engine/item-registry.mjs +2 -10
  94. package/engine/journals.mjs +1 -5
  95. package/engine/literature-notes.mjs +4 -12
  96. package/engine/macros.mjs +1 -2
  97. package/engine/map-graphviz.mjs +12 -1
  98. package/engine/markdown-expressions.mjs +12 -1
  99. package/engine/markdownlint-run.mjs +567 -0
  100. package/engine/metadata-index.mjs +42 -12
  101. package/engine/note-addresses.mjs +143 -35
  102. package/engine/note-claims.mjs +14 -28
  103. package/engine/note-dates.mjs +3 -3
  104. package/engine/note-frontmatter.mjs +6 -0
  105. package/engine/note-ids.mjs +8 -14
  106. package/engine/note-package.mjs +8 -25
  107. package/engine/note-state.mjs +1 -3
  108. package/engine/note-vocabulary.mjs +148 -84
  109. package/engine/number-words.mjs +12 -1
  110. package/engine/pack-config.mjs +15 -25
  111. package/engine/pack-router.mjs +10 -16
  112. package/engine/pathnames.mjs +8 -22
  113. package/engine/pdf-build.mjs +773 -121
  114. package/engine/pdf-coverage.mjs +188 -0
  115. package/engine/pdf-index.mjs +54 -7
  116. package/engine/pdf-pages.mjs +247 -0
  117. package/engine/pdf-render.mjs +114 -4
  118. package/engine/pdf-toc.mjs +29 -32
  119. package/engine/project-init.mjs +46 -6
  120. package/engine/prose-config.mjs +1 -1
  121. package/engine/prose-lint.mjs +52 -88
  122. package/engine/readability-lint.mjs +9 -0
  123. package/engine/reckoning-markers.mjs +12 -1
  124. package/engine/reference-targets.mjs +7 -0
  125. package/engine/runtime-only-fields.mjs +5 -8
  126. package/engine/scene-levels.mjs +2 -2
  127. package/engine/scenes.mjs +1 -1
  128. package/engine/shortcode-references.mjs +7 -0
  129. package/engine/site-build.mjs +30 -16
  130. package/engine/site-index.mjs +10 -16
  131. package/engine/site-preview.mjs +6 -0
  132. package/engine/site-root.mjs +8 -1
  133. package/engine/social-tie-terms.mjs +12 -1
  134. package/engine/social-ties.mjs +3 -0
  135. package/engine/sql-tables.mjs +56 -20
  136. package/engine/standing-terms.mjs +11 -1
  137. package/engine/standings.mjs +11 -27
  138. package/engine/system-block.mjs +39 -141
  139. package/engine/system-vocabulary.mjs +6 -0
  140. package/engine/walk-files.mjs +69 -0
  141. package/engine/web-wikilinks.mjs +23 -3
  142. package/engine/wikilink-syntax.mjs +32 -21
  143. package/engine/wikilinks.mjs +27 -16
  144. package/githooks/attribution.mjs +8 -0
  145. package/githooks/commit-message.mjs +9 -0
  146. package/hm3/actors.mjs +9 -48
  147. package/hm3/default-item-art.mjs +9 -9
  148. package/hm3/item-fields.mjs +2 -9
  149. package/hm3/template-priority.mjs +6 -7
  150. package/hugo-theme/data/banner-kinds.yaml +78 -0
  151. package/hugo-theme/data/banners.yaml +2 -3
  152. package/hugo-theme/layouts/404.html +3 -3
  153. package/hugo-theme/layouts/partials/banner-url.html +31 -7
  154. package/hugo-theme/layouts/partials/hero-banner.html +13 -14
  155. package/hugo-theme/layouts/partials/related.html +1 -1
  156. package/hugo-theme/layouts/partials/warn-missing-banner.html +1 -1
  157. package/hugo-theme/static/css/style.css +1 -1
  158. package/labels.mjs +2 -2
  159. package/manifest.mjs +11 -4
  160. package/markdownlint-config.mjs +1 -1
  161. package/package.json +6 -3
  162. package/release.mjs +21 -14
  163. package/sohl/actors.mjs +16 -27
  164. package/sohl/default-item-art.mjs +3 -3
  165. package/sohl/document-subtypes.mjs +1 -16
  166. package/sohl/infobox.mjs +2 -3
  167. package/sohl/item-builders.mjs +5 -8
  168. package/sohl/item-fields.mjs +5 -35
  169. package/sohl/items.mjs +0 -4
  170. package/sohl/kb-passes.mjs +3 -3
  171. package/types/container.d.mts +116 -5
  172. package/types/content-config.d.mts +83 -35
  173. package/types/deploy.d.mts +42 -6
  174. package/types/e2e.d.mts +35 -3
  175. package/types/engine/address-charset.d.mts +4 -17
  176. package/types/engine/address.d.mts +17 -8
  177. package/types/engine/art-fields.d.mts +7 -4
  178. package/types/engine/art-slots.d.mts +6 -0
  179. package/types/engine/base-compiler.d.mts +15 -17
  180. package/types/engine/content-address.d.mts +9 -12
  181. package/types/engine/content-embeds.d.mts +4 -2
  182. package/types/engine/content-format-check.d.mts +0 -64
  183. package/types/engine/content-index.d.mts +82 -3
  184. package/types/engine/content-links.d.mts +2 -4
  185. package/types/engine/culture-choice.d.mts +51 -8
  186. package/types/engine/data-keys.d.mts +2 -2
  187. package/types/engine/derived-fields.d.mts +1 -2
  188. package/types/engine/diagnostics.d.mts +27 -4
  189. package/types/engine/document-subtypes.d.mts +12 -31
  190. package/types/engine/event-views.d.mts +14 -2
  191. package/types/engine/exported-scene.d.mts +3 -2
  192. package/types/engine/field-spec.d.mts +13 -122
  193. package/types/engine/folder-notes.d.mts +0 -39
  194. package/types/engine/foreign-catalog.d.mts +2 -1
  195. package/types/engine/foundry-entries.d.mts +4 -5
  196. package/types/engine/frontmatter-lint.d.mts +2 -2
  197. package/types/engine/frontmatter.d.mts +19 -45
  198. package/types/engine/generate.d.mts +1 -1
  199. package/types/engine/helpers.d.mts +25 -42
  200. package/types/engine/homepage.d.mts +3 -10
  201. package/types/engine/ids.d.mts +0 -114
  202. package/types/engine/index-records.d.mts +6 -4
  203. package/types/engine/index.d.mts +0 -1
  204. package/types/engine/infobox-render.d.mts +7 -1
  205. package/types/engine/infobox.d.mts +3 -2
  206. package/types/engine/item-docs.d.mts +1 -1
  207. package/types/engine/item-registry.d.mts +2 -4
  208. package/types/engine/literature-notes.d.mts +1 -1
  209. package/types/engine/markdownlint-run.d.mts +40 -0
  210. package/types/engine/metadata-index.d.mts +9 -1
  211. package/types/engine/note-addresses.d.mts +27 -2
  212. package/types/engine/note-claims.d.mts +3 -5
  213. package/types/engine/note-ids.d.mts +2 -4
  214. package/types/engine/note-package.d.mts +1 -1
  215. package/types/engine/note-state.d.mts +1 -3
  216. package/types/engine/note-vocabulary.d.mts +28 -21
  217. package/types/engine/pack-config.d.mts +2 -6
  218. package/types/engine/pack-router.d.mts +4 -4
  219. package/types/engine/pathnames.d.mts +3 -3
  220. package/types/engine/pdf-build.d.mts +98 -12
  221. package/types/engine/pdf-coverage.d.mts +60 -0
  222. package/types/engine/pdf-index.d.mts +34 -5
  223. package/types/engine/pdf-pages.d.mts +143 -0
  224. package/types/engine/pdf-render.d.mts +45 -1
  225. package/types/engine/pdf-toc.d.mts +12 -14
  226. package/types/engine/prose-config.d.mts +1 -1
  227. package/types/engine/prose-lint.d.mts +11 -10
  228. package/types/engine/runtime-only-fields.d.mts +2 -2
  229. package/types/engine/site-build.d.mts +9 -5
  230. package/types/engine/site-index.d.mts +4 -5
  231. package/types/engine/site-root.d.mts +1 -1
  232. package/types/engine/sql-tables.d.mts +31 -2
  233. package/types/engine/standing-terms.d.mts +2 -1
  234. package/types/engine/standings.d.mts +7 -9
  235. package/types/engine/system-block.d.mts +10 -44
  236. package/types/engine/walk-files.d.mts +15 -0
  237. package/types/engine/wikilink-syntax.d.mts +18 -19
  238. package/types/hm3/actors.d.mts +1 -2
  239. package/types/hm3/item-fields.d.mts +2 -4
  240. package/types/hm3/template-priority.d.mts +6 -7
  241. package/types/manifest.d.mts +3 -2
  242. package/types/release.d.mts +8 -7
  243. package/types/sohl/actors.d.mts +2 -1
  244. package/types/sohl/document-subtypes.d.mts +1 -2
  245. package/types/sohl/item-fields.d.mts +3 -7
  246. package/engine/retired-fields.mjs +0 -631
  247. package/types/engine/retired-fields.d.mts +0 -354
@@ -80,12 +80,14 @@ import { fileURLToPath } from "node:url";
80
80
 
81
81
  import YAML from "yaml";
82
82
 
83
- import { loadPackConfig } from "./pack-config.mjs";
83
+ import { CONFIG_FILENAMES, loadPackConfig, packConfigPath } from "./pack-config.mjs";
84
84
  import { routerFor } from "./pack-router.mjs";
85
85
  import { publishesContentPages } from "../content-config.mjs";
86
86
  import { indexRecordsFor } from "./content-index.mjs";
87
- import { isNoteRecord, isStub, noteFile } from "./index-records.mjs";
88
- import { openNotesDatabase, prepareTreeSqlTables } from "./sql-tables.mjs";
87
+ import { noteFile } from "./index-records.mjs";
88
+ import { coverage, isPrintable, recordKey } from "./pdf-coverage.mjs";
89
+ import { elsewhereFor, homeEntries, movedPages, pageTable, readEntryPages } from "./pdf-pages.mjs";
90
+ import { cachedDependencies, openNotesDatabase, prepareTreeSqlTables } from "./sql-tables.mjs";
89
91
  import { parseDocumentTree, runTreeFilters, planDocument } from "./pdf-toc.mjs";
90
92
  import {
91
93
  collectContentPages,
@@ -102,7 +104,12 @@ import {
102
104
  } from "./site-index.mjs";
103
105
  import { resolveWebWikilinks } from "./web-wikilinks.mjs";
104
106
  import { linkFindingMessage } from "./wikilink-syntax.mjs";
105
- import { positionOfLiteral } from "./diagnostics.mjs";
107
+ import {
108
+ positionOfFrontmatterPath,
109
+ positionOfLiteral,
110
+ positionOfYamlPath,
111
+ yamlKeyPath,
112
+ } from "./diagnostics.mjs";
106
113
  import { artPathname, assetAddressIndex, pathnameRoles } from "./art-fields.mjs";
107
114
  import { embedRole } from "./content-embeds.mjs";
108
115
  import { expandContentTables } from "./content-tables.mjs";
@@ -138,7 +145,7 @@ import {
138
145
  resolveDanglingLabels,
139
146
  } from "./pdf-render.mjs";
140
147
  import { draftNoticeTypst } from "./draft-notice.mjs";
141
- import { isDraftNote, isGmNote } from "./note-vocabulary.mjs";
148
+ import { isDraftNote } from "./note-vocabulary.mjs";
142
149
  import { infoboxTypstPreamble, infoboxesToTypst, linkToTypst } from "./infobox-render.mjs";
143
150
  import { noteInfoboxes } from "./infobox-registry.mjs";
144
151
  import { resolveIconGlyphs } from "./pdf-fonts.mjs";
@@ -150,7 +157,7 @@ import { resolveAssetReplacement } from "./asset-replacement.mjs";
150
157
  * The file on disk an authored image pathname names, or `null`.
151
158
  *
152
159
  * Two of the four forms {@link module:engine/pathnames.resolvePathname}
153
- * derives, used together: `local` is the file in this repository's own tree,
160
+ * derives, taken together: `local` is the file in this repository's own tree,
154
161
  * and `pdf` is where the book stages a copy of it.
155
162
  *
156
163
  * **Typst decides where that copy goes.** It resolves a path against its root —
@@ -357,21 +364,33 @@ const BOOK_MAP_DIR = "maps";
357
364
  * @param {object} config - The resolved configuration.
358
365
  * @param {string} outDir - Where the book is written.
359
366
  * @param {object[]} findings - Collected here rather than thrown.
367
+ * @param {string} treeFile - The file the book's tree was read from — its
368
+ * `document`, or the configuration for a tree written inline — which is
369
+ * where a banner is declared.
370
+ * @param {string} [treeText] - That file's source, so a finding is located at
371
+ * the `banner:` key that declared the value.
360
372
  * @returns {Map<string, string>} Declared path → the staged file's path,
361
373
  * relative to the `.typ`.
362
374
  */
363
- export function stageBanners(entries, config, outDir, findings = []) {
375
+ export function stageBanners(entries, config, outDir, findings, treeFile, treeText = "") {
364
376
  const staged = new Map();
365
377
  const seen = new Set();
366
378
  for (const entry of entries ?? []) {
367
379
  const declared = entry?.presentation?.page?.banner;
368
380
  if (typeof declared !== "string" || !declared.trim() || seen.has(declared)) continue;
369
381
  seen.add(declared);
382
+ // The banner is declared by the `page:` mapping in force for the entry,
383
+ // which the plan carries as the path to the section that wrote it.
384
+ const at =
385
+ Array.isArray(entry.pageAt) ?
386
+ positionOfYamlPath(treeText, [...entry.pageAt, "banner"], { key: true })
387
+ : {};
370
388
  const from = path.resolve(config.rootDir, declared);
371
389
  const within = path.relative(config.rootDir, from);
372
390
  if (within.startsWith("..") || path.isAbsolute(within)) {
373
391
  findings.push({
374
- file: config.pdf.document,
392
+ file: treeFile,
393
+ ...at,
375
394
  severity: "warning",
376
395
  message:
377
396
  `the banner \`${declared}\` is outside this package, so the section ` +
@@ -386,7 +405,8 @@ export function stageBanners(entries, config, outDir, findings = []) {
386
405
  fs.copyFileSync(from, dest);
387
406
  } catch (err) {
388
407
  findings.push({
389
- file: config.pdf.document,
408
+ file: treeFile,
409
+ ...at,
390
410
  severity: "warning",
391
411
  message:
392
412
  `the banner \`${declared}\` cannot be read, so the section plate is ` +
@@ -404,15 +424,15 @@ export function stageBanners(entries, config, outDir, findings = []) {
404
424
  *
405
425
  * The zip and the manifest take their names from the manifest, so an asset's
406
426
  * name and its advertised URL cannot disagree. A PDF has no advertised URL, so
407
- * its name is a free choice — which is exactly why it is fixed here rather than
408
- * left for each of six consumers to invent.
427
+ * its name is the book's own file stem — its `out`, default
428
+ * `<contentPackage>-<id>` — with the version appended.
409
429
  *
410
- * @param {string} artifact - The package's artifact name.
430
+ * @param {string} stem - The book's file stem.
411
431
  * @param {string} version - The version being released.
412
- * @returns {string} `<artifact>-<version>.pdf`, or `<artifact>.pdf` unversioned.
432
+ * @returns {string} `<stem>-<version>.pdf`, or `<stem>.pdf` unversioned.
413
433
  */
414
- export function pdfFileName(artifact, version) {
415
- const safe = String(artifact || "content").replace(/[^\w.-]+/g, "-");
434
+ export function pdfFileName(stem, version) {
435
+ const safe = String(stem || "content").replace(/[^\w.-]+/g, "-");
416
436
  return version ? `${safe}-${version}.pdf` : `${safe}.pdf`;
417
437
  }
418
438
 
@@ -420,9 +440,10 @@ export function pdfFileName(artifact, version) {
420
440
  * Read and parse the declared document tree.
421
441
  *
422
442
  * @param {string} file - Absolute path to the document tree.
443
+ * @param {string} field - The key that names it, for the finding.
423
444
  * @returns {{raw: unknown, text: string, findings: object[]}} What it held.
424
445
  */
425
- function readDocumentTree(file) {
446
+ function readDocumentTree(file, field) {
426
447
  let text;
427
448
  try {
428
449
  text = fs.readFileSync(file, "utf8");
@@ -434,7 +455,7 @@ function readDocumentTree(file) {
434
455
  {
435
456
  file,
436
457
  severity: "error",
437
- message: "the document tree named by `pdf.document` cannot be read",
458
+ message: `the document tree named by \`${field}\` cannot be read`,
438
459
  },
439
460
  ],
440
461
  };
@@ -457,96 +478,498 @@ function readDocumentTree(file) {
457
478
  }
458
479
 
459
480
  /**
460
- * Build the book.
481
+ * The release assets the declared books produce, in configuration order.
482
+ *
483
+ * Builds nothing and reads no note: each entry is the file {@link buildPdf}
484
+ * writes for that book at `version`, as a path relative to the working
485
+ * directory inside `out` (default the configured `pdf.out`, else
486
+ * `build/dist`). A package that publishes no book — no `pdf:` block, or a
487
+ * content tree holding only a homepage — lists none.
488
+ *
489
+ * @param {object} [opts] - Options.
490
+ * @param {object} [opts.config] - A resolved configuration; loaded when absent.
491
+ * @param {string} [opts.version] - The version stamped in each file name.
492
+ * @param {string} [opts.out] - Where the books are written, overriding `pdf.out`.
493
+ * @returns {Array<{id: string, title: string, file: string}>} One entry per book.
494
+ */
495
+ export function listBookAssets({ config, version = "", out } = {}) {
496
+ const resolved = config ?? loadPackConfig();
497
+ if (!resolved.pdf || !publishesContentPages(resolved)) return [];
498
+ const outDir = path.resolve(
499
+ resolved.rootDir,
500
+ out || resolved.pdf.out || path.join("build", "dist"),
501
+ );
502
+ return resolved.pdf.books.map((book) => ({
503
+ id: book.id,
504
+ title: book.title,
505
+ file: path.relative(process.cwd(), path.join(outDir, pdfFileName(book.out, version))),
506
+ }));
507
+ }
508
+
509
+ /**
510
+ * The configuration file a finding about a configured key names.
511
+ *
512
+ * The path the loader read when it loaded the configuration; for a
513
+ * configuration handed in by a caller, the one configuration file in its
514
+ * `rootDir`, which is where a YAML configuration always sits.
515
+ *
516
+ * @param {object|undefined} config - The configuration the caller supplied.
517
+ * @param {string} rootDir - The resolved configuration's root.
518
+ * @returns {string} Its absolute path, or `rootDir` when there is none to name.
519
+ */
520
+ function configFileFor(config, rootDir) {
521
+ if (!config) return packConfigPath();
522
+ const found = CONFIG_FILENAMES.map((name) => path.join(rootDir, name)).find((file) =>
523
+ fs.existsSync(file),
524
+ );
525
+ return found ?? rootDir;
526
+ }
527
+
528
+ /**
529
+ * Build the books the `pdf:` block declares — every one, or the one `book`
530
+ * names.
531
+ *
532
+ * Every declared book's filters run against one corpus and one database first,
533
+ * so coverage and each note's home book are computed over the whole set
534
+ * whichever books are requested. A book whose filters select no note is an
535
+ * error at its `id` and is not built; a section that selects none is left out.
536
+ *
537
+ * A finding about a configured key — an unknown `--book`, a `coverImage`,
538
+ * `document` or `front` file that does not exist — names the configuration
539
+ * file and, for a YAML one, the line and column of that key. A book with a
540
+ * missing file is not built; the others still are.
461
541
  *
462
542
  * @param {object} [opts] - Options.
463
543
  * @param {object} [opts.config] - A resolved configuration; loaded when absent.
544
+ * @param {string} [opts.configPath] - The file `config` was read from, for
545
+ * findings at a configured key. Defaults to the file the loader read, or to
546
+ * the configuration file in `config.rootDir`.
547
+ * @param {string} [opts.book] - The `id` of the one book to build; every book
548
+ * when absent.
464
549
  * @param {string} [opts.out] - Where to write, overriding `pdf.out`.
465
550
  * @param {string} [opts.version] - Stamped on the title page and the file name.
466
551
  * @param {boolean} [opts.compile] - Whether to run Typst. False leaves the
467
- * `.typ` source, which is what the unit tests read.
552
+ * `.typ` source, which is what the unit tests read, and runs a single pass:
553
+ * a reference to a note another book is home to prints `p. ?`. True renders
554
+ * every book, measures the page each entry starts on, writes `pages.json`
555
+ * into the output directory, and renders again with those pages — see
556
+ * {@link module:engine/pdf-pages}.
557
+ * @param {Function} [opts.readPages] - {@link
558
+ * module:engine/pdf-pages.readEntryPages}, for tests.
468
559
  * @param {Function} [opts.resolveReplacement] - {@link
469
560
  * module:engine/asset-replacement.resolveAssetReplacement}, for tests.
470
561
  * Loaded from that module otherwise, and only when a relationship declares
471
562
  * `assetReplacement: true` — a build with none never imports it.
472
- * @returns {Promise<object>} `{ built, reason, findings, typ, pdf, stats }`.
563
+ * @returns {Promise<{built: boolean, reason: string|null, findings: object[],
564
+ * imageReports: object[], books: Array<{id: string, title: string,
565
+ * built: boolean, typ: string, pdf: string|null, stats: object}>,
566
+ * homes: Map<string, string>, summary: object|null}>}
567
+ * `built` is true when every requested book was written (and compiled, when
568
+ * asked); `books` holds each book whose source was written. `homes` maps a
569
+ * selected note's canonical address to the id of the first book that selects
570
+ * it, and `summary` is {@link module:engine/pdf-coverage.coverage}'s, over
571
+ * every declared book whichever was requested; both are empty when a book's
572
+ * selection could not be read.
473
573
  */
474
574
  export async function buildPdf({
475
575
  config,
576
+ configPath,
577
+ book,
476
578
  out,
477
579
  version = "",
478
580
  compile = true,
581
+ readPages = readEntryPages,
479
582
  resolveReplacement,
480
583
  } = {}) {
481
584
  const resolved = config ?? loadPackConfig();
482
585
  const findings = [];
586
+ const none = (reason) => ({
587
+ built: false,
588
+ reason,
589
+ findings,
590
+ imageReports: [],
591
+ books: [],
592
+ homes: new Map(),
593
+ summary: null,
594
+ });
483
595
 
484
- // The fence, first — before the tree is read, before anything is walked.
596
+ // The fence, first — before any tree is read, before anything is walked.
485
597
  if (!publishesContentPages(resolved)) {
486
- return {
487
- built: false,
488
- reason:
489
- fs.existsSync(resolved.paths.content) ?
490
- "The content tree contains only a homepage, so there is no book to build."
491
- : "this package has no content tree, so there is nothing to print",
492
- findings,
493
- typ: null,
494
- pdf: null,
495
- stats: null,
496
- };
598
+ return none(
599
+ fs.existsSync(resolved.paths.content) ?
600
+ "The content tree contains only a homepage, so there is no book to build."
601
+ : "this package has no content tree, so there is nothing to print",
602
+ );
497
603
  }
498
604
  if (!resolved.pdf) {
499
- return {
500
- built: false,
501
- reason: "no `pdf:` block is configured, so this package publishes no book",
502
- findings,
503
- typ: null,
504
- pdf: null,
505
- stats: null,
506
- };
605
+ return none("no `pdf:` block is configured, so this package publishes no book");
507
606
  }
508
-
509
607
  const contentBase = resolved.paths.content;
510
608
  if (!fs.existsSync(contentBase)) {
511
- return {
512
- built: false,
513
- reason: "this package has no content tree, so there is nothing to print",
514
- findings,
515
- typ: null,
516
- pdf: null,
517
- stats: null,
518
- };
609
+ return none("this package has no content tree, so there is nothing to print");
519
610
  }
520
611
 
521
- const tree = readDocumentTree(resolved.pdf.document);
522
- findings.push(...tree.findings);
523
- if (!tree.raw) {
524
- return { built: false, reason: null, findings, typ: null, pdf: null, stats: null };
612
+ const configFile = configPath ?? configFileFor(config, resolved.rootDir);
613
+ // Only a YAML configuration is searched for positions: JavaScript fed to a
614
+ // YAML parser parses as something, and a position that is wrong is worse
615
+ // than none.
616
+ let configText = "";
617
+ if (/\.ya?ml$/i.test(configFile)) {
618
+ try {
619
+ configText = fs.readFileSync(configFile, "utf8");
620
+ } catch {
621
+ configText = "";
622
+ }
525
623
  }
624
+ const atKey = (field) => ({
625
+ file: configFile,
626
+ ...positionOfYamlPath(configText, yamlKeyPath(field), { key: true }),
627
+ });
628
+
629
+ const declared = resolved.pdf.books.map((entry, index) => ({ book: entry, index }));
630
+ const requested =
631
+ book === undefined ? declared : declared.filter((entry) => entry.book.id === book);
632
+ if (!requested.length) {
633
+ findings.push({
634
+ ...atKey("pdf.books"),
635
+ severity: "error",
636
+ message:
637
+ `--book \`${book}\` names no declared book; declared: ` +
638
+ declared.map((entry) => entry.book.id).join(", "),
639
+ });
640
+ return none(null);
641
+ }
642
+
643
+ const outDir = path.resolve(
644
+ resolved.rootDir,
645
+ out || resolved.pdf.out || path.join("build", "dist"),
646
+ );
647
+
648
+ const world = await prepareWorld(resolved, { contentBase, findings, resolveReplacement });
649
+ if (!world) return none(null);
650
+
651
+ const books = [];
652
+ const imageReports = [];
653
+ let built = true;
654
+ let homes = new Map();
655
+ let summary = null;
656
+ try {
657
+ // Every declared book's filters run before any book renders, because
658
+ // coverage and the home book are properties of the whole set.
659
+ const selections = [];
660
+ for (const { book: entry, index } of declared) {
661
+ const where = `pdf.books[${index}]`;
662
+ const wanted = requested.some((r) => r.index === index);
663
+ // Checked here rather than when the configuration loads, so a command
664
+ // that builds no book never fails for a cover held outside the tree.
665
+ // A book that is not requested is read for its selection alone.
666
+ const missing = [
667
+ ...(wanted && entry.coverImage ?
668
+ [[`${where}.coverImage`, entry.coverImage, "cover image"]]
669
+ : []),
670
+ ...(entry.document ? [[`${where}.document`, entry.document, "document tree"]] : []),
671
+ ...(wanted ?
672
+ entry.front.map((file, j) => [
673
+ `${where}.front[${j}]`,
674
+ file,
675
+ "front-matter file",
676
+ ])
677
+ : []),
678
+ ].filter(([, file]) => !fs.existsSync(file));
679
+ for (const [field, file, what] of missing) {
680
+ findings.push({
681
+ ...atKey(field),
682
+ severity: "error",
683
+ message:
684
+ `book \`${entry.id}\`: the ${what} \`` +
685
+ `${path.relative(resolved.rootDir, file).split(path.sep).join("/")}\` ` +
686
+ (wanted ?
687
+ "does not exist, so the book is not built"
688
+ : "does not exist, so coverage cannot be computed"),
689
+ });
690
+ }
691
+ if (missing.length) {
692
+ selections.push({ usable: false, broken: true });
693
+ continue;
694
+ }
695
+ selections.push(
696
+ await selectBook(entry, index, {
697
+ world,
698
+ configFile,
699
+ configText,
700
+ findings,
701
+ }),
702
+ );
703
+ }
526
704
 
527
- const parsed = parseDocumentTree(tree.raw, { text: tree.text });
528
- findings.push(...parsed.findings.map((f) => ({ file: resolved.pdf.document, ...f })));
705
+ // Coverage, over every book whether or not it renders.
706
+ let unprinted = [];
707
+ let unprintedKnown = true;
708
+ if (resolved.pdf.unprinted) {
709
+ try {
710
+ ({ rows: unprinted } = await world.db.query(
711
+ `SELECT * FROM notes WHERE ${resolved.pdf.unprinted}`,
712
+ ));
713
+ } catch (err) {
714
+ unprintedKnown = false;
715
+ findings.push({
716
+ ...atKey("pdf.unprinted"),
717
+ severity: "error",
718
+ message:
719
+ "the query did not run, so coverage cannot be computed — " +
720
+ String(err.message).split("\n")[0],
721
+ });
722
+ }
723
+ }
724
+ if (unprintedKnown && selections.every((selection) => selection.usable)) {
725
+ const covered = coverage({
726
+ books: declared.map(({ book: entry }, i) => ({
727
+ id: entry.id,
728
+ records: selections[i].records,
729
+ })),
730
+ corpus: world.records,
731
+ unprinted,
732
+ mode: resolved.pdf.coverage,
733
+ locate: (record) => locateType(contentBase, record),
734
+ });
735
+ findings.push(...covered.findings);
736
+ homes = covered.homes;
737
+ summary = covered.summary;
738
+ }
529
739
 
740
+ // Which books render, and which of those the caller asked for. A book
741
+ // whose selection could not be read, or selects nothing, is reported
742
+ // when it is requested and left out of the page table either way.
743
+ const buildable = [];
744
+ for (const { book: entry, index } of declared) {
745
+ const selection = selections[index];
746
+ const wanted = requested.some((r) => r.index === index);
747
+ if (!selection.usable) {
748
+ if (wanted) built = false;
749
+ continue;
750
+ }
751
+ if (!selection.records.length) {
752
+ if (wanted) {
753
+ built = false;
754
+ // A tree that failed to read already has its findings.
755
+ if (!selection.broken) {
756
+ findings.push({
757
+ ...atKey(`pdf.books[${index}].id`),
758
+ severity: "error",
759
+ message: `book \`${entry.id}\` selects no note`,
760
+ });
761
+ }
762
+ }
763
+ continue;
764
+ }
765
+ buildable.push({ entry, index, selection, wanted });
766
+ }
767
+
768
+ // The books that render are the books that are measured, so a page
769
+ // table is only ever read from the books it describes.
770
+ const toRender = compile ? buildable : buildable.filter((b) => b.wanted);
771
+ const homed = homeEntries({
772
+ books: buildable.map((b) => ({
773
+ id: b.entry.id,
774
+ title: b.entry.title,
775
+ plan: b.selection.plan,
776
+ })),
777
+ homes,
778
+ });
779
+
780
+ /**
781
+ * Render every book once against a page table.
782
+ *
783
+ * @param {Record<string, {book: string, page: number}>|undefined} table
784
+ * @returns {Promise<{written: Map<string, object>, local: object[], used: number}>}
785
+ */
786
+ const renderAll = async (table) => {
787
+ const local = [];
788
+ const written = new Map();
789
+ let used = 0;
790
+ for (const { entry, selection } of toRender) {
791
+ const elsewhere = elsewhereFor({
792
+ entries: homed,
793
+ book: entry.id,
794
+ printed: selection.plan.links,
795
+ table,
796
+ seeAlso: resolved.pdf.indexSeeAlso,
797
+ });
798
+ const result = await buildBook(entry, {
799
+ resolved,
800
+ world,
801
+ selection,
802
+ authors: resolved.authors ?? [],
803
+ copyright: resolved.pdf.copyright,
804
+ license: resolved.pdf.license,
805
+ outDir,
806
+ contentBase,
807
+ version,
808
+ findings: local,
809
+ elsewhere,
810
+ homes,
811
+ });
812
+ used += elsewhere.used;
813
+ written.set(entry.id, result);
814
+ }
815
+ return { written, local, used };
816
+ };
817
+
818
+ /**
819
+ * Lay every written book out and read the page each entry starts on.
820
+ *
821
+ * @param {Map<string, object>} written - From {@link renderAll}.
822
+ * @returns {{table: Record<string, {book: string, page: number}>|null,
823
+ * message: string}} The table, or null with why it could not be read.
824
+ */
825
+ const measureAll = (written) => {
826
+ const fontArgs = typstFontArgs(resolved.pdf);
827
+ const measured = new Map();
828
+ for (const { entry } of toRender) {
829
+ const result = readPages(
830
+ written.get(entry.id).typ,
831
+ fontArgs,
832
+ resolved.pdf.binary || "typst",
833
+ );
834
+ if (!result.ok) return { table: null, message: result.message };
835
+ measured.set(entry.id, result.pages);
836
+ }
837
+ return { table: pageTable(homed, measured), message: "" };
838
+ };
839
+
840
+ // The first pass prints `p. ?` for every note another book is home to.
841
+ // Without Typst there is nothing to measure, and that pass is the
842
+ // output.
843
+ let pass = await renderAll(undefined);
844
+ if (compile) {
845
+ const unread = (message) => {
846
+ findings.push({
847
+ file: configFile,
848
+ severity: "error",
849
+ message: `the page of each entry could not be read: ${message}`,
850
+ });
851
+ built = false;
852
+ };
853
+ let measure = measureAll(pass.written);
854
+ if (!measure.table) unread(measure.message);
855
+ else {
856
+ let table = measure.table;
857
+ // A second pass prints the measured pages. Those words change
858
+ // the layout, so the pages are measured again: when none moved
859
+ // the pass stands, and when any did the books are rendered a
860
+ // final time against the new table and whatever still differs
861
+ // is reported.
862
+ if (pass.used) {
863
+ pass = await renderAll(table);
864
+ measure = measureAll(pass.written);
865
+ if (!measure.table) unread(measure.message);
866
+ else if (movedPages(table, measure.table).length) {
867
+ const printed = measure.table;
868
+ pass = await renderAll(printed);
869
+ measure = measureAll(pass.written);
870
+ if (!measure.table) unread(measure.message);
871
+ else {
872
+ for (const moved of movedPages(printed, measure.table)) {
873
+ const home = homed.find((h) => h.key === moved.key);
874
+ findings.push({
875
+ file: noteFile(contentBase, home.record),
876
+ severity: "warning",
877
+ message:
878
+ `\`${home.record?.name?.full ?? home.slug}\` starts on page ` +
879
+ `${moved.after} of \`${home.title}\`, but the references ` +
880
+ `to it in other books print page ${moved.before}`,
881
+ });
882
+ }
883
+ }
884
+ }
885
+ table = measure.table ?? table;
886
+ }
887
+ fs.mkdirSync(outDir, { recursive: true });
888
+ fs.writeFileSync(
889
+ path.join(outDir, "pages.json"),
890
+ `${JSON.stringify(table, null, 2)}\n`,
891
+ );
892
+ }
893
+ }
894
+ findings.push(...pass.local);
895
+
896
+ for (const { entry, wanted } of toRender) {
897
+ if (!wanted) continue;
898
+ const result = pass.written.get(entry.id);
899
+ imageReports.push(...(result.imageReports ?? []));
900
+ const ok = compile ? compileBook(result, resolved.pdf, findings) : true;
901
+ if (!ok) built = false;
902
+ books.push({
903
+ id: entry.id,
904
+ title: entry.title,
905
+ built: ok,
906
+ typ: result.typ,
907
+ pdf: compile && ok ? result.pdf : null,
908
+ stats: result.stats,
909
+ });
910
+ }
911
+ } finally {
912
+ await world.db.close?.();
913
+ fs.rmSync(world.dir, { recursive: true, force: true });
914
+ }
915
+ return { built, reason: null, findings, imageReports, books, homes, summary };
916
+ }
917
+
918
+ /**
919
+ * Where a note's `type:` key sits, for a finding about the note as a whole.
920
+ *
921
+ * @param {string} contentBase - The content tree.
922
+ * @param {object} record - The note's index record.
923
+ * @returns {{file: string, line?: number, column?: number}} The note's file and,
924
+ * when the key is found, its line and column.
925
+ */
926
+ function locateType(contentBase, record) {
927
+ const file = noteFile(contentBase, record);
928
+ let raw;
929
+ try {
930
+ raw = fs.readFileSync(file, "utf8");
931
+ } catch {
932
+ return { file };
933
+ }
934
+ return { file, ...positionOfFrontmatterPath(raw, ["type"], { key: true }) };
935
+ }
936
+
937
+ /**
938
+ * Everything every book reads and none writes: the corpus, the notes database,
939
+ * the collected pages and their gates, the SQL tables, the address space
940
+ * pictures resolve against and the staging options.
941
+ *
942
+ * Built once per run. A book renders from it; coverage and the home book are
943
+ * computed over it.
944
+ *
945
+ * @param {object} resolved - The resolved configuration.
946
+ * @param {object} shared - Options.
947
+ * @param {string} shared.contentBase - The content tree.
948
+ * @param {object[]} shared.findings - Collected here rather than thrown.
949
+ * @param {Function} [shared.resolveReplacement] - As {@link buildPdf}.
950
+ * @returns {Promise<object|null>} The world, or null when a site gate fired
951
+ * and the findings say why.
952
+ */
953
+ async function prepareWorld(resolved, { contentBase, findings, resolveReplacement }) {
530
954
  // The corpus, once — the same records the index, the site and the SQL
531
955
  // tables read, so the book cannot select a note the other surfaces do not
532
956
  // have.
957
+ const parsed = new Map();
533
958
  const records = indexRecordsFor({
534
959
  contentBase,
535
960
  config: resolved,
536
961
  skipDirectories: resolved.skipDirectories,
962
+ parsed,
537
963
  });
538
964
 
539
965
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), "content-pdf-"));
540
- const db = await openNotesDatabase(records, { dir, audience: "public" });
541
- const ran = await runTreeFilters(parsed.nodes, db, {
542
- // A stub is a note and is deliberately in the corpus, but it has no
543
- // body — selecting one would print a blank page under its name.
544
- keep: (record) => isNoteRecord(record) && !isStub(record) && !isGmNote(record),
545
- text: tree.text,
966
+ // The one database over the corpus: the books' filters, the generated
967
+ // sections and every authored query read it.
968
+ const db = await openNotesDatabase(records, {
969
+ dir,
970
+ dependencies: await cachedDependencies(resolved),
971
+ audience: "public",
546
972
  });
547
- findings.push(...ran.findings.map((f) => ({ file: resolved.pdf.document, ...f })));
548
-
549
- const plan = planDocument(parsed.nodes, { selections: ran.selections });
550
973
 
551
974
  // Pages, indexed and gated exactly as the site does. A book of a tree whose
552
975
  // addresses collide would print the wrong entry under the right name.
@@ -594,7 +1017,7 @@ export async function buildPdf({
594
1017
  });
595
1018
  }
596
1019
  }
597
- return { built: false, reason: null, findings, typ: null, pdf: null, stats: null };
1020
+ return null;
598
1021
  }
599
1022
 
600
1023
  const byFile = new Map(collected.pages.map((page) => [page.file, page]));
@@ -603,6 +1026,8 @@ export async function buildPdf({
603
1026
  config: resolved,
604
1027
  skipDirectories: resolved.skipDirectories,
605
1028
  records,
1029
+ parsed,
1030
+ database: db,
606
1031
  audience: "public",
607
1032
  });
608
1033
  // The address space an `![[…]]` embed resolves against — the same one the
@@ -626,10 +1051,141 @@ export async function buildPdf({
626
1051
  (assetReplacementRelationships(resolved).length ? resolveAssetReplacement : null),
627
1052
  };
628
1053
 
629
- const outDir = path.resolve(
630
- resolved.rootDir,
631
- out || resolved.pdf.out || path.join("build", "dist"),
632
- );
1054
+ return {
1055
+ records,
1056
+ db,
1057
+ dir,
1058
+ site,
1059
+ gates,
1060
+ byFile,
1061
+ universe,
1062
+ sqlTables,
1063
+ assets,
1064
+ md,
1065
+ glyphs,
1066
+ stagingOpts,
1067
+ };
1068
+ }
1069
+
1070
+ /**
1071
+ * Read one book's tree and run its filters against the shared database.
1072
+ *
1073
+ * @param {import("../content-config.mjs").ResolvedPdfBook} book - The book.
1074
+ * @param {number} index - Its place in `pdf.books`.
1075
+ * @param {object} shared - What the selection reads.
1076
+ * @param {object} shared.world - From {@link prepareWorld}.
1077
+ * @param {string} shared.configFile - The configuration file.
1078
+ * @param {string} shared.configText - Its text, for positions.
1079
+ * @param {object[]} shared.findings - Collected here rather than thrown.
1080
+ * @returns {Promise<{usable: boolean, broken: boolean, treeFile?: string,
1081
+ * treeText?: string, plan?: object, records?: object[]}>} `usable` is false when the tree
1082
+ * cannot be read; `broken` is true when it was read with findings;
1083
+ * `records` holds every record the filters selected, a repeat counted again.
1084
+ */
1085
+ async function selectBook(book, index, { world, configFile, configText, findings }) {
1086
+ // The tree is written inline in the configuration or kept in its own
1087
+ // file, and every finding about it names whichever file holds it.
1088
+ let treeFile;
1089
+ let treeText;
1090
+ let raw;
1091
+ let at;
1092
+ if (book.contents) {
1093
+ treeFile = configFile;
1094
+ treeText = configText;
1095
+ raw = { contents: book.contents };
1096
+ at = ["pdf", "books", index];
1097
+ } else {
1098
+ const tree = readDocumentTree(book.document, `pdf.books[${index}].document`);
1099
+ findings.push(...tree.findings);
1100
+ if (!tree.raw) return { usable: false, broken: true };
1101
+ treeFile = book.document;
1102
+ treeText = tree.text;
1103
+ raw = tree.raw;
1104
+ at = [];
1105
+ }
1106
+
1107
+ const parsed = parseDocumentTree(raw, { text: treeText, at });
1108
+ const parseFindings = parsed.findings.map((f) => ({ file: treeFile, ...f }));
1109
+ findings.push(...parseFindings);
1110
+ const ran = await runTreeFilters(parsed.nodes, world.db, {
1111
+ // A stub is a note and is deliberately in the corpus, but it has no
1112
+ // body — selecting one would print a blank page under its name.
1113
+ keep: isPrintable,
1114
+ text: treeText,
1115
+ });
1116
+ const filterFindings = ran.findings.map((f) => ({ file: treeFile, ...f }));
1117
+ findings.push(...filterFindings);
1118
+ return {
1119
+ usable: true,
1120
+ broken: [...parseFindings, ...filterFindings].some((f) => f.severity === "error"),
1121
+ treeFile,
1122
+ treeText,
1123
+ plan: planDocument(parsed.nodes, { selections: ran.selections }),
1124
+ records: [...ran.selections.values()].flat(),
1125
+ };
1126
+ }
1127
+
1128
+ /**
1129
+ * Build one book.
1130
+ *
1131
+ * Everything a book does not share with its siblings — its tree, its title,
1132
+ * subtitle and cover, its front matter and its file stem — is read from
1133
+ * `book`; everything shared from `shared`. The corpus, the database, the site
1134
+ * gates and the book's selection are computed before any book renders. Pictures
1135
+ * are staged under a directory named for the book's file stem, so one book's
1136
+ * resampling cannot change a file another book's source names.
1137
+ *
1138
+ * @param {import("../content-config.mjs").ResolvedPdfBook} book - The book.
1139
+ * @param {object} shared - What every book is built with.
1140
+ * @param {object} shared.resolved - The resolved configuration.
1141
+ * @param {number} shared.index - The book's place in `pdf.books`.
1142
+ * @param {object} shared.world - From {@link prepareWorld}.
1143
+ * @param {{treeFile: string, treeText: string, plan: object}} shared.selection - From
1144
+ * {@link selectBook}.
1145
+ * @param {readonly object[]} shared.authors - The top-level `authors`.
1146
+ * @param {string} shared.copyright - `pdf.copyright`; `""` when none.
1147
+ * @param {string} shared.license - `pdf.license`; `""` when none.
1148
+ * @param {string} shared.outDir - Where the book is written.
1149
+ * @param {string} shared.contentBase - The content tree.
1150
+ * @param {string} shared.version - Stamped on the title page and the file name.
1151
+ * @param {Map<string, string>} [shared.homes] - Each note's key → its home
1152
+ * book's id.
1153
+ * @param {{get: Function}} [shared.elsewhere] - From
1154
+ * {@link module:engine/pdf-pages.elsewhereFor}: the notes another book is
1155
+ * home to, which this book names by book and page rather than links.
1156
+ * @param {Function} [shared.resolveReplacement] - As {@link buildPdf}.
1157
+ * @param {object[]} shared.findings - Collected here rather than thrown.
1158
+ * @returns {Promise<{built: boolean, typ: string|null, pdf: string|null,
1159
+ * stats: object|null, imageReports?: object[]}>} What was written: the
1160
+ * source and its resampled pictures. The compile is {@link compileBook}'s.
1161
+ */
1162
+ async function buildBook(book, shared) {
1163
+ const { resolved, world, selection, outDir, contentBase, version, findings, elsewhere } =
1164
+ shared;
1165
+ // The index lists the notes this book is home to; a note whose home is
1166
+ // another book is a reprint, and its term is in that book's index.
1167
+ const index = {
1168
+ owns: (entry) => (shared.homes?.get(recordKey(entry.record)) ?? book.id) === book.id,
1169
+ ...(elsewhere?.seeAlso ? { foreign: elsewhere.listed } : {}),
1170
+ };
1171
+
1172
+ const { treeFile, treeText, plan } = selection;
1173
+ // The directory this book stages its pictures in, beside its source.
1174
+ const stageDir = pdfFileName(book.out, "").replace(/\.pdf$/, "");
1175
+ const inBook = (relative) => `${stageDir}/${relative}`;
1176
+ /**
1177
+ * {@link stagedImagePath}, with the file placed in this book's directory.
1178
+ *
1179
+ * @param {string} src - The authored address.
1180
+ * @returns {{from: string, to: string}|null} As {@link stagedImagePath}.
1181
+ */
1182
+ const stage = (src) => {
1183
+ const staged = stagedImagePath(src, resolved, world.stagingOpts);
1184
+ return staged && { ...staged, to: inBook(staged.to) };
1185
+ };
1186
+ const { records, site, gates, byFile, universe, sqlTables, assets, md, glyphs, stagingOpts } =
1187
+ world;
1188
+
633
1189
  fs.mkdirSync(outDir, { recursive: true });
634
1190
 
635
1191
  /** @type {Map<string, string>} Authored address → the staged file's path. */
@@ -659,14 +1215,32 @@ export async function buildPdf({
659
1215
  * @returns {void}
660
1216
  */
661
1217
  const stageImages = (body, file, columns = 2) => {
1218
+ // A finding about an image is located by searching the note for the
1219
+ // address the body matched, at the occurrence this image is among the
1220
+ // body's images of that address. Read once, and only for a finding.
1221
+ let raw;
1222
+ const seenHere = new Map();
1223
+ /** @param {string} src @returns {{line?: number, column?: number}} */
1224
+ const where = (src, occurrence) => {
1225
+ if (raw === undefined) {
1226
+ try {
1227
+ raw = fs.readFileSync(file, "utf8");
1228
+ } catch {
1229
+ raw = "";
1230
+ }
1231
+ }
1232
+ return positionOfLiteral(raw, src, occurrence);
1233
+ };
662
1234
  for (const image of imagesIn(body)) {
663
1235
  const src = image.src;
1236
+ const occurrence = (seenHere.get(src) ?? 0) + 1;
1237
+ seenHere.set(src, occurrence);
664
1238
  // A generated section's drawing is the one this build drew beside
665
1239
  // its source — see `drawn` below — and is vector, so nothing
666
1240
  // resamples it.
667
1241
  const drawing = generatedDrawing(src);
668
1242
  if (drawing) {
669
- if (drawn.maps.has(drawing)) images.set(src, `${BOOK_MAP_DIR}/${drawing}`);
1243
+ if (drawn.maps.has(drawing)) images.set(src, inBook(`${BOOK_MAP_DIR}/${drawing}`));
670
1244
  continue;
671
1245
  }
672
1246
  const directive = parseImageDirective(image.directive);
@@ -698,19 +1272,30 @@ export async function buildPdf({
698
1272
  // is mechanical and named in the message.
699
1273
  const problem = pathnameProblem(src);
700
1274
  if (problem) {
701
- findings.push({ file, severity: "error", message: problem });
1275
+ findings.push({
1276
+ file,
1277
+ ...where(src, occurrence),
1278
+ severity: "error",
1279
+ message: problem,
1280
+ });
702
1281
  continue;
703
1282
  }
704
1283
  let staged;
705
1284
  try {
706
- staged = stagedImagePath(src, resolved, stagingOpts);
1285
+ staged = stage(src);
707
1286
  } catch (err) {
708
- findings.push({ file, severity: "error", message: err.message });
1287
+ findings.push({
1288
+ file,
1289
+ ...where(src, occurrence),
1290
+ severity: "error",
1291
+ message: err.message,
1292
+ });
709
1293
  continue;
710
1294
  }
711
1295
  if (!staged) {
712
1296
  findings.push({
713
1297
  file,
1298
+ ...where(src, occurrence),
714
1299
  severity: "warning",
715
1300
  message:
716
1301
  `\`${src}\` names a file this package does not ship, so the book ` +
@@ -726,6 +1311,7 @@ export async function buildPdf({
726
1311
  } catch (err) {
727
1312
  findings.push({
728
1313
  file,
1314
+ ...where(src, occurrence),
729
1315
  severity: "warning",
730
1316
  message:
731
1317
  `\`${src}\` cannot be read from ` +
@@ -766,7 +1352,7 @@ export async function buildPdf({
766
1352
  const resolveRole = (address) => embedRole(assets, address);
767
1353
  const pathRoles = pathnameRoles(assets);
768
1354
  const frontFigures = new Map();
769
- for (const file of resolved.pdf.front) {
1355
+ for (const file of book.front) {
770
1356
  try {
771
1357
  const scan = scanFigures(fs.readFileSync(file, "utf8"), { resolveRole });
772
1358
  // Numbered here, because a number runs across the whole book and only
@@ -802,7 +1388,7 @@ export async function buildPdf({
802
1388
  // any body is rendered so the section's image names a file the book holds.
803
1389
  // A place's map is printed there, inside its entry, and nowhere else.
804
1390
  const drawn = drawMapsFrom(records, {
805
- outDir: path.join(outDir, BOOK_MAP_DIR),
1391
+ outDir: path.join(outDir, stageDir, BOOK_MAP_DIR),
806
1392
  foreignIndex: gates.foreign.index,
807
1393
  config: resolved,
808
1394
  only: plan.entries
@@ -1018,6 +1604,7 @@ export async function buildPdf({
1018
1604
  headingOffset,
1019
1605
  anchorPrefix,
1020
1606
  url: site,
1607
+ elsewhere,
1021
1608
  findings,
1022
1609
  file: page.file,
1023
1610
  bodyLine: page.bodyLine,
@@ -1045,7 +1632,7 @@ export async function buildPdf({
1045
1632
  contentPackage: gates.index.contentPackage,
1046
1633
  });
1047
1634
  const panel = infoboxesToTypst(boxes, {
1048
- link: (value) => linkToTypst(value, plan.links, labelFor, site),
1635
+ link: (value) => linkToTypst(value, plan.links, labelFor, site, elsewhere),
1049
1636
  });
1050
1637
  // Whether the entry is settled comes before anything it says: the
1051
1638
  // notice leads the leaf, before the authored body.
@@ -1081,7 +1668,7 @@ export async function buildPdf({
1081
1668
  text = fs.readFileSync(file, "utf8");
1082
1669
  } catch {
1083
1670
  findings.push({
1084
- file: resolved.pdf.document,
1671
+ file: treeFile,
1085
1672
  severity: "error",
1086
1673
  message: `the prose file \`${entry.file}\` cannot be read`,
1087
1674
  });
@@ -1100,6 +1687,7 @@ export async function buildPdf({
1100
1687
  anchorPrefix: entry.anchor,
1101
1688
  captions: prepared.get(entry.anchor).numberedFigures,
1102
1689
  url: site,
1690
+ elsewhere,
1103
1691
  indexMentions: true,
1104
1692
  findings,
1105
1693
  file,
@@ -1117,7 +1705,7 @@ export async function buildPdf({
1117
1705
  });
1118
1706
  }
1119
1707
 
1120
- const front = resolved.pdf.front.map((file, index) => {
1708
+ const front = book.front.map((file, position) => {
1121
1709
  try {
1122
1710
  const text = fs.readFileSync(file, "utf8");
1123
1711
  stageImages(text, file, 1);
@@ -1127,9 +1715,10 @@ export async function buildPdf({
1127
1715
  glyphs,
1128
1716
  images,
1129
1717
  assets: imageAssets,
1130
- footnotePrefix: `footnote-front-${index + 1}`,
1718
+ footnotePrefix: `footnote-front-${position + 1}`,
1131
1719
  captions: frontFigures.get(file),
1132
1720
  url: site,
1721
+ elsewhere,
1133
1722
  indexMentions: true,
1134
1723
  findings,
1135
1724
  file,
@@ -1138,20 +1727,31 @@ export async function buildPdf({
1138
1727
  findings.push({
1139
1728
  file,
1140
1729
  severity: "error",
1141
- message: "the front-matter file named by `pdf.front` cannot be read",
1730
+ message: `the front-matter file named by \`pdf.books[${index}].front\` cannot be read`,
1142
1731
  });
1143
1732
  return "";
1144
1733
  }
1145
1734
  });
1146
1735
 
1147
- const banners = stageBanners(plan.entries, resolved, outDir, findings);
1736
+ const banners = new Map(
1737
+ [
1738
+ ...stageBanners(
1739
+ plan.entries,
1740
+ resolved,
1741
+ path.join(outDir, stageDir),
1742
+ findings,
1743
+ treeFile,
1744
+ treeText,
1745
+ ),
1746
+ ].map(([declared, to]) => [declared, inBook(to)]),
1747
+ );
1148
1748
  for (const [declared, to] of banners) {
1149
1749
  const from = path.resolve(resolved.rootDir, declared);
1150
1750
  imageCandidates.set(`banner:${declared}`, {
1151
1751
  from,
1152
1752
  to,
1153
1753
  relative: path.relative(resolved.rootDir, from),
1154
- file: resolved.pdf.document,
1754
+ file: treeFile,
1155
1755
  uses: [{ width: PDF_PAGE.width, height: Infinity }],
1156
1756
  });
1157
1757
  }
@@ -1162,19 +1762,31 @@ export async function buildPdf({
1162
1762
  if (!maps.has(anchor)) maps.set(anchor, []);
1163
1763
  maps.get(anchor).push(page);
1164
1764
  };
1165
- const stageMapBackground = (entry, value, title) => {
1765
+ const stageMapBackground = (entry, value, title, keyPath) => {
1166
1766
  const file = noteFile(contentBase, entry.record);
1767
+ // Located at the frontmatter key that declared the value. Read only
1768
+ // for a finding.
1769
+ const where = () => {
1770
+ let raw = "";
1771
+ try {
1772
+ raw = fs.readFileSync(file, "utf8");
1773
+ } catch {
1774
+ return {};
1775
+ }
1776
+ return positionOfFrontmatterPath(raw, keyPath);
1777
+ };
1167
1778
  const found = artPathname(assets, value, "image", ["image", "icon"]);
1168
1779
  let staged;
1169
1780
  try {
1170
- staged = stagedImagePath(found.pathname ?? value, resolved, stagingOpts);
1781
+ staged = stage(found.pathname ?? value);
1171
1782
  } catch (err) {
1172
- findings.push({ file, severity: "error", message: err.message });
1783
+ findings.push({ file, ...where(), severity: "error", message: err.message });
1173
1784
  return;
1174
1785
  }
1175
1786
  if (!staged || !fs.existsSync(staged.from)) {
1176
1787
  findings.push({
1177
1788
  file,
1789
+ ...where(),
1178
1790
  severity: "warning",
1179
1791
  message: `the map background "${value}" cannot be resolved for the book`,
1180
1792
  });
@@ -1218,32 +1830,76 @@ export async function buildPdf({
1218
1830
  : []),
1219
1831
  ];
1220
1832
  const fixups = entry.record.data?.fixup;
1221
- const fixup = (Array.isArray(fixups) ? fixups : []).find((item) =>
1833
+ const fixupIndex = (Array.isArray(fixups) ? fixups : []).findIndex((item) =>
1222
1834
  paths.includes(item?.path),
1223
1835
  );
1836
+ const fixup = fixupIndex < 0 ? undefined : fixups[fixupIndex];
1224
1837
  const value = fixup?.value ?? level.background?.src;
1838
+ // The key that supplied the value: the fixup's, else the Scene's.
1839
+ const keyPath =
1840
+ fixup?.value ?
1841
+ ["data", "fixup", fixupIndex, "value"]
1842
+ : ["data", "scene", "levels", index, "background", "src"];
1225
1843
  if (value)
1226
- stageMapBackground(entry, value, level.name ? `${title}: ${level.name}` : title);
1844
+ stageMapBackground(
1845
+ entry,
1846
+ value,
1847
+ level.name ? `${title}: ${level.name}` : title,
1848
+ keyPath,
1849
+ );
1850
+ }
1851
+ }
1852
+
1853
+ // The cover is staged like a banner and resampled to the larger of the
1854
+ // two sizes that fill the page when cropped.
1855
+ let cover = "";
1856
+ if (book.coverImage) {
1857
+ cover = inBook(`cover/${path.basename(book.coverImage)}`);
1858
+ try {
1859
+ fs.mkdirSync(path.dirname(path.join(outDir, cover)), { recursive: true });
1860
+ fs.copyFileSync(book.coverImage, path.join(outDir, cover));
1861
+ imageCandidates.set(`cover:${book.coverImage}`, {
1862
+ from: book.coverImage,
1863
+ to: cover,
1864
+ relative: path.relative(resolved.rootDir, book.coverImage),
1865
+ file: treeFile,
1866
+ uses: [
1867
+ { width: PDF_PAGE.width, height: Infinity },
1868
+ { width: Infinity, height: PDF_PAGE.height },
1869
+ ],
1870
+ });
1871
+ } catch (err) {
1872
+ cover = "";
1873
+ findings.push({
1874
+ file: treeFile,
1875
+ severity: "warning",
1876
+ message: `the cover image \`${book.coverImage}\` cannot be read, so the book opens on its title page: ${err.message}`,
1877
+ });
1227
1878
  }
1228
1879
  }
1229
1880
 
1230
1881
  const assembled = renderBook({
1231
1882
  plan,
1232
1883
  bodies,
1233
- title: resolved.pdf.title,
1234
- subtitle: resolved.pdf.subtitle,
1884
+ title: book.title,
1885
+ subtitle: book.subtitle,
1235
1886
  front,
1236
1887
  fonts: resolved.pdf.fonts,
1237
1888
  version,
1238
1889
  preamble: infoboxTypstPreamble(),
1239
1890
  banners,
1240
1891
  maps,
1892
+ coverImage: cover,
1893
+ authors: shared.authors,
1894
+ copyright: shared.copyright,
1895
+ license: shared.license,
1896
+ index,
1241
1897
  });
1242
1898
  // Last, over the whole document: a reference can only be checked once every
1243
1899
  // declaration is in one string, and Typst treats a dangling one as fatal.
1244
1900
  const source = resolveDanglingLabels(assembled, findings);
1245
1901
 
1246
- const stem = pdfFileName(resolved.foundryPackage?.id ?? resolved.contentPackage, version);
1902
+ const stem = pdfFileName(book.out, version);
1247
1903
  const typPath = path.join(outDir, stem.replace(/\.pdf$/, ".typ"));
1248
1904
  const pdfPath = path.join(outDir, stem);
1249
1905
  fs.writeFileSync(typPath, source);
@@ -1251,33 +1907,25 @@ export async function buildPdf({
1251
1907
  const imageReports = await resamplePdfImages(imageCandidates, outDir);
1252
1908
 
1253
1909
  const stats = { ...plan.stats, entries: plan.entries.length, bytes: source.length };
1254
- if (!compile) {
1255
- return {
1256
- built: true,
1257
- reason: null,
1258
- findings,
1259
- imageReports,
1260
- typ: typPath,
1261
- pdf: null,
1262
- stats,
1263
- };
1264
- }
1910
+ return { built: true, imageReports, typ: typPath, pdf: pdfPath, stats };
1911
+ }
1265
1912
 
1266
- const compiled = compileTypst(typPath, pdfPath, resolved.pdf);
1913
+ /**
1914
+ * Compile one written book.
1915
+ *
1916
+ * @param {{typ: string, pdf: string}} written - From {@link buildBook}.
1917
+ * @param {object} pdf - The resolved `pdf:` block.
1918
+ * @param {object[]} findings - Collected here rather than thrown.
1919
+ * @returns {boolean} Whether the PDF was written.
1920
+ */
1921
+ function compileBook(written, pdf, findings) {
1922
+ const compiled = compileTypst(written.typ, written.pdf, pdf);
1267
1923
  findings.push(...compiled.findings);
1268
1924
  if (!compiled.ok) {
1269
- findings.push({ file: typPath, severity: "error", message: compiled.message });
1270
- return {
1271
- built: false,
1272
- reason: null,
1273
- findings,
1274
- imageReports,
1275
- typ: typPath,
1276
- pdf: null,
1277
- stats,
1278
- };
1925
+ findings.push({ file: written.typ, severity: "error", message: compiled.message });
1926
+ return false;
1279
1927
  }
1280
- return { built: true, reason: null, findings, imageReports, typ: typPath, pdf: pdfPath, stats };
1928
+ return true;
1281
1929
  }
1282
1930
 
1283
1931
  /**
@@ -1318,19 +1966,23 @@ export const BOOK_FONTS_PATH = path.join(
1318
1966
  * @returns {string[]} The arguments, in order.
1319
1967
  */
1320
1968
  export function typstArgs(typPath, pdfPath, pdf = {}) {
1969
+ return ["compile", ...typstFontArgs(pdf), typPath, pdfPath];
1970
+ }
1971
+
1972
+ /**
1973
+ * The flags that decide which faces a layout uses, shared by the compile and by
1974
+ * the page measurement so both lay the book out in the same metrics.
1975
+ *
1976
+ * @param {object} [pdf] - The resolved `pdf:` block.
1977
+ * @returns {string[]} The arguments, in order.
1978
+ */
1979
+ export function typstFontArgs(pdf = {}) {
1321
1980
  // One flag, the paths joined by the platform's separator: the compiler
1322
1981
  // takes a list, and the shipped faces come first so a consumer's own
1323
1982
  // directory extends the set rather than standing in for it.
1324
1983
  const fontPaths = [BOOK_FONTS_PATH];
1325
1984
  if (pdf.fonts?.path) fontPaths.push(pdf.fonts.path);
1326
- return [
1327
- "compile",
1328
- "--ignore-system-fonts",
1329
- "--font-path",
1330
- fontPaths.join(path.delimiter),
1331
- typPath,
1332
- pdfPath,
1333
- ];
1985
+ return ["--ignore-system-fonts", "--font-path", fontPaths.join(path.delimiter)];
1334
1986
  }
1335
1987
 
1336
1988
  /**