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.
- package/assets/skills/consulting-report/SKILL.md +68 -74
- package/assets/skills/consulting-report/demo/app/globals.css +344 -0
- package/assets/skills/consulting-report/demo/app/layout.tsx +32 -0
- package/assets/skills/consulting-report/demo/app/page.tsx +255 -0
- package/assets/skills/consulting-report/demo/bun.lock +240 -0
- package/assets/skills/consulting-report/demo/components/callout.tsx +13 -0
- package/assets/skills/consulting-report/demo/components/cover-page.tsx +34 -0
- package/assets/skills/consulting-report/demo/components/exhibit.tsx +21 -0
- package/assets/skills/consulting-report/demo/components/finding-card.tsx +28 -0
- package/assets/skills/consulting-report/demo/components/quote-block.tsx +17 -0
- package/assets/skills/consulting-report/demo/components/recommendation-card.tsx +52 -0
- package/assets/skills/consulting-report/demo/components/section.tsx +16 -0
- package/assets/skills/consulting-report/demo/components/severity-badge.tsx +19 -0
- package/assets/skills/consulting-report/demo/components/timeline.tsx +20 -0
- package/assets/skills/consulting-report/demo/lib/report-data.ts +247 -0
- package/assets/skills/consulting-report/demo/lib/utils.ts +6 -0
- package/assets/skills/consulting-report/demo/next.config.js +9 -0
- package/assets/skills/consulting-report/demo/package.json +27 -0
- package/assets/skills/consulting-report/demo/postcss.config.mjs +5 -0
- package/assets/skills/consulting-report/demo/tsconfig.json +41 -0
- package/assets/skills/consulting-report/template/app/globals.css +344 -0
- package/assets/skills/consulting-report/template/app/layout.tsx +32 -0
- package/assets/skills/consulting-report/template/app/page.tsx +255 -0
- package/assets/skills/consulting-report/template/bun.lock +240 -0
- package/assets/skills/consulting-report/template/components/callout.tsx +13 -0
- package/assets/skills/consulting-report/template/components/cover-page.tsx +34 -0
- package/assets/skills/consulting-report/template/components/exhibit.tsx +21 -0
- package/assets/skills/consulting-report/template/components/finding-card.tsx +28 -0
- package/assets/skills/consulting-report/template/components/quote-block.tsx +17 -0
- package/assets/skills/consulting-report/template/components/recommendation-card.tsx +52 -0
- package/assets/skills/consulting-report/template/components/section.tsx +16 -0
- package/assets/skills/consulting-report/template/components/severity-badge.tsx +19 -0
- package/assets/skills/consulting-report/template/components/timeline.tsx +20 -0
- package/assets/skills/consulting-report/template/lib/report-data.ts +176 -0
- package/assets/skills/consulting-report/template/lib/utils.ts +6 -0
- package/assets/skills/consulting-report/template/next.config.js +9 -0
- package/assets/skills/consulting-report/template/package.json +27 -0
- package/assets/skills/consulting-report/template/postcss.config.mjs +5 -0
- package/assets/skills/consulting-report/template/tsconfig.json +27 -0
- package/assets/skills/consulting-report/tools/dev.ts +47 -0
- package/assets/skills/consulting-report/tools/generate-pdf.ts +140 -408
- package/assets/skills/consulting-report/tools/scaffold.ts +83 -48
- package/assets/skills/presentation/SKILL.md +1 -1
- package/package.json +9 -9
- package/assets/skills/consulting-report/demo/content/current-state.md +0 -33
- package/assets/skills/consulting-report/demo/content/executive-summary.md +0 -19
- package/assets/skills/consulting-report/demo/content/report-data.ts +0 -101
- package/assets/skills/consulting-report/demo/diagrams/.gitkeep +0 -0
- package/assets/skills/consulting-report/template/README.md +0 -28
- package/assets/skills/consulting-report/template/content/executive-summary.md +0 -19
- package/assets/skills/consulting-report/template/content/report-data.ts +0 -59
- 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:
|
|
4
|
-
// Pipeline:
|
|
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
|
|
7
|
-
// because it uses --remote-debugging-pipe over stdio and Bun's Windows
|
|
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,
|
|
18
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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: "#
|
|
84
|
-
blue: "#
|
|
85
|
-
red: "#
|
|
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
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
|
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
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
const
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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
|
-
|
|
400
|
-
|
|
401
|
-
async function renderPdf(
|
|
124
|
+
export async function renderPdf(
|
|
402
125
|
htmlPath: string,
|
|
403
126
|
pdfPath: string,
|
|
404
|
-
|
|
405
|
-
|
|
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(
|
|
134
|
+
await page.goto(url, { waitUntil: "networkidle" });
|
|
411
135
|
|
|
412
|
-
// Wait for
|
|
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.
|
|
430
|
-
<span style="font-weight:
|
|
431
|
-
<span style="color:#94a3b8;
|
|
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:
|
|
436
|
-
<span style="color:${COLOR.red}; font-weight:600; letter-spacing:0.05em;">${escapeHtml(
|
|
437
|
-
<span style="color:${COLOR.navy};">${escapeHtml(
|
|
438
|
-
<span style="color:${COLOR.navy};"
|
|
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.
|
|
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
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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
|
-
|
|
485
|
-
|
|
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
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
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
|
|
499
|
-
|
|
500
|
-
|
|
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
|
-
|
|
503
|
-
|
|
213
|
+
await renderPdf(htmlPath, pdfPath, meta);
|
|
214
|
+
return { htmlPath, pdfPath };
|
|
215
|
+
}
|
|
504
216
|
|
|
505
|
-
|
|
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
|
-
|
|
508
|
-
|
|
509
|
-
|
|
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
|
-
|
|
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
|
+
}
|