@heroiclands/package-build 20.5.0 → 20.6.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 +12 -0
- package/docs/api.md +10 -10
- package/engine/pdf-render.mjs +99 -21
- package/package.json +1 -1
- package/types/engine/pdf-render.d.mts +27 -15
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @heroiclands/package-build
|
|
2
2
|
|
|
3
|
+
## 20.6.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- a2742cc: **PDF book outline** — a book's printed table of contents now lists only the
|
|
8
|
+
sections its document tree declares; every note stays reachable from the PDF
|
|
9
|
+
bookmarks panel, titled from its own name, without also cluttering the printed
|
|
10
|
+
contents. A note's own body headings never surface on either surface, so a note
|
|
11
|
+
opening with a heading that repeats its own title no longer shows a duplicate
|
|
12
|
+
entry in the sidebar. Every heading — printed, bookmarked or neither — is still
|
|
13
|
+
a working link target, including one an author never gave an explicit anchor.
|
|
14
|
+
|
|
3
15
|
## 20.5.0
|
|
4
16
|
|
|
5
17
|
### Minor Changes
|
package/docs/api.md
CHANGED
|
@@ -714,16 +714,16 @@ The document tree a PDF is built from, and the plan it resolves to (#316). The p
|
|
|
714
714
|
|
|
715
715
|
A note's markdown, and a document plan, rendered as Typst source. **This module emits text and reads nothing.** It takes markdown and a plan and returns a `.typ` document; the filesystem, the note bodies and the compiler that turns the result into a PDF all live in {@link module:engine/pdf-build}. That split is what lets the outline, the table of contents, every anchor and every link destination be asserted in a unit test with no renderer installed — which is most of what a book has to get right, and all of what a test can check without eyes.
|
|
716
716
|
|
|
717
|
-
| Export | Signature
|
|
718
|
-
| ----------------------- |
|
|
719
|
-
| `escapeTypst` | `function escapeTypst(text)`
|
|
720
|
-
| `escapeTypstString` | `function escapeTypstString(text)`
|
|
721
|
-
| `labelFor` | `function labelFor(anchor)`
|
|
722
|
-
| `createParser` | `function createParser(registry)`
|
|
723
|
-
| `markdownToTypst` | `function markdownToTypst(markdown, opts`
|
|
724
|
-
| `renderBook` | `renderBook({ plan, bodies, title, subtitle, front, fonts,
|
|
725
|
-
| `resolveDanglingLabels` | `function resolveDanglingLabels(source, findings`
|
|
726
|
-
| `iconNamesIn` | `function iconNamesIn(markdown)`
|
|
717
|
+
| Export | Signature | Returns | Use it when |
|
|
718
|
+
| ----------------------- | ---------------------------------------------------------------------- | ------------------------------------------------------------ | ------------------------------------------------------------------------ |
|
|
719
|
+
| `escapeTypst` | `function escapeTypst(text)` | {string} The same text, inert. | Escape literal text for Typst markup. |
|
|
720
|
+
| `escapeTypstString` | `function escapeTypstString(text)` | {string} The same value, quotable. | Escape a string going inside Typst string quotes, as a `#link` URL does. |
|
|
721
|
+
| `labelFor` | `function labelFor(anchor)` | {string} A Typst label name. | A Typst label, from a plan anchor. |
|
|
722
|
+
| `createParser` | `function createParser(registry)` | {object} A markdown-it instance. | A markdown-it configured to parse, not to render. |
|
|
723
|
+
| `markdownToTypst` | `function markdownToTypst(markdown, opts` | {string} Typst markup. | Render markdown as Typst content. |
|
|
724
|
+
| `renderBook` | `renderBook({ plan, bodies, title, subtitle, front, fonts, version })` | {string} A complete `.typ` document. | The whole book, as one Typst document. |
|
|
725
|
+
| `resolveDanglingLabels` | `function resolveDanglingLabels(source, findings` | {string} The same document, with no reference left dangling. | Point every internal link at a label the document actually declares. |
|
|
726
|
+
| `iconNamesIn` | `function iconNamesIn(markdown)` | {string[]} The names, in order of appearance, with repeats. | Every icon name a body uses, so a build can resolve them once. |
|
|
727
727
|
|
|
728
728
|
### `engine.pdfFonts`
|
|
729
729
|
|
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 { slugify } from "./content-slug.mjs";
|
|
67
68
|
|
|
68
69
|
/**
|
|
69
70
|
* Characters that mean something to Typst's markup parser.
|
|
@@ -170,7 +171,11 @@ export function markdownToTypst(markdown, opts = {}) {
|
|
|
170
171
|
anchorPrefix = "",
|
|
171
172
|
} = opts;
|
|
172
173
|
const tokens = md.parse(String(markdown ?? ""), {});
|
|
173
|
-
|
|
174
|
+
// One map for the whole body, not one per block: a heading inside a
|
|
175
|
+
// blockquote or a list item shares the entry's anchor namespace with every
|
|
176
|
+
// other heading in the same body, because `sectionLabel` scopes by entry
|
|
177
|
+
// rather than by container.
|
|
178
|
+
return renderTokens(tokens, { links, glyphs, headingOffset, anchorPrefix, seen: new Map() });
|
|
174
179
|
}
|
|
175
180
|
|
|
176
181
|
/**
|
|
@@ -209,15 +214,26 @@ function renderBlock(tokens, i, out, ctx) {
|
|
|
209
214
|
// Typst caps headings at a depth no book reaches by accident; going
|
|
210
215
|
// past it would be a compile error in the middle of a 2,500-entry
|
|
211
216
|
// run, so it clamps and keeps setting.
|
|
212
|
-
const level = Math.min(6, Number(token.tag.slice(1)) + ctx.headingOffset);
|
|
217
|
+
const level = Math.max(1, Math.min(6, Number(token.tag.slice(1)) + ctx.headingOffset));
|
|
213
218
|
const inline = tokens[i + 1];
|
|
214
219
|
// `## Appearance {#appearance}` declares an addressable section. The
|
|
215
220
|
// journals compiler strips the suffix and surfaces it as an anchor;
|
|
216
221
|
// so does this, because a book that printed the braces would show
|
|
217
222
|
// every reader the markup that makes a link work.
|
|
218
223
|
const { text, anchor } = splitHeadingAnchor(inline, ctx);
|
|
219
|
-
|
|
220
|
-
|
|
224
|
+
// A heading with no authored anchor still needs a link target, so one
|
|
225
|
+
// is derived from its own text. `anchorFor` keeps it from colliding
|
|
226
|
+
// with an authored anchor, or with another derived one, that lands on
|
|
227
|
+
// the same words later in the same entry.
|
|
228
|
+
const base = anchor || slugify(plainHeadingText(inline)) || "heading";
|
|
229
|
+
const unique = anchorFor(base, ctx.seen);
|
|
230
|
+
const label = ` <${sectionLabel(ctx.anchorPrefix, unique)}>`;
|
|
231
|
+
// A body heading is never printed and never bookmarked — it is
|
|
232
|
+
// structure a reader reaches only by following a link, not a
|
|
233
|
+
// destination either outline offers on its own.
|
|
234
|
+
out.push(
|
|
235
|
+
`\n#heading(level: ${level}, outlined: false, bookmarked: false)[${text}]${label}\n\n`,
|
|
236
|
+
);
|
|
221
237
|
return 3;
|
|
222
238
|
}
|
|
223
239
|
case "paragraph_open": {
|
|
@@ -285,6 +301,44 @@ function splitHeadingAnchor(inline, ctx) {
|
|
|
285
301
|
return { text: renderInline({ ...inline, children }, ctx), anchor: match[2] };
|
|
286
302
|
}
|
|
287
303
|
|
|
304
|
+
/**
|
|
305
|
+
* A heading's text, unescaped and with any `{#anchor}` suffix still attached.
|
|
306
|
+
*
|
|
307
|
+
* Used only to derive an anchor when the author wrote none, so it wants the
|
|
308
|
+
* words as typed rather than the Typst-escaped, suffix-stripped text
|
|
309
|
+
* {@link splitHeadingAnchor} renders — {@link module:engine/content-slug.slugify}
|
|
310
|
+
* normalises punctuation and case itself and has no use for an escape
|
|
311
|
+
* backslash.
|
|
312
|
+
*
|
|
313
|
+
* @param {object} inline - The heading's `inline` token.
|
|
314
|
+
* @returns {string} The heading's raw text.
|
|
315
|
+
*/
|
|
316
|
+
function plainHeadingText(inline) {
|
|
317
|
+
const children = inline?.children ?? [];
|
|
318
|
+
return children.map((child) => child.content ?? "").join("");
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* A unique anchor within one render pass, suffixed when the base repeats.
|
|
323
|
+
*
|
|
324
|
+
* A derived anchor is only as good as its uniqueness: two headings reading
|
|
325
|
+
* "Notes" in one entry, or a derived "description" landing on an author's own
|
|
326
|
+
* `{#description}`, would otherwise give one label two meanings. First use of
|
|
327
|
+
* a base anchor keeps it exactly as written or slugified; every later use in
|
|
328
|
+
* the same body is suffixed, in the order headings are walked — which is
|
|
329
|
+
* stable across rebuilds because the body's markdown is.
|
|
330
|
+
*
|
|
331
|
+
* @param {string} base - The preferred anchor.
|
|
332
|
+
* @param {Map<string, number>} seen - How many times each base has been used,
|
|
333
|
+
* scoped to one call to {@link markdownToTypst}.
|
|
334
|
+
* @returns {string} The anchor.
|
|
335
|
+
*/
|
|
336
|
+
function anchorFor(base, seen) {
|
|
337
|
+
const n = (seen.get(base) ?? 0) + 1;
|
|
338
|
+
seen.set(base, n);
|
|
339
|
+
return n === 1 ? base : `${base}-${n}`;
|
|
340
|
+
}
|
|
341
|
+
|
|
288
342
|
/**
|
|
289
343
|
* A label for an anchor declared inside a note.
|
|
290
344
|
*
|
|
@@ -612,25 +666,39 @@ function renderIcon(token, ctx) {
|
|
|
612
666
|
* with no filesystem and no compiler. {@link module:engine/pdf-build} supplies
|
|
613
667
|
* both and runs Typst over the result.
|
|
614
668
|
*
|
|
615
|
-
* ##
|
|
669
|
+
* ## Three surfaces, one heading tree
|
|
616
670
|
*
|
|
617
671
|
* A roster of 2,500 entries wants every entry reachable from a viewer's
|
|
618
672
|
* sidebar, and emphatically does not want all 2,500 printed in the front
|
|
619
|
-
* matter: that is forty pages of contents before the book starts.
|
|
620
|
-
*
|
|
621
|
-
*
|
|
622
|
-
*
|
|
623
|
-
*
|
|
624
|
-
*
|
|
625
|
-
*
|
|
673
|
+
* matter: that is forty pages of contents before the book starts. It also
|
|
674
|
+
* wants every heading in a note's own body to keep working as a link target,
|
|
675
|
+
* without appearing on either surface — the anchor an author writes for
|
|
676
|
+
* `[[note#appearance]]` is structure, not a destination either outline offers
|
|
677
|
+
* on its own.
|
|
678
|
+
*
|
|
679
|
+
* `heading` carries `outlined` and `bookmarked` independently, so the three
|
|
680
|
+
* wants are three settings rather than three passes:
|
|
681
|
+
*
|
|
682
|
+
* - A **section** — `outlined: true, bookmarked: true` — prints in the paper
|
|
683
|
+
* contents and the PDF sidebar alike.
|
|
684
|
+
* - A **note leaf**, titled from `name.full`, is `outlined: false,
|
|
685
|
+
* bookmarked: true`: reachable from the sidebar, absent from the printed
|
|
686
|
+
* contents.
|
|
687
|
+
* - A **body heading**, inside a note's own markdown, is `outlined: false,
|
|
688
|
+
* bookmarked: false`: a real heading with a label, so it still supplies a
|
|
689
|
+
* link target, a running head and a page break, but neither outline lists
|
|
690
|
+
* it. {@link markdownToTypst} emits these.
|
|
691
|
+
*
|
|
692
|
+
* `#outline()` needs no depth limit under this model: what prints is decided
|
|
693
|
+
* per heading, not by how deep the tree happens to go.
|
|
626
694
|
*
|
|
627
695
|
* ## Headings carry the structure, so nothing else has to
|
|
628
696
|
*
|
|
629
697
|
* Every section, every prose file and every entry is a real Typst heading at
|
|
630
|
-
* its plan depth. That single decision supplies
|
|
631
|
-
*
|
|
632
|
-
*
|
|
633
|
-
*
|
|
698
|
+
* its plan depth. That single decision supplies both outlines, the running
|
|
699
|
+
* heads and the page breaks at once — where drawing titles as styled text
|
|
700
|
+
* would have meant building all four by hand and keeping them agreeing with
|
|
701
|
+
* each other.
|
|
634
702
|
*
|
|
635
703
|
* @param {object} opts - Options.
|
|
636
704
|
* @param {object} opts.plan - From {@link module:engine/pdf-toc.planDocument}.
|
|
@@ -640,7 +708,6 @@ function renderIcon(token, ctx) {
|
|
|
640
708
|
* @param {string} [opts.subtitle] - Shown under it on the title page.
|
|
641
709
|
* @param {string[]} [opts.front] - Rendered Typst for each front-matter file.
|
|
642
710
|
* @param {object} [opts.fonts] - `{ serif, sans, mono }` family names.
|
|
643
|
-
* @param {number} [opts.tocDepth] - How deep the *printed* contents go.
|
|
644
711
|
* @param {string} [opts.version] - Stamped on the title page when given.
|
|
645
712
|
* @returns {string} A complete `.typ` document.
|
|
646
713
|
*/
|
|
@@ -651,7 +718,6 @@ export function renderBook({
|
|
|
651
718
|
subtitle = "",
|
|
652
719
|
front = [],
|
|
653
720
|
fonts = {},
|
|
654
|
-
tocDepth = 2,
|
|
655
721
|
version = "",
|
|
656
722
|
} = {}) {
|
|
657
723
|
const serif = fonts.serif || "Libertinus Serif";
|
|
@@ -700,7 +766,9 @@ export function renderBook({
|
|
|
700
766
|
out.push("");
|
|
701
767
|
}
|
|
702
768
|
|
|
703
|
-
|
|
769
|
+
// No `depth:` limit: what prints is decided per heading by `outlined`,
|
|
770
|
+
// below, not by how deep the plan's tree happens to go.
|
|
771
|
+
out.push("#outline(title: [Contents])");
|
|
704
772
|
out.push("#pagebreak()");
|
|
705
773
|
out.push("");
|
|
706
774
|
|
|
@@ -708,7 +776,12 @@ export function renderBook({
|
|
|
708
776
|
const label = labelFor(entry.anchor);
|
|
709
777
|
const depth = Math.min(6, Math.max(1, Number(entry.depth) || 1));
|
|
710
778
|
if (entry.kind === "section") {
|
|
711
|
-
|
|
779
|
+
// A declared `sectionName:` — the structure the printed contents
|
|
780
|
+
// shows and the bookmarks panel shows alongside it.
|
|
781
|
+
out.push(
|
|
782
|
+
`#heading(level: ${depth}, outlined: true, bookmarked: true)` +
|
|
783
|
+
`[${escapeTypst(entry.title)}] <${label}>`,
|
|
784
|
+
);
|
|
712
785
|
out.push("");
|
|
713
786
|
continue;
|
|
714
787
|
}
|
|
@@ -721,8 +794,13 @@ export function renderBook({
|
|
|
721
794
|
out.push("");
|
|
722
795
|
continue;
|
|
723
796
|
}
|
|
797
|
+
// A note leaf: reachable from the bookmarks panel, titled from
|
|
798
|
+
// `name.full`, and never printed in the paper contents.
|
|
724
799
|
const name = entry.record?.name?.full ?? entry.record?.address?.slug ?? "(untitled)";
|
|
725
|
-
out.push(
|
|
800
|
+
out.push(
|
|
801
|
+
`#heading(level: ${Math.min(6, depth + 1)}, outlined: false, bookmarked: true)` +
|
|
802
|
+
`[${escapeTypst(name)}] <${label}>`,
|
|
803
|
+
);
|
|
726
804
|
out.push("");
|
|
727
805
|
const body = bodies.get(entry.anchor);
|
|
728
806
|
if (body) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@heroiclands/package-build",
|
|
3
|
-
"version": "20.
|
|
3
|
+
"version": "20.6.0",
|
|
4
4
|
"description": "Shared toolchain for building and shipping a HeroicLands Foundry VTT package — content compilation, manifest, localization, staging, bundle, release and deployment.",
|
|
5
5
|
"license": "GPL-3.0-or-later",
|
|
6
6
|
"type": "module",
|
|
@@ -70,25 +70,39 @@ export function markdownToTypst(markdown: string, opts?: {
|
|
|
70
70
|
* with no filesystem and no compiler. {@link module:engine/pdf-build} supplies
|
|
71
71
|
* both and runs Typst over the result.
|
|
72
72
|
*
|
|
73
|
-
* ##
|
|
73
|
+
* ## Three surfaces, one heading tree
|
|
74
74
|
*
|
|
75
75
|
* A roster of 2,500 entries wants every entry reachable from a viewer's
|
|
76
76
|
* sidebar, and emphatically does not want all 2,500 printed in the front
|
|
77
|
-
* matter: that is forty pages of contents before the book starts.
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
77
|
+
* matter: that is forty pages of contents before the book starts. It also
|
|
78
|
+
* wants every heading in a note's own body to keep working as a link target,
|
|
79
|
+
* without appearing on either surface — the anchor an author writes for
|
|
80
|
+
* `[[note#appearance]]` is structure, not a destination either outline offers
|
|
81
|
+
* on its own.
|
|
82
|
+
*
|
|
83
|
+
* `heading` carries `outlined` and `bookmarked` independently, so the three
|
|
84
|
+
* wants are three settings rather than three passes:
|
|
85
|
+
*
|
|
86
|
+
* - A **section** — `outlined: true, bookmarked: true` — prints in the paper
|
|
87
|
+
* contents and the PDF sidebar alike.
|
|
88
|
+
* - A **note leaf**, titled from `name.full`, is `outlined: false,
|
|
89
|
+
* bookmarked: true`: reachable from the sidebar, absent from the printed
|
|
90
|
+
* contents.
|
|
91
|
+
* - A **body heading**, inside a note's own markdown, is `outlined: false,
|
|
92
|
+
* bookmarked: false`: a real heading with a label, so it still supplies a
|
|
93
|
+
* link target, a running head and a page break, but neither outline lists
|
|
94
|
+
* it. {@link markdownToTypst} emits these.
|
|
95
|
+
*
|
|
96
|
+
* `#outline()` needs no depth limit under this model: what prints is decided
|
|
97
|
+
* per heading, not by how deep the tree happens to go.
|
|
84
98
|
*
|
|
85
99
|
* ## Headings carry the structure, so nothing else has to
|
|
86
100
|
*
|
|
87
101
|
* Every section, every prose file and every entry is a real Typst heading at
|
|
88
|
-
* its plan depth. That single decision supplies
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
102
|
+
* its plan depth. That single decision supplies both outlines, the running
|
|
103
|
+
* heads and the page breaks at once — where drawing titles as styled text
|
|
104
|
+
* would have meant building all four by hand and keeping them agreeing with
|
|
105
|
+
* each other.
|
|
92
106
|
*
|
|
93
107
|
* @param {object} opts - Options.
|
|
94
108
|
* @param {object} opts.plan - From {@link module:engine/pdf-toc.planDocument}.
|
|
@@ -98,18 +112,16 @@ export function markdownToTypst(markdown: string, opts?: {
|
|
|
98
112
|
* @param {string} [opts.subtitle] - Shown under it on the title page.
|
|
99
113
|
* @param {string[]} [opts.front] - Rendered Typst for each front-matter file.
|
|
100
114
|
* @param {object} [opts.fonts] - `{ serif, sans, mono }` family names.
|
|
101
|
-
* @param {number} [opts.tocDepth] - How deep the *printed* contents go.
|
|
102
115
|
* @param {string} [opts.version] - Stamped on the title page when given.
|
|
103
116
|
* @returns {string} A complete `.typ` document.
|
|
104
117
|
*/
|
|
105
|
-
export function renderBook({ plan, bodies, title, subtitle, front, fonts,
|
|
118
|
+
export function renderBook({ plan, bodies, title, subtitle, front, fonts, version, }?: {
|
|
106
119
|
plan: object;
|
|
107
120
|
bodies: Map<string, string>;
|
|
108
121
|
title: string;
|
|
109
122
|
subtitle?: string | undefined;
|
|
110
123
|
front?: string[] | undefined;
|
|
111
124
|
fonts?: object | undefined;
|
|
112
|
-
tocDepth?: number | undefined;
|
|
113
125
|
version?: string | undefined;
|
|
114
126
|
}): string;
|
|
115
127
|
/**
|