@heroiclands/package-build 20.6.0 → 21.0.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 (65) hide show
  1. package/CHANGELOG.md +181 -0
  2. package/CONTENT.md +134 -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 +381 -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 +208 -9
  26. package/engine/pdf-render.mjs +461 -21
  27. package/engine/pdf-toc.mjs +77 -5
  28. package/engine/scenes.mjs +2 -1
  29. package/engine/site-build.mjs +115 -8
  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 +3 -2
  36. package/sohl/being-info.mjs +9 -3
  37. package/sohl/default-item-art.mjs +18 -16
  38. package/sohl/index.mjs +3 -0
  39. package/sohl/infobox.mjs +499 -0
  40. package/types/content-config.d.mts +7 -0
  41. package/types/engine/content-format.d.mts +36 -0
  42. package/types/engine/content-images.d.mts +281 -0
  43. package/types/engine/dependency-bump.d.mts +89 -0
  44. package/types/engine/frontmatter-lint.d.mts +23 -0
  45. package/types/engine/helpers.d.mts +30 -72
  46. package/types/engine/index.d.mts +5 -0
  47. package/types/engine/infobox-registry.d.mts +36 -0
  48. package/types/engine/infobox-render.d.mts +87 -0
  49. package/types/engine/infobox.d.mts +443 -0
  50. package/types/engine/item-registry.d.mts +5 -5
  51. package/types/engine/journals.d.mts +9 -1
  52. package/types/engine/note-vocabulary.d.mts +51 -0
  53. package/types/engine/pathnames.d.mts +189 -0
  54. package/types/engine/pdf-build.d.mts +46 -0
  55. package/types/engine/pdf-render.d.mts +99 -1
  56. package/types/engine/pdf-toc.d.mts +10 -5
  57. package/types/engine/site-build.d.mts +19 -3
  58. package/types/engine/site-index.d.mts +35 -3
  59. package/types/engine/wikilinks.d.mts +22 -0
  60. package/types/hm3/default-item-art.d.mts +5 -6
  61. package/types/hm3/index.d.mts +1 -0
  62. package/types/hm3/infobox.d.mts +22 -0
  63. package/types/sohl/being-info.d.mts +4 -3
  64. package/types/sohl/index.d.mts +1 -0
  65. package/types/sohl/infobox.d.mts +145 -0
@@ -64,6 +64,7 @@
64
64
  import MarkdownIt from "markdown-it";
65
65
 
66
66
  import { iconPlugin, ICON_PATTERN } from "./content-icons.mjs";
67
+ import { IMAGE_CLASSES, IMAGE_FLOATS, imagePlugin } from "./content-images.mjs";
67
68
  import { slugify } from "./content-slug.mjs";
68
69
 
69
70
  /**
@@ -143,6 +144,9 @@ export function labelFor(anchor) {
143
144
  export function createParser(registry) {
144
145
  const md = new MarkdownIt({ html: false, linkify: false, typographer: false });
145
146
  md.use(iconPlugin(registry));
147
+ // The same plugin the HTML surfaces use, so one directive is read once and
148
+ // three renderers read the same `meta` off the same token.
149
+ md.use(imagePlugin());
146
150
  return md;
147
151
  }
148
152
 
@@ -156,10 +160,17 @@ export function createParser(registry) {
156
160
  * @param {object} [opts.registry] - The icon registry, when no parser is passed.
157
161
  * @param {Map<string, string>} [opts.links] - Address slug → plan anchor.
158
162
  * @param {Map<string, string>} [opts.glyphs] - Icon name → `{font, char}`.
163
+ * @param {Map<string, string>} [opts.images] - An image's address as authored →
164
+ * the staged file's path, relative to the `.typ`. An address this does not
165
+ * carry has no file the compiler can open, so the figure prints its caption
166
+ * alone — see {@link renderImage}.
159
167
  * @param {number} [opts.headingOffset] - Added to every heading level, so a
160
168
  * note's own `##` nests beneath the entry heading the book gave it.
161
169
  * @param {string} [opts.anchorPrefix] - The entry's anchor, which namespaces
162
170
  * every `{#slug}` the body declares.
171
+ * @param {boolean} [opts.dropCap] - Whether to open the body with a raised
172
+ * capital. Set for an entry, which begins a page; not for front matter or a
173
+ * prose file, which carry headings of their own.
163
174
  * @returns {string} Typst markup.
164
175
  */
