fix(make-pdf): stop content before the first H1 from forcing a blank page 1 (#1904)

`make-pdf` emitted a spurious blank/near-blank first page whenever any content
preceded the first <h1>. Two triggers:

1. YAML frontmatter — marked has no frontmatter awareness, so a leading
   `---\n...\n---` block rendered as a literal paragraph of body text.
2. A leading top-of-file <style> block — invisible, but still produced an
   empty first page.

Root cause: `wrapChaptersByH1()` wraps everything before the first <h1> in a
preamble `.chapter`. That section becomes `:first-of-type`, claiming the
`break-before: auto` exception, so the first *real* chapter inherits
`break-before: page` and starts on page 2. `preamble.trim().length > 0` was
true even when the preamble was only non-rendering markup.

Fix:
- Strip a leading YAML frontmatter block before `marked.parse` (anchored to the
  document start, so a `---` thematic break elsewhere is untouched).
- Don't give a non-rendering preamble (style/script/comment-only) its own
  chapter. Fold the markup into the first real chapter so its styling still
  applies but it no longer forces a page break.

Adds render-unit regression tests for both triggers, the "real text preamble
still gets its own chapter" invariant, and a non-frontmatter `---` thematic
break. All fail on main (2 chapters / leaked frontmatter), pass with the fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jayesh Betala 2026-06-08 13:32:48 +05:30
parent d8c91c6267
commit ced81536fe
2 changed files with 82 additions and 4 deletions

View File

@ -57,8 +57,10 @@ export interface RenderResult {
* Pure renderer. No side effects.
*/
export function render(opts: RenderOptions): RenderResult {
// 1. Markdown → HTML
const rawHtml = marked.parse(opts.markdown, { async: false }) as string;
// 1. Markdown → HTML (strip a leading YAML frontmatter block first; marked
// has no frontmatter awareness and would otherwise render it as a literal
// paragraph of body text on its own first page).
const rawHtml = marked.parse(stripFrontmatter(opts.markdown), { async: false }) as string;
// 2. Sanitize
const cleanHtml = sanitizeUntrustedHtml(rawHtml);
@ -311,17 +313,51 @@ function wrapChaptersByH1(html: string): string {
}
const chunks: string[] = [];
const preamble = html.slice(0, matches[0]);
// A preamble that renders nothing visible (a leading <style> block, an HTML
// comment) must NOT become its own .chapter. That section would take the
// `.chapter:first-of-type { break-before: auto }` exception, so the first
// *real* chapter inherits `break-before: page` and starts on page 2 — leaving
// a blank page 1. Keep the non-rendering markup (so its styling still applies)
// but fold it into the first real chapter instead of giving it a page break.
let carriedPreamble = "";
if (preamble.trim().length > 0) {
chunks.push(`<section class="chapter">${preamble}</section>`);
if (stripNonRendering(preamble).trim().length > 0) {
chunks.push(`<section class="chapter">${preamble}</section>`);
} else {
carriedPreamble = preamble;
}
}
for (let i = 0; i < matches.length; i++) {
const start = matches[i];
const end = i + 1 < matches.length ? matches[i + 1] : html.length;
chunks.push(`<section class="chapter">${html.slice(start, end)}</section>`);
const body = i === 0 ? carriedPreamble + html.slice(start, end) : html.slice(start, end);
chunks.push(`<section class="chapter">${body}</section>`);
}
return chunks.join("\n");
}
/**
* Strip leading YAML frontmatter (`---\n...\n---`). Only a block at the very
* start of the document is removed, so a `---` thematic break elsewhere is
* untouched.
*/
function stripFrontmatter(md: string): string {
return md.replace(/^---[ \t]*\r?\n[\s\S]*?\r?\n---[ \t]*(?:\r?\n|$)/, "");
}
/**
* Remove non-rendering markup (style/script blocks, HTML comments) so an
* otherwise-empty preamble is recognized as visually empty. Used only to decide
* whether a preamble deserves its own page the original markup is preserved in
* the output.
*/
function stripNonRendering(html: string): string {
return html
.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, "")
.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, "")
.replace(/<!--[\s\S]*?-->/g, "");
}
function extractFirstHeading(html: string): string | null {
const m = html.match(/<h1\b[^>]*>([\s\S]*?)<\/h1>/i);
return m ? decodeTextEntities(stripTags(m[1]).trim()) : null;

View File

@ -249,6 +249,48 @@ describe("render (end-to-end)", () => {
expect(result.printCss).toContain("Hiragino Kaku Gothic");
expect(result.printCss).toContain("Noto Sans CJK");
});
// ─── blank-first-page regression (#1904) ────────────────────────
// Any content before the first <h1> used to become a standalone preamble
// .chapter. That section takes the `:first-of-type { break-before: auto }`
// exception, so the first real chapter inherits `break-before: page` and
// starts on page 2 — leaving a blank/near-blank page 1.
test("a leading <style> preamble does not get its own chapter (no blank page 1)", () => {
const result = render({ markdown: `<style>h1{color:#000}</style>\n\n# Hello\n\nBody.\n` });
const chapters = result.html.match(/class="chapter"/g) ?? [];
// One chapter, not two — the invisible <style> no longer forces a page break.
expect(chapters.length).toBe(1);
// ...and the style is preserved (folded into the first chapter, not dropped).
expect(result.html).toContain("<style>h1{color:#000}</style>");
expect(result.html).toMatch(/<section class="chapter"><style>[\s\S]*?<\/style>[\s\S]*?<h1/);
});
test("leading YAML frontmatter is stripped, not rendered as body text", () => {
const result = render({
markdown: `---\ntitle: My Doc\ntype: memo\ntags: [a, b]\n---\n\n# Hello\n\nBody.\n`,
});
// Frontmatter keys must not leak into the rendered body.
expect(result.html).not.toContain("type: memo");
expect(result.html).not.toContain("tags: [a, b]");
// No preamble chapter -> single chapter, first H1 on page 1.
const chapters = result.html.match(/class="chapter"/g) ?? [];
expect(chapters.length).toBe(1);
expect(result.meta.title).toBe("Hello");
});
test("a real text preamble still gets its own chapter (unchanged behavior)", () => {
const result = render({ markdown: `Intro paragraph.\n\n# Hello\n\nBody.\n` });
const chapters = result.html.match(/class="chapter"/g) ?? [];
expect(chapters.length).toBe(2);
});
test("a `---` thematic break that is not frontmatter is left intact", () => {
// Opening with visible text means the later `---` is a real <hr>, not frontmatter.
const result = render({ markdown: `# Hello\n\nBefore.\n\n---\n\nAfter.\n` });
expect(result.html).toContain("<hr>");
expect(result.html).toContain("After.");
});
});
// ─── print-css ──────────────────────────────────────────────