mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-05 05:05:08 +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>
257 lines
8.5 KiB
TypeScript
257 lines
8.5 KiB
TypeScript
#!/usr/bin/env bun
|
|
/**
|
|
* make-pdf CLI — argv parse, dispatch, exit.
|
|
*
|
|
* Output contract (per CEO plan DX spec):
|
|
* stdout: ONLY the output path on success. One line. Nothing else.
|
|
* stderr: progress spinner per stage, final "Done in Xs. N pages."
|
|
* --quiet: suppress progress. Errors still print.
|
|
* --verbose: per-stage timings.
|
|
* exit 0 success / 1 bad args / 2 render error / 3 Paged.js timeout / 4 browse unavailable.
|
|
*/
|
|
|
|
import { COMMANDS } from "./commands";
|
|
import { ExitCode, BrowseClientError } from "./types";
|
|
import type { GenerateOptions, PreviewOptions } from "./types";
|
|
|
|
interface ParsedArgs {
|
|
command: string;
|
|
positional: string[];
|
|
flags: Record<string, string | boolean>;
|
|
}
|
|
|
|
function parseArgs(argv: string[]): ParsedArgs {
|
|
const args = argv.slice(2);
|
|
if (args.length === 0) {
|
|
printUsage();
|
|
process.exit(ExitCode.Success);
|
|
}
|
|
|
|
// First non-flag arg is the command.
|
|
let command = "";
|
|
const positional: string[] = [];
|
|
const flags: Record<string, string | boolean> = {};
|
|
|
|
for (let i = 0; i < args.length; i++) {
|
|
const a = args[i];
|
|
if (a.startsWith("--")) {
|
|
const key = a.slice(2);
|
|
const next = args[i + 1];
|
|
if (next !== undefined && !next.startsWith("--")) {
|
|
flags[key] = next;
|
|
i++;
|
|
} else {
|
|
flags[key] = true;
|
|
}
|
|
} else if (!command) {
|
|
command = a;
|
|
} else {
|
|
positional.push(a);
|
|
}
|
|
}
|
|
|
|
return { command, positional, flags };
|
|
}
|
|
|
|
function printUsage(): void {
|
|
const lines = [
|
|
"make-pdf — turn markdown into publication-quality PDFs",
|
|
"",
|
|
"Usage:",
|
|
];
|
|
for (const [name, info] of COMMANDS) {
|
|
lines.push(` $P ${info.usage}`);
|
|
lines.push(` ${info.description}`);
|
|
}
|
|
lines.push("");
|
|
lines.push("Page layout:");
|
|
lines.push(" --margins <dim> All four margins (default: 1in). in, pt, cm, mm.");
|
|
lines.push(" --page-size letter|a4|legal (aliases: --format)");
|
|
lines.push("");
|
|
lines.push("Document structure:");
|
|
lines.push(" --cover Add a cover page.");
|
|
lines.push(" --toc Generate clickable table of contents.");
|
|
lines.push(" --no-chapter-breaks Don't start a new page at every H1.");
|
|
lines.push("");
|
|
lines.push("Branding:");
|
|
lines.push(" --watermark <text> Diagonal watermark on every page.");
|
|
lines.push(" --header-template <html>");
|
|
lines.push(" --footer-template <html> Mutex with --page-numbers.");
|
|
lines.push(" --no-confidential Suppress the CONFIDENTIAL footer.");
|
|
lines.push("");
|
|
lines.push("Output control:");
|
|
lines.push(" --page-numbers / --no-page-numbers (default: on)");
|
|
lines.push(" --tagged / --no-tagged (default: on, accessible PDF)");
|
|
lines.push(" --outline / --no-outline (default: on, PDF bookmarks)");
|
|
lines.push(" --quiet Suppress progress on stderr.");
|
|
lines.push(" --verbose Per-stage timings on stderr.");
|
|
lines.push("");
|
|
lines.push("Network:");
|
|
lines.push(" --allow-network Load external images (off by default).");
|
|
lines.push("");
|
|
lines.push("Examples:");
|
|
lines.push(" $P generate letter.md");
|
|
lines.push(" $P generate --cover --toc essay.md essay.pdf");
|
|
lines.push(" $P generate --watermark DRAFT memo.md draft.pdf");
|
|
lines.push(" $P preview letter.md");
|
|
lines.push("");
|
|
lines.push("Run `$P setup` to verify browse + Chromium + pdftotext install.");
|
|
console.error(lines.join("\n"));
|
|
}
|
|
|
|
function generateOptionsFromFlags(parsed: ParsedArgs): GenerateOptions {
|
|
const p = parsed.positional;
|
|
if (p.length === 0) {
|
|
console.error("$P generate: missing <input.md>");
|
|
console.error("Usage: $P generate <input.md> [output.pdf] [options]");
|
|
process.exit(ExitCode.BadArgs);
|
|
}
|
|
const f = parsed.flags;
|
|
const booleanFlag = (key: string, def: boolean): boolean => {
|
|
if (f[key] === true) return true;
|
|
if (f[`no-${key}`] === true) return false;
|
|
return def;
|
|
};
|
|
return {
|
|
input: p[0],
|
|
output: p[1],
|
|
margins: f.margins as string | undefined,
|
|
marginTop: f["margin-top"] as string | undefined,
|
|
marginRight: f["margin-right"] as string | undefined,
|
|
marginBottom: f["margin-bottom"] as string | undefined,
|
|
marginLeft: f["margin-left"] as string | undefined,
|
|
pageSize: ((f["page-size"] ?? f.format) as any),
|
|
cover: f.cover === true,
|
|
toc: f.toc === true,
|
|
noChapterBreaks: f["no-chapter-breaks"] === true,
|
|
watermark: typeof f.watermark === "string" ? f.watermark : undefined,
|
|
headerTemplate: typeof f["header-template"] === "string"
|
|
? f["header-template"] : undefined,
|
|
footerTemplate: typeof f["footer-template"] === "string"
|
|
? f["footer-template"] : undefined,
|
|
confidential: booleanFlag("confidential", true),
|
|
pageNumbers: booleanFlag("page-numbers", true),
|
|
tagged: booleanFlag("tagged", true),
|
|
outline: booleanFlag("outline", true),
|
|
quiet: f.quiet === true,
|
|
verbose: f.verbose === true,
|
|
allowNetwork: f["allow-network"] === true,
|
|
title: typeof f.title === "string" ? f.title : undefined,
|
|
author: typeof f.author === "string" ? f.author : undefined,
|
|
date: typeof f.date === "string" ? f.date : undefined,
|
|
};
|
|
}
|
|
|
|
function previewOptionsFromFlags(parsed: ParsedArgs): PreviewOptions {
|
|
const p = parsed.positional;
|
|
if (p.length === 0) {
|
|
console.error("$P preview: missing <input.md>");
|
|
console.error("Usage: $P preview <input.md> [options]");
|
|
process.exit(ExitCode.BadArgs);
|
|
}
|
|
const f = parsed.flags;
|
|
const booleanFlag = (key: string, def: boolean): boolean => {
|
|
if (f[key] === true) return true;
|
|
if (f[`no-${key}`] === true) return false;
|
|
return def;
|
|
};
|
|
return {
|
|
input: p[0],
|
|
cover: f.cover === true,
|
|
toc: f.toc === true,
|
|
watermark: typeof f.watermark === "string" ? f.watermark : undefined,
|
|
noChapterBreaks: f["no-chapter-breaks"] === true,
|
|
confidential: booleanFlag("confidential", true),
|
|
allowNetwork: f["allow-network"] === true,
|
|
title: typeof f.title === "string" ? f.title : undefined,
|
|
author: typeof f.author === "string" ? f.author : undefined,
|
|
date: typeof f.date === "string" ? f.date : undefined,
|
|
quiet: f.quiet === true,
|
|
verbose: f.verbose === true,
|
|
};
|
|
}
|
|
|
|
async function main(): Promise<void> {
|
|
const parsed = parseArgs(process.argv);
|
|
|
|
if (!parsed.command) {
|
|
printUsage();
|
|
process.exit(ExitCode.BadArgs);
|
|
}
|
|
|
|
if (!COMMANDS.has(parsed.command)) {
|
|
console.error(`$P: unknown command: ${parsed.command}`);
|
|
console.error("");
|
|
printUsage();
|
|
process.exit(ExitCode.BadArgs);
|
|
}
|
|
|
|
try {
|
|
switch (parsed.command) {
|
|
case "version": {
|
|
// Read from VERSION file or fall back to a hard-coded default.
|
|
try {
|
|
const fs = await import("node:fs");
|
|
const path = await import("node:path");
|
|
const versionFile = path.resolve(
|
|
path.dirname(process.argv[1] || ""),
|
|
"../../VERSION",
|
|
);
|
|
const version = fs.readFileSync(versionFile, "utf8").trim();
|
|
console.log(version);
|
|
} catch {
|
|
console.log("make-pdf (version unknown)");
|
|
}
|
|
process.exit(ExitCode.Success);
|
|
}
|
|
|
|
case "setup": {
|
|
const { runSetup } = await import("./setup");
|
|
await runSetup();
|
|
process.exit(ExitCode.Success);
|
|
}
|
|
|
|
case "generate": {
|
|
const opts = generateOptionsFromFlags(parsed);
|
|
const { generate } = await import("./orchestrator");
|
|
const outputPath = await generate(opts);
|
|
// Contract: stdout = output path only
|
|
console.log(outputPath);
|
|
process.exit(ExitCode.Success);
|
|
}
|
|
|
|
case "preview": {
|
|
const opts = previewOptionsFromFlags(parsed);
|
|
const { preview } = await import("./orchestrator");
|
|
const htmlPath = await preview(opts);
|
|
console.log(htmlPath);
|
|
process.exit(ExitCode.Success);
|
|
}
|
|
|
|
default:
|
|
// Unreachable: COMMANDS.has guarded above
|
|
process.exit(ExitCode.BadArgs);
|
|
}
|
|
} catch (err: any) {
|
|
if (err instanceof BrowseClientError) {
|
|
console.error(`$P: ${err.message}`);
|
|
process.exit(ExitCode.BrowseUnavailable);
|
|
}
|
|
if (err?.code === "ENOENT") {
|
|
console.error(`$P: file not found: ${err.path ?? err.message}`);
|
|
process.exit(ExitCode.BadArgs);
|
|
}
|
|
if (err?.name === "PagedJsTimeout") {
|
|
console.error(`$P: ${err.message}`);
|
|
process.exit(ExitCode.PagedJsTimeout);
|
|
}
|
|
console.error(`$P: ${err?.message ?? String(err)}`);
|
|
if (parsed.flags.verbose && err?.stack) {
|
|
console.error(err.stack);
|
|
}
|
|
process.exit(ExitCode.RenderError);
|
|
}
|
|
}
|
|
|
|
main();
|