Files
gstack/make-pdf/src/browseClient.ts
T
Garry Tan d0782c4c4d feat(v1.4.0.0): /make-pdf — markdown to publication-quality PDFs (#1086)
* 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>
2026-04-20 13:20:30 +08:00

327 lines
10 KiB
TypeScript

/**
* Typed shell-out wrapper for the browse CLI.
*
* Every browse call goes through this file. Reasons:
* - One place to do binary resolution.
* - One place to enforce the --from-file convention for large payloads
* (Windows argv cap is 8191 chars; 200KB HTML dies without this).
* - One place that maps non-zero exit codes to typed errors.
*
* Binary resolution order (Codex round 2 #4):
* 1. $BROWSE_BIN env override
* 2. sibling dir: dirname(argv[0])/../browse/dist/browse
* 3. ~/.claude/skills/gstack/browse/dist/browse
* 4. PATH lookup: `browse`
* 5. error with setup hint
*/
import { execFileSync } from "node:child_process";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import * as crypto from "node:crypto";
import { BrowseClientError } from "./types";
export interface LoadHtmlOptions {
html: string; // raw HTML string
waitUntil?: "load" | "domcontentloaded" | "networkidle";
tabId: number;
}
export interface PdfOptions {
output: string;
tabId: number;
format?: string;
width?: string;
height?: string;
marginTop?: string;
marginRight?: string;
marginBottom?: string;
marginLeft?: string;
headerTemplate?: string;
footerTemplate?: string;
pageNumbers?: boolean;
tagged?: boolean;
outline?: boolean;
printBackground?: boolean;
preferCSSPageSize?: boolean;
toc?: boolean;
}
export interface JsOptions {
tabId: number;
expression: string; // JS expression to evaluate
}
/**
* Locate the browse binary. Throws a BrowseClientError with a
* canonical setup message if not found.
*/
export function resolveBrowseBin(): string {
const envOverride = process.env.BROWSE_BIN;
if (envOverride && isExecutable(envOverride)) return envOverride;
// Sibling: look relative to this process's binary
// (for when make-pdf and browse live next to each other in dist/)
const selfDir = path.dirname(process.argv[0]);
const siblingCandidates = [
path.resolve(selfDir, "../browse/dist/browse"),
path.resolve(selfDir, "../../browse/dist/browse"),
path.resolve(selfDir, "../browse"),
];
for (const candidate of siblingCandidates) {
if (isExecutable(candidate)) return candidate;
}
// Global install
const home = os.homedir();
const globalPath = path.join(home, ".claude/skills/gstack/browse/dist/browse");
if (isExecutable(globalPath)) return globalPath;
// PATH lookup
try {
const which = execFileSync("which", ["browse"], { encoding: "utf8" }).trim();
if (which && isExecutable(which)) return which;
} catch {
// `which` exited non-zero; fall through to error
}
throw new BrowseClientError(
/* exitCode */ 127,
"resolve",
[
"browse binary not found.",
"",
"make-pdf needs browse (the gstack Chromium daemon) to render PDFs.",
"Tried:",
` - $BROWSE_BIN (${envOverride || "unset"})`,
` - sibling: ${siblingCandidates.join(", ")}`,
` - global: ${globalPath}`,
" - PATH: `browse`",
"",
"To fix: run gstack setup from the gstack repo:",
" cd ~/.claude/skills/gstack && ./setup",
"",
"Or set BROWSE_BIN explicitly:",
" export BROWSE_BIN=/path/to/browse",
].join("\n"),
);
}
function isExecutable(p: string): boolean {
try {
fs.accessSync(p, fs.constants.X_OK);
return true;
} catch {
return false;
}
}
/**
* Run a browse command. Returns stdout on success.
* Throws BrowseClientError on non-zero exit.
*/
function runBrowse(args: string[]): string {
const bin = resolveBrowseBin();
try {
return execFileSync(bin, args, {
encoding: "utf8",
maxBuffer: 16 * 1024 * 1024, // 16MB; tab content can be large
stdio: ["ignore", "pipe", "pipe"],
});
} catch (err: any) {
const exitCode = typeof err.status === "number" ? err.status : 1;
const stderr = typeof err.stderr === "string"
? err.stderr
: (err.stderr?.toString() ?? "");
throw new BrowseClientError(exitCode, args[0] || "unknown", stderr);
}
}
/**
* Write a payload to a tmp file and return the path. Used for any payload
* >4KB to avoid Windows argv limits (Codex round 2 #3).
*/
function writePayloadFile(payload: Record<string, unknown>): string {
const hash = crypto.createHash("sha256")
.update(JSON.stringify(payload))
.digest("hex")
.slice(0, 12);
const tmpPath = path.join(os.tmpdir(), `make-pdf-browse-${process.pid}-${hash}.json`);
fs.writeFileSync(tmpPath, JSON.stringify(payload), "utf8");
return tmpPath;
}
function cleanupPayloadFile(p: string): void {
try { fs.unlinkSync(p); } catch { /* best-effort */ }
}
// ─── Public API ─────────────────────────────────────────────────
/**
* Open a new tab. Returns the tabId.
* Requires `$B newtab --json` to be available (added in the browse flag
* extension for this feature). If --json isn't supported yet, the fallback
* parses "Opened tab N" from stdout.
*/
export function newtab(url?: string): number {
const args = ["newtab"];
if (url) args.push(url);
// Try --json first (preferred path for programmatic use)
try {
const out = runBrowse([...args, "--json"]);
const parsed = JSON.parse(out);
if (typeof parsed.tabId === "number") return parsed.tabId;
} catch {
// Fall back to stdout-string parsing. Brittle, but works on older browse builds.
}
const out = runBrowse(args);
const m = out.match(/tab\s+(\d+)/i);
if (!m) throw new BrowseClientError(1, "newtab", `could not parse tab id from: ${out}`);
return parseInt(m[1], 10);
}
/**
* Close a tab (by id or the active tab).
*/
export function closetab(tabId?: number): void {
const args = ["closetab"];
if (tabId !== undefined) args.push(String(tabId));
runBrowse(args);
}
/**
* Load raw HTML into a specific tab.
* Uses --from-file for any payload >4KB (Codex round 2 #3).
*/
export function loadHtml(opts: LoadHtmlOptions): void {
// Always use --from-file to dodge argv limits. The HTML is almost always >4KB.
const payload = {
html: opts.html,
waitUntil: opts.waitUntil ?? "domcontentloaded",
};
const payloadFile = writePayloadFile(payload);
try {
runBrowse([
"load-html",
"--from-file", payloadFile,
"--tab-id", String(opts.tabId),
]);
} finally {
cleanupPayloadFile(payloadFile);
}
}
/**
* Evaluate a JS expression in a tab. Returns the serialized result as string.
*/
export function js(opts: JsOptions): string {
return runBrowse([
"js",
opts.expression,
"--tab-id", String(opts.tabId),
]).trim();
}
/**
* Poll a boolean JS expression until it evaluates to true, or timeout.
* Returns true if it succeeded, false if timed out.
*/
export function waitForExpression(opts: {
expression: string;
tabId: number;
timeoutMs: number;
pollIntervalMs?: number;
}): boolean {
const poll = opts.pollIntervalMs ?? 200;
const deadline = Date.now() + opts.timeoutMs;
while (Date.now() < deadline) {
try {
const result = js({ expression: opts.expression, tabId: opts.tabId });
if (result === "true") return true;
} catch {
// Tab may still be loading; keep polling
}
const wait = Math.min(poll, Math.max(0, deadline - Date.now()));
if (wait <= 0) break;
// Synchronous sleep is fine — this only runs once per PDF render
const end = Date.now() + wait;
while (Date.now() < end) { /* busy wait */ }
}
return false;
}
/**
* Generate a PDF from the given tab. Uses --from-file when header/footer
* templates are present (they can be HTML strings of arbitrary size).
*/
export function pdf(opts: PdfOptions): void {
// If any large payload is present, send via --from-file
const hasLargePayload =
(opts.headerTemplate && opts.headerTemplate.length > 1024) ||
(opts.footerTemplate && opts.footerTemplate.length > 1024);
if (hasLargePayload) {
const payloadFile = writePayloadFile({
output: opts.output,
tabId: opts.tabId,
...optionsToPdfFlags(opts),
});
try {
runBrowse(["pdf", "--from-file", payloadFile]);
} finally {
cleanupPayloadFile(payloadFile);
}
return;
}
// Small payload: pass flags via argv
const args = ["pdf", opts.output, "--tab-id", String(opts.tabId)];
pushFlagsFromOptions(args, opts);
runBrowse(args);
}
function optionsToPdfFlags(opts: PdfOptions): Record<string, unknown> {
// Shape mirrors what the browse `pdf` case expects when reading --from-file
const out: Record<string, unknown> = {};
if (opts.format) out.format = opts.format;
if (opts.width) out.width = opts.width;
if (opts.height) out.height = opts.height;
if (opts.marginTop) out.marginTop = opts.marginTop;
if (opts.marginRight) out.marginRight = opts.marginRight;
if (opts.marginBottom) out.marginBottom = opts.marginBottom;
if (opts.marginLeft) out.marginLeft = opts.marginLeft;
if (opts.headerTemplate !== undefined) out.headerTemplate = opts.headerTemplate;
if (opts.footerTemplate !== undefined) out.footerTemplate = opts.footerTemplate;
if (opts.pageNumbers !== undefined) out.pageNumbers = opts.pageNumbers;
if (opts.tagged !== undefined) out.tagged = opts.tagged;
if (opts.outline !== undefined) out.outline = opts.outline;
if (opts.printBackground !== undefined) out.printBackground = opts.printBackground;
if (opts.preferCSSPageSize !== undefined) out.preferCSSPageSize = opts.preferCSSPageSize;
if (opts.toc !== undefined) out.toc = opts.toc;
return out;
}
function pushFlagsFromOptions(args: string[], opts: PdfOptions): void {
if (opts.format) { args.push("--format", opts.format); }
if (opts.width) { args.push("--width", opts.width); }
if (opts.height) { args.push("--height", opts.height); }
if (opts.marginTop) { args.push("--margin-top", opts.marginTop); }
if (opts.marginRight) { args.push("--margin-right", opts.marginRight); }
if (opts.marginBottom) { args.push("--margin-bottom", opts.marginBottom); }
if (opts.marginLeft) { args.push("--margin-left", opts.marginLeft); }
if (opts.headerTemplate !== undefined) {
args.push("--header-template", opts.headerTemplate);
}
if (opts.footerTemplate !== undefined) {
args.push("--footer-template", opts.footerTemplate);
}
if (opts.pageNumbers === true) args.push("--page-numbers");
if (opts.tagged === true) args.push("--tagged");
if (opts.outline === true) args.push("--outline");
if (opts.printBackground === true) args.push("--print-background");
if (opts.preferCSSPageSize === true) args.push("--prefer-css-page-size");
if (opts.toc === true) args.push("--toc");
}