@heroiclands/package-build 20.7.0 → 21.1.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 (63) hide show
  1. package/CHANGELOG.md +183 -0
  2. package/CONTENT.md +132 -44
  3. package/bin/content-build.mjs +37 -5
  4. package/bin/package-build.mjs +77 -0
  5. package/content-config.mjs +59 -1
  6. package/docs/api.md +149 -19
  7. package/docs/commands.md +75 -0
  8. package/docs/configuration.md +37 -10
  9. package/docs/content-format.md +450 -49
  10. package/engine/content-format.mjs +52 -3
  11. package/engine/content-images.mjs +699 -0
  12. package/engine/dependency-bump.mjs +218 -0
  13. package/engine/frontmatter-lint.mjs +89 -2
  14. package/engine/helpers.mjs +81 -142
  15. package/engine/index.mjs +15 -0
  16. package/engine/infobox-registry.mjs +81 -0
  17. package/engine/infobox-render.mjs +382 -0
  18. package/engine/infobox.mjs +963 -0
  19. package/engine/item-registry.mjs +5 -5
  20. package/engine/journals.mjs +22 -1
  21. package/engine/map-notes.mjs +11 -5
  22. package/engine/metadata-index.mjs +5 -0
  23. package/engine/note-vocabulary.mjs +57 -2
  24. package/engine/pathnames.mjs +374 -0
  25. package/engine/pdf-build.mjs +206 -9
  26. package/engine/pdf-render.mjs +453 -20
  27. package/engine/pdf-toc.mjs +77 -5
  28. package/engine/scenes.mjs +2 -1
  29. package/engine/site-build.mjs +106 -7
  30. package/engine/site-index.mjs +93 -4
  31. package/engine/wikilinks.mjs +93 -0
  32. package/hm3/default-item-art.mjs +14 -15
  33. package/hm3/index.mjs +3 -0
  34. package/hm3/infobox.mjs +64 -0
  35. package/package.json +1 -1
  36. package/sohl/default-item-art.mjs +18 -16
  37. package/sohl/index.mjs +3 -0
  38. package/sohl/infobox.mjs +499 -0
  39. package/types/content-config.d.mts +7 -0
  40. package/types/engine/content-format.d.mts +36 -0
  41. package/types/engine/content-images.d.mts +281 -0
  42. package/types/engine/dependency-bump.d.mts +89 -0
  43. package/types/engine/frontmatter-lint.d.mts +23 -0
  44. package/types/engine/helpers.d.mts +30 -72
  45. package/types/engine/index.d.mts +5 -0
  46. package/types/engine/infobox-registry.d.mts +36 -0
  47. package/types/engine/infobox-render.d.mts +87 -0
  48. package/types/engine/infobox.d.mts +443 -0
  49. package/types/engine/item-registry.d.mts +5 -5
  50. package/types/engine/journals.d.mts +9 -1
  51. package/types/engine/note-vocabulary.d.mts +51 -0
  52. package/types/engine/pathnames.d.mts +189 -0
  53. package/types/engine/pdf-build.d.mts +46 -0
  54. package/types/engine/pdf-render.d.mts +97 -1
  55. package/types/engine/pdf-toc.d.mts +10 -5
  56. package/types/engine/site-build.d.mts +11 -3
  57. package/types/engine/site-index.d.mts +35 -3
  58. package/types/engine/wikilinks.d.mts +22 -0
  59. package/types/hm3/default-item-art.d.mts +5 -6
  60. package/types/hm3/index.d.mts +1 -0
  61. package/types/hm3/infobox.d.mts +22 -0
  62. package/types/sohl/index.d.mts +1 -0
  63. package/types/sohl/infobox.d.mts +145 -0
@@ -81,13 +81,129 @@ import { isNoteRecord, noteFile } from "./index-records.mjs";
81
81
  import { openNotesDatabase, prepareTreeSqlTables } from "./sql-tables.mjs";
82
82
  import { parseDocumentTree, runTreeFilters, planDocument } from "./pdf-toc.mjs";
83
83
  import { collectContentPages, siteGates, tableUniverse, gatesFailed } from "./site-build.mjs";
