@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.
- package/CHANGELOG.md +183 -0
- package/CONTENT.md +132 -44
- package/bin/content-build.mjs +37 -5
- package/bin/package-build.mjs +77 -0
- package/content-config.mjs +59 -1
- package/docs/api.md +149 -19
- package/docs/commands.md +75 -0
- package/docs/configuration.md +37 -10
- package/docs/content-format.md +450 -49
- package/engine/content-format.mjs +52 -3
- package/engine/content-images.mjs +699 -0
- package/engine/dependency-bump.mjs +218 -0
- package/engine/frontmatter-lint.mjs +89 -2
- package/engine/helpers.mjs +81 -142
- package/engine/index.mjs +15 -0
- package/engine/infobox-registry.mjs +81 -0
- package/engine/infobox-render.mjs +382 -0
- package/engine/infobox.mjs +963 -0
- package/engine/item-registry.mjs +5 -5
- package/engine/journals.mjs +22 -1
- package/engine/map-notes.mjs +11 -5
- package/engine/metadata-index.mjs +5 -0
- package/engine/note-vocabulary.mjs +57 -2
- package/engine/pathnames.mjs +374 -0
- package/engine/pdf-build.mjs +206 -9
- package/engine/pdf-render.mjs +453 -20
- package/engine/pdf-toc.mjs +77 -5
- package/engine/scenes.mjs +2 -1
- package/engine/site-build.mjs +106 -7
- package/engine/site-index.mjs +93 -4
- package/engine/wikilinks.mjs +93 -0
- package/hm3/default-item-art.mjs +14 -15
- package/hm3/index.mjs +3 -0
- package/hm3/infobox.mjs +64 -0
- package/package.json +1 -1
- package/sohl/default-item-art.mjs +18 -16
- package/sohl/index.mjs +3 -0
- package/sohl/infobox.mjs +499 -0
- package/types/content-config.d.mts +7 -0
- package/types/engine/content-format.d.mts +36 -0
- package/types/engine/content-images.d.mts +281 -0
- package/types/engine/dependency-bump.d.mts +89 -0
- package/types/engine/frontmatter-lint.d.mts +23 -0
- package/types/engine/helpers.d.mts +30 -72
- package/types/engine/index.d.mts +5 -0
- package/types/engine/infobox-registry.d.mts +36 -0
- package/types/engine/infobox-render.d.mts +87 -0
- package/types/engine/infobox.d.mts +443 -0
- package/types/engine/item-registry.d.mts +5 -5
- package/types/engine/journals.d.mts +9 -1
- package/types/engine/note-vocabulary.d.mts +51 -0
- package/types/engine/pathnames.d.mts +189 -0
- package/types/engine/pdf-build.d.mts +46 -0
- package/types/engine/pdf-render.d.mts +97 -1
- package/types/engine/pdf-toc.d.mts +10 -5
- package/types/engine/site-build.d.mts +11 -3
- package/types/engine/site-index.d.mts +35 -3
- package/types/engine/wikilinks.d.mts +22 -0
- package/types/hm3/default-item-art.d.mts +5 -6
- package/types/hm3/index.d.mts +1 -0
- package/types/hm3/infobox.d.mts +22 -0
- package/types/sohl/index.d.mts +1 -0
- package/types/sohl/infobox.d.mts +145 -0
package/engine/pdf-render.mjs
CHANGED
|
@@ -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, {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
730
|
-
out
|
|
731
|
-
out.push(
|
|
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
|
-
`#
|
|
783
|
-
`[${escapeTypst(entry
|
|
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
|
-
`#
|
|
802
|
-
`
|
|
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
|
*
|