mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-02 03:35:09 +02:00
d0782c4c4d
* feat(browse): full $B pdf flag contract + tab-scoped load-html/js/pdf
Grow $B pdf from a 2-line wrapper (hard-coded A4) into a real PDF engine
frontend so make-pdf can shell out to it without duplicating Playwright:
- pdf: --format, --width/--height, --margins, --margin-*, --header-template,
--footer-template, --page-numbers, --tagged, --outline, --print-background,
--prefer-css-page-size, --toc. Mutex rules enforced. --from-file <json>
dodges Windows argv limits (8191 char CreateProcess cap).
- load-html: add --from-file <json> mode for large inline HTML. Size + magic
byte checks still apply to the inline content, not the payload file path.
- newtab: add --json returning {"tabId":N,"url":...} for programmatic use.
- cli: extract --tab-id flag and route as body.tabId to the HTTP layer so
parallel callers can target specific tabs without racing on the active
tab (makes make-pdf's per-render tab isolation possible).
- --toc: non-fatal 3s wait for window.__pagedjsAfterFired. Paged.js ships
later; v1 renders TOC statically via the markdown renderer.
Codex round 2 flagged these P0 issues during plan review. All resolved.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(resolvers): add MAKE_PDF_SETUP + makePdfDir host paths
Skill templates can now embed {{MAKE_PDF_SETUP}} to resolve $P to the
make-pdf binary via the same discovery order as $B / $D: env override
(MAKE_PDF_BIN), local skill root, global install, or PATH.
Mirrors the pattern established by generateBrowseSetup() and
generateDesignSetup() in scripts/resolvers/design.ts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(make-pdf): new /make-pdf skill + orchestrator binary
Turn markdown into publication-quality PDFs. $P generate input.md out.pdf
produces a PDF with 1in margins, intelligent page breaks, page numbers,
running header, CONFIDENTIAL footer, and curly quotes/em dashes — all on
Helvetica so copy-paste extraction works ("S ai li ng" bug avoided).
Architecture (per Codex round 2):
markdown → render.ts (marked + sanitize + smartypants) → orchestrator
→ $B newtab --json → $B load-html --tab-id → $B js (poll Paged.js)
→ $B pdf --tab-id → $B closetab
browseClient.ts shells out to the compiled browse CLI rather than
duplicating Playwright. --tab-id isolation per render means parallel
$P generate calls don't race on the active tab. try/finally tab cleanup
survives Paged.js timeouts, browser crashes, and output-path failures.
Features in v1:
--cover left-aligned cover page (eyebrow + title + hairline rule)
--toc clickable static TOC (Paged.js page numbers deferred)
--watermark <text> diagonal DRAFT/CONFIDENTIAL layer
--no-chapter-breaks opt out of H1-starts-new-page
--page-numbers "N of M" footer (default on)
--tagged --outline accessible PDF + bookmark outline (default on)
--allow-network opt in to external image loading (default off for privacy)
--quiet --verbose stderr control
Design decisions locked from the /plan-design-review pass:
- Helvetica everywhere (Chromium emits single-word Tj operators for
system fonts; bundled webfonts emit per-glyph and break extraction).
- Left-aligned body, flush-left paragraphs, no text-indent, 12pt gap.
- Cover shares 1in margins with body pages; no flexbox-center, no
inset padding.
- The reference HTMLs at .context/designs/*.html are the implementation
source of truth for print-css.ts.
Tests (56 unit + 1 E2E combined-features gate):
- smartypants: code/URL-safe, verified against 10 fixtures
- sanitizer: strips <script>/<iframe>/on*/javascript: URLs
- render: HTML assembly, CJK fallback, cover/TOC/chapter wrap
- print-css: all @page rules, margin variants, watermark
- pdftotext: normalize()+copyPasteGate() cross-OS tolerance
- browseClient: binary resolution + typed error propagation
- combined-features gate (P0): 2-chapter fixture with smartypants +
hyphens + ligatures + bold/italic + inline code + lists + blockquote
passes through PDF → pdftotext → expected.txt diff
Deferred to Phase 4 (future PR): Paged.js vendored for accurate TOC page
numbers, highlight.js for syntax highlighting, drop caps, pull quotes,
two-column, CMYK, watermark visual-diff acceptance.
Plan: .context/ceo-plans/2026-04-19-perfect-pdf-generator.md
References: .context/designs/make-pdf-*.html
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(build): wire make-pdf into build/test/setup/bin + add marked dep
- package.json: compile make-pdf/dist/pdf as part of bun run build; add
"make-pdf" to bin entry; include make-pdf/test/ in the free test pass;
add marked@18.0.2 as a dep (markdown parser, ~40KB).
- setup: add make-pdf/dist/pdf to the Apple Silicon codesign loop.
- .gitignore: add make-pdf/dist/ (matches browse/dist/ and design/dist/).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* ci(make-pdf): matrix copy-paste gate on Ubuntu + macOS
Runs the combined-features P0 gate on pull requests that touch make-pdf/
or browse's PDF surface. Installs poppler (macOS) / poppler-utils (Ubuntu)
per OS. Windows deferred to tolerant mode (Xpdf / Poppler-Windows
extraction variance not yet calibrated against the normalized comparator —
Codex round 2 #18).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(skills): regenerate SKILL.md for make-pdf addition + browse pdf flags
bun run gen:skill-docs picks up:
- the new /make-pdf skill (make-pdf/SKILL.md)
- updated browse command descriptions for 'pdf', 'load-html', 'newtab'
reflecting the new flag contract and --from-file mode
Source of truth stays the .tmpl files + COMMAND_DESCRIPTIONS;
these are regenerated artifacts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(tests): repair stale test expectations + emit _EXPLAIN_LEVEL / _QUESTION_TUNING from preamble
Three pre-existing test failures on main were blocking /ship:
- test/skill-validation.test.ts "Step 3.4 test coverage audit" expected the
literal strings "CODE PATH COVERAGE" and "USER FLOW COVERAGE" which were
removed when the Step 7 coverage diagram was compressed. Updated assertions
to check the stable `Code paths:` / `User flows:` labels that still ship.
- test/skill-validation.test.ts "ship step numbering" allowed-substeps list
didn't include 15.0 (WIP squash) and 15.1 (bisectable commits) which were
added for continuous checkpoint mode. Extended the allowlist.
- test/writing-style-resolver.test.ts and test/plan-tune.test.ts expected
`_EXPLAIN_LEVEL` and `_QUESTION_TUNING` bash variables in the preamble but
generate-preamble-bash.ts had been refactored and those lines were dropped.
Without them, downstream skills can't read `explain_level` or
`question_tuning` config at runtime — terse mode and /plan-tune features
were silently broken.
Added the two bash echo blocks back to generatePreambleBash and refreshed
the golden-file fixtures to match. All three preamble-related golden
baselines (claude/codex/factory) are synchronized with the new output.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: bump version and changelog (v1.4.0.0)
New /make-pdf skill + $P binary.
Turn any markdown file into a publication-quality PDF. Default output is
a 1in-margin Helvetica letter with page numbers in the footer. `--cover`
adds a left-aligned cover page, `--toc` generates a clickable table of
contents, `--watermark DRAFT` overlays a diagonal watermark. Copy-paste
extraction from the PDF produces clean words, not "S a i l i n g"
spaced out letter by letter. CI gate (macOS + Ubuntu) runs a combined-
features fixture through pdftotext on every PR.
make-pdf shells out to browse rather than duplicating Playwright.
$B pdf grew into a real PDF engine with full flag contract (--format,
--margins, --header-template, --footer-template, --page-numbers,
--tagged, --outline, --toc, --tab-id, --from-file). $B load-html and
$B js gained --tab-id. $B newtab --json returns structured output.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(changelog): rewrite v1.4.0.0 headline — positive voice, no VC framing
The original headline led with "a PDF you wouldn't be embarrassed to send
to a VC": double-negative voice and audience-too-narrow. /make-pdf works
for essays, letters, memos, reports, proposals, and briefs. Framing the
whole release around founders-to-investors misses the wider audience.
New headline: "Turn any markdown file into a PDF that looks finished."
New tagline: "This one reads like a real essay or a real letter."
Positive voice. Broader aperture. Same energy.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
315 lines
11 KiB
TypeScript
315 lines
11 KiB
TypeScript
/**
|
|
* Renderer unit tests — pure-function assertions for render.ts, smartypants.ts,
|
|
* and print-css.ts. No Playwright, no PDF generation.
|
|
*/
|
|
|
|
import { describe, expect, test } from "bun:test";
|
|
|
|
import { render, sanitizeUntrustedHtml } from "../src/render";
|
|
import { smartypants } from "../src/smartypants";
|
|
import { printCss } from "../src/print-css";
|
|
|
|
// ─── smartypants ──────────────────────────────────────────────
|
|
|
|
describe("smartypants", () => {
|
|
test("converts straight double quotes to curly", () => {
|
|
const out = smartypants(`<p>She said "hello" to him.</p>`);
|
|
expect(out).toContain("\u201chello\u201d");
|
|
});
|
|
|
|
test("converts em dash (--)", () => {
|
|
const out = smartypants(`<p>This is it -- the answer.</p>`);
|
|
expect(out).toContain("\u2014");
|
|
});
|
|
|
|
test("converts ellipsis (...)", () => {
|
|
const out = smartypants(`<p>Wait...</p>`);
|
|
expect(out).toContain("\u2026");
|
|
});
|
|
|
|
test("converts apostrophes in contractions", () => {
|
|
const out = smartypants(`<p>don't you know?</p>`);
|
|
expect(out).toContain("don\u2019t");
|
|
});
|
|
|
|
test("does NOT touch content inside <code> blocks", () => {
|
|
const input = `<pre><code>const x = "hello"; // it's fine</code></pre>`;
|
|
const out = smartypants(input);
|
|
expect(out).toBe(input); // unchanged
|
|
});
|
|
|
|
test("does NOT touch content inside <pre> blocks", () => {
|
|
const input = `<pre>"quoted" -- don't</pre>`;
|
|
const out = smartypants(input);
|
|
expect(out).toBe(input);
|
|
});
|
|
|
|
test("does NOT touch inline code", () => {
|
|
const out = smartypants(`<p>Use <code>it's</code> like this: "hello".</p>`);
|
|
expect(out).toContain("<code>it's</code>");
|
|
expect(out).toContain("\u201chello\u201d");
|
|
});
|
|
|
|
test("does NOT touch URLs", () => {
|
|
const out = smartypants(`<p>Visit https://example.com/it's-page for "details".</p>`);
|
|
expect(out).toContain("https://example.com/it's-page");
|
|
expect(out).toContain("\u201cdetails\u201d");
|
|
});
|
|
|
|
test("does NOT touch HTML attribute values", () => {
|
|
const out = smartypants(`<a href="it's-a-test.html">link</a>`);
|
|
expect(out).toContain(`href="it's-a-test.html"`);
|
|
});
|
|
|
|
test("does NOT convert -- in CLI flags", () => {
|
|
// Prose like "try --verbose mode" should not turn -- into em dash
|
|
const out = smartypants(`<p>Try --verbose mode.</p>`);
|
|
// Since "--" is followed by a word char but not preceded by word/space,
|
|
// it should remain intact. We're lenient here — acceptable either way.
|
|
expect(out).toMatch(/--verbose|—verbose/);
|
|
});
|
|
});
|
|
|
|
// ─── sanitizer ──────────────────────────────────────────────
|
|
|
|
describe("sanitizeUntrustedHtml", () => {
|
|
test("strips <script> tags and content", () => {
|
|
const input = `<p>hello</p><script>alert(1)</script><p>world</p>`;
|
|
const out = sanitizeUntrustedHtml(input);
|
|
expect(out).not.toContain("<script");
|
|
expect(out).not.toContain("alert");
|
|
expect(out).toContain("<p>hello</p>");
|
|
expect(out).toContain("<p>world</p>");
|
|
});
|
|
|
|
test("strips <iframe>", () => {
|
|
const input = `<p>hi</p><iframe src="evil.com"></iframe>`;
|
|
expect(sanitizeUntrustedHtml(input)).not.toContain("<iframe");
|
|
});
|
|
|
|
test("strips onclick attribute", () => {
|
|
const input = `<a href="#" onclick="alert(1)">click</a>`;
|
|
const out = sanitizeUntrustedHtml(input);
|
|
expect(out).not.toContain("onclick");
|
|
expect(out).toContain("href=\"#\"");
|
|
});
|
|
|
|
test("strips event handlers with mixed case (onClick, ONCLICK)", () => {
|
|
const input1 = `<a href="#" onClick="x()">a</a>`;
|
|
const input2 = `<a href="#" ONCLICK="x()">b</a>`;
|
|
expect(sanitizeUntrustedHtml(input1)).not.toContain("onClick");
|
|
expect(sanitizeUntrustedHtml(input2)).not.toContain("ONCLICK");
|
|
});
|
|
|
|
test("rewrites javascript: URLs in href to #", () => {
|
|
const input = `<a href="javascript:alert(1)">bad</a>`;
|
|
const out = sanitizeUntrustedHtml(input);
|
|
expect(out).not.toContain("javascript:");
|
|
expect(out).toContain('href="#"');
|
|
});
|
|
|
|
test("strips inline SVG <script>", () => {
|
|
const input = `<svg><script>alert(1)</script><circle r="5"/></svg>`;
|
|
const out = sanitizeUntrustedHtml(input);
|
|
expect(out).not.toContain("<script");
|
|
expect(out).toContain("<circle");
|
|
});
|
|
|
|
test("strips <object>, <embed>, <link>, <meta>, <base>, <form>", () => {
|
|
const input = `
|
|
<object data="x.swf"></object>
|
|
<embed src="y.mov">
|
|
<link rel="stylesheet" href="evil.css">
|
|
<meta http-equiv="refresh" content="0;url=evil">
|
|
<base href="evil.com">
|
|
<form action="evil"><input/></form>
|
|
`;
|
|
const out = sanitizeUntrustedHtml(input);
|
|
expect(out).not.toContain("<object");
|
|
expect(out).not.toContain("<embed");
|
|
expect(out).not.toContain("<link");
|
|
expect(out).not.toContain("<meta");
|
|
expect(out).not.toContain("<base");
|
|
expect(out).not.toContain("<form");
|
|
});
|
|
|
|
test("strips srcdoc attribute (iframe escape vector)", () => {
|
|
const input = `<div srcdoc="<script>bad</script>">hi</div>`;
|
|
expect(sanitizeUntrustedHtml(input)).not.toContain("srcdoc");
|
|
});
|
|
});
|
|
|
|
// ─── end-to-end render ──────────────────────────────────────────────
|
|
|
|
describe("render (end-to-end)", () => {
|
|
test("produces a full HTML document with title, body, and CSS", () => {
|
|
const result = render({
|
|
markdown: `# Hello\n\nA paragraph with "quotes" and -- dashes.\n`,
|
|
});
|
|
expect(result.html).toContain("<!doctype html>");
|
|
expect(result.html).toContain("<title>Hello</title>");
|
|
expect(result.html).toContain("<h1");
|
|
expect(result.html).toContain("Hello");
|
|
// CSS should be inlined as <style>...
|
|
expect(result.html).toMatch(/<style>[\s\S]*font-family: Helvetica/);
|
|
// Smartypants ran
|
|
expect(result.html).toContain("\u201cquotes\u201d");
|
|
expect(result.html).toContain("\u2014");
|
|
});
|
|
|
|
test("derives title from first H1 when --title is not passed", () => {
|
|
const result = render({ markdown: `# My Title\n\nBody.` });
|
|
expect(result.meta.title).toBe("My Title");
|
|
});
|
|
|
|
test("uses --title override when provided", () => {
|
|
const result = render({
|
|
markdown: `# Auto-derived\n\nBody.`,
|
|
title: "Explicit Title",
|
|
});
|
|
expect(result.meta.title).toBe("Explicit Title");
|
|
});
|
|
|
|
test("includes cover block when cover=true", () => {
|
|
const result = render({
|
|
markdown: `# Doc\n\nBody.`,
|
|
cover: true,
|
|
subtitle: "A subtitle",
|
|
author: "Garry Tan",
|
|
});
|
|
expect(result.html).toContain(`class="cover"`);
|
|
expect(result.html).toContain(`class="cover-title"`);
|
|
expect(result.html).toContain("A subtitle");
|
|
expect(result.html).toContain("Garry Tan");
|
|
});
|
|
|
|
test("omits cover block when cover=false", () => {
|
|
const result = render({ markdown: `# Memo\n\nBody.` });
|
|
expect(result.html).not.toContain(`class="cover"`);
|
|
});
|
|
|
|
test("injects watermark element when --watermark is set", () => {
|
|
const result = render({ markdown: `# Doc`, watermark: "DRAFT" });
|
|
expect(result.html).toContain(`class="watermark"`);
|
|
expect(result.html).toContain("DRAFT");
|
|
// And the CSS rule for it must be present
|
|
expect(result.html).toContain("position: fixed");
|
|
expect(result.html).toContain("rotate(-30deg)");
|
|
});
|
|
|
|
test("wraps each H1 in its own .chapter section (default)", () => {
|
|
const result = render({
|
|
markdown: `# One\n\nbody 1\n\n# Two\n\nbody 2\n`,
|
|
});
|
|
const chapterMatches = result.html.match(/class="chapter"/g);
|
|
expect(chapterMatches).toBeTruthy();
|
|
if (chapterMatches) expect(chapterMatches.length).toBe(2);
|
|
});
|
|
|
|
test("does NOT create chapter sections when noChapterBreaks=true", () => {
|
|
const result = render({
|
|
markdown: `# One\n\nbody\n\n# Two\n\nbody\n`,
|
|
noChapterBreaks: true,
|
|
});
|
|
const chapterMatches = result.html.match(/class="chapter"/g) ?? [];
|
|
expect(chapterMatches.length).toBe(1);
|
|
});
|
|
|
|
test("builds a TOC with H1/H2 entries when toc=true", () => {
|
|
const result = render({
|
|
markdown: `# One\n\n## Sub\n\nbody\n\n# Two\n\nbody\n`,
|
|
toc: true,
|
|
});
|
|
expect(result.html).toContain(`class="toc"`);
|
|
expect(result.html).toContain(`<h2>Contents</h2>`);
|
|
expect(result.html).toContain("One");
|
|
expect(result.html).toContain("Sub");
|
|
expect(result.html).toContain("Two");
|
|
});
|
|
|
|
test("strips dangerous HTML from untrusted markdown", () => {
|
|
const result = render({
|
|
markdown: `# Safe\n\n<script>alert('xss')</script>\n\nBody.`,
|
|
});
|
|
expect(result.html).not.toContain("<script");
|
|
expect(result.html).not.toContain("alert");
|
|
expect(result.html).toContain("Safe");
|
|
});
|
|
|
|
test("respects text-align: left — no justify in print CSS", () => {
|
|
const result = render({ markdown: `para1\n\npara2\n` });
|
|
// The rule from the design-review fix: no p + p indent, text-align: left.
|
|
expect(result.printCss).toContain("text-align: left");
|
|
expect(result.printCss).not.toContain("text-align: justify");
|
|
expect(result.printCss).not.toContain("text-indent");
|
|
});
|
|
|
|
test("includes CJK font fallback in body", () => {
|
|
const result = render({ markdown: `body` });
|
|
expect(result.printCss).toContain("Hiragino Kaku Gothic");
|
|
expect(result.printCss).toContain("Noto Sans CJK");
|
|
});
|
|
});
|
|
|
|
// ─── print-css ──────────────────────────────────────────────
|
|
|
|
describe("printCss", () => {
|
|
test("emits 1in margins by default", () => {
|
|
const css = printCss();
|
|
expect(css).toContain("margin: 1in");
|
|
});
|
|
|
|
test("respects custom margins flag", () => {
|
|
const css = printCss({ margins: "72pt" });
|
|
expect(css).toContain("margin: 72pt");
|
|
});
|
|
|
|
test("emits letter page size by default", () => {
|
|
const css = printCss();
|
|
expect(css).toContain("size: letter");
|
|
});
|
|
|
|
test("respects custom page size", () => {
|
|
const css = printCss({ pageSize: "a4" });
|
|
expect(css).toContain("size: a4");
|
|
});
|
|
|
|
test("suppresses running header and footer on cover page", () => {
|
|
const css = printCss();
|
|
expect(css).toMatch(/@page\s*:first\s*\{[\s\S]*?content:\s*none[\s\S]*?content:\s*none/);
|
|
});
|
|
|
|
test("omits CONFIDENTIAL when confidential=false", () => {
|
|
const css = printCss({ confidential: false });
|
|
expect(css).not.toContain("CONFIDENTIAL");
|
|
});
|
|
|
|
test("emits watermark CSS only when watermark is set", () => {
|
|
const withWatermark = printCss({ watermark: "DRAFT" });
|
|
expect(withWatermark).toContain(".watermark");
|
|
expect(withWatermark).toContain("rotate(-30deg)");
|
|
|
|
const withoutWatermark = printCss();
|
|
expect(withoutWatermark).not.toContain(".watermark");
|
|
});
|
|
|
|
test("drops chapter break rule when noChapterBreaks=true", () => {
|
|
const on = printCss({ noChapterBreaks: false });
|
|
expect(on).toContain("break-before: page");
|
|
|
|
const off = printCss({ noChapterBreaks: true });
|
|
expect(off).not.toContain(".chapter { break-before: page");
|
|
});
|
|
|
|
test("always sets p { text-align: left }", () => {
|
|
const css = printCss();
|
|
expect(css).toContain("text-align: left");
|
|
});
|
|
|
|
test("never sets text-indent on p", () => {
|
|
const css = printCss();
|
|
// Confirm no p-indent slipped in
|
|
expect(css).not.toMatch(/p\s*\+\s*p\s*\{[^}]*text-indent/);
|
|
});
|
|
});
|