@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 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 | 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, tocDepth, 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. |
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
 
@@ -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
- return renderTokens(tokens, { links, glyphs, headingOffset, anchorPrefix });
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
- const label = anchor ? ` <${sectionLabel(ctx.anchorPrefix, anchor)}>` : "";
220
- out.push(`\n${"=".repeat(Math.max(1, level))} ${text}${label}\n\n`);
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
- * ## Two outlines, and why they are not the same outline
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
- * Typst separates the two for us. **The PDF bookmark outline is built from
622
- * every heading**, so each entry gets its own node at its own depth for free
623
- * and the sidebar is the navigational interface the issue asks for.
624
- * **`#outline()` prints only to `tocDepth`**, so the paper table of contents
625
- * stays the sections. Both are page-numbered and both are links.
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 the bookmarks, the printed
631
- * contents, the running heads and the page breaks at once — where drawing
632
- * titles as styled text would have meant building all four by hand and keeping
633
- * them agreeing with each other.
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
- out.push(`#outline(title: [Contents], depth: ${Math.max(1, Number(tocDepth) || 2)})`);
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
- out.push(`${"=".repeat(depth)} ${escapeTypst(entry.title)} <${label}>`);
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(`${"=".repeat(Math.min(6, depth + 1))} ${escapeTypst(name)} <${label}>`);
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.5.0",
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
- * ## Two outlines, and why they are not the same outline
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
- * Typst separates the two for us. **The PDF bookmark outline is built from
80
- * every heading**, so each entry gets its own node at its own depth for free
81
- * and the sidebar is the navigational interface the issue asks for.
82
- * **`#outline()` prints only to `tocDepth`**, so the paper table of contents
83
- * stays the sections. Both are page-numbered and both are links.
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 the bookmarks, the printed
89
- * contents, the running heads and the page breaks at once — where drawing
90
- * titles as styled text would have meant building all four by hand and keeping
91
- * them agreeing with each other.
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, tocDepth, version, }?: {
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
  /**