portable-agent-layer 0.85.9 → 0.85.10

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.
@@ -87,7 +87,7 @@ Wraps `bun run dev` in the report directory. Open the URL printed by Next, edit
87
87
  ### 4. Render the PDF
88
88
 
89
89
  ```bash
90
- pal cli skill run consulting-report generate-pdf.mjs <report-dir>
90
+ pal cli skill run consulting-report generate-pdf <report-dir>
91
91
  ```
92
92
 
93
93
  Runs `next build` (which produces a static export at `out/`), then Playwright loads it via a tiny in-process HTTP server and prints the PDF with page-numbered header/footer. Output:
@@ -98,8 +98,6 @@ Runs `next build` (which produces a static export at `out/`), then Playwright lo
98
98
 
99
99
  Override with `--pdf <path>`. Pass `--skip-build` to re-render the PDF from the existing `out/` without re-building.
100
100
 
101
- Run with **Node**, not Bun — Playwright's `chromium.launch()` hangs under Bun on Windows.
102
-
103
101
  ## Directory Layout
104
102
 
105
103
  ```
@@ -149,14 +147,13 @@ Run with **Node**, not Bun — Playwright's `chromium.launch()` hangs under Bun
149
147
  ## Demo
150
148
 
151
149
  ```bash
152
- pal cli skill run consulting-report generate-pdf.mjs ~/.pal/skills/consulting-report/demo
150
+ pal cli skill run consulting-report generate-pdf ~/.pal/skills/consulting-report/demo
153
151
  ```
154
152
 
155
153
  Renders the bundled Acme Industries example end-to-end. Inspect the resulting PDF to see the full layout before authoring your own.
156
154
 
157
155
  ## Important
158
156
 
159
- - Runs on Node (Playwright); the tool ships as a compiled `.mjs`, and `pal cli skill run` picks Node for that extension so no `--experimental-strip-types` is needed
160
157
  - Bundled fonts come from Google Fonts via `next/font/google` — no licensing surface, no CDN at runtime, glyphs embedded at build time
161
158
  - Reports are disposable artifacts of `lib/report-data.ts` + `app/page.tsx`; commit the source, not the PDF
162
159
  - The scaffolder runs `bun install` inside the target by default — pass `--no-install` to skip
@@ -1,25 +1,18 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env bun
2
2
 
3
3
  // consulting-report skill tool: build the Next.js report and render it to PDF.
4
4
  // Pipeline: `next build` produces a static export at out/ — Playwright loads
5
5
  // out/index.html and prints a PDF with branded header/footer/page numbers.
6
6
  //
7
- // Run with Node (not Bun) — Playwright's chromium.launch() hangs under Bun on
8
- // Windows because it uses --remote-debugging-pipe over stdio and Bun's Windows
9
- // child-process pipe handling doesn't complete the CDP handshake.
10
- //
11
- // pal-build:mjs — ships as a compiled generate-pdf.mjs sibling (scripts/build-skill-tools.ts)
12
- // and is invoked as that .mjs: a .ts under node_modules can't be type-stripped by Node.
13
- //
14
7
  // Usage:
15
- // pal cli skill run consulting-report generate-pdf.mjs <report-dir> [--pdf <out>] [--html <out>] [--skip-build]
8
+ // pal cli skill run consulting-report generate-pdf <report-dir> [--pdf <out>] [--html <out>] [--skip-build]
16
9
 
17
10
  import { spawnSync } from "node:child_process";
18
- import { createReadStream, constants as fsConstants, realpathSync } from "node:fs";
11
+ import { createReadStream, constants as fsConstants } from "node:fs";
19
12
  import { access, readFile, stat, unlink, writeFile } from "node:fs/promises";
20
13
  import { createServer, type Server } from "node:http";
21
14
  import { extname, join, resolve } from "node:path";
22
- import { fileURLToPath, pathToFileURL } from "node:url";
15
+ import { pathToFileURL } from "node:url";
23
16
  import { PDFDocument } from "pdf-lib";
24
17
  import { chromium } from "playwright";
25
18
 
@@ -312,22 +305,4 @@ async function run(argv: string[] = process.argv.slice(2)): Promise<void> {
312
305
  console.log(`PDF: ${pdfPath} (${(pdfStat.size / 1024).toFixed(1)} KB)`);
313
306
  }
314
307
 
315
- // Node ≥ 22.6 doesn't expose import.meta.main; gate on argv[1] instead.
316
- // Use realpathSync on both sides so symlinked skill paths (e.g. ~/.pal/skills →
317
- // PAL repo) match the resolved import.meta.url.
318
- // fileURLToPath, never new URL().pathname: on Windows the latter yields
319
- // "/C:/Users/..." — leading slash, forward slashes — which can never equal argv[1]'s
320
- // "C:\Users\...", so the gate silently failed and run() never executed there.
321
- function realResolve(p: string): string {
322
- try {
323
- return realpathSync(resolve(p));
324
- } catch {
325
- return resolve(p);
326
- }
327
- }
328
- const isMain =
329
- process.argv[1] &&
330
- realResolve(process.argv[1]) === realResolve(fileURLToPath(import.meta.url));
331
- if (isMain) {
332
- await run();
333
- }
308
+ if (import.meta.main) await run();
@@ -100,7 +100,7 @@ async function run(argv: string[] = process.argv.slice(2)): Promise<void> {
100
100
  console.log(` 2. Edit lib/report-data.ts (metadata) and app/page.tsx (layout)`);
101
101
  console.log(` 3. Live preview: bun run dev`);
102
102
  console.log(
103
- ` 4. Generate PDF: pal cli skill run consulting-report generate-pdf.mjs ${opts.targetDir}`
103
+ ` 4. Generate PDF: pal cli skill run consulting-report generate-pdf ${opts.targetDir}`
104
104
  );
105
105
  }
106
106
 
@@ -68,13 +68,13 @@ Invoke the skill tool. Flags:
68
68
  Single-file example:
69
69
 
70
70
  ```bash
