@uniqbit/mate-core 0.15.1 → 0.15.2-canary.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.
@@ -1,5 +1,4 @@
1
- import fs from "node:fs/promises";
2
- import path from "node:path";
1
+ import { readFile } from "node:fs/promises";
3
2
 
4
3
  import { resolveFrameworkContext } from "../../../lib/orchestrator/framework-context";
5
4
  import { ensureUnambiguousCompanion } from "../shared/companion-selection";
@@ -10,149 +9,211 @@ import {
10
9
  collectTokenSaveSavings,
11
10
  mergeResults,
12
11
  } from "./collector";
13
- import { renderJSON, renderMarkdown } from "./renderer";
14
12
  import type { CollectorDeps } from "./collector";
15
- import type { ReportData, ReportOptions } from "./types";
13
+ import { reportDataToDocument } from "./adapter";
14
+ import { parseReportDocument, type ReportValidationError } from "./contract";
15
+ import { openReportInBrowser, writeTemporaryReport } from "./delivery";
16
+ import { renderHTML, renderJSON } from "./renderer";
17
+ import type { ReportData, ReportDocument, ReportOptions } from "./types";
16
18
 
17
19
  interface ReportCommandDeps extends CollectorDeps {
18
20
  ensureUnambiguousCompanion?: (cwd: string) => Promise<boolean>;
19
21
  resolveFrameworkContext?: typeof resolveFrameworkContext;
22
+ writeTemporaryReport?: typeof writeTemporaryReport;
23
+ openReportInBrowser?: typeof openReportInBrowser;
24
+ readInput?: (input: string) => Promise<string>;
20
25
  }
21
26
 
