portable-agent-layer 0.30.1 → 0.31.0

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.
Files changed (52) hide show
  1. package/assets/skills/consulting-report/SKILL.md +68 -74
  2. package/assets/skills/consulting-report/demo/app/globals.css +344 -0
  3. package/assets/skills/consulting-report/demo/app/layout.tsx +32 -0
  4. package/assets/skills/consulting-report/demo/app/page.tsx +255 -0
  5. package/assets/skills/consulting-report/demo/bun.lock +240 -0
  6. package/assets/skills/consulting-report/demo/components/callout.tsx +13 -0
  7. package/assets/skills/consulting-report/demo/components/cover-page.tsx +34 -0
  8. package/assets/skills/consulting-report/demo/components/exhibit.tsx +21 -0
  9. package/assets/skills/consulting-report/demo/components/finding-card.tsx +28 -0
  10. package/assets/skills/consulting-report/demo/components/quote-block.tsx +17 -0
  11. package/assets/skills/consulting-report/demo/components/recommendation-card.tsx +52 -0
  12. package/assets/skills/consulting-report/demo/components/section.tsx +16 -0
  13. package/assets/skills/consulting-report/demo/components/severity-badge.tsx +19 -0
  14. package/assets/skills/consulting-report/demo/components/timeline.tsx +20 -0
  15. package/assets/skills/consulting-report/demo/lib/report-data.ts +247 -0
  16. package/assets/skills/consulting-report/demo/lib/utils.ts +6 -0
  17. package/assets/skills/consulting-report/demo/next.config.js +9 -0
  18. package/assets/skills/consulting-report/demo/package.json +27 -0
  19. package/assets/skills/consulting-report/demo/postcss.config.mjs +5 -0
  20. package/assets/skills/consulting-report/demo/tsconfig.json +41 -0
  21. package/assets/skills/consulting-report/template/app/globals.css +344 -0
  22. package/assets/skills/consulting-report/template/app/layout.tsx +32 -0
  23. package/assets/skills/consulting-report/template/app/page.tsx +255 -0
  24. package/assets/skills/consulting-report/template/bun.lock +240 -0
  25. package/assets/skills/consulting-report/template/components/callout.tsx +13 -0
  26. package/assets/skills/consulting-report/template/components/cover-page.tsx +34 -0
  27. package/assets/skills/consulting-report/template/components/exhibit.tsx +21 -0
  28. package/assets/skills/consulting-report/template/components/finding-card.tsx +28 -0
  29. package/assets/skills/consulting-report/template/components/quote-block.tsx +17 -0
  30. package/assets/skills/consulting-report/template/components/recommendation-card.tsx +52 -0
  31. package/assets/skills/consulting-report/template/components/section.tsx +16 -0
  32. package/assets/skills/consulting-report/template/components/severity-badge.tsx +19 -0
  33. package/assets/skills/consulting-report/template/components/timeline.tsx +20 -0
  34. package/assets/skills/consulting-report/template/lib/report-data.ts +176 -0
  35. package/assets/skills/consulting-report/template/lib/utils.ts +6 -0
  36. package/assets/skills/consulting-report/template/next.config.js +9 -0
  37. package/assets/skills/consulting-report/template/package.json +27 -0
  38. package/assets/skills/consulting-report/template/postcss.config.mjs +5 -0
  39. package/assets/skills/consulting-report/template/tsconfig.json +27 -0
  40. package/assets/skills/consulting-report/tools/dev.ts +47 -0
  41. package/assets/skills/consulting-report/tools/generate-pdf.ts +140 -408
  42. package/assets/skills/consulting-report/tools/scaffold.ts +83 -48
  43. package/assets/skills/presentation/SKILL.md +1 -1
  44. package/package.json +9 -9
  45. package/assets/skills/consulting-report/demo/content/current-state.md +0 -33
  46. package/assets/skills/consulting-report/demo/content/executive-summary.md +0 -19
  47. package/assets/skills/consulting-report/demo/content/report-data.ts +0 -101
  48. package/assets/skills/consulting-report/demo/diagrams/.gitkeep +0 -0
  49. package/assets/skills/consulting-report/template/README.md +0 -28
  50. package/assets/skills/consulting-report/template/content/executive-summary.md +0 -19
  51. package/assets/skills/consulting-report/template/content/report-data.ts +0 -59
  52. package/assets/skills/consulting-report/template/diagrams/.gitkeep +0 -0