71
- pal cli skill run create-pdf md-to-html-pdf.mjs /path/to/report.md --pdf /path/to/report.pdf
71
+ pal cli skill run create-pdf md-to-html-pdf /path/to/report.md --pdf /path/to/report.pdf
72
72
  ```
73
73
 
74
74
  Multi-file example (after Step 2):
75
75
 
76
76
  ```bash
77
- pal cli skill run create-pdf md-to-html-pdf.mjs /tmp/combined.md --pdf /path/to/report.pdf --html /path/to/report.html
77
+ pal cli skill run create-pdf md-to-html-pdf /tmp/combined.md --pdf /path/to/report.pdf --html /path/to/report.html
78
78
  ```
79
79
 
80
80
  The tool writes the self-contained HTML (inline CSS, UTF-8) and the PDF, and prints both paths + sizes on stdout.
@@ -95,13 +95,13 @@ Default styling (A4, 25mm margins, GitHub-ish look, table-friendly, page-break-a
95
95
  - `--header <html|file>` / `--footer <html|file>` — running header/footer on every page. The value is either an inline HTML string or a path to an HTML file. Templates may use Playwright's injected classes: `pageNumber`, `totalPages`, `date`, `title`, `url`.
96
96
 
97
97
  ```bash
98
- pal cli skill run create-pdf md-to-html-pdf.mjs report.md --pdf report.pdf \
98
+ pal cli skill run create-pdf md-to-html-pdf report.md --pdf report.pdf \
99
99
  --margin 18mm \
100
100
  --header '<div style="font-size:9px;width:100%;text-align:center;color:#888">CONFIDENTIAL</div>' \
101
101
  --footer '<div style="font-size:9px;width:100%;text-align:right;padding-right:12mm;color:#888"><span class="pageNumber"></span>/<span class="totalPages"></span></div>'