22
27
  function parseOptions(argv: string[]): ReportOptions {
23
28
  let days = 7;
24
29
  let json = false;
30
+ let input: string | undefined;
31
+ let daysSpecified = false;
25
32
 
26
33
  for (let i = 0; i < argv.length; i++) {
27
- if (argv[i] === "--days" && argv[i + 1]) {
28
- days = parseInt(argv[i + 1], 10);
29
- if (isNaN(days) || days < 1) days = 7;
34
+ if (argv[i] === "--days") {
35
+ daysSpecified = true;
36
+ const value = argv[i + 1];
37
+ if (!value) throw new Error("--days requires a positive integer");
38
+ days = Number.parseInt(value, 10);
39
+ if (!Number.isInteger(days) || days < 1) {
40
+ throw new Error("--days requires a positive integer");
41
+ }
30
42
  i++;
43
+ continue;
31
44
  }
32
- if (argv[i] === "--json") {
33
- json = true;
45
+ if (argv[i] === "--input") {
46
+ const value = argv[i + 1];
47
+ if (!value) throw new Error("--input requires a JSON file path or - for stdin");
48
+ if (input !== undefined) throw new Error("--input may only be provided once");
49
+ input = value;
50
+ i++;
51
+ continue;
34
52
  }
53
+ if (argv[i] === "--json") json = true;
35
54
  }
36
55
 
37
- return { days, json };
56
+ if (input !== undefined && daysSpecified) {
57
+ throw new Error("--days cannot be combined with --input; the document supplies its own period");
58
+ }
59
+ return { days, json, input };
60
+ }
61
+
62
+ async function readStdin(): Promise<string> {
63
+ const chunks: Uint8Array[] = [];
64
+ for await (const chunk of process.stdin) {
65
+ chunks.push(typeof chunk === "string" ? new TextEncoder().encode(chunk) : chunk);
66
+ }
67
+ const size = chunks.reduce((total, chunk) => total + chunk.byteLength, 0);
68
+ const result = new Uint8Array(size);
69
+ let offset = 0;
70
+ for (const chunk of chunks) {
71
+ result.set(chunk, offset);
72
+ offset += chunk.byteLength;
73
+ }
74
+ return new TextDecoder().decode(result);
75
+ }
76
+
77
+ async function readStructuredInput(input: string, deps: ReportCommandDeps): Promise<string> {
78
+ if (deps.readInput) return deps.readInput(input);
79
+ return input === "-" ? readStdin() : readFile(input, "utf8");
80
+ }
81
+
82
+ function validationMessage(error: unknown): string {
83
+ if (error && typeof error === "object" && "issues" in error) {
84
+ const issues = (error as ReportValidationError).issues;
85
+ return issues.map((issue) => `${issue.path}: ${issue.message}`).join("; ");
86
+ }
87
+ return error instanceof Error ? error.message : String(error);
38
88
  }
39
89
 
40
90
  /**
41
- * @command mate report [--days N] [--json]
42
- * @description Generates a token usage report aggregating spending (via ccusage)
43
- * and savings (via TokenSave/Headroom), merged across working and companion repos.
44
- * Writes REPORT.md to companion repo by default. Use --json for machine-readable stdout.
91
+ * @command mate report [--days N] [--input FILE|-] [--json]
92
+ * @description Generates a usage report from built-in collectors or a validated JSON document.
45
93
  */
46
94
  export async function runReportCommand(
47
95
  argv: string[] = [],
48
96
  deps: ReportCommandDeps = {},
49
97
  ): Promise<void> {
50
- const resolveCtx = deps.resolveFrameworkContext ?? resolveFrameworkContext;
51
- const ensureCompanion = deps.ensureUnambiguousCompanion ?? ensureUnambiguousCompanion;
52
- const options = parseOptions(argv);
53
-
54
- if (!(await ensureCompanion(process.cwd()))) {
98
+ let options: ReportOptions;
99
+ try {
100
+ options = parseOptions(argv);
101
+ } catch (error) {
102
+ console.error(`Invalid report options: ${validationMessage(error)}`);
55
103
  process.exitCode = 1;
56
104
  return;
57
105
  }
58
106
 
59
- const { companionPath, configStore, workingRepoStore, repository, contextKind } =
60
- await resolveCtx(process.cwd());
61
- const config = await configStore.load();
62
- const working =
63
- repository || contextKind === "companion-root" ? null : await workingRepoStore.load();
64
-
65
- const workingRepoPath = repository?.path ?? working?.repos[0]?.path ?? process.cwd();
66
- const companionRepoPath = companionPath;
67
- const capabilities = config.capabilities ?? [];
68
- const enabledCapabilities = capabilities.map((c) => c.name);
69
- const activeAgents = process.env.MATE_ALLOWED_AGENTS
70
- ? process.env.MATE_ALLOWED_AGENTS.split(",")
71
- .map((a) => a.trim())
72
- .filter(Boolean)
73
- : [];
74
-
75
- const tokensaveEnabled = enabledCapabilities.includes("tokensave");
76
- const headroomEnabled = enabledCapabilities.includes("headroom");
77
- const rtkEnabled = enabledCapabilities.includes("rtk");
78
-
79
- // Collect spending via ccusage (covers all agents: Claude, OpenCode, Codex, etc.)
80
- const ccusageResult = await collectCcusageSpending(options.days, deps);
81
- if (ccusageResult.friendlyError) {
82
- console.warn(ccusageResult.friendlyError);
83
- }
84
-
85
- // Collect savings in parallel
86
- let tokensaveWorking: Awaited<ReturnType<typeof collectTokenSaveSavings>> = {
87
- entry: null,
88
- status: { name: "tokensave (savings)", enabled: false, status: "not enabled" },
89
- };
90
- let tokensaveCompanion: Awaited<ReturnType<typeof collectTokenSaveSavings>> = {
91
- entry: null,
92
- status: { name: "tokensave (savings)", enabled: false, status: "not enabled" },
93
- };
94
- let rtkResult: Awaited<ReturnType<typeof collectRTKSavings>> = {
95
- entry: null,
96
- status: { name: "rtk", enabled: false, status: "not enabled" },
97
- };
98
- let headroomResult: Awaited<ReturnType<typeof collectHeadroomSavings>> = {
99
- entry: null,
100
- status: { name: "headroom", enabled: false, status: "not enabled" },
101
- };
102
-
103
- if (tokensaveEnabled) {
104
- [tokensaveWorking, tokensaveCompanion] = await Promise.all([
105
- collectTokenSaveSavings(workingRepoPath, options.days, deps),
106
- collectTokenSaveSavings(companionRepoPath, options.days, deps),
107
- ]);
108
- }
109
-
110
- if (rtkEnabled) {
111
- rtkResult = await collectRTKSavings(workingRepoPath, deps);
107
+ const ensureCompanion = deps.ensureUnambiguousCompanion ?? ensureUnambiguousCompanion;
108
+ if (!(await ensureCompanion(process.cwd()))) {
109
+ process.exitCode = 1;
110
+ return;
112
111
  }
113
112
 
114
- if (headroomEnabled) {
115
- headroomResult = await collectHeadroomSavings(options.days, deps);
113
+ let reportDocument: ReportDocument;
114
+ if (options.input !== undefined) {
115
+ try {
116
+ reportDocument = parseReportDocument(await readStructuredInput(options.input, deps));
117
+ } catch (error) {
118
+ console.error(`Invalid report input: ${validationMessage(error)}`);
119
+ process.exitCode = 1;
120
+ return;
121
+ }
122
+ } else {
123
+ const resolveCtx = deps.resolveFrameworkContext ?? resolveFrameworkContext;
124
+ const { companionPath, configStore, workingRepoStore, repository, contextKind } =
125
+ await resolveCtx(process.cwd());
126
+ const config = await configStore.load();
127
+ const working =
128
+ repository || contextKind === "companion-root" ? null : await workingRepoStore.load();
129
+
130
+ const workingRepoPath = repository?.path ?? working?.repos[0]?.path ?? process.cwd();
131
+ const capabilities = config.capabilities ?? [];
132
+ const enabledCapabilities = capabilities.map((c) => c.name);
133
+ const activeAgents = process.env.MATE_ALLOWED_AGENTS
134
+ ? process.env.MATE_ALLOWED_AGENTS.split(",")
135
+ .map((a) => a.trim())
136
+ .filter(Boolean)
137
+ : [];
138
+
139
+ const tokensaveEnabled = enabledCapabilities.includes("tokensave");
140
+ const headroomEnabled = enabledCapabilities.includes("headroom");
141
+ const rtkEnabled = enabledCapabilities.includes("rtk");
142
+
143
+ const ccusageResult = await collectCcusageSpending(options.days, deps);
144
+ if (ccusageResult.friendlyError) console.warn(ccusageResult.friendlyError);
145
+
146
+ let tokensaveWorking: Awaited<ReturnType<typeof collectTokenSaveSavings>> = {
147
+ entry: null,
148
+ status: { name: "tokensave (savings)", enabled: false, status: "not enabled" },
149
+ };
150
+ let tokensaveCompanion: Awaited<ReturnType<typeof collectTokenSaveSavings>> = {
151
+ entry: null,
152
+ status: { name: "tokensave (savings)", enabled: false, status: "not enabled" },
153
+ };
154
+ let rtkResult: Awaited<ReturnType<typeof collectRTKSavings>> = {
155
+ entry: null,
156
+ status: { name: "rtk", enabled: false, status: "not enabled" },
157
+ };
158
+ let headroomResult: Awaited<ReturnType<typeof collectHeadroomSavings>> = {
159
+ entry: null,
160
+ status: { name: "headroom", enabled: false, status: "not enabled" },
161
+ };
162
+
163
+ if (tokensaveEnabled) {
164
+ [tokensaveWorking, tokensaveCompanion] = await Promise.all([
165
+ collectTokenSaveSavings(workingRepoPath, options.days, deps),
166
+ collectTokenSaveSavings(companionPath, options.days, deps),
167
+ ]);
168
+ }
169
+ if (rtkEnabled) rtkResult = await collectRTKSavings(workingRepoPath, deps);
170
+ if (headroomEnabled) headroomResult = await collectHeadroomSavings(options.days, deps);
171
+
172
+ const merged = mergeResults(
173
+ ccusageResult.entries,
174
+ tokensaveWorking.entry,
175
+ tokensaveCompanion.entry,
176
+ rtkResult.entry,
177
+ headroomResult.entry,
178
+ );
179
+ const reportData: ReportData = {
180
+ days: options.days,
181
+ generatedAt: new Date().toISOString(),
182
+ workingRepoPath,
183
+ companionRepoPath: companionPath,
184
+ activeAgents,
185
+ enabledCapabilities,
186
+ spending: merged.spending,
187
+ savings: merged.savings,
188
+ toolStatus: [
189
+ ccusageResult.status,
190
+ tokensaveWorking.status,
191
+ rtkResult.status,
192
+ headroomResult.status,
193
+ ],
194
+ totalSpending: merged.totalSpending,
195
+ totalSavings: merged.totalSavings,
196
+ netSpend: merged.netSpend,
197
+ };
198
+ reportDocument = reportDataToDocument(reportData);
116
199
  }
117
200
 
118
- // Merge results
119
- const merged = mergeResults(
120
- ccusageResult.entries,
121
- tokensaveWorking.entry,
122
- tokensaveCompanion.entry,
123
- rtkResult.entry,
124
- headroomResult.entry,
125
- );
126
-
127
- const allToolStatus = [
128
- ccusageResult.status,
129
- tokensaveWorking.status,
130
- rtkResult.status,
131
- headroomResult.status,
132
- ];
133
-
134
- const reportData: ReportData = {
135
- days: options.days,
136
- generatedAt: new Date().toISOString(),
137
- workingRepoPath,
138
- companionRepoPath,
139
- activeAgents,
140
- enabledCapabilities,
141
- spending: merged.spending,
142
- savings: merged.savings,
143
- toolStatus: allToolStatus,
144
- totalSpending: merged.totalSpending,
145
- totalSavings: merged.totalSavings,
146
- netSpend: merged.netSpend,
147
- };
148
-
149
201
  if (options.json) {
150
- console.log(renderJSON(reportData));
202
+ console.log(renderJSON(reportDocument));
151
203
  return;
152
204
  }
153
205
 
154
- const markdown = renderMarkdown(reportData);
155
- const reportPath = path.join(companionRepoPath, "REPORT.md");
156
- await fs.writeFile(reportPath, markdown);
157
- console.log(`Report written to ${reportPath}`);
206
+ let reportPath: string | undefined;
207
+ try {
208
+ reportPath = await (deps.writeTemporaryReport ?? writeTemporaryReport)(
209
+ renderHTML(reportDocument),
210
+ );
211
+ await (deps.openReportInBrowser ?? openReportInBrowser)(reportPath);
212
+ console.log(`Report opened from ${reportPath}`);
213
+ } catch (error) {
214
+ const message = error instanceof Error ? error.message : String(error);
215
+ const location = reportPath ? ` at ${reportPath}` : "";
216
+ console.warn(`Unable to open HTML report${location}: ${message}`);
217
+ console.log(renderJSON(reportDocument));
218
+ }
158
219
  }
@@ -1,108 +1,263 @@
1
- import type { ReportData } from "./types";
2
- import { formatCost, formatTokens } from "./formatter";
1
+ import { reportDataToDocument } from "./adapter";
2
+ import { validateReportDocument } from "./contract";
3
+ import type {
4
+ ReportData,
5
+ ReportDocument,
6
+ ReportKeyValue,
7
+ ReportSection,
8
+ ReportValue,
9
+ } from "./types";
10
+
11
+ type RenderInput = ReportDocument | ReportData;
12
+
13
+ function isReportDocument(input: RenderInput): input is ReportDocument {
14
+ return "version" in input;
15
+ }
16
+
17
+ function normalize(input: RenderInput): ReportDocument {
18
+ return validateReportDocument(isReportDocument(input) ? input : reportDataToDocument(input));
19
+ }
20
+
21
+ function valueToString(value: ReportValue): string {
22
+ if (value === null) return "N/A";
23
+ return String(value);
24
+ }
3
25
 
4
26
  function renderTable(headers: string[], rows: string[][]): string {
5
- if (rows.length === 0) {
6
- return `| ${headers.join(" | ")} |\n`;
7
- }
27
+ if (rows.length === 0) return `| ${headers.join(" | ")} |`;
8
28
 
9
29
  const widths = headers.map((header, index) =>
10
30
  Math.max(header.length, ...rows.map((row) => (row[index] ?? "").length)),
11
31
  );
12
-
13
32
  const renderRow = (row: string[]): string =>
14
33
  `| ${row.map((value, index) => value.padEnd(widths[index])).join(" | ")} |`;
15
-
16
34
  const divider = `| ${widths.map((width) => "-".repeat(width)).join(" | ")} |`;
17
35
  return [renderRow(headers), divider, ...rows.map(renderRow)].join("\n");
18
36
  }
19
37
 
20
- function renderKeyValueTable(rows: Array<[string, string]>): string {
21
- return renderTable(["Field", "Value"], rows);
38
+ function renderKeyValueTable(rows: ReportKeyValue[]): string {
39
+ return renderTable(
40
+ ["Field", "Value"],
41
+ rows.map((row) => [row.label, valueToString(row.value)]),
42
+ );
22
43
  }
23
44
 
24
45
  function printSection(title: string, body: string): string {
25
46
  return `${title}\n\n${body}\n`;
26
47
  }
27
48
 
28
- export function renderMarkdown(data: ReportData): string {
29
- const sections: string[] = [];
49
+ function emptyTableMessage(title: string): string {
50
+ const normalized = title.toLowerCase();
51
+ return normalized === "spending" || normalized === "savings"
52
+ ? `No ${normalized} data available.`
53
+ : "No data available.";
54
+ }
30
55
 
31
- // Header
32
- sections.push(`# Token Usage Report\n`);
33
- sections.push(
34
- renderKeyValueTable([
35
- ["Window", `Last ${data.days} days`],
36
- ["Generated", data.generatedAt],
37
- ["Capabilities", data.enabledCapabilities.join(", ") || "None"],
38
- ]),
39
- );
56
+ function sectionMarkdown(section: ReportSection): string {
57
+ switch (section.type) {
58
+ case "metadata":
59
+ case "key-value":
60
+ return renderKeyValueTable(section.items);
61
+ case "metrics":
62
+ return renderTable(
63
+ ["Metric", "Value"],
64
+ section.items.map((item) => [item.label, valueToString(item.value)]),
65
+ );
66
+ case "table":
67
+ return section.rows.length
68
+ ? renderTable(
69
+ section.columns,
70
+ section.rows.map((row) => row.map(valueToString)),
71
+ )
72
+ : emptyTableMessage(section.title);
73
+ case "statuses":
74
+ return section.items.length
75
+ ? renderTable(
76
+ ["Status", "Value"],
77
+ section.items.map((item) => [
78
+ item.label,
79
+ item.detail ? `${item.status}: ${item.detail}` : item.status,
80
+ ]),
81
+ )
82
+ : "No data available.";
83
+ case "text":
84
+ return section.content;
85
+ }
86
+ }
40
87
 
41
- // Summary
88
+ export function renderMarkdown(input: RenderInput): string {
89
+ const data = normalize(input);
90
+ const sections: string[] = [`# ${data.title}\n`];
91
+ const metadata = [...data.metadata];
92
+ if (data.period) metadata.unshift({ label: "Period", value: data.period });
93
+ if (data.context) metadata.push({ label: "Context", value: data.context });
94
+ sections.push(renderKeyValueTable(metadata));
42
95
  sections.push(
43
96
  "\n" +
44
97
  printSection(
45
98
  "## Summary",
46
- renderKeyValueTable([
47
- ["Total Spending", formatCost(data.totalSpending)],
48
- ["Total Savings", formatCost(data.totalSavings)],
49
- ["Net Spend", formatCost(data.netSpend)],
50
- ]),
51
- ),
52
- );
53
-
54
- // Spending
55
- if (data.spending.length > 0) {
56
- const spendingRows = data.spending.map((entry) => [
57
- entry.model,
58
- formatTokens(entry.inputTokens),
59
- formatTokens(entry.outputTokens),
60
- formatTokens(entry.cacheReadTokens),
61
- formatTokens(entry.cacheWriteTokens),
62
- formatCost(entry.cost),
63
- ]);
64
- sections.push(
65
- printSection(
66
- "## Spending",
67
99
  renderTable(
68
- ["Model", "Input", "Output", "Cache Read", "Cache Write", "Cost (est.)"],
69
- spendingRows,
100
+ ["Metric", "Value"],
101
+ data.summary.map((item) => [item.label, valueToString(item.value)]),
70
102
  ),
71
103
  ),
72
- );
73
- } else {
74
- sections.push(printSection("## Spending", "No spending data available."));
104
+ );
105
+ for (const section of data.sections) {
106
+ sections.push(printSection(`## ${section.title}`, sectionMarkdown(section)));
75
107
  }
108
+ return sections.join("\n");
109
+ }
76
110
 
77
- // Savings
78
- if (data.savings.length > 0) {
79
- const savingsRows = data.savings.map((entry) => [
80
- entry.tool,
81
- entry.tokensSaved === 0 ? "N/A" : formatTokens(entry.tokensSaved),
82
- String(entry.calls),
83
- entry.costSaved === 0 ? "N/A" : formatCost(entry.costSaved),
84
- entry.efficiency,
85
- ]);
86
- sections.push(
87
- printSection(
88
- "## Savings",
89
- renderTable(["Tool", "Tokens Saved", "Calls", "Cost Saved", "Efficiency"], savingsRows),
90
- ),
91
- );
92
- } else {
93
- sections.push(printSection("## Savings", "No savings data available."));
94
- }
111
+ export function renderJSON(input: RenderInput): string {
112
+ if (!isReportDocument(input)) return JSON.stringify(input, null, 2);
113
+ return JSON.stringify(normalize(input), null, 2);
114
+ }
95
115
 
96
- // Tool Status
97
- const toolStatusRows = data.toolStatus.map((entry) => {
98
- const icon = entry.enabled ? "✅" : "⏸️";
99
- return [entry.name, `${icon} ${entry.status}`];
116
+ function escapeHTML(value: string): string {
117
+ return value.replace(/[&<>"']/g, (character) => {
118
+ switch (character) {
119
+ case "&":
120
+ return "&amp;";
121
+ case "<":
122
+ return "&lt;";
123
+ case ">":
124
+ return "&gt;";
125
+ case '"':
126
+ return "&quot;";
127
+ default:
128
+ return "&#39;";
129
+ }
100
130
  });
101
- sections.push(printSection("## Tool Status", renderTable(["Tool", "Status"], toolStatusRows)));
131
+ }
102
132
 
103
- return sections.join("\n");
133
+ function renderHTMLTable(headers: string[], rows: string[][]): string {
134
+ const headerHTML = headers.map((header) => `<th scope="col">${escapeHTML(header)}</th>`).join("");
135
+ const rowsHTML = rows.length
136
+ ? rows
137
+ .map((row) => `<tr>${row.map((value) => `<td>${escapeHTML(value)}</td>`).join("")}</tr>`)
138
+ .join("")
139
+ : "";
140
+ return `<div class="table-wrap"><table><thead><tr>${headerHTML}</tr></thead><tbody>${rowsHTML}</tbody></table></div>`;
104
141
  }
105
142
 
106
- export function renderJSON(data: ReportData): string {
107
- return JSON.stringify(data, null, 2);
143
+ function emptyHTML(message: string): string {
144
+ return `<p class="empty">${escapeHTML(message)}</p>`;
108
145
  }
146
+
147
+ function renderHTMLKeyValues(items: ReportKeyValue[]): string {
148
+ return items.length
149
+ ? renderHTMLTable(
150
+ ["Field", "Value"],
151
+ items.map((item) => [item.label, valueToString(item.value)]),
152
+ )
153
+ : emptyHTML("No metadata available.");
154
+ }
155
+
156
+ function renderHTMLSectionBody(section: ReportSection): string {
157
+ switch (section.type) {
158
+ case "metadata":
159
+ case "key-value":
160
+ return renderHTMLKeyValues(section.items);
161
+ case "metrics":
162
+ return section.items.length
163
+ ? renderHTMLTable(
164
+ ["Metric", "Value"],
165
+ section.items.map((item) => [item.label, valueToString(item.value)]),
166
+ )
167
+ : emptyHTML("No metrics available.");
168
+ case "table":
169
+ return section.rows.length
170
+ ? renderHTMLTable(
171
+ section.columns,
172
+ section.rows.map((row) => row.map(valueToString)),
173
+ )
174
+ : emptyHTML(emptyTableMessage(section.title));
175
+ case "statuses":
176
+ return section.items.length
177
+ ? renderHTMLTable(
178
+ ["Status", "Value"],
179
+ section.items.map((item) => [
180
+ item.label,
181
+ item.detail ? `${item.status}: ${item.detail}` : item.status,
182
+ ]),
183
+ )
184
+ : emptyHTML("No status data available.");
185
+ case "text":
186
+ return section.content
187
+ ? `<p class="report-text">${escapeHTML(section.content).replaceAll("\n", "<br>")}</p>`
188
+ : emptyHTML("No text available.");
189
+ }
190
+ }
191
+
192
+ function renderHTMLSection(section: ReportSection): string {
193
+ return `<section id="${escapeHTML(section.id)}"><h2>${escapeHTML(section.title)}</h2>${renderHTMLSectionBody(section)}</section>`;
194
+ }
195
+
196
+ export function renderHTML(input: RenderInput): string {
197
+ const data = normalize(input);
198
+ const legacy = isReportDocument(input) ? undefined : input;
199
+ const metadata = [...data.metadata];
200
+ if (data.period) metadata.unshift({ label: "Period", value: data.period });
201
+ if (data.context) metadata.push({ label: "Context", value: data.context });
202
+ const summary: ReportMetricsSection = {
203
+ id: "summary",
204
+ title: "Summary",
205
+ type: "metrics",
206
+ items: data.summary,
207
+ };
208
+
209
+ const repositoryAttributes = legacy
210
+ ? ` data-working-repository="${escapeHTML(legacy.workingRepoPath)}" data-companion-repository="${escapeHTML(legacy.companionRepoPath)}"`
211
+ : "";
212
+
213
+ return `<!doctype html>
214
+ <html lang="en">
215
+ <head>
216
+ <meta charset="utf-8">
217
+ <meta name="viewport" content="width=device-width, initial-scale=1">
218
+ <title>${escapeHTML(data.title)}</title>
219
+ <style>
220
+ :root { color-scheme: light; font-family: system-ui, sans-serif; background: #f4f6f8; color: #18212b; }
221
+ body { margin: 0; }
222
+ .report { max-width: 1120px; margin: 0 auto; padding: 32px 20px 56px; }
223
+ header, section { background: #fff; border: 1px solid #dce2e8; border-radius: 14px; margin-bottom: 20px; padding: 24px; box-shadow: 0 8px 24px rgb(24 33 43 / 6%); }
224
+ h1, h2 { margin-top: 0; }
225
+ h1 { margin-bottom: 8px; }
226
+ h2 { font-size: 1.15rem; }
227
+ .eyebrow { color: #536273; font-size: .8rem; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; }
228
+ .print-control { background: #18212b; border: 0; border-radius: 8px; color: #fff; cursor: pointer; padding: 10px 14px; }
229
+ .table-wrap { overflow-x: auto; }
230
+ table { border-collapse: collapse; min-width: 100%; }
231
+ th, td { border-bottom: 1px solid #e8edf1; padding: 10px 12px; text-align: left; vertical-align: top; }
232
+ th { color: #536273; font-size: .8rem; text-transform: uppercase; }
233
+ .empty { color: #536273; margin: 0; }
234
+ @media (max-width: 640px) { .report { padding: 16px 12px 32px; } header, section { padding: 16px; } }
235
+ @media print {
236
+ :root, body { background: #fff; color: #000; }
237
+ .report { max-width: none; padding: 0; }
238
+ header, section { border: 0; border-radius: 0; box-shadow: none; margin-bottom: 16px; padding: 0; }
239
+ .print-control, .interactive-only { display: none !important; }
240
+ .table-wrap { overflow: visible; }
241
+ table { width: 100%; }
242
+ th, td { color: #000; }
243
+ }
244
+ </style>
245
+ </head>
246
+ <body>
247
+ <main class="report" data-report-version="${data.version}"${repositoryAttributes}>
248
+ <header>
249
+ <p class="eyebrow">Mate report</p>
250
+ <h1>${escapeHTML(data.title)}</h1>
251
+ ${renderHTMLKeyValues(metadata)}
252
+ <button class="print-control interactive-only" type="button" onclick="window.print()">Print / Save as PDF</button>
253
+ </header>
254
+ ${renderHTMLSection(summary)}
255
+ ${data.sections.map(renderHTMLSection).join("\n ")}
256
+ </main>
257
+ </body>
258
+ </html>`;
259
+ }
260
+
261
+ type ReportMetricsSection = Extract<ReportSection, { type: "metrics" }>;
262
+
263
+ export { escapeHTML };