@@ -1,94 +1,37 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- // consulting-report skill tool: render a structured report directory to a branded PDF.
4
- // Pipeline: report-data.ts + section markdown + diagrams -> HTML -> PDF (Playwright).
3
+ // consulting-report skill tool: build the Next.js report and render it to PDF.
4
+ // Pipeline: `next build` produces a static export at out/ Playwright loads
5
+ // out/index.html and prints a PDF with branded header/footer/page numbers.
5
6
  //
6
- // Run with Node (not Bun) — Playwright's chromium.launch hangs under Bun on Windows
7
- // because it uses --remote-debugging-pipe over stdio and Bun's Windows child-process
8
- // pipe handling doesn't complete the CDP handshake.
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.
9
10
  //
10
11
  // Usage:
11
- // node --experimental-strip-types ~/.pal/skills/consulting-report/tools/generate-pdf.ts <report-dir> [--pdf <out>] [--html <out>]
12
- //
13
- // <report-dir> must contain content/report-data.ts (default export or named `report`).
12
+ // node --experimental-strip-types ~/.pal/skills/consulting-report/tools/generate-pdf.ts <report-dir> [--pdf <out>] [--html <out>] [--skip-build]
14
13
 
15
14
  import { spawnSync } from "node:child_process";
16
- import { constants as fsConstants } from "node:fs";
17
- import { access, readdir, readFile, stat, writeFile } from "node:fs/promises";
18
- import { isAbsolute, join, resolve } from "node:path";
15
+ import { createReadStream, constants as fsConstants } from "node:fs";
16
+ import { access, stat } from "node:fs/promises";
17
+ import { createServer, type Server } from "node:http";
18
+ import { extname, join, resolve } from "node:path";
19
19
  import { pathToFileURL } from "node:url";
20
- import { marked } from "marked";
21
20
  import { chromium } from "playwright";
22
21
 