102
102
  ```
103
103
 
104
- Header/footer templates **need an explicit `font-size`** (Playwright defaults them to 0) and render *inside* the page margin — widen `--margin` so they have room. Do NOT also add CSS `@page` margin-box rules; they duplicate. For deeper changes (fonts, base CSS), edit the `css` string in `tools/md-to-html-pdf.ts` (the `.mjs` is generated from it — never edit the `.mjs`).
104
+ Header/footer templates **need an explicit `font-size`** (Playwright defaults them to 0) and render *inside* the page margin — widen `--margin` so they have room. Do NOT also add CSS `@page` margin-box rules; they duplicate. For deeper changes (fonts, base CSS), edit the `css` string in `tools/md-to-html-pdf.ts`.
105
105
 
106
106
  ## Translation Variant
107
107
 
@@ -1,16 +1,9 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env bun
2
2
  // create-pdf skill tool: Markdown -> HTML (marked, GFM) -> PDF (Playwright).
3
3
  // Self-contained HTML: all CSS inlined, no CDN at render time.
4
4
  //
5
- // Run with Node (not Bun) — Playwright's chromium.launch hangs under Bun on Windows
6
- // because it uses --remote-debugging-pipe over stdio and Bun's Windows child-process
7
- // pipe handling doesn't complete the CDP handshake.
8
- //
9
- // pal-build:mjs — ships as a compiled md-to-html-pdf.mjs sibling (scripts/build-skill-tools.ts)
10
- // and is invoked as that .mjs: a .ts under node_modules can't be type-stripped by Node.
11
- //
12
5
  // Usage:
13
- // pal cli skill run create-pdf md-to-html-pdf.mjs <input.md> \
6
+ // pal cli skill run create-pdf md-to-html-pdf <input.md> \
14
7
  // [--html <out.html>] [--pdf <out.pdf>] [--margin <css>] [--header <html|file>] [--footer <html|file>]
15
8
  // --margin defaults to 25mm (all sides). --header/--footer accept inline HTML or a file path.
16
9
 
@@ -50,18 +50,6 @@ prefix_rule(
50
50
  ],
51
51
  )
52
52
 
53
- prefix_rule(
54
- pattern = ["node", "--experimental-strip-types", "~/.pal/skills/consulting-report/tools/generate-pdf.ts"],
55
- decision = "allow",
56
- justification = "PAL consulting-report PDF generation requires Node for Playwright compatibility",
57
- match = [
58
- "node --experimental-strip-types ~/.pal/skills/consulting-report/tools/generate-pdf.ts ./report",
59
- ],
60
- not_match = [
61
- "node ~/.pal/skills/consulting-report/tools/generate-pdf.ts ./report",
62
- ],
63
- )
64
-
65
53
  prefix_rule(
66
54
  pattern = ["pal", "cli", "project"],
67
55
  decision = "allow",
@@ -120,7 +108,7 @@ prefix_rule(
120
108
  justification = "A skill's own tools ship with PAL and are safe to run without repeated approval",
121
109
  match = [
122
110
  "pal cli skill run presentation build ./deck",
123
- "pal cli skill run consulting-report generate-pdf.mjs ./report",
111
+ "pal cli skill run consulting-report generate-pdf ./report",
124
112
  ],
125
113
  not_match = [
126
114
  "pal cli skill link my-skill",
@@ -34,7 +34,6 @@
34
34
  "Bash(rtk gain *)",
35
35
  "Bash(bun ~/.pal/skills/*/tools/*.ts *)",
36
36
  "Bash(bun ~/.pal/tools/*.ts *)",
37
- "Bash(node --experimental-strip-types ~/.pal/skills/consulting-report/tools/generate-pdf.ts *)",
38
37
  "Bash(pal cli skill run *)",
39
38
  "Bash(pal cli algorithm-reflect *)",
40
39
  "Bash(pal cli algorithm-synthesize *)",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portable-agent-layer",
3
- "version": "0.85.9",
3
+ "version": "0.85.10",
4
4
  "description": "PAL — Portable Agent Layer: persistent personal context for AI coding assistants",
5
5
  "type": "module",
6
6
  "bin": {
@@ -55,10 +55,9 @@
55
55
  "secretlint": "secretlint \"**/*\"",
56
56
  "lint-staged": "lint-staged",
57
57
  "prepare": "bun .husky/install.mjs",
58
- "build:skill-tools": "bun run scripts/build-skill-tools.ts",
59
58
  "build:ui": "bun run --filter @pal/control-room-ui build",
60
59
  "dev:ui": "bun run --filter @pal/control-room-ui dev",
61
- "prepack": "bun run build:skill-tools && bun run build:ui",
60
+ "prepack": "bun run build:ui",
62
61
  "install:all": "bun run src/cli/index.ts cli install",
63
62
  "uninstall": "bun run src/cli/index.ts cli uninstall",
64
63
  "tool:synthesize": "bun run src/tools/agent/synthesize.ts",
package/src/cli/index.ts CHANGED
@@ -700,39 +700,6 @@ function checkPlaywrightChromium(): boolean {
700
700
  }
701
701
  }
702
702
 
703
- interface NodeCheck {
704
- available: boolean;
705
- version?: string;
706
- meetsMinimum?: boolean;
707
- }
708
-
709
- // Minimum Node version with `--experimental-strip-types` is 22.6.0 — required
710
- // by the consulting-report skill, which runs under Node on Windows because
711
- // Playwright's chromium.launch() hangs under Bun.
712
- function checkNode(): NodeCheck {
713
- const minMajor = 22;
714
- const minMinor = 6;
715
- const result = checkTool("node");
716
- if (!result.available) return { available: false };
717
- const raw = (result.version || "").replace(/^v/, "");
718
- const [majorStr = "", minorStr = ""] = raw.split(".");
719
- const major = Number(majorStr);
720
- const minor = Number(minorStr);
721
- const meetsMinimum =
722
- Number.isFinite(major) &&
723
- Number.isFinite(minor) &&
724
- (major > minMajor || (major === minMajor && minor >= minMinor));
725
- return { available: true, version: raw, meetsMinimum };
726
- }
727
-
728
- function nodeInstallHint(): string {
729
- if (process.platform === "win32")
730
- return "install Node ≥ 22.6 (`winget install OpenJS.NodeJS.LTS` or https://nodejs.org)";
731
- if (process.platform === "darwin")
732
- return "install Node ≥ 22.6 (`brew install node` or https://nodejs.org)";
733
- return "install Node ≥ 22.6 (see https://nodejs.org or your package manager)";
734
- }
735
-
736
703
  function rtkInstallHint(): string {
737
704
  if (process.platform === "win32")
738
705
  return "download rtk.exe from https://github.com/rtk-ai/rtk/releases and add it to PATH";
@@ -806,18 +773,6 @@ function doctor(silent = false): DoctorResult {
806
773
  : fail(
807
774
  "pal — not on PATH; skills and docs invoke tools as 'pal cli ...', which will not resolve. Install globally: bun add -g portable-agent-layer"
808
775
  );
809
- const node = checkNode();
810
- if (!node.available) {
811
- warn(
812
- `Node — not found; consulting-report PDF skill will not work. ${nodeInstallHint()}`
813
- );
814
- } else if (!node.meetsMinimum) {
815
- warn(
816
- `Node ${node.version} — too old for consulting-report PDF skill (needs ≥ 22.6 for --experimental-strip-types). ${nodeInstallHint()}`
817
- );
818
- } else {
819
- ok(`Node ${node.version}`);
820
- }
821
776
  claude.available
822
777
  ? ok(`Claude Code ${claude.version || ""}`.trim())
823
778
  : fail("Claude Code — not found");
@@ -1201,20 +1156,6 @@ async function install(targets: Targets) {
1201
1156
  }
1202
1157
  }
1203
1158
 
1204
- // Node check — the consulting-report skill runs under `node --experimental-strip-types`
1205
- // because Playwright's chromium.launch() hangs under Bun on Windows (CDP handshake over
1206
- // stdio pipes). Node ≥ 22.6 is required for --experimental-strip-types.
1207
- const node = checkNode();
1208
- if (!node.available) {
1209
- log.warn(
1210
- `Node not found — consulting-report PDF skill will not work. ${nodeInstallHint()}`
1211
- );
1212
- } else if (!node.meetsMinimum) {
1213
- log.warn(
1214
- `Node ${node.version} is older than 22.6 — consulting-report PDF skill will not work (needs --experimental-strip-types). ${nodeInstallHint()}`
1215
- );
1216
- }
1217
-
1218
1159
  // Scaffold TELOS + PAL settings, then prompt for missing identity
1219
1160
  const { scaffoldTelos, scaffoldPalSettings, copyPalDocs, generateSkillIndex } =
1220
1161
  await import("../targets/lib");
package/src/cli/skill.ts CHANGED
@@ -76,11 +76,6 @@ function isPlainSegment(part: string): boolean {
76
76
  );
77
77
  }
78
78
 
79
- /** Playwright tools ship compiled as .mjs and need Node; everything else is Bun. */
80
- function runtimeFor(file: string): string {
81
- return file.endsWith(".mjs") ? "node" : "bun";
82
- }
83
-
84
79
  function toolFileName(tool: string): string {
85
80
  return new RegExp(/\.[a-z]+$/).test(tool) ? tool : `${tool}.ts`;
86
81
  }
@@ -96,7 +91,7 @@ function runSkillTool(skill: string, tool: string, toolArgs: string[]): number {
96
91
  log.error(`No tool '${file}' in skill '${skill}' — looked in ${path}`);
97
92
  return 1;
98
93
  }
99
- const { status } = spawnSync(runtimeFor(file), [path, ...toolArgs], {
94
+ const { status } = spawnSync("bun", [path, ...toolArgs], {
100
95
  stdio: "inherit",
101
96
  });
102
97
  return status ?? 1;
@@ -1,226 +0,0 @@
1
- import { spawnSync } from "node:child_process";
2
- import { createReadStream, constants as fsConstants, realpathSync } from "node:fs";
3
- import { access, readFile, stat, unlink, writeFile } from "node:fs/promises";
4
- import { createServer } from "node:http";
5
- import { extname, join, resolve } from "node:path";
6
- import { fileURLToPath, pathToFileURL } from "node:url";
7
- import { PDFDocument } from "pdf-lib";
8
- import { chromium } from "playwright";
9
- const COLOR = {
10
- navy: "#0f172a",
11
- blue: "#1d4ed8",
12
- red: "#dc2626"
13
- };
14
- async function exists(p) {
15
- try {
16
- await access(p, fsConstants.F_OK);
17
- return true;
18
- } catch {
19
- return false;
20
- }
21
- }
22
- function escapeHtml(s) {
23
- return s.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
24
- }
25
- function slugify(s) {
26
- return s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
27
- }
28
- async function logoDataUri(outDir, publicPath) {
29
- if (!publicPath)
30
- return null;
31
- const filePath = join(outDir, publicPath);
32
- if (!await exists(filePath))
33
- return null;
34
- const buf = await readFile(filePath);
35
- const ext = extname(filePath).toLowerCase();
36
- let mime = "image/jpeg";
37
- if (ext === ".svg") {
38
- mime = "image/svg+xml";
39
- } else if (ext === ".png") {
40
- mime = "image/png";
41
- }
42
- return `data:${mime};base64,${buf.toString("base64")}`;
43
- }
44
- async function loadMeta(reportDir) {
45
- const dataPath = join(reportDir, "lib", "report-data.ts");
46
- if (!await exists(dataPath)) {
47
- throw new Error(`lib/report-data.ts not found at ${dataPath}`);
48
- }
49
- const mod = await import(pathToFileURL(dataPath).href);
50
- if (!mod.reportData) {
51
- throw new Error(`lib/report-data.ts must export a named 'reportData' constant`);
52
- }
53
- return mod.reportData;
54
- }
55
- function buildNext(reportDir) {
56
- const result = spawnSync("bun", ["run", "build"], {
57
- cwd: reportDir,
58
- stdio: "inherit",
59
- shell: true
60
- });
61
- if (result.status !== 0) {
62
- throw new Error(`next build failed (exit ${result.status})`);
63
- }
64
- }
65
- function serveStatic(rootDir) {
66
- const MIME = {
67
- ".html": "text/html; charset=utf-8",
68
- ".css": "text/css; charset=utf-8",
69
- ".js": "application/javascript; charset=utf-8",
70
- ".json": "application/json; charset=utf-8",
71
- ".woff": "font/woff",
72
- ".woff2": "font/woff2",
73
- ".svg": "image/svg+xml",
74
- ".png": "image/png",
75
- ".jpg": "image/jpeg",
76
- ".jpeg": "image/jpeg",
77
- ".webp": "image/webp",
78
- ".ico": "image/x-icon"
79
- };
80
- return new Promise((res) => {
81
- const server = createServer((req, response) => {
82
- const urlPath = decodeURIComponent((req.url ?? "/").split("?")[0]);
83
- let filePath = join(rootDir, urlPath === "/" ? "/index.html" : urlPath);
84
- if (filePath.endsWith("/"))
85
- filePath = join(filePath, "index.html");
86
- const ext = extname(filePath).toLowerCase();
87
- response.setHeader("Content-Type", MIME[ext] ?? "application/octet-stream");
88
- const stream = createReadStream(filePath);
89
- stream.on("error", () => {
90
- response.statusCode = 404;
91
- response.end();
92
- });
93
- stream.pipe(response);
94
- });
95
- server.listen(0, "127.0.0.1", () => {
96
- const addr = server.address();
97
- const port = typeof addr === "object" && addr ? addr.port : 0;
98
- res({ server, url: `http://127.0.0.1:${port}/` });
99
- });
100
- });
101
- }
102
- async function renderPdf(htmlPath, pdfPath, meta) {
103
- const outDir = resolve(htmlPath, "..");
104
- const { server, url } = await serveStatic(outDir);
105
- const browser = await chromium.launch();
106
- try {
107
- const page = await browser.newPage();
108
- await page.goto(url, { waitUntil: "networkidle" });
109
- await page.evaluate(async () => {
110
- await document.fonts?.ready;
111
- const imgs = Array.from(document.querySelectorAll("img"));
112
- await Promise.all(imgs.map((img) => img.complete ? Promise.resolve() : new Promise((res) => {
113
- img.onload = () => res(null);
114
- img.onerror = () => res(null);
115
- setTimeout(() => res(null), 5000);
116
- })));
117
- });
118
- const [clientUri, consultancyUri] = await Promise.all([
119
- logoDataUri(outDir, meta.clientLogoSrc),
120
- logoDataUri(outDir, meta.consultancyLogoSrc)
121
- ]);
122
- const clientSlot = clientUri ? `<img src="${clientUri}" style="height:18px; width:auto; object-fit:contain; display:block;">` : `<span style="font-weight:600; color:${COLOR.navy}; letter-spacing:0.05em;">${escapeHtml(meta.clientName.toUpperCase())}</span>`;
123
- const consultancySlot = consultancyUri ? `<img src="${consultancyUri}" style="height:14px; width:auto; object-fit:contain; display:block;">` : `<span style="color:${COLOR.navy};">${escapeHtml(meta.consultancyName)}</span>`;
124
- const header = `
125
- <div style="width:100%; font-family:Inter,'Helvetica Neue',Arial,sans-serif; font-size:7.5pt; padding:0 0.7in; display:flex; justify-content:space-between; align-items:center;">
126
- ${clientSlot}
127
- <span style="color:#94a3b8;">${escapeHtml(meta.reportTitle)}</span>
128
- </div>`;
129
- const footer = `
130
- <div style="width:100%; font-family:Inter,'Helvetica Neue',Arial,sans-serif; font-size:7.5pt; padding:0 0.7in; display:flex; justify-content:space-between; align-items:center;">
131
- ${consultancySlot}
132
- <span style="color:${COLOR.navy};"><span class="pageNumber"></span></span>
133
- </div>`;
134
- const margin = { top: "0.7in", right: "0.7in", bottom: "0.7in", left: "0.7in" };
135
- const tmpCover = `${pdfPath}.cover.tmp.pdf`;
136
- const tmpBody = `${pdfPath}.body.tmp.pdf`;
137
- await Promise.all([
138
- page.pdf({
139
- path: tmpCover,
140
- format: "A4",
141
- pageRanges: "1",
142
- printBackground: true,
143
- displayHeaderFooter: false,
144
- margin: { top: "0", right: "0", bottom: "0", left: "0" },
145
- preferCSSPageSize: false
146
- }),
147
- page.pdf({
148
- path: tmpBody,
149
- format: "A4",
150
- pageRanges: "2-",
151
- printBackground: true,
152
- displayHeaderFooter: true,
153
- headerTemplate: header,
154
- footerTemplate: footer,
155
- margin,
156
- preferCSSPageSize: false
157
- })
158
- ]);
159
- const [coverBytes, bodyBytes] = await Promise.all([
160
- readFile(tmpCover),
161
- readFile(tmpBody)
162
- ]);
163
- const [coverDoc, bodyDoc] = await Promise.all([
164
- PDFDocument.load(coverBytes),
165
- PDFDocument.load(bodyBytes)
166
- ]);
167
- const [coverPage] = await bodyDoc.copyPages(coverDoc, [0]);
168
- bodyDoc.insertPage(0, coverPage);
169
- await writeFile(pdfPath, await bodyDoc.save());
170
- await Promise.all([unlink(tmpCover), unlink(tmpBody)]);
171
- } finally {
172
- await browser.close();
173
- server.close();
174
- }
175
- }
176
- async function generate(opts) {
177
- const dir = resolve(opts.reportDir);
178
- if (!await exists(join(dir, "package.json"))) {
179
- throw new Error(`not a scaffolded report (missing package.json): ${dir}`);
180
- }
181
- if (!opts.skipBuild) {
182
- buildNext(dir);
183
- }
184
- const htmlPath = join(dir, "out", "index.html");
185
- if (!await exists(htmlPath)) {
186
- throw new Error(`static export missing: ${htmlPath} — run without --skip-build`);
187
- }
188
- const meta = await loadMeta(dir);
189
- const slug = `${slugify(meta.clientName)}-${slugify(meta.reportTitle)}-${slugify(new Date().toISOString().slice(0, 10))}`;
190
- const pdfPath = opts.pdfOut ? resolve(opts.pdfOut) : join(dir, `${slug}.pdf`);
191
- await renderPdf(htmlPath, pdfPath, meta);
192
- return { htmlPath, pdfPath };
193
- }
194
- function parseArgs(argv) {
195
- if (argv.length === 0) {
196
- throw new Error("usage: generate-pdf.ts <report-dir> [--pdf <out>] [--skip-build]");
197
- }
198
- const opts = { reportDir: argv[0] };
199
- for (let i = 1;i < argv.length; i++) {
200
- if (argv[i] === "--pdf")
201
- opts.pdfOut = argv[++i];
202
- else if (argv[i] === "--html")
203
- opts.htmlOut = argv[++i];
204
- else if (argv[i] === "--skip-build")
205
- opts.skipBuild = true;
206
- }
207
- return opts;
208
- }
209
- async function run(argv = process.argv.slice(2)) {
210
- const opts = parseArgs(argv);
211
- const { htmlPath, pdfPath } = await generate(opts);
212
- const [htmlStat, pdfStat] = await Promise.all([stat(htmlPath), stat(pdfPath)]);
213
- console.log(`HTML: ${htmlPath} (${(htmlStat.size / 1024).toFixed(1)} KB)`);
214
- console.log(`PDF: ${pdfPath} (${(pdfStat.size / 1024).toFixed(1)} KB)`);
215
- }
216
- function realResolve(p) {
217
- try {
218
- return realpathSync(resolve(p));
219
- } catch {
220
- return resolve(p);
221
- }
222
- }
223
- const isMain = process.argv[1] && realResolve(process.argv[1]) === realResolve(fileURLToPath(import.meta.url));
224
- if (isMain) {
225
- await run();
226
- }
@@ -1,103 +0,0 @@
1
- import { readFile, stat, writeFile } from "node:fs/promises";
2
- import { basename, dirname, extname, resolve } from "node:path";
3
- import { pathToFileURL } from "node:url";
4
- import { marked } from "marked";
5
- import { chromium } from "playwright";
6
- const args = process.argv.slice(2);
7
- if (args.length === 0) {
8
- console.error("usage: md-to-html-pdf.ts <input.md> [--html <out>] [--pdf <out>] [--margin <css>] [--header <html|file>] [--footer <html|file>]");
9
- process.exit(1);
10
- }
11
- const input = resolve(args[0]);
12
- let htmlOut = "";
13
- let pdfOut = "";
14
- let margin = "25mm";
15
- let headerArg = "";
16
- let footerArg = "";
17
- for (let i = 1;i < args.length; i++) {
18
- if (args[i] === "--html")
19
- htmlOut = resolve(args[++i]);
20
- else if (args[i] === "--pdf")
21
- pdfOut = resolve(args[++i]);
22
- else if (args[i] === "--margin")
23
- margin = args[++i];
24
- else if (args[i] === "--header")
25
- headerArg = args[++i];
26
- else if (args[i] === "--footer")
27
- footerArg = args[++i];
28
- }
29
- const stem = basename(input, extname(input));
30
- const dir = dirname(input);
31
- htmlOut ||= resolve(dir, `${stem}.html`);
32
- pdfOut ||= resolve(dir, `${stem}.pdf`);
33
- async function resolveTemplate(value) {
34
- if (!value)
35
- return "";
36
- try {
37
- return await readFile(resolve(value), "utf8");
38
- } catch {
39
- return value;
40
- }
41
- }
42
- const headerTemplate = await resolveTemplate(headerArg);
43
- const footerTemplate = await resolveTemplate(footerArg);
44
- const displayHeaderFooter = Boolean(headerTemplate || footerTemplate);
45
- const md = await readFile(input, "utf8");
46
- marked.setOptions({ gfm: true, breaks: false });
47
- const body = await marked.parse(md);
48
- const css = `
49
- html { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
50
- body {
51
- font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
52
- font-size: 11px; line-height: 1.6; color: #1a1a1a; margin: 0;
53
- }
54
- h1 { font-size: 22px; border-bottom: 2px solid #333; padding-bottom: 8px; margin-top: 0; }
55
- h2 { font-size: 17px; margin-top: 1.6em; }
56
- h3 { font-size: 14px; }
57
- h1, h2, h3, h4 { page-break-after: avoid; }
58
- p, li { orphans: 3; widows: 3; }
59
- hr { border: none; border-top: 1px solid #ccc; margin: 20px 0; }
60
- a { color: #0366d6; text-decoration: none; }
61
- blockquote { border-left: 3px solid #666; padding-left: 12px; color: #444; margin: 12px 0; }
62
- code { background: #f4f4f4; padding: 1px 4px; border-radius: 3px; font-size: 10px; }
63
- pre { background: #f4f4f4; padding: 10px; border-radius: 4px; overflow-x: auto; }
64
- pre code { background: transparent; padding: 0; }
65
- table { border-collapse: collapse; width: 100%; margin: 12px 0; page-break-inside: avoid; }
66
- th, td { border: 1px solid #ccc; padding: 6px 10px; text-align: left; font-size: 10px; vertical-align: top; }
67
- th { background: #f0f0f0; }
68
- tr { page-break-inside: avoid; }
69
- ul, ol { padding-left: 1.4em; }
70
- `;
71
- const html = `<!doctype html>
72
- <html>
73
- <head>
74
- <meta charset="utf-8">
75
- <title>${stem}</title>
76
- <style>${css}</style>
77
- </head>
78
- <body>
79
- ${body}
80
- </body>
81
- </html>
82
- `;
83
- await writeFile(htmlOut, html, "utf8");
84
- const browser = await chromium.launch();
85
- try {
86
- const page = await browser.newPage();
87
- await page.goto(pathToFileURL(htmlOut).href, { waitUntil: "networkidle" });
88
- await page.pdf({
89
- path: pdfOut,
90
- format: "A4",
91
- margin: { top: margin, right: margin, bottom: margin, left: margin },
92
- printBackground: true,
93
- preferCSSPageSize: false,
94
- displayHeaderFooter,
95
- headerTemplate: headerTemplate || "<span></span>",
96
- footerTemplate: footerTemplate || "<span></span>"
97
- });
98
- } finally {
99
- await browser.close();
100
- }
101
- const [htmlStat, pdfStat] = await Promise.all([stat(htmlOut), stat(pdfOut)]);
102
- console.log(`HTML: ${htmlOut} (${(htmlStat.size / 1024).toFixed(1)} KB)`);
103
- console.log(`PDF: ${pdfOut} (${(pdfStat.size / 1024).toFixed(1)} KB)`);