84
- import { wikiContext } from "./site-index.mjs";
84
+ import { resolveInfoboxRef, wikiContext } from "./site-index.mjs";
85
85
  import { resolveWebWikilinks } from "./web-wikilinks.mjs";
86
86
  import { expandContentTables } from "./content-tables.mjs";
87
87
  import { protectCode } from "./code-fences.mjs";
88
- import { createParser, markdownToTypst, renderBook, resolveDanglingLabels } from "./pdf-render.mjs";
88
+ import { imageSourcesIn } from "./content-images.mjs";
89
+ import { pathnameProblem, resolvePathname } from "./pathnames.mjs";
90
+ import {
91
+ createParser,
92
+ labelFor,
93
+ markdownToTypst,
94
+ renderBook,
95
+ resolveDanglingLabels,
96
+ } from "./pdf-render.mjs";
97
+ import { infoboxTypstPreamble, infoboxesToTypst, linkToTypst } from "./infobox-render.mjs";
98
+ import { noteInfoboxes } from "./infobox-registry.mjs";
89
99
  import { resolveIconGlyphs } from "./pdf-fonts.mjs";
90
100
 
101
+ /**
102
+ * The file on disk an authored image pathname names, or `null`.
103
+ *
104
+ * Two of the four forms {@link module:engine/pathnames.resolvePathname}
105
+ * derives, used together: `local` is the file in this repository's own tree,
106
+ * and `pdf` is where the book stages a copy of it.
107
+ *
108
+ * **Typst decides where that copy goes.** It resolves a path against its root —
109
+ * the directory holding the source it is given — and refuses to read anything
110
+ * above it. So a file reaches the compiler by being copied under the output
111
+ * directory rather than by widening the root to the whole repository: the
112
+ * emitted `.typ` and everything it opens sit in one directory, which is what
113
+ * makes the source a consumer can compile by hand with no flags, and what keeps
114
+ * a build from touching a path outside its own output.
115
+ *
116
+ * Only a file **this** package ships can be staged. A pathname naming another
117
+ * package's file, or a URL, names something no build here can open — a build
118
+ * reaches no network — and the caller reports it as a picture the book will not
119
+ * carry.
120
+ *
121
+ * @param {string} src - The pathname, as authored.
122
+ * @param {object} config - The resolved configuration.
123
+ * @returns {{from: string, to: string}|null} The file, and where under the
124
+ * output directory it is staged.
125
+ */
126
+ export function stagedImagePath(src, config) {
127
+ const forms = resolvePathname(src, config);
128
+ if (!forms || forms.state !== "package" || !forms.own) return null;
129
+ return {
130
+ from: path.resolve(config.rootDir, forms.local),
131
+ to: forms.pdf,
132
+ };
133
+ }
134
+
135
+ /**
136
+ * Where the book keeps the pictures its section plates are drawn over.
137
+ *
138
+ * Typst resolves a path against its root, which is the directory holding the
139
+ * source it is given, and refuses to read anything above it. So a banner
140
+ * reaches the compiler by being **copied under the output directory** rather
141
+ * than by widening the root to the whole repository: the emitted `.typ` and
142
+ * everything it opens then sit in one directory, which is what makes the
143
+ * source a consumer can compile by hand with no flags.
144
+ *
145
+ * The declared path is mirrored beneath it, so two banners with the same base
146
+ * name cannot land on each other.
147
+ *
148
+ * @type {string}
149
+ */
150
+ const STAGED_PLATES = "plates";
151
+
152
+ /**
153
+ * Copy every banner the document tree names into the output directory.
154
+ *
155
+ * **A missing banner is not a failure.** A section plate implies a banner per
156
+ * section and art arrives later than rendering does, so a section that names
157
+ * none draws its plate over the book's ink and says nothing about it. One that
158
+ * names a file the build cannot read is a different matter — that is a
159
+ * statement the tree makes and the build cannot honour — and it is reported.
160
+ *
161
+ * @param {object[]} entries - The plan's entries.
162
+ * @param {object} config - The resolved configuration.
163
+ * @param {string} outDir - Where the book is written.
164
+ * @param {object[]} findings - Collected here rather than thrown.
165
+ * @returns {Map<string, string>} Declared path → the staged file's path,
166
+ * relative to the `.typ`.
167
+ */
168
+ export function stageBanners(entries, config, outDir, findings = []) {
169
+ const staged = new Map();
170
+ const seen = new Set();
171
+ for (const entry of entries ?? []) {
172
+ const declared = entry?.presentation?.page?.banner;
173
+ if (typeof declared !== "string" || !declared.trim() || seen.has(declared)) continue;
174
+ seen.add(declared);
175
+ const from = path.resolve(config.rootDir, declared);
176
+ const within = path.relative(config.rootDir, from);
177
+ if (within.startsWith("..") || path.isAbsolute(within)) {
178
+ findings.push({
179
+ file: config.pdf.document,
180
+ severity: "warning",
181
+ message:
182
+ `the banner \`${declared}\` is outside this package, so the section ` +
183
+ "plate is drawn without a picture — a banner is a file this repository ships",
184
+ });
185
+ continue;
186
+ }
187
+ const to = `${STAGED_PLATES}/${within.split(path.sep).join("/")}`;
188
+ const dest = path.join(outDir, to);
189
+ try {
190
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
191
+ fs.copyFileSync(from, dest);
192
+ } catch (err) {
193
+ findings.push({
194
+ file: config.pdf.document,
195
+ severity: "warning",
196
+ message:
197
+ `the banner \`${declared}\` cannot be read, so the section plate is ` +
198
+ `drawn without a picture: ${err.message}`,
199
+ });
200
+ continue;
201
+ }
202
+ staged.set(declared, to);
203
+ }
204
+ return staged;
205
+ }
206
+
91
207
  /**
92
208
  * The file name a downloaded book identifies itself by.
93
209
  *
@@ -266,6 +382,71 @@ export async function buildPdf({ config, out, version = "", compile = true } = {
266
382
  const md = createParser(resolved.icons);
267
383
  const glyphs = resolveIconGlyphs(resolved.icons, resolved.pdf.iconFonts, findings);
268
384
 
385
+ const outDir = path.resolve(
386
+ resolved.rootDir,
387
+ out || resolved.pdf.out || path.join("build", "dist"),
388
+ );
389
+ fs.mkdirSync(outDir, { recursive: true });
390
+
391
+ /** @type {Map<string, string>} Authored address → the staged file's path. */
392
+ const images = new Map();
393
+ /** @type {Set<string>} Addresses already looked for, staged or not. */
394
+ const seenImages = new Set();
395
+
396
+ /**
397
+ * Copy every picture one body names into the output directory.
398
+ *
399
+ * Run per body rather than in a pass of its own, because the addresses are
400
+ * read from the markdown *after* its tables have expanded — a generated
401
+ * table is as free to carry an image as prose is.
402
+ *
403
+ * @param {string} body - The rendered markdown.
404
+ * @param {string} file - The note, for the finding.
405
+ * @returns {void}
406
+ */
407
+ const stageImages = (body, file) => {
408
+ for (const src of imageSourcesIn(body)) {
409
+ if (seenImages.has(src)) continue;
410
+ seenImages.add(src);
411
+ // An **error**, where a picture the book cannot carry is a warning:
412
+ // this pathname resolves on no surface at all, and the replacement
413
+ // is mechanical and named in the message.
414
+ const problem = pathnameProblem(src);
415
+ if (problem) {
416
+ findings.push({ file, severity: "error", message: problem });
417
+ continue;
418
+ }
419
+ const staged = stagedImagePath(src, resolved);
420
+ if (!staged) {
421
+ findings.push({
422
+ file,
423
+ severity: "warning",
424
+ message:
425
+ `\`${src}\` names a file this package does not ship, so the book ` +
426
+ "prints the caption where the picture would be — an image the book " +
427
+ "carries is addressed inside this package",
428
+ });
429
+ continue;
430
+ }
431
+ const dest = path.join(outDir, staged.to);
432
+ try {
433
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
434
+ fs.copyFileSync(staged.from, dest);
435
+ } catch (err) {
436
+ findings.push({
437
+ file,
438
+ severity: "warning",
439
+ message:
440
+ `\`${src}\` cannot be read from ` +
441
+ `\`${path.relative(resolved.rootDir, staged.from)}\`, so the book ` +
442
+ `prints the caption where the picture would be: ${err.message}`,
443
+ });
444
+ continue;
445
+ }
446
+ images.set(src, staged.to);
447
+ }
448
+ };
449
+
269
450
  /**
270
451
  * One note's markdown, through the same passes the site runs.
271
452
  *
@@ -309,13 +490,25 @@ export async function buildPdf({ config, out, version = "", compile = true } = {
309
490
  message: String(err.message ?? err),
310
491
  });
311
492
  }
312
- return markdownToTypst(resolvedBody, {
493
+ stageImages(resolvedBody, page.file);
494
+ const prose = markdownToTypst(resolvedBody, {
313
495
  md,
314
496
  links: plan.links,
315
497
  glyphs,
498
+ images,
316
499
  headingOffset,
317
500
  anchorPrefix,
318
501
  });
502
+ // The infobox is generated content in document order — prepended,
503
+ // before the prose. An image the note authored ahead of it still comes
504
+ // first, because the image lives in the body and the body follows.
505
+ const boxes = noteInfoboxes(page.fm, {
506
+ resolve: (ref, hint) => resolveInfoboxRef(gates.index, ref, hint),
507
+ });
508
+ const panel = infoboxesToTypst(boxes, {
509
+ link: (value) => linkToTypst(value, plan.links, labelFor),
510
+ });
511
+ return panel ? `${panel}\n\n${prose}` : prose;
319
512
  };
320
513
 
321
514
  const bodies = new Map();
@@ -348,12 +541,14 @@ export async function buildPdf({ config, out, version = "", compile = true } = {
348
541
  });
349
542
  continue;
350
543
  }
544
+ stageImages(text, file);
351
545
  bodies.set(
352
546
  entry.anchor,
353
547
  markdownToTypst(text, {
354
548
  md,
355
549
  links: plan.links,
356
550
  glyphs,
551
+ images,
357
552
  headingOffset: entry.depth,
358
553
  anchorPrefix: entry.anchor,
359
554
  }),
@@ -372,10 +567,13 @@ export async function buildPdf({ config, out, version = "", compile = true } = {
372
567
 
373
568
  const front = resolved.pdf.front.map((file) => {
374
569
  try {
375
- return markdownToTypst(fs.readFileSync(file, "utf8"), {
570
+ const text = fs.readFileSync(file, "utf8");
571
+ stageImages(text, file);
572
+ return markdownToTypst(text, {
376
573
  md,
377
574
  links: plan.links,
378
575
  glyphs,
576
+ images,
379
577
  });
380
578
  } catch {
381
579
  findings.push({
@@ -387,6 +585,8 @@ export async function buildPdf({ config, out, version = "", compile = true } = {
387
585
  }
388
586
  });
389
587
 
588
+ const banners = stageBanners(plan.entries, resolved, outDir, findings);
589
+
390
590
  const assembled = renderBook({
391
591
  plan,
392
592
  bodies,
@@ -395,16 +595,13 @@ export async function buildPdf({ config, out, version = "", compile = true } = {
395
595
  front,
396
596
  fonts: resolved.pdf.fonts,
397
597
  version,
598
+ preamble: infoboxTypstPreamble(),
599
+ banners,
398
600
  });
399
601
  // Last, over the whole document: a reference can only be checked once every
400
602
  // declaration is in one string, and Typst treats a dangling one as fatal.
401
603
  const source = resolveDanglingLabels(assembled, findings);
402
604
 
403
- const outDir = path.resolve(
404
- resolved.rootDir,
405
- out || resolved.pdf.out || path.join("build", "dist"),
406
- );
407
- fs.mkdirSync(outDir, { recursive: true });
408
605
  const stem = pdfFileName(resolved.foundryPackage?.id ?? resolved.contentPackage, version);
409
606
  const typPath = path.join(outDir, stem.replace(/\.pdf$/, ".typ"));
410
607
  const pdfPath = path.join(outDir, stem);