@heroiclands/package-build 20.7.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.
- package/CHANGELOG.md +159 -0
- package/CONTENT.md +134 -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 +381 -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 +208 -9
- package/engine/pdf-render.mjs +461 -21
- 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 +99 -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,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, {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
730
|
-
out
|
|
731
|
-
out.push(
|
|
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
|
-
`#
|
|
783
|
-
`
|
|
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
|
-
`#
|
|
802
|
-
`
|
|
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
|
*
|