@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
@@ -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,16 @@ 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
+ * capital. Set for an entry, which begins a page; not for front matter or a
172
+ * prose file, which carry headings of their own.
163
173
  * @returns {string} Typst markup.
164
174
  */
165
175
  export function markdownToTypst(markdown, opts = {}) {
@@ -167,6 +177,7 @@ export function markdownToTypst(markdown, opts = {}) {
167
177
  md = createParser(opts.registry),
168
178
  links = new Map(),
169
179
  glyphs = new Map(),
180
+ images = new Map(),
170
181
  headingOffset = 0,
171
182
  anchorPrefix = "",
172
183
  } = opts;
@@ -175,7 +186,14 @@ export function markdownToTypst(markdown, opts = {}) {
175
186
  // blockquote or a list item shares the entry's anchor namespace with every
176
187
  // other heading in the same body, because `sectionLabel` scopes by entry
177
188
  // rather than by container.
178
- return renderTokens(tokens, { links, glyphs, headingOffset, anchorPrefix, seen: new Map() });
189
+ return renderTokens(tokens, {
190
+ links,
191
+ glyphs,
192
+ images,
193
+ headingOffset,
194
+ anchorPrefix,
195
+ seen: new Map(),
196
+ });
179
197
  }
180
198
 
181
199
  /**
@@ -250,7 +268,7 @@ function renderBlock(tokens, i, out, ctx) {
250
268
  return 1;
251
269
  case "blockquote_open": {
252
270
  const end = matching(tokens, i, "blockquote_open", "blockquote_close");
253
- const inner = renderTokens(tokens.slice(i + 1, end), ctx);
271
+ const inner = renderTokens(tokens.slice(i + 1, end), { ...ctx });
254
272
  out.push(`\n#quote(block: true)[${inner}]\n\n`);
255
273
  return end - i + 1;
256
274
  }
@@ -394,7 +412,7 @@ function listItems(tokens, start, end, ctx) {
394
412
  continue;
395
413
  }
396
414
  const close = matching(tokens, i, "list_item_open", "list_item_close");
397
- items.push(renderTokens(tokens.slice(i + 1, close), ctx));
415
+ items.push(renderTokens(tokens.slice(i + 1, close), { ...ctx }));
398
416
  i = close + 1;
399
417
  }
400
418
  return items;
@@ -472,7 +490,12 @@ function renderTable(tokens, ctx) {
472
490
  columns,
473
491
  )}),`
474
492
  : "";
475
- return `\n#table(\n columns: ${columns},${alignment}${header}\n${body}\n)\n\n`;
493
+ const drawn = `#table(\n columns: ${columns},${alignment}${header}\n${body}\n)`;
494
+ // Wide content is given an explicit span rather than left to overflow the
495
+ // measure: past three columns a table is set across the page, and
496
+ // `book-wide` decides between a float and pages of its own by measuring it.
497
+ if (columns > WIDE_TABLE_COLUMNS) return `\n#book-wide[\n${drawn}\n]\n\n`;
498
+ return `\n${drawn}\n\n`;
476
499
  }
477
500
 
478
501
  /**
@@ -559,14 +582,9 @@ function renderInline(token, ctx) {
559
582
  i = close;
560
583
  break;
561
584
  }
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)}]`);
585
+ case "image":
586
+ out.push(renderImage(child, ctx));
568
587
  break;
569
- }
570
588
  default:
571
589
  if (child.content) out.push(escapeTypst(child.content));
572
590
  break;
@@ -643,6 +661,61 @@ function renderLink(href, inner, ctx) {
643
661
  return `#link("${escapeTypstString(url)}")[${inner}]`;
644
662
  }
645
663
 
664
+ /**
665
+ * One image, as the figure the book prints.
666
+ *
667
+ * ## The width class is the measure
668
+ *
669
+ * An image with no class is one column wide. That is `width: 100%` of whatever
670
+ * container it is set in — the page today, and a column once the book is set in
671
+ * two — so the ordinary case needs nothing but an ordinary block and stays
672
+ * correct through the change.
673
+ *
674
+ * `.full-width` has to leave its column, and a block cannot: only a float
675
+ * placed with `scope: "parent"` spans every column of the page. So a
676
+ * full-width image is always placed, whether or not it states a `float:`, and
677
+ * an image that states neither is left in the flow where it was written.
678
+ *
679
+ * ## A float occupies the measure
680
+ *
681
+ * Typst has no shaped text flow, so `#place(…, float: true)` reserves the whole
682
+ * measure and sets the text above and below rather than beside. The horizontal
683
+ * half of a position therefore has no effect on the page; it is emitted anyway,
684
+ * because it costs nothing and says what the note asked for.
685
+ *
686
+ * ## No file, no picture
687
+ *
688
+ * `#image` on a path Typst cannot open is a compile error, and a compile error
689
+ * in a 2,500-entry book is fatal at the very end of a run that otherwise
690
+ * succeeded — over an illustration, which is the least important thing on the
691
+ * page. An address the build could not stage prints its caption alone instead,
692
+ * and the build reports the address it could not find.
693
+ *
694
+ * @param {object} token - An `image` token.
695
+ * @param {object} ctx - Render context.
696
+ * @returns {string} Typst markup.
697
+ */
698
+ function renderImage(token, ctx) {
699
+ const alt = token.content || token.attrGet?.("alt") || "";
700
+ const caption =
701
+ alt ? `\n #text(size: 7.6pt, style: "italic", fill: luma(45%))[${escapeTypst(alt)}]` : "";
702
+ const staged = ctx.images.get(token.attrGet?.("src") ?? "");
703
+ if (!staged) return caption ? `\n#block(below: 0.6em)[${caption}\n]\n\n` : "";
704
+
705
+ const figure =
706
+ `#block(width: 100%, below: 0.6em)[\n` +
707
+ ` #image("${escapeTypstString(staged)}", width: 100%)${caption}\n]`;
708
+
709
+ const width = token.meta?.classes?.[0];
710
+ const scope = IMAGE_CLASSES[width]?.scope ?? "column";
711
+ const float = IMAGE_FLOATS[token.meta?.float];
712
+ // In the flow where it was written: no class asking for the page, and no
713
+ // position asking for the top or the bottom of the column.
714
+ if (!float && scope === "column") return `\n${figure}\n\n`;
715
+ const align = float?.align ?? "top";
716
+ return `\n#place(${align}, float: true, scope: "${scope}", clearance: 0.7em)[\n${figure}\n]\n\n`;
717
+ }
718
+
646
719
  /**
647
720
  * One icon, as its glyph when a font carries it and as its name otherwise.
648
721
  *
@@ -657,6 +730,193 @@ function renderIcon(token, ctx) {
657
730
  return `#text(font: "${escapeTypstString(glyph.font)}")[\\u{${glyph.codepoint.toString(16)}}]`;
658
731
  }
659
732
 
733
+ /**
734
+ * How many columns a table may hold before it is set across the page.
735
+ *
736
+ * Three is where a column measure gives out. A two- or three-column table of
737
+ * names and numbers sets comfortably in half a US Letter page; a fourth column
738
+ * is where the cells start breaking one word to a line, and by five — a roster
739
+ * of nomes with a sentence of character in the last cell — the table is wider
740
+ * than the measure whatever the renderer does with it.
741
+ *
742
+ * The count is the rule because it is the one thing known without laying the
743
+ * page out. How *tall* the result is decides the rest, and that is measured in
744
+ * Typst rather than guessed here: see `book-wide` in {@link bookTypstPreamble}.
745
+ *
746
+ * @type {number}
747
+ */
748
+ const WIDE_TABLE_COLUMNS = 3;
749
+
750
+ /**
751
+ * The Typst definitions the book's page furniture is drawn with.
752
+ *
753
+ * Emitted once at the head of the document, for the reason the infobox panel's
754
+ * rules are: 2,000 entries each restating the plate, the running foot and the
755
+ * drop cap is a megabyte of repetition, and the one place a reader changes how
756
+ * the book looks should be one place.
757
+ *
758
+ * ## The geometry is stated, not discovered
759
+ *
760
+ * The page is US Letter with a 1.9cm margin, and the plate bleeds off all
761
+ * three edges it touches — so the plate has to know the paper's width and the
762
+ * margin it is escaping. Both are `#let` bindings here rather than numbers
763
+ * repeated down the file, and every other measure is arithmetic on them.
764
+ *
765
+ * ## Three things that cost time to discover, encoded here
766
+ *
767
+ * - **A title inherits the body's justification and hyphenation** unless told
768
+ * otherwise. Both are habits of body text that make a display line look
769
+ * broken, so the plate turns them off inside itself.
770
+ * - **The plate's height follows its title**, and the line count is derived
771
+ * from the title's *natural* width: measuring an already-wrapped block does
772
+ * not report the wrapped height, and a percentage width cannot resolve
773
+ * inside `measure`. So the title arrives as a string to be measured
774
+ * alongside the heading that is actually drawn.
775
+ * - **A float is not breakable.** A table taller than the page placed as one
776
+ * silently piles its rows on top of each other at the foot of the page —
777
+ * no warning, no error. So `book-wide` measures first and gives a table that
778
+ * will not fit its own single-column pages instead.
779
+ *
780
+ * ## The ornament is drawn, not typed
781
+ *
782
+ * The running foot's centre mark is a rotated square rather than a dingbat
783
+ * character, because the faces a consumer names and the faces a build runner
784
+ * carries are not the same set, and a missing glyph on 2,000 feet is a
785
+ * tofu box on every page of the book.
786
+ *
787
+ * @returns {string} Typst markup.
788
+ */
789
+ export function bookTypstPreamble() {
790
+ return [
791
+ "#let book-margin = 1.9cm",
792
+ "#let book-page-width = 8.5in",
793
+ "#let book-page-height = 11in",
794
+ "#let book-text-width = book-page-width - 2 * book-margin",
795
+ "#let book-text-height = book-page-height - 2 * book-margin",
796
+ // The title sets short of the measure, so a plate's last line does not
797
+ // run to the trimmed edge of the paper.
798
+ "#let book-title-measure = book-text-width - 2.5cm",
799
+ '#let book-ink = rgb("#241f1a")',
800
+ '#let book-paper = rgb("#f4efe4")',
801
+ '#let book-accent = rgb("#7c3b1e")',
802
+ '#let book-head = rgb("#5e2b14")',
803
+ '#let book-faint = rgb("#6b6357")',
804
+ "#let book-ornament = box(baseline: 1pt, " +
805
+ "rotate(45deg, rect(width: 3pt, height: 3pt, fill: book-accent)))",
806
+ "#let book-footer(name) = context {\n" +
807
+ " set text(size: 8pt, fill: book-faint)\n" +
808
+ " grid(columns: (1fr, auto, 1fr), align(left)[#name], align(center)[#book-ornament],\n" +
809
+ " align(right)[#counter(page).display()])\n" +
810
+ "}",
811
+ // A heading justifies and hyphenates like body text unless told
812
+ // otherwise, and both make a display line look broken.
813
+ //
814
+ // Size, weight and the space above follow the level, so a parent reads
815
+ // as one without the words being read. The rule belongs to the note's own
816
+ // top level alone; every level under it is set apart by space. Headings set
817
+ // in the sans face and in the case they were authored in, so neither
818
+ // capitals nor tracking is carrying the distinction. The scale starts
819
+ // at 3: `book-plate` shadows this rule while it draws the title, so
820
+ // the section landing and the entry title never arrive here, and the
821
+ // headings that do are the note's own.
822
+ "#let book-sechead(it) = {\n" +
823
+ " let lv = it.level\n" +
824
+ " let size = if lv <= 3 { 26pt } else if lv == 4 { 19pt }\n" +
825
+ " else if lv == 5 { 14pt } else { 11pt }\n" +
826
+ " let above = if lv <= 3 { 2.5em } else if lv == 4 { 2.0em }\n" +
827
+ " else { 1.0em }\n" +
828
+ " let below = if lv <= 3 { 0.5em } else if lv == 4 { 0.36em }\n" +
829
+ " else { 0.28em }\n" +
830
+ ' let weight = if lv <= 5 { "bold" } else { "regular" }\n' +
831
+ " block(width: 100%, above: above, below: below, breakable: false)[\n" +
832
+ " #set par(justify: false, first-line-indent: 0em)\n" +
833
+ " #set text(hyphenate: false)\n" +
834
+ " #text(size: size, weight: weight, fill: book-head)[#it.body]\n" +
835
+ " #if lv <= 3 {\n" +
836
+ " v(-0.30em)\n" +
837
+ " line(length: 100%, stroke: 0.5pt + book-accent)\n" +
838
+ " }\n" +
839
+ " ]\n" +
840
+ "}",
841
+ // The plate bleeds off the paper: the placed panel is the full width of
842
+ // the sheet and starts a margin above and to the left of wherever the
843
+ // flow has reached, which on an entry's first page is the top corner.
844
+ "#let book-plate(kicker, title, banner, floor, body) = context {\n" +
845
+ ' let natural = measure(text(size: 25pt, weight: "bold", tracking: 1.4pt)[#title]).width\n' +
846
+ " let lines = calc.max(1, calc.ceil(natural / book-title-measure))\n" +
847
+ " let height = calc.max(floor, lines * 1.15cm + 1.75cm)\n" +
848
+ " block(width: 100%, height: height - book-margin, above: 0pt, below: 0pt)[\n" +
849
+ " #place(top + left, dx: -book-margin, dy: -book-margin)[\n" +
850
+ " #block(width: book-page-width, height: height, clip: true, inset: 0pt,\n" +
851
+ " fill: book-ink)[\n" +
852
+ " #if banner != none {\n" +
853
+ ' place(top + left, image(banner, width: 100%, height: height, fit: "cover"))\n' +
854
+ " }\n" +
855
+ " #place(top + left, rect(width: 100%, height: height,\n" +
856
+ " fill: gradient.linear(rgb(10, 8, 6, 70), rgb(10, 8, 6, 175),\n" +
857
+ " rgb(10, 8, 6, 240), angle: 90deg)))\n" +
858
+ " #place(bottom + left, dx: book-margin, dy: -0.55cm)[\n" +
859
+ " #block(width: book-title-measure)[\n" +
860
+ " #set par(justify: false, leading: 0.35em, first-line-indent: 0em)\n" +
861
+ " #set text(hyphenate: false)\n" +
862
+ ' #text(fill: rgb("#e8dcc2"), size: 7.5pt, tracking: 2.6pt)[#upper(kicker)]\n' +
863
+ " #v(-0.10em)\n" +
864
+ " #{\n" +
865
+ " show heading: it => it.body\n" +
866
+ ' set text(fill: white, size: 25pt, weight: "bold", tracking: 1.4pt)\n' +
867
+ " body\n" +
868
+ " }\n" +
869
+ " ]\n" +
870
+ " ]\n" +
871
+ " ]\n" +
872
+ " ]\n" +
873
+ " ]\n" +
874
+ "}",
875
+ "#let book-epigraph(body) = {\n" +
876
+ " v(0.42cm)\n" +
877
+ " align(center)[\n" +
878
+ " #line(length: 38%, stroke: 0.6pt + book-accent)\n" +
879
+ " #v(0.28em)\n" +
880
+ " #block(width: 78%)[\n" +
881
+ " #set par(justify: false, first-line-indent: 0em)\n" +
882
+ ' #text(size: 10pt, style: "italic", fill: rgb("#3d352b"))[#body]\n' +
883
+ " ]\n" +
884
+ " #v(0.28em)\n" +
885
+ " #line(length: 38%, stroke: 0.6pt + book-accent)\n" +
886
+ " ]\n" +
887
+ "}",
888
+ // An entry owns its page. The plate is a float scoped to the parent
889
+ // because that is the only placement that spans every column, and the
890
+ // body has to set *below* it rather than beside it.
891
+ "#let book-entry(kicker, title, banner, epigraph, body) = {\n" +
892
+ " pagebreak(weak: true)\n" +
893
+ ' place(top, float: true, scope: "parent", clearance: 0.55cm)[\n' +
894
+ " #book-plate(kicker, title, banner, 3.5cm, body)\n" +
895
+ " #if epigraph != none { book-epigraph(epigraph) }\n" +
896
+ " ]\n" +
897
+ "}",
898
+ // A section opener holds nothing but its plate, so the plate needs no
899
+ // float: placed out of the flow it covers the sheet whichever column
900
+ // the flow happens to be in, and the break after it is what makes the
901
+ // page exist.
902
+ "#let book-section(kicker, title, banner, body) = {\n" +
903
+ " pagebreak(weak: true)\n" +
904
+ " place(top + left)[#book-plate(kicker, title, banner, 9cm, body)]\n" +
905
+ " pagebreak()\n" +
906
+ "}",
907
+ // Wide content spans the page, and how it spans depends on how tall it
908
+ // is: a float is unbreakable and silently overflows, so anything taller
909
+ // than a page takes pages of its own instead.
910
+ "#let book-wide(body) = context {\n" +
911
+ " if measure(block(width: book-text-width)[#body]).height < book-text-height * 0.88 {\n" +
912
+ ' place(top, float: true, scope: "parent", clearance: 0.8em)[#body]\n' +
913
+ " } else {\n" +
914
+ " page(columns: 1)[#body]\n" +
915
+ " }\n" +
916
+ "}",
917
+ ].join("\n");
918
+ }
919
+
660
920
  /**
661
921
  * The whole book, as one Typst document.
662
922
  *
@@ -692,6 +952,46 @@ function renderIcon(token, ctx) {
692
952
  * `#outline()` needs no depth limit under this model: what prints is decided
693
953
  * per heading, not by how deep the tree happens to go.
694
954
  *
955
+ * ## An entry owns its page, and the page is set in two columns
956
+ *
957
+ * A reference book is consulted rather than read through. An entry beginning
958
+ * halfway down a page is harder to find, cannot carry its own running head
959
+ * honestly, and makes a page number in the contents point at the middle of
960
+ * something else — so every entry opens a page of its own, under a full-bleed
961
+ * plate carrying a kicker and its name.
962
+ *
963
+ * The body is set in **two columns**, the measure a reference work wants and
964
+ * the one every other decision follows from: an image with no width class is a
965
+ * column wide, the infobox flows in the column measure and breaks between its
966
+ * sections, and a table wider than {@link WIDE_TABLE_COLUMNS} spans the page.
967
+ * The columns are the *page's* rather than a `columns()` block's, because only
968
+ * a page with columns can carry a float that spans them — which is what the
969
+ * plate, a wide table and a full-width figure all need.
970
+ *
971
+ * Two columns are print's answer and print's alone: a scrolling page has no
972
+ * fixed viewport, so the website keeps one measure.
973
+ *
974
+ * ## What a section declares, its entries inherit
975
+ *
976
+ * {@link module:engine/pdf-toc.PRESENTATION_KEYS} travels down the document
977
+ * tree, and two of those keys are read here:
978
+ *
979
+ * - **`page`** — `banner:`, the plate's picture; `kicker:`, the line above an
980
+ * entry's name; and `columns:`, the measure the section's pages are set in.
981
+ * - **`footer`** — the name the running foot carries, which is the section's
982
+ * own title when nothing says otherwise.
983
+ *
984
+ * `header` and `infobox` are reserved and read by nothing: the running head is
985
+ * a foot in this design, and which infobox a note draws is decided by the
986
+ * note's type.
987
+ *
988
+ * ## A missing banner is a plate without a picture
989
+ *
990
+ * A section plate implies a banner per section, and art arrives later than
991
+ * rendering does. A section that names no banner — or names one the build
992
+ * cannot read — still gets its plate, its kicker and its title, set over the
993
+ * book's ink.
994
+ *
695
995
  * ## Headings carry the structure, so nothing else has to
696
996
  *
697
997
  * Every section, every prose file and every entry is a real Typst heading at
@@ -709,6 +1009,13 @@ function renderIcon(token, ctx) {
709
1009
  * @param {string[]} [opts.front] - Rendered Typst for each front-matter file.
710
1010
  * @param {object} [opts.fonts] - `{ serif, sans, mono }` family names.
711
1011
  * @param {string} [opts.version] - Stamped on the title page when given.
1012
+ * @param {string} [opts.preamble] - Definitions the bodies call, emitted once
1013
+ * above the title page. A panel every entry draws is a set of rules stated
1014
+ * here rather than repeated 2,500 times.
1015
+ * @param {Map<string, string>} [opts.banners] - A banner as the document tree
1016
+ * declared it → the staged file's path, relative to the `.typ`. A declared
1017
+ * banner this map does not carry has no file the compiler can open, so the
1018
+ * plate draws without a picture.
712
1019
  * @returns {string} A complete `.typ` document.
713
1020
  */
714
1021
  export function renderBook({
@@ -719,21 +1026,35 @@ export function renderBook({
719
1026
  front = [],
720
1027
  fonts = {},
721
1028
  version = "",
1029
+ preamble = "",
1030
+ banners = new Map(),
722
1031
  } = {}) {
723
1032
  const serif = fonts.serif || "Libertinus Serif";
724
1033
  const sans = fonts.sans || serif;
725
1034
  const mono = fonts.mono || "DejaVu Sans Mono";
726
1035
  const out = [];
727
1036
 
1037
+ out.push(bookTypstPreamble());
1038
+ out.push("");
728
1039
  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)");
1040
+ // Cream stock and dark ink rather than a dark screen theme: 2,000 pages of
1041
+ // reversed-out text is a different proposition on paper than on a display.
1042
+ out.push(
1043
+ '#set page(paper: "us-letter", margin: book-margin, fill: book-paper, numbering: "1")',
1044
+ );
1045
+ out.push(
1046
+ `#set text(font: "${escapeTypstString(serif)}", size: 9.6pt, fill: book-ink, lang: "en")`,
1047
+ );
1048
+ out.push("#set par(justify: true, leading: 0.55em, first-line-indent: 1.2em)");
732
1049
  // The mono face is a separate claim from the book face: a fenced block is
733
1050
  // the one place the corpus is allowed box-drawing characters, and the
734
1051
  // serif that sets the prose is not the font that carries them.
735
1052
  out.push(`#show raw: set text(font: "${escapeTypstString(mono)}")`);
736
1053
  out.push(`#show heading: set text(font: "${escapeTypstString(sans)}")`);
1054
+ // Every heading the reader sees inside an entry is a section rule in the
1055
+ // accent: the entry's own name is drawn on its plate, where a nested show
1056
+ // rule takes the heading back to its words.
1057
+ out.push("#show heading: book-sechead");
737
1058
  // A link the reader can see is the difference between a cross-reference and
738
1059
  // a sentence that happens to mention something.
739
1060
  out.push('#show link: set text(fill: rgb("#1b4d7a"))');
@@ -744,6 +1065,11 @@ export function renderBook({
744
1065
  out.push("#show table.cell.where(y: 0): strong");
745
1066
  out.push("");
746
1067
 
1068
+ if (preamble.trim()) {
1069
+ out.push(preamble);
1070
+ out.push("");
1071
+ }
1072
+
747
1073
  // Title page.
748
1074
  out.push("#align(center + horizon)[");
749
1075
  out.push(` #text(size: 30pt, weight: "bold")[${escapeTypst(title)}]`);
@@ -772,15 +1098,34 @@ export function renderBook({
772
1098
  out.push("#pagebreak()");
773
1099
  out.push("");
774
1100
 
1101
+ // The body's geometry, once. Each section restates the columns and the
1102
+ // running foot below, because a `set page` rule starts a page and a
1103
+ // section opener starts one anyway — so the two cost nothing together.
1104
+ out.push(
1105
+ "#set page(margin: book-margin, columns: 2, " +
1106
+ `footer: book-footer[${escapeTypst(title)}])`,
1107
+ );
1108
+ out.push("");
1109
+
775
1110
  for (const entry of plan?.entries ?? []) {
776
1111
  const label = labelFor(entry.anchor);
777
1112
  const depth = Math.min(6, Math.max(1, Number(entry.depth) || 1));
1113
+ const page = presentationPage(entry);
1114
+ const banner = plateBanner(page, banners);
778
1115
  if (entry.kind === "section") {
779
1116
  // A declared `sectionName:` — the structure the printed contents
780
- // shows and the bookmarks panel shows alongside it.
1117
+ // shows and the bookmarks panel shows alongside it. It opens a page
1118
+ // of its own so that a section reads as a section rather than as
1119
+ // the first entry beneath it.
781
1120
  out.push(
782
- `#heading(level: ${depth}, outlined: true, bookmarked: true)` +
783
- `[${escapeTypst(entry.title)}] <${label}>`,
1121
+ `#set page(columns: ${columnsOf(page)}, ` +
1122
+ `footer: book-footer[${escapeTypst(footerName(entry))}])`,
1123
+ );
1124
+ out.push(
1125
+ `#book-section("${escapeTypstString(sectionKicker(entry, title))}", ` +
1126
+ `"${escapeTypstString(entry.title)}", ${banner})[` +
1127
+ `#heading(level: ${depth}, outlined: true, bookmarked: true)` +
1128
+ `[${escapeTypst(entry.title)}] <${label}>]`,
784
1129
  );
785
1130
  out.push("");
786
1131
  continue;
@@ -789,17 +1134,25 @@ export function renderBook({
789
1134
  // Prose carries no title of its own — its headings are its own. The
790
1135
  // label goes on a zero-width marker so the contents and any inbound
791
1136
  // link still have somewhere to land.
1137
+ out.push("#pagebreak(weak: true)");
792
1138
  out.push(`#metadata(none) <${label}>`);
793
1139
  out.push(bodies.get(entry.anchor) ?? "");
794
1140
  out.push("");
795
1141
  continue;
796
1142
  }
797
1143
  // A note leaf: reachable from the bookmarks panel, titled from
798
- // `name.full`, and never printed in the paper contents.
1144
+ // `name.full`, and never printed in the paper contents. The heading is
1145
+ // handed to the plate, which draws it as the entry's name — one
1146
+ // element, so the bookmark, the anchor and the title a reader sees
1147
+ // cannot drift apart.
799
1148
  const name = entry.record?.name?.full ?? entry.record?.address?.slug ?? "(untitled)";
1149
+ const description = String(entry.record?.description ?? "").trim();
1150
+ const epigraph = description ? `[${escapeTypst(description)}]` : "none";
800
1151
  out.push(
801
- `#heading(level: ${Math.min(6, depth + 1)}, outlined: false, bookmarked: true)` +
802
- `[${escapeTypst(name)}] <${label}>`,
1152
+ `#book-entry("${escapeTypstString(entryKicker(entry))}", ` +
1153
+ `"${escapeTypstString(name)}", ${banner}, ${epigraph})[` +
1154
+ `#heading(level: ${Math.min(6, depth + 1)}, outlined: false, bookmarked: true)` +
1155
+ `[${escapeTypst(name)}] <${label}>]`,
803
1156
  );
804
1157
  out.push("");
805
1158
  const body = bodies.get(entry.anchor);
@@ -812,6 +1165,86 @@ export function renderBook({
812
1165
  return `${out.join("\n")}\n`;
813
1166
  }
814
1167
 
1168
+ /**
1169
+ * The `page:` presentation an entry inherited, as a mapping.
1170
+ *
1171
+ * @param {object} entry - A plan entry.
1172
+ * @returns {object} The mapping, or an empty one.
1173
+ */
1174
+ function presentationPage(entry) {
1175
+ const page = entry?.presentation?.page;
1176
+ return page && typeof page === "object" && !Array.isArray(page) ? page : {};
1177
+ }
1178
+
1179
+ /**
1180
+ * How many columns an entry's pages are set in.
1181
+ *
1182
+ * @param {object} page - The `page:` presentation.
1183
+ * @returns {number} The column count.
1184
+ */
1185
+ function columnsOf(page) {
1186
+ const columns = Number(page.columns);
1187
+ return Number.isInteger(columns) && columns >= 1 && columns <= 4 ? columns : 2;
1188
+ }
1189
+
1190
+ /**
1191
+ * The staged banner an entry's plate draws, as a Typst argument.
1192
+ *
1193
+ * @param {object} page - The `page:` presentation.
1194
+ * @param {Map<string, string>} banners - Declared path → staged path.
1195
+ * @returns {string} A quoted path, or `none`.
1196
+ */
1197
+ function plateBanner(page, banners) {
1198
+ const staged = typeof page.banner === "string" ? banners.get(page.banner) : undefined;
1199
+ return staged ? `"${escapeTypstString(staged)}"` : "none";
1200
+ }
1201
+
1202
+ /**
1203
+ * The line an entry's name is set under.
1204
+ *
1205
+ * The section that holds it, which is what a reader needs to place an entry
1206
+ * they have arrived at from the index. A tree that wants something else —
1207
+ * the volume's own name, a series line — declares `page.kicker`.
1208
+ *
1209
+ * @param {object} entry - A plan entry.
1210
+ * @returns {string} The kicker.
1211
+ */
1212
+ function entryKicker(entry) {
1213
+ const declared = presentationPage(entry).kicker;
1214
+ if (typeof declared === "string" && declared.trim()) return declared.trim();
1215
+ const trail = Array.isArray(entry.trail) ? entry.trail : [];
1216
+ return trail.join(" · ");
1217
+ }
1218
+
1219
+ /**
1220
+ * The line a section's own name is set under.
1221
+ *
1222
+ * The sections above it, and the book's title at the top of the tree — where
1223
+ * repeating the section's own name would say nothing.
1224
+ *
1225
+ * @param {object} entry - A section entry.
1226
+ * @param {string} title - The book's title.
1227
+ * @returns {string} The kicker.
1228
+ */
1229
+ function sectionKicker(entry, title) {
1230
+ const declared = presentationPage(entry).kicker;
1231
+ if (typeof declared === "string" && declared.trim()) return declared.trim();
1232
+ const trail = Array.isArray(entry.trail) ? entry.trail : [];
1233
+ return trail.slice(0, -1).join(" · ") || String(title ?? "");
1234
+ }
1235
+
1236
+ /**
1237
+ * The name the running foot carries beneath a section and everything under it.
1238
+ *
1239
+ * @param {object} entry - A section entry.
1240
+ * @returns {string} The name.
1241
+ */
1242
+ function footerName(entry) {
1243
+ const declared = entry?.presentation?.footer;
1244
+ if (typeof declared === "string" && declared.trim()) return declared.trim();
1245
+ return String(entry?.title ?? "");
1246
+ }
1247
+
815
1248
  /**
816
1249
  * Point every internal link at a label the document actually declares.
817
1250
  *