165
176
  export function markdownToTypst(markdown, opts = {}) {
@@ -167,15 +178,25 @@ export function markdownToTypst(markdown, opts = {}) {
167
178
  md = createParser(opts.registry),
168
179
  links = new Map(),
169
180
  glyphs = new Map(),
181
+ images = new Map(),
170
182
  headingOffset = 0,
171
183
  anchorPrefix = "",
184
+ dropCap = false,
172
185
  } = opts;
173
186
  const tokens = md.parse(String(markdown ?? ""), {});
174
187
  // One map for the whole body, not one per block: a heading inside a
175
188
  // blockquote or a list item shares the entry's anchor namespace with every
176
189
  // other heading in the same body, because `sectionLabel` scopes by entry
177
190
  // rather than by container.
178
- return renderTokens(tokens, { links, glyphs, headingOffset, anchorPrefix, seen: new Map() });
191
+ return renderTokens(tokens, {
192
+ links,
193
+ glyphs,
194
+ images,
195
+ headingOffset,
196
+ anchorPrefix,
197
+ dropCap,
198
+ seen: new Map(),
199
+ });
179
200
  }
180
201
 
181
202
  /**
@@ -237,7 +258,7 @@ function renderBlock(tokens, i, out, ctx) {
237
258
  return 3;
238
259
  }
239
260
  case "paragraph_open": {
240
- out.push(`\n${renderInline(tokens[i + 1], ctx)}\n\n`);
261
+ out.push(`\n${openingParagraph(renderInline(tokens[i + 1], ctx), ctx)}\n\n`);
241
262
  return 3;
242
263
  }
243
264
  case "fence":
@@ -250,7 +271,7 @@ function renderBlock(tokens, i, out, ctx) {
250
271
  return 1;
251
272
  case "blockquote_open": {
252
273
  const end = matching(tokens, i, "blockquote_open", "blockquote_close");
253
- const inner = renderTokens(tokens.slice(i + 1, end), ctx);
274
+ const inner = renderTokens(tokens.slice(i + 1, end), { ...ctx, dropCap: false });
254
275
  out.push(`\n#quote(block: true)[${inner}]\n\n`);
255
276
  return end - i + 1;
256
277
  }
@@ -277,6 +298,29 @@ function renderBlock(tokens, i, out, ctx) {
277
298
  }
278
299
  }
279
300
 
301
+ /**
302
+ * The first paragraph of an entry, opened with a raised capital.
303
+ *
304
+ * Only the first, and only when it begins with a letter: a paragraph opening
305
+ * on a link, a bold run or a number has no character to raise, and raising
306
+ * whatever happened to be first would put a 26pt accent on a bracket. The
307
+ * chance is spent either way — a body opens once — so a paragraph that cannot
308
+ * take the capital simply sets as itself.
309
+ *
310
+ * @param {string} rendered - The paragraph's Typst markup.
311
+ * @param {object} ctx - Render context.
312
+ * @returns {string} The same markup, or it with a raised capital.
313
+ */
314
+ function openingParagraph(rendered, ctx) {
315
+ if (!ctx.dropCap) return rendered;
316
+ ctx.dropCap = false;
317
+ // A letter is never escaped, so the first character of the markup is the
318
+ // first character of the prose whenever the prose starts with one.
319
+ const match = /^(\p{L})([\s\S]*)$/u.exec(rendered);
320
+ if (!match) return rendered;
321
+ return `#book-dropcap[${match[1]}]#h(1pt)${match[2]}`;
322
+ }
323
+
280
324
  /**
281
325
  * A heading's text, and the `{#slug}` it may end with.
282
326
  *
@@ -394,7 +438,7 @@ function listItems(tokens, start, end, ctx) {
394
438
  continue;
395
439
  }
396
440
  const close = matching(tokens, i, "list_item_open", "list_item_close");
397
- items.push(renderTokens(tokens.slice(i + 1, close), ctx));
441
+ items.push(renderTokens(tokens.slice(i + 1, close), { ...ctx, dropCap: false }));
398
442
  i = close + 1;
399
443
  }
400
444
  return items;
@@ -472,7 +516,12 @@ function renderTable(tokens, ctx) {
472
516
  columns,
473
517
  )}),`
474
518
  : "";
475
- return `\n#table(\n columns: ${columns},${alignment}${header}\n${body}\n)\n\n`;
519
+ const drawn = `#table(\n columns: ${columns},${alignment}${header}\n${body}\n)`;
520
+ // Wide content is given an explicit span rather than left to overflow the
521
+ // measure: past three columns a table is set across the page, and
522
+ // `book-wide` decides between a float and pages of its own by measuring it.
523
+ if (columns > WIDE_TABLE_COLUMNS) return `\n#book-wide[\n${drawn}\n]\n\n`;
524
+ return `\n${drawn}\n\n`;
476
525
  }
477
526
 
478
527
  /**
@@ -559,14 +608,9 @@ function renderInline(token, ctx) {
559
608
  i = close;
560
609
  break;
561
610
  }
562
- case "image": {
563
- // An image has no route into a book that does not also carry the
564
- // file, and the asset tree is not this pass's to resolve. The
565
- // alt text is what the note said the picture was for.
566
- const alt = child.content || child.attrGet?.("alt") || "";
567
- if (alt) out.push(`#emph[${escapeTypst(alt)}]`);
611
+ case "image":
612
+ out.push(renderImage(child, ctx));
568
613
  break;
569
- }
570
614
  default:
571
615
  if (child.content) out.push(escapeTypst(child.content));
572
616
  break;
@@ -643,6 +687,61 @@ function renderLink(href, inner, ctx) {
643
687
  return `#link("${escapeTypstString(url)}")[${inner}]`;
644
688
  }
645
689
 
690
+ /**
691
+ * One image, as the figure the book prints.
692
+ *
693
+ * ## The width class is the measure
694
+ *
695
+ * An image with no class is one column wide. That is `width: 100%` of whatever
696
+ * container it is set in — the page today, and a column once the book is set in
697
+ * two — so the ordinary case needs nothing but an ordinary block and stays
698
+ * correct through the change.
699
+ *
700
+ * `.full-width` has to leave its column, and a block cannot: only a float
701
+ * placed with `scope: "parent"` spans every column of the page. So a
702
+ * full-width image is always placed, whether or not it states a `float:`, and
703
+ * an image that states neither is left in the flow where it was written.
704
+ *
705
+ * ## A float occupies the measure
706
+ *
707
+ * Typst has no shaped text flow, so `#place(…, float: true)` reserves the whole
708
+ * measure and sets the text above and below rather than beside. The horizontal
709
+ * half of a position therefore has no effect on the page; it is emitted anyway,
710
+ * because it costs nothing and says what the note asked for.
711
+ *
712
+ * ## No file, no picture
713
+ *
714
+ * `#image` on a path Typst cannot open is a compile error, and a compile error
715
+ * in a 2,500-entry book is fatal at the very end of a run that otherwise
716
+ * succeeded — over an illustration, which is the least important thing on the
717
+ * page. An address the build could not stage prints its caption alone instead,
718
+ * and the build reports the address it could not find.
719
+ *
720
+ * @param {object} token - An `image` token.
721
+ * @param {object} ctx - Render context.
722
+ * @returns {string} Typst markup.
723
+ */
724
+ function renderImage(token, ctx) {
725
+ const alt = token.content || token.attrGet?.("alt") || "";
726
+ const caption =
727
+ alt ? `\n #text(size: 7.6pt, style: "italic", fill: luma(45%))[${escapeTypst(alt)}]` : "";
728
+ const staged = ctx.images.get(token.attrGet?.("src") ?? "");
729
+ if (!staged) return caption ? `\n#block(below: 0.6em)[${caption}\n]\n\n` : "";
730
+
731
+ const figure =
732
+ `#block(width: 100%, below: 0.6em)[\n` +
733
+ ` #image("${escapeTypstString(staged)}", width: 100%)${caption}\n]`;
734
+
735
+ const width = token.meta?.classes?.[0];
736
+ const scope = IMAGE_CLASSES[width]?.scope ?? "column";
737
+ const float = IMAGE_FLOATS[token.meta?.float];
738
+ // In the flow where it was written: no class asking for the page, and no
739
+ // position asking for the top or the bottom of the column.
740
+ if (!float && scope === "column") return `\n${figure}\n\n`;
741
+ const align = float?.align ?? "top";
742
+ return `\n#place(${align}, float: true, scope: "${scope}", clearance: 0.7em)[\n${figure}\n]\n\n`;
743
+ }
744
+
646
745
  /**
647
746
  * One icon, as its glyph when a font carries it and as its name otherwise.
648
747
  *
@@ -657,6 +756,174 @@ function renderIcon(token, ctx) {
657
756
  return `#text(font: "${escapeTypstString(glyph.font)}")[\\u{${glyph.codepoint.toString(16)}}]`;
658
757
  }
659
758
 
759
+ /**
760
+ * How many columns a table may hold before it is set across the page.
761
+ *
762
+ * Three is where a column measure gives out. A two- or three-column table of
763
+ * names and numbers sets comfortably in half a US Letter page; a fourth column
764
+ * is where the cells start breaking one word to a line, and by five — a roster
765
+ * of nomes with a sentence of character in the last cell — the table is wider
766
+ * than the measure whatever the renderer does with it.
767
+ *
768
+ * The count is the rule because it is the one thing known without laying the
769
+ * page out. How *tall* the result is decides the rest, and that is measured in
770
+ * Typst rather than guessed here: see `book-wide` in {@link bookTypstPreamble}.
771
+ *
772
+ * @type {number}
773
+ */
774
+ const WIDE_TABLE_COLUMNS = 3;
775
+
776
+ /**
777
+ * The Typst definitions the book's page furniture is drawn with.
778
+ *
779
+ * Emitted once at the head of the document, for the reason the infobox panel's
780
+ * rules are: 2,000 entries each restating the plate, the running foot and the
781
+ * drop cap is a megabyte of repetition, and the one place a reader changes how
782
+ * the book looks should be one place.
783
+ *
784
+ * ## The geometry is stated, not discovered
785
+ *
786
+ * The page is US Letter with a 1.9cm margin, and the plate bleeds off all
787
+ * three edges it touches — so the plate has to know the paper's width and the
788
+ * margin it is escaping. Both are `#let` bindings here rather than numbers
789
+ * repeated down the file, and every other measure is arithmetic on them.
790
+ *
791
+ * ## Three things that cost time to discover, encoded here
792
+ *
793
+ * - **A title inherits the body's justification and hyphenation** unless told
794
+ * otherwise. Both are habits of body text that make a display line look
795
+ * broken, so the plate turns them off inside itself.
796
+ * - **The plate's height follows its title**, and the line count is derived
797
+ * from the title's *natural* width: measuring an already-wrapped block does
798
+ * not report the wrapped height, and a percentage width cannot resolve
799
+ * inside `measure`. So the title arrives as a string to be measured
800
+ * alongside the heading that is actually drawn.
801
+ * - **A float is not breakable.** A table taller than the page placed as one
802
+ * silently piles its rows on top of each other at the foot of the page —
803
+ * no warning, no error. So `book-wide` measures first and gives a table that
804
+ * will not fit its own single-column pages instead.
805
+ *
806
+ * ## The ornament is drawn, not typed
807
+ *
808
+ * The running foot's centre mark is a rotated square rather than a dingbat
809
+ * character, because the faces a consumer names and the faces a build runner
810
+ * carries are not the same set, and a missing glyph on 2,000 feet is a
811
+ * tofu box on every page of the book.
812
+ *
813
+ * @returns {string} Typst markup.
814
+ */
815
+ export function bookTypstPreamble() {
816
+ return [
817
+ "#let book-margin = 1.9cm",
818
+ "#let book-page-width = 8.5in",
819
+ "#let book-page-height = 11in",
820
+ "#let book-text-width = book-page-width - 2 * book-margin",
821
+ "#let book-text-height = book-page-height - 2 * book-margin",
822
+ // The title sets short of the measure, so a plate's last line does not
823
+ // run to the trimmed edge of the paper.
824
+ "#let book-title-measure = book-text-width - 2.5cm",
825
+ '#let book-ink = rgb("#241f1a")',
826
+ '#let book-paper = rgb("#f4efe4")',
827
+ '#let book-accent = rgb("#7c3b1e")',
828
+ '#let book-faint = rgb("#6b6357")',
829
+ "#let book-ornament = box(baseline: 1pt, " +
830
+ "rotate(45deg, rect(width: 3pt, height: 3pt, fill: book-accent)))",
831
+ "#let book-footer(name) = context {\n" +
832
+ " set text(size: 8pt, fill: book-faint)\n" +
833
+ " grid(columns: (1fr, auto, 1fr), align(left)[#name], align(center)[#book-ornament],\n" +
834
+ " align(right)[#counter(page).display()])\n" +
835
+ "}",
836
+ // A heading justifies and hyphenates like body text unless told
837
+ // otherwise, and both make a display line look broken.
838
+ "#let book-sechead(it) = block(width: 100%, above: 0.9em, below: 0.5em, " +
839
+ "breakable: false)[\n" +
840
+ " #set par(justify: false, first-line-indent: 0em)\n" +
841
+ " #set text(hyphenate: false)\n" +
842
+ ' #text(size: 11pt, weight: "bold", tracking: 1.6pt, fill: book-accent)[#upper(it.body)]\n' +
843
+ " #v(-0.35em)\n" +
844
+ " #line(length: 100%, stroke: 0.5pt + book-accent)\n" +
845
+ "]",
846
+ '#let book-dropcap(letter) = text(size: 26pt, weight: "bold", fill: book-accent, ' +
847
+ "baseline: 5pt)[#letter]",
848
+ // The plate bleeds off the paper: the placed panel is the full width of
849
+ // the sheet and starts a margin above and to the left of wherever the
850
+ // flow has reached, which on an entry's first page is the top corner.
851
+ "#let book-plate(kicker, title, banner, floor, body) = context {\n" +
852
+ ' let natural = measure(text(size: 25pt, weight: "bold", tracking: 1.4pt)[#title]).width\n' +
853
+ " let lines = calc.max(1, calc.ceil(natural / book-title-measure))\n" +
854
+ " let height = calc.max(floor, lines * 1.15cm + 1.75cm)\n" +
855
+ " block(width: 100%, height: height - book-margin, above: 0pt, below: 0pt)[\n" +
856
+ " #place(top + left, dx: -book-margin, dy: -book-margin)[\n" +
857
+ " #block(width: book-page-width, height: height, clip: true, inset: 0pt,\n" +
858
+ " fill: book-ink)[\n" +
859
+ " #if banner != none {\n" +
860
+ ' place(top + left, image(banner, width: 100%, height: height, fit: "cover"))\n' +
861
+ " }\n" +
862
+ " #place(top + left, rect(width: 100%, height: height,\n" +
863
+ " fill: gradient.linear(rgb(10, 8, 6, 70), rgb(10, 8, 6, 175),\n" +
864
+ " rgb(10, 8, 6, 240), angle: 90deg)))\n" +
865
+ " #place(bottom + left, dx: book-margin, dy: -0.55cm)[\n" +
866
+ " #block(width: book-title-measure)[\n" +
867
+ " #set par(justify: false, leading: 0.35em, first-line-indent: 0em)\n" +
868
+ " #set text(hyphenate: false)\n" +
869
+ ' #text(fill: rgb("#e8dcc2"), size: 7.5pt, tracking: 2.6pt)[#upper(kicker)]\n' +
870
+ " #v(-0.10em)\n" +
871
+ " #{\n" +
872
+ " show heading: it => it.body\n" +
873
+ ' set text(fill: white, size: 25pt, weight: "bold", tracking: 1.4pt)\n' +
874
+ " body\n" +
875
+ " }\n" +
876
+ " ]\n" +
877
+ " ]\n" +
878
+ " ]\n" +
879
+ " ]\n" +
880
+ " ]\n" +
881
+ "}",
882
+ "#let book-epigraph(body) = {\n" +
883
+ " v(0.42cm)\n" +
884
+ " align(center)[\n" +
885
+ " #line(length: 38%, stroke: 0.6pt + book-accent)\n" +
886
+ " #v(0.28em)\n" +
887
+ " #block(width: 78%)[\n" +
888
+ " #set par(justify: false, first-line-indent: 0em)\n" +
889
+ ' #text(size: 10pt, style: "italic", fill: rgb("#3d352b"))[#body]\n' +
890
+ " ]\n" +
891
+ " #v(0.28em)\n" +
892
+ " #line(length: 38%, stroke: 0.6pt + book-accent)\n" +
893
+ " ]\n" +
894
+ "}",
895
+ // An entry owns its page. The plate is a float scoped to the parent
896
+ // because that is the only placement that spans every column, and the
897
+ // body has to set *below* it rather than beside it.
898
+ "#let book-entry(kicker, title, banner, epigraph, body) = {\n" +
899
+ " pagebreak(weak: true)\n" +
900
+ ' place(top, float: true, scope: "parent", clearance: 0.55cm)[\n' +
901
+ " #book-plate(kicker, title, banner, 3.5cm, body)\n" +
902
+ " #if epigraph != none { book-epigraph(epigraph) }\n" +
903
+ " ]\n" +
904
+ "}",
905
+ // A section opener holds nothing but its plate, so the plate needs no
906
+ // float: placed out of the flow it covers the sheet whichever column
907
+ // the flow happens to be in, and the break after it is what makes the
908
+ // page exist.
909
+ "#let book-section(kicker, title, banner, body) = {\n" +
910
+ " pagebreak(weak: true)\n" +
911
+ " place(top + left)[#book-plate(kicker, title, banner, 9cm, body)]\n" +
912
+ " pagebreak()\n" +
913
+ "}",
914
+ // Wide content spans the page, and how it spans depends on how tall it
915
+ // is: a float is unbreakable and silently overflows, so anything taller
916
+ // than a page takes pages of its own instead.
917
+ "#let book-wide(body) = context {\n" +
918
+ " if measure(block(width: book-text-width)[#body]).height < book-text-height * 0.88 {\n" +
919
+ ' place(top, float: true, scope: "parent", clearance: 0.8em)[#body]\n' +
920
+ " } else {\n" +
921
+ " page(columns: 1)[#body]\n" +
922
+ " }\n" +
923
+ "}",
924
+ ].join("\n");
925
+ }
926
+
660
927
  /**
661
928
  * The whole book, as one Typst document.
662
929
  *
@@ -692,6 +959,46 @@ function renderIcon(token, ctx) {
692
959
  * `#outline()` needs no depth limit under this model: what prints is decided
693
960
  * per heading, not by how deep the tree happens to go.
694
961
  *
962
+ * ## An entry owns its page, and the page is set in two columns
963
+ *
964
+ * A reference book is consulted rather than read through. An entry beginning
965
+ * halfway down a page is harder to find, cannot carry its own running head
966
+ * honestly, and makes a page number in the contents point at the middle of
967
+ * something else — so every entry opens a page of its own, under a full-bleed
968
+ * plate carrying a kicker and its name.
969
+ *
970
+ * The body is set in **two columns**, the measure a reference work wants and
971
+ * the one every other decision follows from: an image with no width class is a
972
+ * column wide, the infobox flows in the column measure and breaks between its
973
+ * sections, and a table wider than {@link WIDE_TABLE_COLUMNS} spans the page.
974
+ * The columns are the *page's* rather than a `columns()` block's, because only
975
+ * a page with columns can carry a float that spans them — which is what the
976
+ * plate, a wide table and a full-width figure all need.
977
+ *
978
+ * Two columns are print's answer and print's alone: a scrolling page has no
979
+ * fixed viewport, so the website keeps one measure.
980
+ *
981
+ * ## What a section declares, its entries inherit
982
+ *
983
+ * {@link module:engine/pdf-toc.PRESENTATION_KEYS} travels down the document
984
+ * tree, and two of those keys are read here:
985
+ *
986
+ * - **`page`** — `banner:`, the plate's picture; `kicker:`, the line above an
987
+ * entry's name; and `columns:`, the measure the section's pages are set in.
988
+ * - **`footer`** — the name the running foot carries, which is the section's
989
+ * own title when nothing says otherwise.
990
+ *
991
+ * `header` and `infobox` are reserved and read by nothing: the running head is
992
+ * a foot in this design, and which infobox a note draws is decided by the
993
+ * note's type.
994
+ *
995
+ * ## A missing banner is a plate without a picture
996
+ *
997
+ * A section plate implies a banner per section, and art arrives later than
998
+ * rendering does. A section that names no banner — or names one the build
999
+ * cannot read — still gets its plate, its kicker and its title, set over the
1000
+ * book's ink.
1001
+ *
695
1002
  * ## Headings carry the structure, so nothing else has to
696
1003
  *
697
1004
  * Every section, every prose file and every entry is a real Typst heading at
@@ -709,6 +1016,13 @@ function renderIcon(token, ctx) {
709
1016
  * @param {string[]} [opts.front] - Rendered Typst for each front-matter file.
710
1017
  * @param {object} [opts.fonts] - `{ serif, sans, mono }` family names.
711
1018
  * @param {string} [opts.version] - Stamped on the title page when given.
1019
+ * @param {string} [opts.preamble] - Definitions the bodies call, emitted once
1020
+ * above the title page. A panel every entry draws is a set of rules stated
1021
+ * here rather than repeated 2,500 times.
1022
+ * @param {Map<string, string>} [opts.banners] - A banner as the document tree
1023
+ * declared it → the staged file's path, relative to the `.typ`. A declared
1024
+ * banner this map does not carry has no file the compiler can open, so the
1025
+ * plate draws without a picture.
712
1026
  * @returns {string} A complete `.typ` document.
713
1027
  */