23
- // ── Types ────────────────────────────────────────────────────────────────────
24
-
25
- export interface Brand {
26
- businessName: string;
27
- brandLabel?: string; // sub-label shown under logo on cover (e.g. "TELOS Assessment")
28
- logoPath?: string; // absolute filesystem path; text-only cover if missing
29
- }
30
-
31
- export interface Section {
32
- id: string;
33
- title: string;
34
- content: string; // markdown string OR a path relative to content/ ending in .md
35
- subsections?: Section[];
36
- }
37
-
38
- export interface Finding {
39
- id: string;
40
- title: string;
41
- severity: "critical" | "high" | "medium" | "low";
42
- evidence: string; // markdown
43
- impact?: string;
44
- }
45
-
46
- export interface Recommendation {
47
- id: string;
48
- title: string;
49
- priority: "immediate" | "short-term" | "long-term";
50
- detail: string; // markdown
51
- owner?: string;
52
- }
53
-
54
- export interface Conclusion {
55
- assessorNote?: string;
56
- contextNote?: string;
57
- closingRemarks?: string;
58
- }
59
-
60
- export interface ConsultingReport {
22
+ interface ReportMeta {
61
23
  clientName: string;
62
24
  reportTitle: string;
63
- reportDate: string;
64
25
  classification: string;
65
- version: string;
66
- brand?: Brand;
67
- sections: Section[];
68
- findings?: Finding[];
69
- recommendations?: Recommendation[];
70
- conclusion?: Conclusion;
71
- supportingEvidence?: Record<string, string[]>;
26
+ consultancyName: string;
72
27
  }
73
28
 
74
- // ── Defaults ─────────────────────────────────────────────────────────────────
75
-
76
- const DEFAULT_BRAND: Brand = {
77
- businessName: "Konvert7",
78
- brandLabel: "Konvert7 Assessment",
79
- };
80
-
81
- // Color palette (ported verbatim from PAI's ConsultingReport workflow).
82
29
  const COLOR = {
83
- navy: "#1B2A4A",
84
- blue: "#2E5090",
85
- red: "#DC2626",
86
- amber: "#D97706",
87
- green: "#059669",
30
+ navy: "#0f172a",
31
+ blue: "#1d4ed8",
32
+ red: "#dc2626",
88
33
  };
89
34
 
90
- // ── Helpers ──────────────────────────────────────────────────────────────────
91
-
92
35
  async function exists(p: string): Promise<boolean> {
93
36
  try {
94
37
  await access(p, fsConstants.F_OK);
@@ -113,304 +56,86 @@ function slugify(s: string): string {
113
56
  .replace(/^-|-$/g, "");
114
57
  }
115
58
 
116
- async function resolveMarkdown(content: string, contentDir: string): Promise<string> {
117
- // If `content` looks like a relative path to an existing .md file, load it.
118
- // Otherwise treat it as inline markdown.
119
- if (content.endsWith(".md") && !content.includes("\n")) {
120
- const p = isAbsolute(content) ? content : join(contentDir, content);
121
- if (await exists(p)) return readFile(p, "utf8");
122
- }
123
- return content;
124
- }
125
-
126
- // Compress diagrams with sips (macOS) to JPEG 70% max 1200px wide.
127
- // Idempotent — outputs to diagrams-compressed/. Gracefully skips if sips missing.
128
- async function compressDiagrams(reportDir: string): Promise<string> {
129
- const srcDir = join(reportDir, "diagrams");
130
- const outDir = join(reportDir, "diagrams-compressed");
131
- if (!(await exists(srcDir))) return srcDir;
132
-
133
- const sipsCheck = spawnSync("which", ["sips"], { stdio: "ignore" });
134
- if (sipsCheck.status !== 0) {
135
- return srcDir; // no sips — serve originals
136
- }
137
-
138
- await spawnSync("mkdir", ["-p", outDir], { stdio: "ignore" });
139
- const files = await readdir(srcDir);
140
- for (const file of files) {
141
- if (!/\.(png|jpg|jpeg)$/i.test(file)) continue;
142
- const base = file.replace(/\.[^.]+$/, "");
143
- const src = join(srcDir, file);
144
- const dst = join(outDir, `${base}.jpg`);
145
- if (await exists(dst)) {
146
- const [srcStat, dstStat] = await Promise.all([stat(src), stat(dst)]);
147
- if (dstStat.mtimeMs >= srcStat.mtimeMs) continue;
148
- }
149
- spawnSync(
150
- "sips",
151
- [
152
- "-s",
153
- "format",
154
- "jpeg",
155
- "-s",
156
- "formatOptions",
157
- "70",
158
- "--resampleWidth",
159
- "1200",
160
- src,
161
- "--out",
162
- dst,
163
- ],
164
- { stdio: "ignore" }
165
- );
166
- }
167
- return outDir;
168
- }
169
-
170
- // ── HTML builders ────────────────────────────────────────────────────────────
171
-
172
- function css(): string {
173
- return `
174
- html { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
175
- body {
176
- font-family: Georgia, Garamond, "Times New Roman", serif;
177
- font-size: 10.5pt;
178
- line-height: 1.55;
179
- color: #1a1a2e;
180
- margin: 0;
181
- }
182
- h1, h2, h3, h4 { font-family: Inter, "Helvetica Neue", Arial, sans-serif; page-break-after: avoid; }
183
- h1 { font-size: 22pt; color: ${COLOR.navy}; border-bottom: 2px solid ${COLOR.navy}; padding-bottom: 6px; margin-top: 0; page-break-before: always; }
184
- h2 { font-size: 15pt; color: ${COLOR.blue}; margin-top: 1.4em; }
185
- h3 { font-size: 12pt; color: ${COLOR.navy}; margin-top: 1.1em; }
186
- p, li { orphans: 3; widows: 3; }
187
- a { color: ${COLOR.blue}; text-decoration: none; }
188
- blockquote { border-left: 3px solid ${COLOR.blue}; padding-left: 12px; color: #333; margin: 12px 0; font-style: italic; }
189
- code { background: #f4f4f4; padding: 1px 4px; border-radius: 3px; font-size: 9.5pt; font-family: "SF Mono", Menlo, Consolas, monospace; }
190
- pre { background: #f4f4f4; padding: 10px; border-radius: 4px; overflow-x: auto; page-break-inside: avoid; }
191
- pre code { background: transparent; padding: 0; }
192
- hr { border: none; border-top: 1px solid #e2e8f0; margin: 20px 0; }
193
- ul, ol { padding-left: 1.4em; }
194
- img { max-width: 100%; height: auto; }
195
-
196
- table { border-collapse: collapse; width: 100%; margin: 12px 0; page-break-inside: avoid; font-family: Inter, "Helvetica Neue", Arial, sans-serif; font-size: 9.5pt; }
197
- thead th { background: ${COLOR.navy}; color: #fff; font-size: 9pt; padding: 8px 10px; text-align: left; }
198
- tbody td { padding: 7px 10px; border-bottom: 1px solid #e2e8f0; vertical-align: top; }
199
- tbody tr:nth-child(even) { background: #f8fafc; }
200
- tr { page-break-inside: avoid; }
201
-
202
- .cover { height: 100vh; display: flex; flex-direction: column; justify-content: center; align-items: center; text-align: center; padding: 0 1in; page-break-after: always; }
203
- .cover .classification { position: absolute; top: 40px; font-family: Inter, sans-serif; font-size: 10pt; font-weight: 700; color: ${COLOR.red}; letter-spacing: 0.2em; }
204
- .cover .logo { max-width: 220px; max-height: 120px; margin-bottom: 16px; }
205
- .cover .brand-label { font-family: Inter, sans-serif; font-size: 11pt; letter-spacing: 0.15em; color: ${COLOR.blue}; text-transform: uppercase; margin-bottom: 48px; }
206
- .cover .report-title { font-family: Inter, sans-serif; font-size: 30pt; font-weight: 700; color: ${COLOR.navy}; line-height: 1.2; margin-bottom: 16px; }
207
- .cover .prepared-for { font-size: 14pt; color: #334155; margin-bottom: 28px; }
208
- .cover .divider { width: 120px; height: 2px; background: ${COLOR.navy}; margin: 16px auto; }
209
- .cover .meta { font-family: Inter, sans-serif; font-size: 10pt; color: #64748b; letter-spacing: 0.05em; }
210
- .cover .company-name { position: absolute; bottom: 80px; font-family: Inter, sans-serif; font-size: 12pt; font-weight: 700; color: ${COLOR.navy}; letter-spacing: 0.2em; }
211
- .cover .footer-note { position: absolute; bottom: 40px; font-size: 8.5pt; color: #94a3b8; font-style: italic; }
212
-
213
- .toc { page-break-after: always; }
214
- .toc h1 { page-break-before: avoid; }
215
- .toc ol { list-style: none; padding-left: 0; font-family: Inter, sans-serif; font-size: 11pt; }
216
- .toc ol ol { padding-left: 1.4em; font-size: 10pt; margin-top: 4px; }
217
- .toc li { margin: 6px 0; }
218
- .toc a { color: ${COLOR.navy}; display: flex; justify-content: space-between; border-bottom: 1px dotted #cbd5e1; padding-bottom: 3px; }
219
- .toc .toc-title { }
220
- .toc .toc-page { color: #94a3b8; font-size: 9pt; }
221
-
222
- .box { padding: 0.7rem 1rem; border-radius: 4px; border-left: 4px solid; margin: 12px 0; page-break-inside: avoid; }
223
- .box-red { border-color: ${COLOR.red}; background: #fef2f2; color: #7f1d1d; }
224
- .box-green { border-color: ${COLOR.green}; background: #f0fdf4; color: #14532d; }
225
- .box-amber { border-color: ${COLOR.amber}; background: #fffbeb; color: #78350f; }
226
- .box-blue { border-color: ${COLOR.blue}; background: #f0f4fa; color: ${COLOR.navy}; }
227
-
228
- .badge { display: inline-block; padding: 1px 8px; border-radius: 3px; font-family: Inter, sans-serif; font-size: 8.5pt; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; margin-left: 8px; vertical-align: middle; }
229
- .badge-critical, .badge-immediate { background: ${COLOR.red}; color: #fff; }
230
- .badge-short-term { background: ${COLOR.amber}; color: #fff; }
231
- .badge-long-term { background: ${COLOR.green}; color: #fff; }
232
- .badge-high { background: ${COLOR.amber}; color: #fff; }
233
- .badge-medium { background: ${COLOR.blue}; color: #fff; }
234
- .badge-low { background: #64748b; color: #fff; }
235
-
236
- .finding, .recommendation { margin: 16px 0; page-break-inside: avoid; }
237
- .finding h3, .recommendation h3 { margin-top: 0; }
238
- .finding .label, .recommendation .label { display: block; font-family: Inter, sans-serif; font-size: 9pt; font-weight: 700; letter-spacing: 0.08em; color: #64748b; text-transform: uppercase; margin-bottom: 4px; }
239
- `;
240
- }
241
-
242
- async function renderSections(
243
- sections: Section[],
244
- contentDir: string,
245
- depth = 1
246
- ): Promise<string> {
247
- const out: string[] = [];
248
- for (const s of sections) {
249
- const md = await resolveMarkdown(s.content, contentDir);
250
- const htmlBody = await marked.parse(md);
251
- const tag = depth === 1 ? "h1" : depth === 2 ? "h2" : "h3";
252
- out.push(`<${tag} id="${s.id}">${escapeHtml(s.title)}</${tag}>`);
253
- out.push(htmlBody as string);
254
- if (s.subsections?.length) {
255
- out.push(await renderSections(s.subsections, contentDir, depth + 1));
256
- }
257
- }
258
- return out.join("\n");
259
- }
260
-
261
- function renderToc(sections: Section[]): string {
262
- const renderItems = (items: Section[]): string =>
263
- `<ol>${items
264
- .map(
265
- (s) =>
266
- `<li><a href="#${s.id}"><span class="toc-title">${escapeHtml(s.title)}</span></a>${
267
- s.subsections?.length ? renderItems(s.subsections) : ""
268
- }</li>`
269
- )
270
- .join("")}</ol>`;
271
-
272
- return `<div class="toc"><h1 id="toc">Table of Contents</h1>${renderItems(sections)}</div>`;
273
- }
274
-
275
- function renderCover(r: ConsultingReport, brand: Brand): string {
276
- const logo = brand.logoPath?.length
277
- ? `<img class="logo" src="file://${brand.logoPath}" alt="${escapeHtml(brand.businessName)}">`
278
- : "";
279
- const brandLabel = brand.brandLabel || `${brand.businessName} Assessment`;
280
- return `
281
- <div class="cover">
282
- <div class="classification">${escapeHtml(r.classification)}</div>
283
- ${logo}
284
- <div class="brand-label">${escapeHtml(brandLabel)}</div>
285
- <div class="report-title">${escapeHtml(r.reportTitle)}</div>
286
- <div class="prepared-for">Prepared for ${escapeHtml(r.clientName)}</div>
287
- <div class="divider"></div>
288
- <div class="meta">${escapeHtml(r.reportDate)} · Version ${escapeHtml(r.version)}</div>
289
- <div class="company-name">${escapeHtml(brand.businessName.toUpperCase())} CONSULTING</div>
290
- <div class="footer-note">${escapeHtml(r.classification)} — For Authorized Recipients Only</div>
291
- </div>
292
- `;
293
- }
294
-
295
- async function renderFindings(findings: Finding[] | undefined): Promise<string> {
296
- if (!findings || !findings.length) return "";
297
- const parts = [`<h1 id="findings">Findings</h1>`];
298
- for (const f of findings) {
299
- const boxClass =
300
- f.severity === "critical" || f.severity === "high"
301
- ? "box-red"
302
- : f.severity === "medium"
303
- ? "box-amber"
304
- : "box-blue";
305
- const evidenceHtml = await marked.parse(f.evidence);
306
- const impactHtml = f.impact ? await marked.parse(f.impact) : "";
307
- parts.push(`
308
- <div class="finding box ${boxClass}" id="${f.id}">
309
- <span class="label">Finding — ${escapeHtml(f.severity)}<span class="badge badge-${escapeHtml(f.severity)}">${escapeHtml(f.severity)}</span></span>
310
- <h3>${escapeHtml(f.title)}</h3>
311
- ${evidenceHtml}
312
- ${impactHtml ? `<p><strong>Impact:</strong></p>${impactHtml}` : ""}
313
- </div>`);
59
+ export async function loadMeta(reportDir: string): Promise<ReportMeta> {
60
+ const dataPath = join(reportDir, "lib", "report-data.ts");
61
+ if (!(await exists(dataPath))) {
62
+ throw new Error(`lib/report-data.ts not found at ${dataPath}`);
314
63
  }
315
- return parts.join("\n");
316
- }
317
-
318
- async function renderRecommendations(
319
- recs: Recommendation[] | undefined
320
- ): Promise<string> {
321
- if (!recs || !recs.length) return "";
322
- const parts = [`<h1 id="recommendations">Recommendations</h1>`];
323
- for (const r of recs) {
324
- const detailHtml = await marked.parse(r.detail);
325
- parts.push(`
326
- <div class="recommendation box box-blue" id="${r.id}">
327
- <span class="label">Recommendation<span class="badge badge-${escapeHtml(r.priority)}">${escapeHtml(r.priority)}</span></span>
328
- <h3>${escapeHtml(r.title)}</h3>
329
- ${detailHtml}
330
- ${r.owner ? `<p><strong>Owner:</strong> ${escapeHtml(r.owner)}</p>` : ""}
331
- </div>`);
64
+ const mod = (await import(pathToFileURL(dataPath).href)) as {
65
+ reportData?: ReportMeta;
66
+ };
67
+ if (!mod.reportData) {
68
+ throw new Error(`lib/report-data.ts must export a named 'reportData' constant`);
332
69
  }
333
- return parts.join("\n");
334
- }
335
-
336
- async function renderConclusion(c: Conclusion | undefined): Promise<string> {
337
- if (!c) return "";
338
- const bits: string[] = [`<h1 id="conclusion">Conclusion</h1>`];
339
- if (c.assessorNote)
340
- bits.push(
341
- `<h3>Assessor's Note</h3>${(await marked.parse(c.assessorNote)) as string}`
342
- );
343
- if (c.contextNote)
344
- bits.push(`<h3>Context</h3>${(await marked.parse(c.contextNote)) as string}`);
345
- if (c.closingRemarks)
346
- bits.push(
347
- `<h3>Closing Remarks</h3>${(await marked.parse(c.closingRemarks)) as string}`
348
- );
349
- return bits.join("\n");
70
+ return mod.reportData;
350
71
  }
351
72
 
352
- async function renderAppendix(
353
- evidence: Record<string, string[]> | undefined
354
- ): Promise<string> {
355
- if (!evidence) return "";
356
- const entries = Object.entries(evidence);
357
- if (!entries.length) return "";
358
- const parts = [`<h1 id="appendix">Appendix — Supporting Evidence</h1>`];
359
- for (const [heading, items] of entries) {
360
- parts.push(`<h3>${escapeHtml(heading)}</h3>`);
361
- parts.push(`<ul>${items.map((i) => `<li>${escapeHtml(i)}</li>`).join("")}</ul>`);
73
+ export function buildNext(reportDir: string): void {
74
+ const result = spawnSync("bun", ["run", "build"], {
75
+ cwd: reportDir,
76
+ stdio: "inherit",
77
+ shell: true,
78
+ });
79
+ if (result.status !== 0) {
80
+ throw new Error(`next build failed (exit ${result.status})`);
362
81
  }
363
- return parts.join("\n");
364
82
  }
365
83
 
366
- async function buildHtml(report: ConsultingReport, contentDir: string): Promise<string> {
367
- marked.setOptions({ gfm: true, breaks: false });
368
- const brand: Brand = { ...DEFAULT_BRAND, ...(report.brand || {}) };
369
-
370
- const [sectionsHtml, findingsHtml, recsHtml, conclusionHtml, appendixHtml] =
371
- await Promise.all([
372
- renderSections(report.sections, contentDir),
373
- renderFindings(report.findings),
374
- renderRecommendations(report.recommendations),
375
- renderConclusion(report.conclusion),
376
- renderAppendix(report.supportingEvidence),
377
- ]);
378
-
379
- return `<!doctype html>
380
- <html lang="en">
381
- <head>
382
- <meta charset="utf-8">
383
- <title>${escapeHtml(report.reportTitle)} — ${escapeHtml(report.clientName)}</title>
384
- <style>${css()}</style>
385
- </head>
386
- <body>
387
- ${renderCover(report, brand)}
388
- ${renderToc(report.sections)}
389
- ${sectionsHtml}
390
- ${findingsHtml}
391
- ${recsHtml}
392
- ${conclusionHtml}
393
- ${appendixHtml}
394
- </body>
395
- </html>
396
- `;
84
+ // Static export uses absolute paths (e.g. /_next/static/...). Loading via
85
+ // file:// breaks those because `/` resolves to the filesystem root. Spin up a
86
+ // tiny HTTP server rooted at out/ so Playwright sees real URLs.
87
+ function serveStatic(rootDir: string): Promise<{ server: Server; url: string }> {
88
+ const MIME: Record<string, string> = {
89
+ ".html": "text/html; charset=utf-8",
90
+ ".css": "text/css; charset=utf-8",
91
+ ".js": "application/javascript; charset=utf-8",
92
+ ".json": "application/json; charset=utf-8",
93
+ ".woff": "font/woff",
94
+ ".woff2": "font/woff2",
95
+ ".svg": "image/svg+xml",
96
+ ".png": "image/png",
97
+ ".jpg": "image/jpeg",
98
+ ".jpeg": "image/jpeg",
99
+ ".webp": "image/webp",
100
+ ".ico": "image/x-icon",
101
+ };
102
+ return new Promise((res) => {
103
+ const server = createServer((req, response) => {
104
+ const urlPath = decodeURIComponent((req.url ?? "/").split("?")[0]);
105
+ let filePath = join(rootDir, urlPath === "/" ? "/index.html" : urlPath);
106
+ if (filePath.endsWith("/")) filePath = join(filePath, "index.html");
107
+ const ext = extname(filePath).toLowerCase();
108
+ response.setHeader("Content-Type", MIME[ext] ?? "application/octet-stream");
109
+ const stream = createReadStream(filePath);
110
+ stream.on("error", () => {
111
+ response.statusCode = 404;
112
+ response.end();
113
+ });
114
+ stream.pipe(response);
115
+ });
116
+ server.listen(0, "127.0.0.1", () => {
117
+ const addr = server.address();
118
+ const port = typeof addr === "object" && addr ? addr.port : 0;
119
+ res({ server, url: `http://127.0.0.1:${port}/` });
120
+ });
121
+ });
397
122
  }
398
123
 
399
- // ── Playwright ───────────────────────────────────────────────────────────────
400
-
401
- async function renderPdf(
124
+ export async function renderPdf(
402
125
  htmlPath: string,
403
126
  pdfPath: string,
404
- report: ConsultingReport,
405
- brand: Brand
406
- ) {
127
+ meta: ReportMeta
128
+ ): Promise<void> {
129
+ const outDir = resolve(htmlPath, "..");
130
+ const { server, url } = await serveStatic(outDir);
407
131
  const browser = await chromium.launch();
408
132
  try {
409
133
  const page = await browser.newPage();
410
- await page.goto(pathToFileURL(htmlPath).href, { waitUntil: "networkidle" });
134
+ await page.goto(url, { waitUntil: "networkidle" });
411
135
 
412
- // Wait for all images
136
+ // Wait for fonts and images to settle
413
137
  await page.evaluate(async () => {
138
+ await (document as { fonts?: { ready: Promise<unknown> } }).fonts?.ready;
414
139
  const imgs = Array.from(document.querySelectorAll("img"));
415
140
  await Promise.all(
416
141
  imgs.map((img) =>
@@ -426,16 +151,16 @@ async function renderPdf(
426
151
  });
427
152
 
428
153
  const header = `
429
- <div style="width:100%; font-family:'Helvetica Neue',Arial,sans-serif; font-size:7.5pt; padding:0 0.9in 4px; display:flex; justify-content:space-between; align-items:center; border-bottom:0.5px solid #d0d5dd;">
430
- <span style="font-weight:700; color:${COLOR.navy}; letter-spacing:0.05em;">${escapeHtml(report.clientName.toUpperCase())}</span>
431
- <span style="color:#94a3b8; letter-spacing:0.03em;">${escapeHtml(report.reportTitle)}</span>
154
+ <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;">
155
+ <span style="font-weight:600; color:${COLOR.navy}; letter-spacing:0.05em;">${escapeHtml(meta.clientName.toUpperCase())}</span>
156
+ <span style="color:#94a3b8;">${escapeHtml(meta.reportTitle)}</span>
432
157
  </div>`;
433
158
 
434
159
  const footer = `
435
- <div style="width:100%; font-family:'Helvetica Neue',Arial,sans-serif; font-size:7.5pt; padding:4px 0.9in 0; display:flex; justify-content:space-between; align-items:center; border-top:0.5px solid #d0d5dd;">
436
- <span style="color:${COLOR.red}; font-weight:600; letter-spacing:0.05em;">${escapeHtml(report.classification)}</span>
437
- <span style="color:${COLOR.navy};">${escapeHtml(brand.businessName)} Consulting</span>
438
- <span style="color:${COLOR.navy};">Page <span class="pageNumber"></span></span>
160
+ <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;">
161
+ <span style="color:${COLOR.red}; font-weight:600; letter-spacing:0.05em;">${escapeHtml(meta.classification)}</span>
162
+ <span style="color:${COLOR.navy};">${escapeHtml(meta.consultancyName)}</span>
163
+ <span style="color:${COLOR.navy};"><span class="pageNumber"></span></span>
439
164
  </div>`;
440
165
 
441
166
  await page.pdf({
@@ -445,68 +170,75 @@ async function renderPdf(
445
170
  displayHeaderFooter: true,
446
171
  headerTemplate: header,
447
172
  footerTemplate: footer,
448
- margin: { top: "0.8in", right: "0.9in", bottom: "0.7in", left: "0.9in" },
173
+ margin: { top: "0.7in", right: "0.7in", bottom: "0.7in", left: "0.7in" },
449
174
  preferCSSPageSize: false,
450
175
  });
451
176
  } finally {
452
177
  await browser.close();
178
+ server.close();
453
179
  }
454
180
  }
455
181
 
456
- // ── Main ─────────────────────────────────────────────────────────────────────
457
-
458
- async function loadReport(
459
- reportDir: string
460
- ): Promise<{ report: ConsultingReport; contentDir: string }> {
461
- const dataPath = join(reportDir, "content", "report-data.ts");
462
- if (!(await exists(dataPath))) {
463
- throw new Error(`report-data.ts not found at ${dataPath}`);
464
- }
465
- const mod: { default?: ConsultingReport; report?: ConsultingReport } = await import(
466
- pathToFileURL(dataPath).href
467
- );
468
- const report = mod.default || mod.report;
469
- if (!report) {
470
- throw new Error(
471
- `report-data.ts must export a default ConsultingReport or a named export 'report'`
472
- );
473
- }
474
- return { report, contentDir: join(reportDir, "content") };
182
+ interface GenerateOptions {
183
+ reportDir: string;
184
+ pdfOut?: string;
185
+ htmlOut?: string;
186
+ skipBuild?: boolean;
475
187
  }
476
188
 
477
- async function main() {
478
- const args = process.argv.slice(2);
479
- if (args.length === 0) {
480
- console.error("usage: generate-pdf.ts <report-dir> [--pdf <out>] [--html <out>]");
481
- process.exit(1);
189
+ export async function generate(opts: GenerateOptions): Promise<{
190
+ htmlPath: string;
191
+ pdfPath: string;
192
+ }> {
193
+ const dir = resolve(opts.reportDir);
194
+ if (!(await exists(join(dir, "package.json")))) {
195
+ throw new Error(`not a scaffolded report (missing package.json): ${dir}`);
482
196
  }
483
197
 
484
- const reportDir = resolve(args[0]);
485
- let pdfOut = "";
486
- let htmlOut = "";
487
- for (let i = 1; i < args.length; i++) {
488
- if (args[i] === "--pdf") pdfOut = resolve(args[++i]);
489
- else if (args[i] === "--html") htmlOut = resolve(args[++i]);
198
+ if (!opts.skipBuild) {
199
+ buildNext(dir);
490
200
  }
491
201
 
492
- const { report, contentDir } = await loadReport(reportDir);
493
- const brand: Brand = { ...DEFAULT_BRAND, ...(report.brand || {}) };
494
-
495
- // Compress diagrams (no-op if dir missing or sips absent)
496
- await compressDiagrams(reportDir);
202
+ const htmlPath = join(dir, "out", "index.html");
203
+ if (!(await exists(htmlPath))) {
204
+ throw new Error(`static export missing: ${htmlPath} — run without --skip-build`);
205
+ }
497
206
 
498
- const slug = `${slugify(report.clientName)}-${slugify(report.reportTitle)}-${slugify(report.reportDate)}`;
499
- if (!pdfOut) pdfOut = join(reportDir, `${slug}.pdf`);
500
- if (!htmlOut) htmlOut = join(reportDir, `${slug}.html`);
207
+ const meta = await loadMeta(dir);
208
+ const slug = `${slugify(meta.clientName)}-${slugify(meta.reportTitle)}-${slugify(
209
+ new Date().toISOString().slice(0, 10)
210
+ )}`;
211
+ const pdfPath = opts.pdfOut ? resolve(opts.pdfOut) : join(dir, `${slug}.pdf`);
501
212
 
502
- const html = await buildHtml(report, contentDir);
503
- await writeFile(htmlOut, html, "utf8");
213
+ await renderPdf(htmlPath, pdfPath, meta);
214
+ return { htmlPath, pdfPath };
215
+ }
504
216
 
505
- await renderPdf(htmlOut, pdfOut, report, brand);
217
+ function parseArgs(argv: string[]): GenerateOptions {
218
+ if (argv.length === 0) {
219
+ throw new Error("usage: generate-pdf.ts <report-dir> [--pdf <out>] [--skip-build]");
220
+ }
221
+ const opts: GenerateOptions = { reportDir: argv[0] };
222
+ for (let i = 1; i < argv.length; i++) {
223
+ if (argv[i] === "--pdf") opts.pdfOut = argv[++i];
224
+ else if (argv[i] === "--html") opts.htmlOut = argv[++i];
225
+ else if (argv[i] === "--skip-build") opts.skipBuild = true;
226
+ }
227
+ return opts;
228
+ }
506
229
 
507
- const [htmlStat, pdfStat] = await Promise.all([stat(htmlOut), stat(pdfOut)]);
508
- console.log(`HTML: ${htmlOut} (${(htmlStat.size / 1024).toFixed(1)} KB)`);
509
- console.log(`PDF: ${pdfOut} (${(pdfStat.size / 1024).toFixed(1)} KB)`);
230
+ export async function run(argv: string[] = process.argv.slice(2)): Promise<void> {
231
+ const opts = parseArgs(argv);
232
+ const { htmlPath, pdfPath } = await generate(opts);
233
+ const [htmlStat, pdfStat] = await Promise.all([stat(htmlPath), stat(pdfPath)]);
234
+ console.log(`HTML: ${htmlPath} (${(htmlStat.size / 1024).toFixed(1)} KB)`);
235
+ console.log(`PDF: ${pdfPath} (${(pdfStat.size / 1024).toFixed(1)} KB)`);
510
236
  }
511
237
 
512
- await main();
238
+ // Node ≥ 22.6 doesn't expose import.meta.main; gate on argv[1] instead.
239
+ const isMain =
240
+ process.argv[1] &&
241
+ resolve(process.argv[1]) === resolve(new URL(import.meta.url).pathname);
242
+ if (isMain) {
243
+ await run();
244
+ }