714
1028
  export function renderBook({
@@ -719,21 +1033,35 @@ export function renderBook({
719
1033
  front = [],
720
1034
  fonts = {},
721
1035
  version = "",
1036
+ preamble = "",
1037
+ banners = new Map(),
722
1038
  } = {}) {
723
1039
  const serif = fonts.serif || "Libertinus Serif";
724
1040
  const sans = fonts.sans || serif;
725
1041
  const mono = fonts.mono || "DejaVu Sans Mono";
726
1042
  const out = [];
727
1043
 
1044
+ out.push(bookTypstPreamble());
1045
+ out.push("");
728
1046
  out.push(`#set document(title: "${escapeTypstString(title)}")`);
729
- out.push('#set page(paper: "us-letter", margin: (x: 2.2cm, y: 2.4cm), numbering: "1")');
730
- out.push(`#set text(font: "${escapeTypstString(serif)}", size: 10pt, lang: "en")`);
731
- out.push("#set par(justify: true, leading: 0.65em)");
1047
+ // Cream stock and dark ink rather than a dark screen theme: 2,000 pages of
1048
+ // reversed-out text is a different proposition on paper than on a display.
1049
+ out.push(
1050
+ '#set page(paper: "us-letter", margin: book-margin, fill: book-paper, numbering: "1")',
1051
+ );
1052
+ out.push(
1053
+ `#set text(font: "${escapeTypstString(serif)}", size: 9.6pt, fill: book-ink, lang: "en")`,
1054
+ );
1055
+ out.push("#set par(justify: true, leading: 0.55em, first-line-indent: 1.2em)");
732
1056
  // The mono face is a separate claim from the book face: a fenced block is
733
1057
  // the one place the corpus is allowed box-drawing characters, and the
734
1058
  // serif that sets the prose is not the font that carries them.
735
1059
  out.push(`#show raw: set text(font: "${escapeTypstString(mono)}")`);
736
1060
  out.push(`#show heading: set text(font: "${escapeTypstString(sans)}")`);
1061
+ // Every heading the reader sees inside an entry is a section rule in the
1062
+ // accent: the entry's own name is drawn on its plate, where a nested show
1063
+ // rule takes the heading back to its words.
1064
+ out.push("#show heading: book-sechead");
737
1065
  // A link the reader can see is the difference between a cross-reference and
738
1066
  // a sentence that happens to mention something.
739
1067
  out.push('#show link: set text(fill: rgb("#1b4d7a"))');
@@ -744,6 +1072,11 @@ export function renderBook({
744
1072
  out.push("#show table.cell.where(y: 0): strong");
745
1073
  out.push("");
746
1074
 
1075
+ if (preamble.trim()) {
1076
+ out.push(preamble);
1077
+ out.push("");
1078
+ }
1079
+
747
1080
  // Title page.
748
1081
  out.push("#align(center + horizon)[");
749
1082
  out.push(` #text(size: 30pt, weight: "bold")[${escapeTypst(title)}]`);
@@ -772,15 +1105,34 @@ export function renderBook({
772
1105
  out.push("#pagebreak()");
773
1106
  out.push("");
774
1107
 
1108
+ // The body's geometry, once. Each section restates the columns and the
1109
+ // running foot below, because a `set page` rule starts a page and a
1110
+ // section opener starts one anyway — so the two cost nothing together.
1111
+ out.push(
1112
+ "#set page(margin: book-margin, columns: 2, " +
1113
+ `footer: book-footer[${escapeTypst(title)}])`,
1114
+ );
1115
+ out.push("");
1116
+
775
1117
  for (const entry of plan?.entries ?? []) {
776
1118
  const label = labelFor(entry.anchor);
777
1119
  const depth = Math.min(6, Math.max(1, Number(entry.depth) || 1));
1120
+ const page = presentationPage(entry);
1121
+ const banner = plateBanner(page, banners);
778
1122
  if (entry.kind === "section") {
779
1123
  // A declared `sectionName:` — the structure the printed contents
780
- // shows and the bookmarks panel shows alongside it.
1124
+ // shows and the bookmarks panel shows alongside it. It opens a page
1125
+ // of its own so that a section reads as a section rather than as
1126
+ // the first entry beneath it.
1127
+ out.push(
1128
+ `#set page(columns: ${columnsOf(page)}, ` +
1129
+ `footer: book-footer[${escapeTypst(footerName(entry))}])`,
1130
+ );
781
1131
  out.push(
782
- `#heading(level: ${depth}, outlined: true, bookmarked: true)` +
783
- `[${escapeTypst(entry.title)}] <${label}>`,
1132
+ `#book-section("${escapeTypstString(sectionKicker(entry, title))}", ` +
1133
+ `"${escapeTypstString(entry.title)}", ${banner})[` +
1134
+ `#heading(level: ${depth}, outlined: true, bookmarked: true)` +
1135
+ `[${escapeTypst(entry.title)}] <${label}>]`,
784
1136
  );
785
1137
  out.push("");
786
1138
  continue;
@@ -789,17 +1141,25 @@ export function renderBook({
789
1141
  // Prose carries no title of its own — its headings are its own. The
790
1142
  // label goes on a zero-width marker so the contents and any inbound
791
1143
  // link still have somewhere to land.
1144
+ out.push("#pagebreak(weak: true)");
792
1145
  out.push(`#metadata(none) <${label}>`);
793
1146
  out.push(bodies.get(entry.anchor) ?? "");
794
1147
  out.push("");
795
1148
  continue;
796
1149
  }
797
1150
  // A note leaf: reachable from the bookmarks panel, titled from
798
- // `name.full`, and never printed in the paper contents.
1151
+ // `name.full`, and never printed in the paper contents. The heading is
1152
+ // handed to the plate, which draws it as the entry's name — one
1153
+ // element, so the bookmark, the anchor and the title a reader sees
1154
+ // cannot drift apart.
799
1155
  const name = entry.record?.name?.full ?? entry.record?.address?.slug ?? "(untitled)";
1156
+ const description = String(entry.record?.description ?? "").trim();
1157
+ const epigraph = description ? `[${escapeTypst(description)}]` : "none";
800
1158
  out.push(
801
- `#heading(level: ${Math.min(6, depth + 1)}, outlined: false, bookmarked: true)` +
802
- `[${escapeTypst(name)}] <${label}>`,
1159
+ `#book-entry("${escapeTypstString(entryKicker(entry))}", ` +
1160
+ `"${escapeTypstString(name)}", ${banner}, ${epigraph})[` +
1161
+ `#heading(level: ${Math.min(6, depth + 1)}, outlined: false, bookmarked: true)` +
1162
+ `[${escapeTypst(name)}] <${label}>]`,
803
1163
  );
804
1164
  out.push("");
805
1165
  const body = bodies.get(entry.anchor);
@@ -812,6 +1172,86 @@ export function renderBook({
812
1172
  return `${out.join("\n")}\n`;
813
1173
  }
814
1174
 
1175
+ /**
1176
+ * The `page:` presentation an entry inherited, as a mapping.
1177
+ *
1178
+ * @param {object} entry - A plan entry.
1179
+ * @returns {object} The mapping, or an empty one.
1180
+ */
1181
+ function presentationPage(entry) {
1182
+ const page = entry?.presentation?.page;
1183
+ return page && typeof page === "object" && !Array.isArray(page) ? page : {};
1184
+ }
1185
+
1186
+ /**
1187
+ * How many columns an entry's pages are set in.
1188
+ *
1189
+ * @param {object} page - The `page:` presentation.
1190
+ * @returns {number} The column count.
1191
+ */
1192
+ function columnsOf(page) {
1193
+ const columns = Number(page.columns);
1194
+ return Number.isInteger(columns) && columns >= 1 && columns <= 4 ? columns : 2;
1195
+ }
1196
+
1197
+ /**
1198
+ * The staged banner an entry's plate draws, as a Typst argument.
1199
+ *
1200
+ * @param {object} page - The `page:` presentation.
1201
+ * @param {Map<string, string>} banners - Declared path → staged path.
1202
+ * @returns {string} A quoted path, or `none`.
1203
+ */
1204
+ function plateBanner(page, banners) {
1205
+ const staged = typeof page.banner === "string" ? banners.get(page.banner) : undefined;
1206
+ return staged ? `"${escapeTypstString(staged)}"` : "none";
1207
+ }
1208
+
1209
+ /**
1210
+ * The line an entry's name is set under.
1211
+ *
1212
+ * The section that holds it, which is what a reader needs to place an entry
1213
+ * they have arrived at from the index. A tree that wants something else —
1214
+ * the volume's own name, a series line — declares `page.kicker`.
1215
+ *
1216
+ * @param {object} entry - A plan entry.
1217
+ * @returns {string} The kicker.
1218
+ */
1219
+ function entryKicker(entry) {
1220
+ const declared = presentationPage(entry).kicker;
1221
+ if (typeof declared === "string" && declared.trim()) return declared.trim();
1222
+ const trail = Array.isArray(entry.trail) ? entry.trail : [];
1223
+ return trail.join(" · ");
1224
+ }
1225
+
1226
+ /**
1227
+ * The line a section's own name is set under.
1228
+ *
1229
+ * The sections above it, and the book's title at the top of the tree — where
1230
+ * repeating the section's own name would say nothing.
1231
+ *
1232
+ * @param {object} entry - A section entry.
1233
+ * @param {string} title - The book's title.
1234
+ * @returns {string} The kicker.
1235
+ */
1236
+ function sectionKicker(entry, title) {
1237
+ const declared = presentationPage(entry).kicker;
1238
+ if (typeof declared === "string" && declared.trim()) return declared.trim();
1239
+ const trail = Array.isArray(entry.trail) ? entry.trail : [];
1240
+ return trail.slice(0, -1).join(" · ") || String(title ?? "");
1241
+ }
1242
+
1243
+ /**
1244
+ * The name the running foot carries beneath a section and everything under it.
1245
+ *
1246
+ * @param {object} entry - A section entry.
1247
+ * @returns {string} The name.
1248
+ */
1249
+ function footerName(entry) {
1250
+ const declared = entry?.presentation?.footer;
1251
+ if (typeof declared === "string" && declared.trim()) return declared.trim();
1252
+ return String(entry?.title ?? "");
1253
+ }
1254
+
815
1255
  /**
816
1256
  * Point every internal link at a label the document actually declares.
817
1257
  *