ai-localize-reporting 1.0.1 → 2.0.1
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/CHANGELOG.md +19 -0
- package/dist/index.d.mts +7 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +141 -65
- package/dist/index.mjs +141 -65
- package/package.json +2 -2
- package/src/html-reporter.ts +327 -65
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# ai-localize-reporting
|
|
2
|
+
|
|
3
|
+
## 2.0.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Updated dependencies
|
|
8
|
+
- ai-localize-shared@2.0.1
|
|
9
|
+
|
|
10
|
+
## 2.0.0
|
|
11
|
+
|
|
12
|
+
### Major Changes
|
|
13
|
+
|
|
14
|
+
- versoion change
|
|
15
|
+
|
|
16
|
+
### Patch Changes
|
|
17
|
+
|
|
18
|
+
- Updated dependencies
|
|
19
|
+
- ai-localize-shared@2.0.0
|
package/dist/index.d.mts
CHANGED
|
@@ -8,6 +8,13 @@ interface ReportInput {
|
|
|
8
8
|
}
|
|
9
9
|
declare function buildReport(input: ReportInput): Report;
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Generates a comprehensive, self-contained HTML report file.
|
|
13
|
+
*
|
|
14
|
+
* @param report - The report data object built by `buildReport()`
|
|
15
|
+
* @param outputPath - Full path to the output HTML file
|
|
16
|
+
* e.g. `.ai-localize-reports/report.html`
|
|
17
|
+
*/
|
|
11
18
|
declare function generateHtmlReport(report: Report, outputPath: string): void;
|
|
12
19
|
|
|
13
20
|
declare function printCliSummary(report: Report): void;
|
package/dist/index.d.ts
CHANGED
|
@@ -8,6 +8,13 @@ interface ReportInput {
|
|
|
8
8
|
}
|
|
9
9
|
declare function buildReport(input: ReportInput): Report;
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Generates a comprehensive, self-contained HTML report file.
|
|
13
|
+
*
|
|
14
|
+
* @param report - The report data object built by `buildReport()`
|
|
15
|
+
* @param outputPath - Full path to the output HTML file
|
|
16
|
+
* e.g. `.ai-localize-reports/report.html`
|
|
17
|
+
*/
|
|
11
18
|
declare function generateHtmlReport(report: Report, outputPath: string): void;
|
|
12
19
|
|
|
13
20
|
declare function printCliSummary(report: Report): void;
|
package/dist/index.js
CHANGED
|
@@ -70,73 +70,149 @@ var path = __toESM(require("path"));
|
|
|
70
70
|
var import_ai_localize_shared = require("ai-localize-shared");
|
|
71
71
|
function generateHtmlReport(report, outputPath) {
|
|
72
72
|
(0, import_ai_localize_shared.ensureDir)(path.dirname(outputPath));
|
|
73
|
-
const html =
|
|
74
|
-
<html lang="en">
|
|
75
|
-
<head>
|
|
76
|
-
<meta charset="UTF-8">
|
|
77
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
78
|
-
<title>ai-localize Report - ${report.timestamp}</title>
|
|
79
|
-
<style>
|
|
80
|
-
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; max-width: 1200px; margin: 0 auto; padding: 24px; background: #f5f5f5; }
|
|
81
|
-
h1 { color: #1a1a2e; } h2 { color: #16213e; border-bottom: 2px solid #0f3460; padding-bottom: 8px; }
|
|
82
|
-
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin: 24px 0; }
|
|
83
|
-
.stat-card { background: white; border-radius: 8px; padding: 20px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); text-align: center; }
|
|
84
|
-
.stat-number { font-size: 2.5rem; font-weight: 700; color: #0f3460; }
|
|
85
|
-
.stat-label { color: #666; font-size: 0.9rem; margin-top: 4px; }
|
|
86
|
-
.error { color: #e53e3e; } .warning { color: #d69e2e; } .success { color: #38a169; }
|
|
87
|
-
table { width: 100%; border-collapse: collapse; background: white; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
|
|
88
|
-
th { background: #0f3460; color: white; padding: 12px 16px; text-align: left; }
|
|
89
|
-
td { padding: 10px 16px; border-bottom: 1px solid #f0f0f0; }
|
|
90
|
-
tr:hover td { background: #f8f9ff; }
|
|
91
|
-
.badge { display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 0.75rem; font-weight: 600; }
|
|
92
|
-
.badge-blue { background: #ebf4ff; color: #2b6cb0; }
|
|
93
|
-
.badge-red { background: #fff5f5; color: #c53030; }
|
|
94
|
-
.badge-green { background: #f0fff4; color: #276749; }
|
|
95
|
-
</style>
|
|
96
|
-
</head>
|
|
97
|
-
<body>
|
|
98
|
-
<h1>\u{1F310} ai-localize Report</h1>
|
|
99
|
-
<p><strong>Generated:</strong> ${report.timestamp} | <strong>Framework:</strong> ${report.framework} | <strong>Duration:</strong> ${report.duration}ms</p>
|
|
100
|
-
|
|
101
|
-
<div class="stats">
|
|
102
|
-
<div class="stat-card"><div class="stat-number">${report.filesScanned}</div><div class="stat-label">Files Scanned</div></div>
|
|
103
|
-
<div class="stat-card"><div class="stat-number error">${report.hardcodedTexts}</div><div class="stat-label">Hardcoded Texts</div></div>
|
|
104
|
-
<div class="stat-card"><div class="stat-number success">${report.localeKeysGenerated}</div><div class="stat-label">Keys Generated</div></div>
|
|
105
|
-
<div class="stat-card"><div class="stat-number warning">${report.unusedKeys}</div><div class="stat-label">Unused Keys</div></div>
|
|
106
|
-
<div class="stat-card"><div class="stat-number error">${report.missingTranslations}</div><div class="stat-label">Missing Translations</div></div>
|
|
107
|
-
<div class="stat-card"><div class="stat-number">${report.assets.uploadedAssets}</div><div class="stat-label">Assets Uploaded</div></div>
|
|
108
|
-
</div>
|
|
109
|
-
|
|
110
|
-
${report.details.detectedTexts.length > 0 ? `
|
|
111
|
-
<h2>Hardcoded Texts (${report.details.detectedTexts.length})</h2>
|
|
112
|
-
<table>
|
|
113
|
-
<thead><tr><th>File</th><th>Line</th><th>Text</th><th>Suggested Key</th><th>Context</th></tr></thead>
|
|
114
|
-
<tbody>
|
|
115
|
-
${report.details.detectedTexts.slice(0, 100).map((t) => `
|
|
116
|
-
<tr>
|
|
117
|
-
<td><code>${t.filePath.split("/").slice(-3).join("/")}</code></td>
|
|
118
|
-
<td>${t.line}</td>
|
|
119
|
-
<td>${t.text.slice(0, 60)}${t.text.length > 60 ? "..." : ""}</td>
|
|
120
|
-
<td><span class="badge badge-blue">${t.suggestedKey}</span></td>
|
|
121
|
-
<td><span class="badge badge-green">${t.context}</span></td>
|
|
122
|
-
</tr>`).join("")}
|
|
123
|
-
${report.details.detectedTexts.length > 100 ? `<tr><td colspan="5" style="text-align:center;color:#666">...and ${report.details.detectedTexts.length - 100} more</td></tr>` : ""}
|
|
124
|
-
</tbody>
|
|
125
|
-
</table>` : ""}
|
|
126
|
-
|
|
127
|
-
${report.details.missingKeys.length > 0 ? `
|
|
128
|
-
<h2>Missing Translations (${report.details.missingKeys.length})</h2>
|
|
129
|
-
<table>
|
|
130
|
-
<thead><tr><th>Key</th><th>Language</th><th>Message</th></tr></thead>
|
|
131
|
-
<tbody>
|
|
132
|
-
${report.details.missingKeys.map((e) => `<tr><td><code>${e.key}</code></td><td><span class="badge badge-red">${e.language || ""}</span></td><td>${e.message}</td></tr>`).join("")}
|
|
133
|
-
</tbody>
|
|
134
|
-
</table>` : ""}
|
|
135
|
-
|
|
136
|
-
</body>
|
|
137
|
-
</html>`;
|
|
73
|
+
const html = buildHtml(report);
|
|
138
74
|
fs.writeFileSync(outputPath, html, "utf-8");
|
|
139
75
|
}
|
|
76
|
+
function esc(s) {
|
|
77
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
78
|
+
}
|
|
79
|
+
function badge(text, color) {
|
|
80
|
+
return '<span class="badge badge-' + color + '">' + esc(text) + "</span>";
|
|
81
|
+
}
|
|
82
|
+
function sectionHeader(title, legend) {
|
|
83
|
+
return '\n<div class="section-header">\n <h2>' + title + '</h2>\n <p class="legend">' + legend + "</p>\n</div>";
|
|
84
|
+
}
|
|
85
|
+
function buildHtml(report) {
|
|
86
|
+
const {
|
|
87
|
+
timestamp,
|
|
88
|
+
framework,
|
|
89
|
+
duration,
|
|
90
|
+
filesScanned,
|
|
91
|
+
hardcodedTexts,
|
|
92
|
+
localeKeysGenerated,
|
|
93
|
+
unusedKeys,
|
|
94
|
+
missingTranslations,
|
|
95
|
+
assets,
|
|
96
|
+
details
|
|
97
|
+
} = report;
|
|
98
|
+
const scanDate = new Date(timestamp).toLocaleString();
|
|
99
|
+
const hardcodedRows = details.detectedTexts.slice(0, 200).map(
|
|
100
|
+
(t) => '<tr><td><code class="path">' + esc(t.filePath.split("/").slice(-3).join("/")) + '</code></td><td class="center">' + t.line + '</td><td class="text-cell">' + esc(t.text.slice(0, 80)) + (t.text.length > 80 ? "…" : "") + '</td><td><code class="key">' + esc(t.suggestedKey) + "</code></td><td>" + badge(t.context, "blue") + "</td></tr>"
|
|
101
|
+
).join("\n");
|
|
102
|
+
const hardcodedOverflow = details.detectedTexts.length > 200 ? '<tr><td colspan="5" class="overflow-row">…and ' + (details.detectedTexts.length - 200) + " more \u2014 run <code>ai-localize scan --output results.json</code> for the full list</td></tr>" : "";
|
|
103
|
+
const hardcodedSection = details.detectedTexts.length > 0 ? sectionHeader(
|
|
104
|
+
"🔍 Hardcoded Texts Found (" + details.detectedTexts.length + ")",
|
|
105
|
+
"These are raw text strings discovered in your source files that are <strong>not yet wrapped</strong> in a translation call. Each row shows the file, line number, the text itself, the suggested locale key that would be generated, and the AST context. Run <code>ai-localize extract</code> to generate locale files and <code>ai-localize full-migrate</code> to wrap them automatically."
|
|
106
|
+
) + "\n<table>\n<thead><tr><th>File (last 3 segments)</th><th>Line</th><th>Text</th><th>Suggested Key</th><th>Context</th></tr></thead>\n<tbody>" + hardcodedRows + hardcodedOverflow + "</tbody>\n</table>" : sectionHeader(
|
|
107
|
+
"✅ Hardcoded Texts Found (0)",
|
|
108
|
+
"No hardcoded text was detected. All user-visible strings appear to be wrapped in translation calls already."
|
|
109
|
+
);
|
|
110
|
+
const missingRows = details.missingKeys.map(
|
|
111
|
+
(e) => '<tr><td><code class="key">' + esc(e.key) + "</code></td><td>" + (e.language ? badge(e.language, "red") : "\u2014") + "</td><td>" + (e.filePath ? '<code class="path">' + esc(e.filePath) + "</code>" : "\u2014") + "</td><td>" + esc(e.message) + "</td></tr>"
|
|
112
|
+
).join("\n");
|
|
113
|
+
const missingSection = details.missingKeys.length > 0 ? sectionHeader(
|
|
114
|
+
"❌ Missing Translations (" + details.missingKeys.length + ")",
|
|
115
|
+
"These locale keys exist in the <strong>default language</strong> file but are <strong>absent</strong> in one or more target language files. A missing translation means your app will fall back to the default language (or show a blank / raw key) for users of that language. Ask your translators to fill in these entries. Running <code>ai-localize extract</code> now seeds all target language files with the source value so nothing is blank."
|
|
116
|
+
) + "\n<table>\n<thead><tr><th>Key</th><th>Missing In Language</th><th>Locale File</th><th>Details</th></tr></thead>\n<tbody>" + missingRows + "</tbody>\n</table>" : sectionHeader(
|
|
117
|
+
"✅ Missing Translations (0)",
|
|
118
|
+
"All keys present in the default language are also present in every target language file."
|
|
119
|
+
);
|
|
120
|
+
const unusedRows = details.unusedKeysList.map((key) => '<tr><td><code class="key">' + esc(key) + "</code></td></tr>").join("\n");
|
|
121
|
+
const unusedSection = details.unusedKeysList.length > 0 ? sectionHeader(
|
|
122
|
+
"🗑️ Unused Keys (" + details.unusedKeysList.length + ")",
|
|
123
|
+
"These translation keys exist in your locale JSON files but are <strong>not referenced anywhere</strong> in the scanned source code. They are likely left over from removed features or renamed components. Unused keys bloat your locale bundles and can confuse translators. Run <code>ai-localize cleanup</code> to remove them (review the list carefully first)."
|
|
124
|
+
) + "\n<table>\n<thead><tr><th>Unused Key</th></tr></thead>\n<tbody>" + unusedRows + "</tbody>\n</table>" : sectionHeader(
|
|
125
|
+
"✅ Unused Keys (0)",
|
|
126
|
+
"No unused translation keys detected. Your locale files are clean."
|
|
127
|
+
);
|
|
128
|
+
const assetRows = details.assets.map(
|
|
129
|
+
(a) => '<tr><td><code class="path">' + esc(a.localPath.split("/").slice(-3).join("/")) + '</code></td><td><code class="key">' + esc(a.s3Key) + "</code></td><td><code>" + esc(a.cloudfrontUrl) + '</code></td><td class="center">' + (a.size / 1024).toFixed(1) + " KB</td><td>" + badge(a.contentType, "grey") + "</td></tr>"
|
|
130
|
+
).join("\n");
|
|
131
|
+
const assetsSectionTitle = "📦 Assets (" + assets.totalAssets + " found · " + assets.uploadedAssets + " uploaded · " + assets.replacedUrls + " URLs replaced · " + assets.legacyCdnUrls + " legacy CDN URLs)";
|
|
132
|
+
const assetsLegend = "<strong>Total assets</strong> = all static asset references found in source (images, fonts, CSS, JS). <strong>Uploaded</strong> = files pushed to S3/CloudFront in this run. <strong>Replaced URLs</strong> = legacy CDN references rewritten to the new CloudFront URL in source files. <strong>Legacy CDN URLs</strong> = old CDN references still present in source that have not yet been replaced \u2014 run <code>ai-localize replace-cdn</code> to fix them.";
|
|
133
|
+
const assetsSection = sectionHeader(assetsSectionTitle, assetsLegend) + (details.assets.length > 0 ? "\n<table>\n<thead><tr><th>Local Path</th><th>S3 Key</th><th>CloudFront URL</th><th>Size</th><th>Content Type</th></tr></thead>\n<tbody>" + assetRows + "</tbody>\n</table>" : '\n<p class="empty-state">No assets were uploaded in this run. Use <code>ai-localize upload-assets</code> to push static assets to S3/CloudFront.</p>');
|
|
134
|
+
const diff = Math.abs(hardcodedTexts - localeKeysGenerated);
|
|
135
|
+
const diffExplainer = hardcodedTexts !== localeKeysGenerated ? '<div class="info-box"><strong>ℹ️ Why do Hardcoded Texts (' + hardcodedTexts + ") and Keys Generated (" + localeKeysGenerated + ") differ?</strong><br><ul><li><strong>Hardcoded Texts</strong> = total raw string occurrences across all files (same string in 5 files = 5).</li><li><strong>Keys Generated</strong> = number of <em>unique</em> locale keys after deduplication. Identical strings share one key.</li><li>The difference (" + diff + ") = duplicate strings consolidated into shared keys.</li></ul></div>" : '<div class="info-box success-box"><strong>✅ Hardcoded Texts and Keys Generated match (' + hardcodedTexts + ").</strong> Every detected string maps to a unique locale key.</div>";
|
|
136
|
+
const statCard = (num, label, hint, colorClass) => '<div class="stat-card"><div class="num ' + colorClass + '">' + num + '</div><div class="lbl">' + label + '</div><div class="hint">' + hint + "</div></div>";
|
|
137
|
+
const summaryCards = '<div class="stats">' + statCard(filesScanned, "Files Scanned", "Source files inspected by AST scanner", "num-neutral") + statCard(hardcodedTexts, "Hardcoded Texts", "Raw strings not yet wrapped in t()", hardcodedTexts > 0 ? "num-warn" : "num-ok") + statCard(localeKeysGenerated, "Keys Generated", "Unique locale keys (deduplicated)", "num-ok") + statCard(missingTranslations, "Missing Translations", "Keys absent in target languages", missingTranslations > 0 ? "num-err" : "num-ok") + statCard(unusedKeys, "Unused Keys", "Keys in locale files not used in code", unusedKeys > 0 ? "num-warn" : "num-ok") + statCard(assets.totalAssets, "Assets Found", "Static asset references in source", "num-neutral") + statCard(assets.uploadedAssets, "Assets Uploaded", "Pushed to S3/CloudFront", "num-ok") + statCard(assets.legacyCdnUrls, "Legacy CDN URLs", "Old CDN refs not yet replaced", assets.legacyCdnUrls > 0 ? "num-warn" : "num-ok") + "</div>";
|
|
138
|
+
return '<!DOCTYPE html>\n<html lang="en">\n<head>\n<meta charset="UTF-8">\n<meta name="viewport" content="width=device-width, initial-scale=1.0">\n<title>ai-localize Report — ' + scanDate + "</title>\n<style>\n" + CSS + "\n</style>\n</head>\n<body>\n<h1>🌐 ai-localize Report <small>— " + esc(framework) + '</small></h1>\n<p class="meta"><strong>Generated:</strong> ' + scanDate + " | <strong>Duration:</strong> " + duration + "ms | <strong>Files Scanned:</strong> " + filesScanned + "</p>\n" + summaryCards + "\n" + diffExplainer + "\n" + hardcodedSection + "\n" + missingSection + "\n" + unusedSection + "\n" + assetsSection + "\n<footer>Generated by <strong>ai-localize-core</strong> — deterministic, offline-capable i18n tooling</footer>\n</body>\n</html>";
|
|
139
|
+
}
|
|
140
|
+
var CSS = `
|
|
141
|
+
*, *::before, *::after { box-sizing: border-box; }
|
|
142
|
+
body {
|
|
143
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
144
|
+
max-width: 1280px; margin: 0 auto; padding: 32px 24px;
|
|
145
|
+
background: #f0f2f5; color: #1a1a2e; line-height: 1.6;
|
|
146
|
+
}
|
|
147
|
+
h1 { font-size: 2rem; color: #0f3460; margin-bottom: 4px; }
|
|
148
|
+
h1 small { font-size: 1rem; font-weight: 400; color: #666; }
|
|
149
|
+
h2 { font-size: 1.2rem; color: #16213e; margin: 0 0 6px; }
|
|
150
|
+
.meta { color: #666; font-size: 0.9rem; margin-bottom: 32px; }
|
|
151
|
+
.meta strong { color: #333; }
|
|
152
|
+
.stats {
|
|
153
|
+
display: grid;
|
|
154
|
+
grid-template-columns: repeat(auto-fit, minmax(155px, 1fr));
|
|
155
|
+
gap: 14px; margin-bottom: 32px;
|
|
156
|
+
}
|
|
157
|
+
.stat-card {
|
|
158
|
+
background: white; border-radius: 10px; padding: 18px 14px;
|
|
159
|
+
box-shadow: 0 2px 8px rgba(0,0,0,0.08); text-align: center;
|
|
160
|
+
}
|
|
161
|
+
.stat-card .num { font-size: 2rem; font-weight: 700; line-height: 1; }
|
|
162
|
+
.stat-card .lbl { color: #444; font-size: 0.82rem; margin-top: 6px; font-weight: 600; }
|
|
163
|
+
.stat-card .hint { color: #999; font-size: 0.75rem; margin-top: 3px; font-style: italic; }
|
|
164
|
+
.num-neutral { color: #0f3460; }
|
|
165
|
+
.num-ok { color: #38a169; }
|
|
166
|
+
.num-warn { color: #d69e2e; }
|
|
167
|
+
.num-err { color: #e53e3e; }
|
|
168
|
+
.section-header { margin: 40px 0 12px; }
|
|
169
|
+
.legend {
|
|
170
|
+
font-size: 0.875rem; color: #555;
|
|
171
|
+
background: #f8f9ff; border-left: 4px solid #0f3460;
|
|
172
|
+
padding: 10px 14px; border-radius: 0 6px 6px 0; margin: 8px 0 14px;
|
|
173
|
+
}
|
|
174
|
+
.legend code { background: #e8ebff; padding: 1px 5px; border-radius: 3px; font-size: 0.85em; }
|
|
175
|
+
table {
|
|
176
|
+
width: 100%; border-collapse: collapse; background: white;
|
|
177
|
+
border-radius: 10px; overflow: hidden;
|
|
178
|
+
box-shadow: 0 2px 8px rgba(0,0,0,0.08); margin-bottom: 8px;
|
|
179
|
+
}
|
|
180
|
+
th {
|
|
181
|
+
background: #0f3460; color: white; padding: 11px 14px;
|
|
182
|
+
text-align: left; font-size: 0.84rem; font-weight: 600;
|
|
183
|
+
}
|
|
184
|
+
td { padding: 9px 14px; border-bottom: 1px solid #f0f0f0; font-size: 0.84rem; vertical-align: top; }
|
|
185
|
+
tr:last-child td { border-bottom: none; }
|
|
186
|
+
tr:hover td { background: #f8f9ff; }
|
|
187
|
+
.center { text-align: center; }
|
|
188
|
+
.text-cell { max-width: 260px; word-break: break-word; }
|
|
189
|
+
code.path { color: #555; font-size: 0.81rem; }
|
|
190
|
+
code.key { color: #0f3460; font-size: 0.81rem; }
|
|
191
|
+
.overflow-row { text-align: center; color: #888; font-style: italic; padding: 12px; }
|
|
192
|
+
.badge {
|
|
193
|
+
display: inline-block; padding: 2px 8px; border-radius: 10px;
|
|
194
|
+
font-size: 0.75rem; font-weight: 600; white-space: nowrap;
|
|
195
|
+
}
|
|
196
|
+
.badge-blue { background: #ebf4ff; color: #2b6cb0; }
|
|
197
|
+
.badge-red { background: #fff5f5; color: #c53030; }
|
|
198
|
+
.badge-green { background: #f0fff4; color: #276749; }
|
|
199
|
+
.badge-orange { background: #fffaf0; color: #c05621; }
|
|
200
|
+
.badge-grey { background: #f7f7f7; color: #555; }
|
|
201
|
+
.info-box {
|
|
202
|
+
background: #ebf8ff; border-left: 4px solid #3182ce;
|
|
203
|
+
padding: 14px 18px; border-radius: 0 8px 8px 0;
|
|
204
|
+
margin: 0 0 28px; font-size: 0.9rem;
|
|
205
|
+
}
|
|
206
|
+
.info-box ul { margin: 8px 0 0 18px; padding: 0; }
|
|
207
|
+
.info-box li { margin-bottom: 4px; }
|
|
208
|
+
.success-box { background: #f0fff4; border-left-color: #38a169; }
|
|
209
|
+
.empty-state { color: #888; font-style: italic; font-size: 0.88rem; padding: 8px 0; }
|
|
210
|
+
footer {
|
|
211
|
+
margin-top: 48px; padding-top: 16px;
|
|
212
|
+
border-top: 1px solid #ddd; color: #aaa;
|
|
213
|
+
font-size: 0.8rem; text-align: center;
|
|
214
|
+
}
|
|
215
|
+
`;
|
|
140
216
|
|
|
141
217
|
// src/cli-reporter.ts
|
|
142
218
|
function printCliSummary(report) {
|
package/dist/index.mjs
CHANGED
|
@@ -32,73 +32,149 @@ import * as path from "path";
|
|
|
32
32
|
import { ensureDir } from "ai-localize-shared";
|
|
33
33
|
function generateHtmlReport(report, outputPath) {
|
|
34
34
|
ensureDir(path.dirname(outputPath));
|
|
35
|
-
const html =
|
|
36
|
-
<html lang="en">
|
|
37
|
-
<head>
|
|
38
|
-
<meta charset="UTF-8">
|
|
39
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
40
|
-
<title>ai-localize Report - ${report.timestamp}</title>
|
|
41
|
-
<style>
|
|
42
|
-
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; max-width: 1200px; margin: 0 auto; padding: 24px; background: #f5f5f5; }
|
|
43
|
-
h1 { color: #1a1a2e; } h2 { color: #16213e; border-bottom: 2px solid #0f3460; padding-bottom: 8px; }
|
|
44
|
-
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin: 24px 0; }
|
|
45
|
-
.stat-card { background: white; border-radius: 8px; padding: 20px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); text-align: center; }
|
|
46
|
-
.stat-number { font-size: 2.5rem; font-weight: 700; color: #0f3460; }
|
|
47
|
-
.stat-label { color: #666; font-size: 0.9rem; margin-top: 4px; }
|
|
48
|
-
.error { color: #e53e3e; } .warning { color: #d69e2e; } .success { color: #38a169; }
|
|
49
|
-
table { width: 100%; border-collapse: collapse; background: white; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
|
|
50
|
-
th { background: #0f3460; color: white; padding: 12px 16px; text-align: left; }
|
|
51
|
-
td { padding: 10px 16px; border-bottom: 1px solid #f0f0f0; }
|
|
52
|
-
tr:hover td { background: #f8f9ff; }
|
|
53
|
-
.badge { display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 0.75rem; font-weight: 600; }
|
|
54
|
-
.badge-blue { background: #ebf4ff; color: #2b6cb0; }
|
|
55
|
-
.badge-red { background: #fff5f5; color: #c53030; }
|
|
56
|
-
.badge-green { background: #f0fff4; color: #276749; }
|
|
57
|
-
</style>
|
|
58
|
-
</head>
|
|
59
|
-
<body>
|
|
60
|
-
<h1>\u{1F310} ai-localize Report</h1>
|
|
61
|
-
<p><strong>Generated:</strong> ${report.timestamp} | <strong>Framework:</strong> ${report.framework} | <strong>Duration:</strong> ${report.duration}ms</p>
|
|
62
|
-
|
|
63
|
-
<div class="stats">
|
|
64
|
-
<div class="stat-card"><div class="stat-number">${report.filesScanned}</div><div class="stat-label">Files Scanned</div></div>
|
|
65
|
-
<div class="stat-card"><div class="stat-number error">${report.hardcodedTexts}</div><div class="stat-label">Hardcoded Texts</div></div>
|
|
66
|
-
<div class="stat-card"><div class="stat-number success">${report.localeKeysGenerated}</div><div class="stat-label">Keys Generated</div></div>
|
|
67
|
-
<div class="stat-card"><div class="stat-number warning">${report.unusedKeys}</div><div class="stat-label">Unused Keys</div></div>
|
|
68
|
-
<div class="stat-card"><div class="stat-number error">${report.missingTranslations}</div><div class="stat-label">Missing Translations</div></div>
|
|
69
|
-
<div class="stat-card"><div class="stat-number">${report.assets.uploadedAssets}</div><div class="stat-label">Assets Uploaded</div></div>
|
|
70
|
-
</div>
|
|
71
|
-
|
|
72
|
-
${report.details.detectedTexts.length > 0 ? `
|
|
73
|
-
<h2>Hardcoded Texts (${report.details.detectedTexts.length})</h2>
|
|
74
|
-
<table>
|
|
75
|
-
<thead><tr><th>File</th><th>Line</th><th>Text</th><th>Suggested Key</th><th>Context</th></tr></thead>
|
|
76
|
-
<tbody>
|
|
77
|
-
${report.details.detectedTexts.slice(0, 100).map((t) => `
|
|
78
|
-
<tr>
|
|
79
|
-
<td><code>${t.filePath.split("/").slice(-3).join("/")}</code></td>
|
|
80
|
-
<td>${t.line}</td>
|
|
81
|
-
<td>${t.text.slice(0, 60)}${t.text.length > 60 ? "..." : ""}</td>
|
|
82
|
-
<td><span class="badge badge-blue">${t.suggestedKey}</span></td>
|
|
83
|
-
<td><span class="badge badge-green">${t.context}</span></td>
|
|
84
|
-
</tr>`).join("")}
|
|
85
|
-
${report.details.detectedTexts.length > 100 ? `<tr><td colspan="5" style="text-align:center;color:#666">...and ${report.details.detectedTexts.length - 100} more</td></tr>` : ""}
|
|
86
|
-
</tbody>
|
|
87
|
-
</table>` : ""}
|
|
88
|
-
|
|
89
|
-
${report.details.missingKeys.length > 0 ? `
|
|
90
|
-
<h2>Missing Translations (${report.details.missingKeys.length})</h2>
|
|
91
|
-
<table>
|
|
92
|
-
<thead><tr><th>Key</th><th>Language</th><th>Message</th></tr></thead>
|
|
93
|
-
<tbody>
|
|
94
|
-
${report.details.missingKeys.map((e) => `<tr><td><code>${e.key}</code></td><td><span class="badge badge-red">${e.language || ""}</span></td><td>${e.message}</td></tr>`).join("")}
|
|
95
|
-
</tbody>
|
|
96
|
-
</table>` : ""}
|
|
97
|
-
|
|
98
|
-
</body>
|
|
99
|
-
</html>`;
|
|
35
|
+
const html = buildHtml(report);
|
|
100
36
|
fs.writeFileSync(outputPath, html, "utf-8");
|
|
101
37
|
}
|
|
38
|
+
function esc(s) {
|
|
39
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
40
|
+
}
|
|
41
|
+
function badge(text, color) {
|
|
42
|
+
return '<span class="badge badge-' + color + '">' + esc(text) + "</span>";
|
|
43
|
+
}
|
|
44
|
+
function sectionHeader(title, legend) {
|
|
45
|
+
return '\n<div class="section-header">\n <h2>' + title + '</h2>\n <p class="legend">' + legend + "</p>\n</div>";
|
|
46
|
+
}
|
|
47
|
+
function buildHtml(report) {
|
|
48
|
+
const {
|
|
49
|
+
timestamp,
|
|
50
|
+
framework,
|
|
51
|
+
duration,
|
|
52
|
+
filesScanned,
|
|
53
|
+
hardcodedTexts,
|
|
54
|
+
localeKeysGenerated,
|
|
55
|
+
unusedKeys,
|
|
56
|
+
missingTranslations,
|
|
57
|
+
assets,
|
|
58
|
+
details
|
|
59
|
+
} = report;
|
|
60
|
+
const scanDate = new Date(timestamp).toLocaleString();
|
|
61
|
+
const hardcodedRows = details.detectedTexts.slice(0, 200).map(
|
|
62
|
+
(t) => '<tr><td><code class="path">' + esc(t.filePath.split("/").slice(-3).join("/")) + '</code></td><td class="center">' + t.line + '</td><td class="text-cell">' + esc(t.text.slice(0, 80)) + (t.text.length > 80 ? "…" : "") + '</td><td><code class="key">' + esc(t.suggestedKey) + "</code></td><td>" + badge(t.context, "blue") + "</td></tr>"
|
|
63
|
+
).join("\n");
|
|
64
|
+
const hardcodedOverflow = details.detectedTexts.length > 200 ? '<tr><td colspan="5" class="overflow-row">…and ' + (details.detectedTexts.length - 200) + " more \u2014 run <code>ai-localize scan --output results.json</code> for the full list</td></tr>" : "";
|
|
65
|
+
const hardcodedSection = details.detectedTexts.length > 0 ? sectionHeader(
|
|
66
|
+
"🔍 Hardcoded Texts Found (" + details.detectedTexts.length + ")",
|
|
67
|
+
"These are raw text strings discovered in your source files that are <strong>not yet wrapped</strong> in a translation call. Each row shows the file, line number, the text itself, the suggested locale key that would be generated, and the AST context. Run <code>ai-localize extract</code> to generate locale files and <code>ai-localize full-migrate</code> to wrap them automatically."
|
|
68
|
+
) + "\n<table>\n<thead><tr><th>File (last 3 segments)</th><th>Line</th><th>Text</th><th>Suggested Key</th><th>Context</th></tr></thead>\n<tbody>" + hardcodedRows + hardcodedOverflow + "</tbody>\n</table>" : sectionHeader(
|
|
69
|
+
"✅ Hardcoded Texts Found (0)",
|
|
70
|
+
"No hardcoded text was detected. All user-visible strings appear to be wrapped in translation calls already."
|
|
71
|
+
);
|
|
72
|
+
const missingRows = details.missingKeys.map(
|
|
73
|
+
(e) => '<tr><td><code class="key">' + esc(e.key) + "</code></td><td>" + (e.language ? badge(e.language, "red") : "\u2014") + "</td><td>" + (e.filePath ? '<code class="path">' + esc(e.filePath) + "</code>" : "\u2014") + "</td><td>" + esc(e.message) + "</td></tr>"
|
|
74
|
+
).join("\n");
|
|
75
|
+
const missingSection = details.missingKeys.length > 0 ? sectionHeader(
|
|
76
|
+
"❌ Missing Translations (" + details.missingKeys.length + ")",
|
|
77
|
+
"These locale keys exist in the <strong>default language</strong> file but are <strong>absent</strong> in one or more target language files. A missing translation means your app will fall back to the default language (or show a blank / raw key) for users of that language. Ask your translators to fill in these entries. Running <code>ai-localize extract</code> now seeds all target language files with the source value so nothing is blank."
|
|
78
|
+
) + "\n<table>\n<thead><tr><th>Key</th><th>Missing In Language</th><th>Locale File</th><th>Details</th></tr></thead>\n<tbody>" + missingRows + "</tbody>\n</table>" : sectionHeader(
|
|
79
|
+
"✅ Missing Translations (0)",
|
|
80
|
+
"All keys present in the default language are also present in every target language file."
|
|
81
|
+
);
|
|
82
|
+
const unusedRows = details.unusedKeysList.map((key) => '<tr><td><code class="key">' + esc(key) + "</code></td></tr>").join("\n");
|
|
83
|
+
const unusedSection = details.unusedKeysList.length > 0 ? sectionHeader(
|
|
84
|
+
"🗑️ Unused Keys (" + details.unusedKeysList.length + ")",
|
|
85
|
+
"These translation keys exist in your locale JSON files but are <strong>not referenced anywhere</strong> in the scanned source code. They are likely left over from removed features or renamed components. Unused keys bloat your locale bundles and can confuse translators. Run <code>ai-localize cleanup</code> to remove them (review the list carefully first)."
|
|
86
|
+
) + "\n<table>\n<thead><tr><th>Unused Key</th></tr></thead>\n<tbody>" + unusedRows + "</tbody>\n</table>" : sectionHeader(
|
|
87
|
+
"✅ Unused Keys (0)",
|
|
88
|
+
"No unused translation keys detected. Your locale files are clean."
|
|
89
|
+
);
|
|
90
|
+
const assetRows = details.assets.map(
|
|
91
|
+
(a) => '<tr><td><code class="path">' + esc(a.localPath.split("/").slice(-3).join("/")) + '</code></td><td><code class="key">' + esc(a.s3Key) + "</code></td><td><code>" + esc(a.cloudfrontUrl) + '</code></td><td class="center">' + (a.size / 1024).toFixed(1) + " KB</td><td>" + badge(a.contentType, "grey") + "</td></tr>"
|
|
92
|
+
).join("\n");
|
|
93
|
+
const assetsSectionTitle = "📦 Assets (" + assets.totalAssets + " found · " + assets.uploadedAssets + " uploaded · " + assets.replacedUrls + " URLs replaced · " + assets.legacyCdnUrls + " legacy CDN URLs)";
|
|
94
|
+
const assetsLegend = "<strong>Total assets</strong> = all static asset references found in source (images, fonts, CSS, JS). <strong>Uploaded</strong> = files pushed to S3/CloudFront in this run. <strong>Replaced URLs</strong> = legacy CDN references rewritten to the new CloudFront URL in source files. <strong>Legacy CDN URLs</strong> = old CDN references still present in source that have not yet been replaced \u2014 run <code>ai-localize replace-cdn</code> to fix them.";
|
|
95
|
+
const assetsSection = sectionHeader(assetsSectionTitle, assetsLegend) + (details.assets.length > 0 ? "\n<table>\n<thead><tr><th>Local Path</th><th>S3 Key</th><th>CloudFront URL</th><th>Size</th><th>Content Type</th></tr></thead>\n<tbody>" + assetRows + "</tbody>\n</table>" : '\n<p class="empty-state">No assets were uploaded in this run. Use <code>ai-localize upload-assets</code> to push static assets to S3/CloudFront.</p>');
|
|
96
|
+
const diff = Math.abs(hardcodedTexts - localeKeysGenerated);
|
|
97
|
+
const diffExplainer = hardcodedTexts !== localeKeysGenerated ? '<div class="info-box"><strong>ℹ️ Why do Hardcoded Texts (' + hardcodedTexts + ") and Keys Generated (" + localeKeysGenerated + ") differ?</strong><br><ul><li><strong>Hardcoded Texts</strong> = total raw string occurrences across all files (same string in 5 files = 5).</li><li><strong>Keys Generated</strong> = number of <em>unique</em> locale keys after deduplication. Identical strings share one key.</li><li>The difference (" + diff + ") = duplicate strings consolidated into shared keys.</li></ul></div>" : '<div class="info-box success-box"><strong>✅ Hardcoded Texts and Keys Generated match (' + hardcodedTexts + ").</strong> Every detected string maps to a unique locale key.</div>";
|
|
98
|
+
const statCard = (num, label, hint, colorClass) => '<div class="stat-card"><div class="num ' + colorClass + '">' + num + '</div><div class="lbl">' + label + '</div><div class="hint">' + hint + "</div></div>";
|
|
99
|
+
const summaryCards = '<div class="stats">' + statCard(filesScanned, "Files Scanned", "Source files inspected by AST scanner", "num-neutral") + statCard(hardcodedTexts, "Hardcoded Texts", "Raw strings not yet wrapped in t()", hardcodedTexts > 0 ? "num-warn" : "num-ok") + statCard(localeKeysGenerated, "Keys Generated", "Unique locale keys (deduplicated)", "num-ok") + statCard(missingTranslations, "Missing Translations", "Keys absent in target languages", missingTranslations > 0 ? "num-err" : "num-ok") + statCard(unusedKeys, "Unused Keys", "Keys in locale files not used in code", unusedKeys > 0 ? "num-warn" : "num-ok") + statCard(assets.totalAssets, "Assets Found", "Static asset references in source", "num-neutral") + statCard(assets.uploadedAssets, "Assets Uploaded", "Pushed to S3/CloudFront", "num-ok") + statCard(assets.legacyCdnUrls, "Legacy CDN URLs", "Old CDN refs not yet replaced", assets.legacyCdnUrls > 0 ? "num-warn" : "num-ok") + "</div>";
|
|
100
|
+
return '<!DOCTYPE html>\n<html lang="en">\n<head>\n<meta charset="UTF-8">\n<meta name="viewport" content="width=device-width, initial-scale=1.0">\n<title>ai-localize Report — ' + scanDate + "</title>\n<style>\n" + CSS + "\n</style>\n</head>\n<body>\n<h1>🌐 ai-localize Report <small>— " + esc(framework) + '</small></h1>\n<p class="meta"><strong>Generated:</strong> ' + scanDate + " | <strong>Duration:</strong> " + duration + "ms | <strong>Files Scanned:</strong> " + filesScanned + "</p>\n" + summaryCards + "\n" + diffExplainer + "\n" + hardcodedSection + "\n" + missingSection + "\n" + unusedSection + "\n" + assetsSection + "\n<footer>Generated by <strong>ai-localize-core</strong> — deterministic, offline-capable i18n tooling</footer>\n</body>\n</html>";
|
|
101
|
+
}
|
|
102
|
+
var CSS = `
|
|
103
|
+
*, *::before, *::after { box-sizing: border-box; }
|
|
104
|
+
body {
|
|
105
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
106
|
+
max-width: 1280px; margin: 0 auto; padding: 32px 24px;
|
|
107
|
+
background: #f0f2f5; color: #1a1a2e; line-height: 1.6;
|
|
108
|
+
}
|
|
109
|
+
h1 { font-size: 2rem; color: #0f3460; margin-bottom: 4px; }
|
|
110
|
+
h1 small { font-size: 1rem; font-weight: 400; color: #666; }
|
|
111
|
+
h2 { font-size: 1.2rem; color: #16213e; margin: 0 0 6px; }
|
|
112
|
+
.meta { color: #666; font-size: 0.9rem; margin-bottom: 32px; }
|
|
113
|
+
.meta strong { color: #333; }
|
|
114
|
+
.stats {
|
|
115
|
+
display: grid;
|
|
116
|
+
grid-template-columns: repeat(auto-fit, minmax(155px, 1fr));
|
|
117
|
+
gap: 14px; margin-bottom: 32px;
|
|
118
|
+
}
|
|
119
|
+
.stat-card {
|
|
120
|
+
background: white; border-radius: 10px; padding: 18px 14px;
|
|
121
|
+
box-shadow: 0 2px 8px rgba(0,0,0,0.08); text-align: center;
|
|
122
|
+
}
|
|
123
|
+
.stat-card .num { font-size: 2rem; font-weight: 700; line-height: 1; }
|
|
124
|
+
.stat-card .lbl { color: #444; font-size: 0.82rem; margin-top: 6px; font-weight: 600; }
|
|
125
|
+
.stat-card .hint { color: #999; font-size: 0.75rem; margin-top: 3px; font-style: italic; }
|
|
126
|
+
.num-neutral { color: #0f3460; }
|
|
127
|
+
.num-ok { color: #38a169; }
|
|
128
|
+
.num-warn { color: #d69e2e; }
|
|
129
|
+
.num-err { color: #e53e3e; }
|
|
130
|
+
.section-header { margin: 40px 0 12px; }
|
|
131
|
+
.legend {
|
|
132
|
+
font-size: 0.875rem; color: #555;
|
|
133
|
+
background: #f8f9ff; border-left: 4px solid #0f3460;
|
|
134
|
+
padding: 10px 14px; border-radius: 0 6px 6px 0; margin: 8px 0 14px;
|
|
135
|
+
}
|
|
136
|
+
.legend code { background: #e8ebff; padding: 1px 5px; border-radius: 3px; font-size: 0.85em; }
|
|
137
|
+
table {
|
|
138
|
+
width: 100%; border-collapse: collapse; background: white;
|
|
139
|
+
border-radius: 10px; overflow: hidden;
|
|
140
|
+
box-shadow: 0 2px 8px rgba(0,0,0,0.08); margin-bottom: 8px;
|
|
141
|
+
}
|
|
142
|
+
th {
|
|
143
|
+
background: #0f3460; color: white; padding: 11px 14px;
|
|
144
|
+
text-align: left; font-size: 0.84rem; font-weight: 600;
|
|
145
|
+
}
|
|
146
|
+
td { padding: 9px 14px; border-bottom: 1px solid #f0f0f0; font-size: 0.84rem; vertical-align: top; }
|
|
147
|
+
tr:last-child td { border-bottom: none; }
|
|
148
|
+
tr:hover td { background: #f8f9ff; }
|
|
149
|
+
.center { text-align: center; }
|
|
150
|
+
.text-cell { max-width: 260px; word-break: break-word; }
|
|
151
|
+
code.path { color: #555; font-size: 0.81rem; }
|
|
152
|
+
code.key { color: #0f3460; font-size: 0.81rem; }
|
|
153
|
+
.overflow-row { text-align: center; color: #888; font-style: italic; padding: 12px; }
|
|
154
|
+
.badge {
|
|
155
|
+
display: inline-block; padding: 2px 8px; border-radius: 10px;
|
|
156
|
+
font-size: 0.75rem; font-weight: 600; white-space: nowrap;
|
|
157
|
+
}
|
|
158
|
+
.badge-blue { background: #ebf4ff; color: #2b6cb0; }
|
|
159
|
+
.badge-red { background: #fff5f5; color: #c53030; }
|
|
160
|
+
.badge-green { background: #f0fff4; color: #276749; }
|
|
161
|
+
.badge-orange { background: #fffaf0; color: #c05621; }
|
|
162
|
+
.badge-grey { background: #f7f7f7; color: #555; }
|
|
163
|
+
.info-box {
|
|
164
|
+
background: #ebf8ff; border-left: 4px solid #3182ce;
|
|
165
|
+
padding: 14px 18px; border-radius: 0 8px 8px 0;
|
|
166
|
+
margin: 0 0 28px; font-size: 0.9rem;
|
|
167
|
+
}
|
|
168
|
+
.info-box ul { margin: 8px 0 0 18px; padding: 0; }
|
|
169
|
+
.info-box li { margin-bottom: 4px; }
|
|
170
|
+
.success-box { background: #f0fff4; border-left-color: #38a169; }
|
|
171
|
+
.empty-state { color: #888; font-style: italic; font-size: 0.88rem; padding: 8px 0; }
|
|
172
|
+
footer {
|
|
173
|
+
margin-top: 48px; padding-top: 16px;
|
|
174
|
+
border-top: 1px solid #ddd; color: #aaa;
|
|
175
|
+
font-size: 0.8rem; text-align: center;
|
|
176
|
+
}
|
|
177
|
+
`;
|
|
102
178
|
|
|
103
179
|
// src/cli-reporter.ts
|
|
104
180
|
function printCliSummary(report) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ai-localize-reporting",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.1",
|
|
4
4
|
"description": "Localization scan reporting: JSON, HTML, CLI summary",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"module": "./dist/index.mjs",
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
}
|
|
14
14
|
},
|
|
15
15
|
"dependencies": {
|
|
16
|
-
"ai-localize-shared": "
|
|
16
|
+
"ai-localize-shared": "2.0.1"
|
|
17
17
|
},
|
|
18
18
|
"devDependencies": {
|
|
19
19
|
"tsup": "^8.0.1",
|
package/src/html-reporter.ts
CHANGED
|
@@ -3,72 +3,334 @@ import * as fs from "fs";
|
|
|
3
3
|
import * as path from "path";
|
|
4
4
|
import { ensureDir } from "ai-localize-shared";
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* Generates a comprehensive, self-contained HTML report file.
|
|
8
|
+
*
|
|
9
|
+
* @param report - The report data object built by `buildReport()`
|
|
10
|
+
* @param outputPath - Full path to the output HTML file
|
|
11
|
+
* e.g. `.ai-localize-reports/report.html`
|
|
12
|
+
*/
|
|
6
13
|
export function generateHtmlReport(report: Report, outputPath: string): void {
|
|
7
14
|
ensureDir(path.dirname(outputPath));
|
|
8
|
-
const html =
|
|
9
|
-
<html lang="en">
|
|
10
|
-
<head>
|
|
11
|
-
<meta charset="UTF-8">
|
|
12
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
13
|
-
<title>ai-localize Report - ${report.timestamp}</title>
|
|
14
|
-
<style>
|
|
15
|
-
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; max-width: 1200px; margin: 0 auto; padding: 24px; background: #f5f5f5; }
|
|
16
|
-
h1 { color: #1a1a2e; } h2 { color: #16213e; border-bottom: 2px solid #0f3460; padding-bottom: 8px; }
|
|
17
|
-
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin: 24px 0; }
|
|
18
|
-
.stat-card { background: white; border-radius: 8px; padding: 20px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); text-align: center; }
|
|
19
|
-
.stat-number { font-size: 2.5rem; font-weight: 700; color: #0f3460; }
|
|
20
|
-
.stat-label { color: #666; font-size: 0.9rem; margin-top: 4px; }
|
|
21
|
-
.error { color: #e53e3e; } .warning { color: #d69e2e; } .success { color: #38a169; }
|
|
22
|
-
table { width: 100%; border-collapse: collapse; background: white; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
|
|
23
|
-
th { background: #0f3460; color: white; padding: 12px 16px; text-align: left; }
|
|
24
|
-
td { padding: 10px 16px; border-bottom: 1px solid #f0f0f0; }
|
|
25
|
-
tr:hover td { background: #f8f9ff; }
|
|
26
|
-
.badge { display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 0.75rem; font-weight: 600; }
|
|
27
|
-
.badge-blue { background: #ebf4ff; color: #2b6cb0; }
|
|
28
|
-
.badge-red { background: #fff5f5; color: #c53030; }
|
|
29
|
-
.badge-green { background: #f0fff4; color: #276749; }
|
|
30
|
-
</style>
|
|
31
|
-
</head>
|
|
32
|
-
<body>
|
|
33
|
-
<h1>🌐 ai-localize Report</h1>
|
|
34
|
-
<p><strong>Generated:</strong> ${report.timestamp} | <strong>Framework:</strong> ${report.framework} | <strong>Duration:</strong> ${report.duration}ms</p>
|
|
35
|
-
|
|
36
|
-
<div class="stats">
|
|
37
|
-
<div class="stat-card"><div class="stat-number">${report.filesScanned}</div><div class="stat-label">Files Scanned</div></div>
|
|
38
|
-
<div class="stat-card"><div class="stat-number error">${report.hardcodedTexts}</div><div class="stat-label">Hardcoded Texts</div></div>
|
|
39
|
-
<div class="stat-card"><div class="stat-number success">${report.localeKeysGenerated}</div><div class="stat-label">Keys Generated</div></div>
|
|
40
|
-
<div class="stat-card"><div class="stat-number warning">${report.unusedKeys}</div><div class="stat-label">Unused Keys</div></div>
|
|
41
|
-
<div class="stat-card"><div class="stat-number error">${report.missingTranslations}</div><div class="stat-label">Missing Translations</div></div>
|
|
42
|
-
<div class="stat-card"><div class="stat-number">${report.assets.uploadedAssets}</div><div class="stat-label">Assets Uploaded</div></div>
|
|
43
|
-
</div>
|
|
44
|
-
|
|
45
|
-
${report.details.detectedTexts.length > 0 ? `
|
|
46
|
-
<h2>Hardcoded Texts (${report.details.detectedTexts.length})</h2>
|
|
47
|
-
<table>
|
|
48
|
-
<thead><tr><th>File</th><th>Line</th><th>Text</th><th>Suggested Key</th><th>Context</th></tr></thead>
|
|
49
|
-
<tbody>
|
|
50
|
-
${report.details.detectedTexts.slice(0, 100).map((t) => `
|
|
51
|
-
<tr>
|
|
52
|
-
<td><code>${t.filePath.split("/").slice(-3).join("/")}</code></td>
|
|
53
|
-
<td>${t.line}</td>
|
|
54
|
-
<td>${t.text.slice(0, 60)}${t.text.length > 60 ? "..." : ""}</td>
|
|
55
|
-
<td><span class="badge badge-blue">${t.suggestedKey}</span></td>
|
|
56
|
-
<td><span class="badge badge-green">${t.context}</span></td>
|
|
57
|
-
</tr>`).join("")}
|
|
58
|
-
${report.details.detectedTexts.length > 100 ? `<tr><td colspan="5" style="text-align:center;color:#666">...and ${report.details.detectedTexts.length - 100} more</td></tr>` : ""}
|
|
59
|
-
</tbody>
|
|
60
|
-
</table>` : ""}
|
|
61
|
-
|
|
62
|
-
${report.details.missingKeys.length > 0 ? `
|
|
63
|
-
<h2>Missing Translations (${report.details.missingKeys.length})</h2>
|
|
64
|
-
<table>
|
|
65
|
-
<thead><tr><th>Key</th><th>Language</th><th>Message</th></tr></thead>
|
|
66
|
-
<tbody>
|
|
67
|
-
${report.details.missingKeys.map((e) => `<tr><td><code>${e.key}</code></td><td><span class="badge badge-red">${e.language || ""}</span></td><td>${e.message}</td></tr>`).join("")}
|
|
68
|
-
</tbody>
|
|
69
|
-
</table>` : ""}
|
|
70
|
-
|
|
71
|
-
</body>
|
|
72
|
-
</html>`;
|
|
15
|
+
const html = buildHtml(report);
|
|
73
16
|
fs.writeFileSync(outputPath, html, "utf-8");
|
|
74
17
|
}
|
|
18
|
+
|
|
19
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
/** Escapes a string for safe insertion into HTML. */
|
|
22
|
+
function esc(s: string): string {
|
|
23
|
+
return s
|
|
24
|
+
.replace(/&/g, "&")
|
|
25
|
+
.replace(/</g, "<")
|
|
26
|
+
.replace(/>/g, ">")
|
|
27
|
+
.replace(/"/g, """);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type BadgeColor = "blue" | "red" | "green" | "orange" | "grey";
|
|
31
|
+
|
|
32
|
+
function badge(text: string, color: BadgeColor): string {
|
|
33
|
+
return '<span class="badge badge-' + color + '">' + esc(text) + "</span>";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function sectionHeader(title: string, legend: string): string {
|
|
37
|
+
return (
|
|
38
|
+
'\n<div class="section-header">\n' +
|
|
39
|
+
" <h2>" + title + "</h2>\n" +
|
|
40
|
+
' <p class="legend">' + legend + "</p>\n" +
|
|
41
|
+
"</div>"
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ─── HTML builder ─────────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
function buildHtml(report: Report): string {
|
|
48
|
+
const {
|
|
49
|
+
timestamp,
|
|
50
|
+
framework,
|
|
51
|
+
duration,
|
|
52
|
+
filesScanned,
|
|
53
|
+
hardcodedTexts,
|
|
54
|
+
localeKeysGenerated,
|
|
55
|
+
unusedKeys,
|
|
56
|
+
missingTranslations,
|
|
57
|
+
assets,
|
|
58
|
+
details,
|
|
59
|
+
} = report;
|
|
60
|
+
|
|
61
|
+
const scanDate = new Date(timestamp).toLocaleString();
|
|
62
|
+
|
|
63
|
+
// ── Hardcoded Texts ───────────────────────────────────────────────────────
|
|
64
|
+
const hardcodedRows = details.detectedTexts
|
|
65
|
+
.slice(0, 200)
|
|
66
|
+
.map(
|
|
67
|
+
(t) =>
|
|
68
|
+
"<tr>" +
|
|
69
|
+
"<td><code class=\"path\">" + esc(t.filePath.split("/").slice(-3).join("/")) + "</code></td>" +
|
|
70
|
+
'<td class="center">' + t.line + "</td>" +
|
|
71
|
+
'<td class="text-cell">' + esc(t.text.slice(0, 80)) + (t.text.length > 80 ? "…" : "") + "</td>" +
|
|
72
|
+
'<td><code class="key">' + esc(t.suggestedKey) + "</code></td>" +
|
|
73
|
+
"<td>" + badge(t.context, "blue") + "</td>" +
|
|
74
|
+
"</tr>"
|
|
75
|
+
)
|
|
76
|
+
.join("\n");
|
|
77
|
+
|
|
78
|
+
const hardcodedOverflow =
|
|
79
|
+
details.detectedTexts.length > 200
|
|
80
|
+
? '<tr><td colspan="5" class="overflow-row">…and ' +
|
|
81
|
+
(details.detectedTexts.length - 200) +
|
|
82
|
+
" more — run <code>ai-localize scan --output results.json</code> for the full list</td></tr>"
|
|
83
|
+
: "";
|
|
84
|
+
|
|
85
|
+
const hardcodedSection =
|
|
86
|
+
details.detectedTexts.length > 0
|
|
87
|
+
? sectionHeader(
|
|
88
|
+
"🔍 Hardcoded Texts Found (" + details.detectedTexts.length + ")",
|
|
89
|
+
"These are raw text strings discovered in your source files that are <strong>not yet wrapped</strong> in a translation call. " +
|
|
90
|
+
"Each row shows the file, line number, the text itself, the suggested locale key that would be generated, and the AST context. " +
|
|
91
|
+
"Run <code>ai-localize extract</code> to generate locale files and " +
|
|
92
|
+
"<code>ai-localize full-migrate</code> to wrap them automatically."
|
|
93
|
+
) +
|
|
94
|
+
"\n<table>\n" +
|
|
95
|
+
"<thead><tr><th>File (last 3 segments)</th><th>Line</th><th>Text</th><th>Suggested Key</th><th>Context</th></tr></thead>\n" +
|
|
96
|
+
"<tbody>" + hardcodedRows + hardcodedOverflow + "</tbody>\n</table>"
|
|
97
|
+
: sectionHeader(
|
|
98
|
+
"✅ Hardcoded Texts Found (0)",
|
|
99
|
+
"No hardcoded text was detected. All user-visible strings appear to be wrapped in translation calls already."
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
// ── Missing Translations ───────────────────────────────────────────────────
|
|
103
|
+
const missingRows = details.missingKeys
|
|
104
|
+
.map(
|
|
105
|
+
(e) =>
|
|
106
|
+
"<tr>" +
|
|
107
|
+
'<td><code class="key">' + esc(e.key) + "</code></td>" +
|
|
108
|
+
"<td>" + (e.language ? badge(e.language, "red") : "—") + "</td>" +
|
|
109
|
+
"<td>" + (e.filePath ? '<code class="path">' + esc(e.filePath) + "</code>" : "—") + "</td>" +
|
|
110
|
+
"<td>" + esc(e.message) + "</td>" +
|
|
111
|
+
"</tr>"
|
|
112
|
+
)
|
|
113
|
+
.join("\n");
|
|
114
|
+
|
|
115
|
+
const missingSection =
|
|
116
|
+
details.missingKeys.length > 0
|
|
117
|
+
? sectionHeader(
|
|
118
|
+
"❌ Missing Translations (" + details.missingKeys.length + ")",
|
|
119
|
+
"These locale keys exist in the <strong>default language</strong> file but are <strong>absent</strong> in one or more target language files. " +
|
|
120
|
+
"A missing translation means your app will fall back to the default language (or show a blank / raw key) for users of that language. " +
|
|
121
|
+
"Ask your translators to fill in these entries. " +
|
|
122
|
+
"Running <code>ai-localize extract</code> now seeds all target language files with the source value so nothing is blank."
|
|
123
|
+
) +
|
|
124
|
+
"\n<table>\n" +
|
|
125
|
+
"<thead><tr><th>Key</th><th>Missing In Language</th><th>Locale File</th><th>Details</th></tr></thead>\n" +
|
|
126
|
+
"<tbody>" + missingRows + "</tbody>\n</table>"
|
|
127
|
+
: sectionHeader(
|
|
128
|
+
"✅ Missing Translations (0)",
|
|
129
|
+
"All keys present in the default language are also present in every target language file."
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
// ── Unused Keys ────────────────────────────────────────────────────────────
|
|
133
|
+
const unusedRows = details.unusedKeysList
|
|
134
|
+
.map((key) => "<tr><td><code class=\"key\">" + esc(key) + "</code></td></tr>")
|
|
135
|
+
.join("\n");
|
|
136
|
+
|
|
137
|
+
const unusedSection =
|
|
138
|
+
details.unusedKeysList.length > 0
|
|
139
|
+
? sectionHeader(
|
|
140
|
+
"🗑️ Unused Keys (" + details.unusedKeysList.length + ")",
|
|
141
|
+
"These translation keys exist in your locale JSON files but are <strong>not referenced anywhere</strong> in the scanned source code. " +
|
|
142
|
+
"They are likely left over from removed features or renamed components. " +
|
|
143
|
+
"Unused keys bloat your locale bundles and can confuse translators. " +
|
|
144
|
+
"Run <code>ai-localize cleanup</code> to remove them (review the list carefully first)."
|
|
145
|
+
) +
|
|
146
|
+
"\n<table>\n" +
|
|
147
|
+
"<thead><tr><th>Unused Key</th></tr></thead>\n" +
|
|
148
|
+
"<tbody>" + unusedRows + "</tbody>\n</table>"
|
|
149
|
+
: sectionHeader(
|
|
150
|
+
"✅ Unused Keys (0)",
|
|
151
|
+
"No unused translation keys detected. Your locale files are clean."
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
// ── Assets ─────────────────────────────────────────────────────────────────
|
|
155
|
+
const assetRows = details.assets
|
|
156
|
+
.map(
|
|
157
|
+
(a) =>
|
|
158
|
+
"<tr>" +
|
|
159
|
+
"<td><code class=\"path\">" + esc(a.localPath.split("/").slice(-3).join("/")) + "</code></td>" +
|
|
160
|
+
'<td><code class="key">' + esc(a.s3Key) + "</code></td>" +
|
|
161
|
+
"<td><code>" + esc(a.cloudfrontUrl) + "</code></td>" +
|
|
162
|
+
'<td class="center">' + (a.size / 1024).toFixed(1) + " KB</td>" +
|
|
163
|
+
"<td>" + badge(a.contentType, "grey") + "</td>" +
|
|
164
|
+
"</tr>"
|
|
165
|
+
)
|
|
166
|
+
.join("\n");
|
|
167
|
+
|
|
168
|
+
const assetsSectionTitle =
|
|
169
|
+
"📦 Assets (" +
|
|
170
|
+
assets.totalAssets + " found · " +
|
|
171
|
+
assets.uploadedAssets + " uploaded · " +
|
|
172
|
+
assets.replacedUrls + " URLs replaced · " +
|
|
173
|
+
assets.legacyCdnUrls + " legacy CDN URLs)";
|
|
174
|
+
|
|
175
|
+
const assetsLegend =
|
|
176
|
+
"<strong>Total assets</strong> = all static asset references found in source (images, fonts, CSS, JS). " +
|
|
177
|
+
"<strong>Uploaded</strong> = files pushed to S3/CloudFront in this run. " +
|
|
178
|
+
"<strong>Replaced URLs</strong> = legacy CDN references rewritten to the new CloudFront URL in source files. " +
|
|
179
|
+
"<strong>Legacy CDN URLs</strong> = old CDN references still present in source that have not yet been replaced — " +
|
|
180
|
+
"run <code>ai-localize replace-cdn</code> to fix them.";
|
|
181
|
+
|
|
182
|
+
const assetsSection =
|
|
183
|
+
sectionHeader(assetsSectionTitle, assetsLegend) +
|
|
184
|
+
(details.assets.length > 0
|
|
185
|
+
? "\n<table>\n" +
|
|
186
|
+
"<thead><tr><th>Local Path</th><th>S3 Key</th><th>CloudFront URL</th><th>Size</th><th>Content Type</th></tr></thead>\n" +
|
|
187
|
+
"<tbody>" + assetRows + "</tbody>\n</table>"
|
|
188
|
+
: '\n<p class="empty-state">No assets were uploaded in this run. ' +
|
|
189
|
+
"Use <code>ai-localize upload-assets</code> to push static assets to S3/CloudFront.</p>");
|
|
190
|
+
|
|
191
|
+
// ── Hardcoded vs Keys Generated explainer ─────────────────────────────────
|
|
192
|
+
const diff = Math.abs(hardcodedTexts - localeKeysGenerated);
|
|
193
|
+
const diffExplainer =
|
|
194
|
+
hardcodedTexts !== localeKeysGenerated
|
|
195
|
+
? '<div class="info-box">' +
|
|
196
|
+
"<strong>ℹ️ Why do Hardcoded Texts (" + hardcodedTexts + ") and Keys Generated (" + localeKeysGenerated + ") differ?</strong><br>" +
|
|
197
|
+
"<ul>" +
|
|
198
|
+
"<li><strong>Hardcoded Texts</strong> = total raw string occurrences across all files (same string in 5 files = 5).</li>" +
|
|
199
|
+
"<li><strong>Keys Generated</strong> = number of <em>unique</em> locale keys after deduplication. Identical strings share one key.</li>" +
|
|
200
|
+
"<li>The difference (" + diff + ") = duplicate strings consolidated into shared keys.</li>" +
|
|
201
|
+
"</ul>" +
|
|
202
|
+
"</div>"
|
|
203
|
+
: '<div class="info-box success-box">' +
|
|
204
|
+
"<strong>✅ Hardcoded Texts and Keys Generated match (" + hardcodedTexts + ").</strong> " +
|
|
205
|
+
"Every detected string maps to a unique locale key." +
|
|
206
|
+
"</div>";
|
|
207
|
+
|
|
208
|
+
// ── Summary cards ──────────────────────────────────────────────────────────
|
|
209
|
+
const statCard = (
|
|
210
|
+
num: number | string,
|
|
211
|
+
label: string,
|
|
212
|
+
hint: string,
|
|
213
|
+
colorClass: string
|
|
214
|
+
): string =>
|
|
215
|
+
'<div class="stat-card">' +
|
|
216
|
+
'<div class="num ' + colorClass + '">' + num + "</div>" +
|
|
217
|
+
'<div class="lbl">' + label + "</div>" +
|
|
218
|
+
'<div class="hint">' + hint + "</div>" +
|
|
219
|
+
"</div>";
|
|
220
|
+
|
|
221
|
+
const summaryCards =
|
|
222
|
+
'<div class="stats">' +
|
|
223
|
+
statCard(filesScanned, "Files Scanned", "Source files inspected by AST scanner", "num-neutral") +
|
|
224
|
+
statCard(hardcodedTexts, "Hardcoded Texts", "Raw strings not yet wrapped in t()", hardcodedTexts > 0 ? "num-warn" : "num-ok") +
|
|
225
|
+
statCard(localeKeysGenerated, "Keys Generated", "Unique locale keys (deduplicated)", "num-ok") +
|
|
226
|
+
statCard(missingTranslations, "Missing Translations", "Keys absent in target languages", missingTranslations > 0 ? "num-err" : "num-ok") +
|
|
227
|
+
statCard(unusedKeys, "Unused Keys", "Keys in locale files not used in code", unusedKeys > 0 ? "num-warn" : "num-ok") +
|
|
228
|
+
statCard(assets.totalAssets, "Assets Found", "Static asset references in source", "num-neutral") +
|
|
229
|
+
statCard(assets.uploadedAssets, "Assets Uploaded", "Pushed to S3/CloudFront", "num-ok") +
|
|
230
|
+
statCard(assets.legacyCdnUrls, "Legacy CDN URLs", "Old CDN refs not yet replaced", assets.legacyCdnUrls > 0 ? "num-warn" : "num-ok") +
|
|
231
|
+
"</div>";
|
|
232
|
+
|
|
233
|
+
// ── Full document ──────────────────────────────────────────────────────────
|
|
234
|
+
return "<!DOCTYPE html>\n" +
|
|
235
|
+
'<html lang="en">\n' +
|
|
236
|
+
"<head>\n" +
|
|
237
|
+
'<meta charset="UTF-8">\n' +
|
|
238
|
+
'<meta name="viewport" content="width=device-width, initial-scale=1.0">\n' +
|
|
239
|
+
"<title>ai-localize Report — " + scanDate + "</title>\n" +
|
|
240
|
+
"<style>\n" + CSS + "\n</style>\n" +
|
|
241
|
+
"</head>\n" +
|
|
242
|
+
"<body>\n" +
|
|
243
|
+
"<h1>🌐 ai-localize Report <small>— " + esc(framework) + "</small></h1>\n" +
|
|
244
|
+
'<p class="meta">' +
|
|
245
|
+
"<strong>Generated:</strong> " + scanDate +
|
|
246
|
+
" | <strong>Duration:</strong> " + duration + "ms" +
|
|
247
|
+
" | <strong>Files Scanned:</strong> " + filesScanned +
|
|
248
|
+
"</p>\n" +
|
|
249
|
+
summaryCards + "\n" +
|
|
250
|
+
diffExplainer + "\n" +
|
|
251
|
+
hardcodedSection + "\n" +
|
|
252
|
+
missingSection + "\n" +
|
|
253
|
+
unusedSection + "\n" +
|
|
254
|
+
assetsSection + "\n" +
|
|
255
|
+
'<footer>Generated by <strong>ai-localize-core</strong> — deterministic, offline-capable i18n tooling</footer>\n' +
|
|
256
|
+
"</body>\n</html>";
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ─── Stylesheet ───────────────────────────────────────────────────────────────
|
|
260
|
+
|
|
261
|
+
const CSS = `
|
|
262
|
+
*, *::before, *::after { box-sizing: border-box; }
|
|
263
|
+
body {
|
|
264
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
265
|
+
max-width: 1280px; margin: 0 auto; padding: 32px 24px;
|
|
266
|
+
background: #f0f2f5; color: #1a1a2e; line-height: 1.6;
|
|
267
|
+
}
|
|
268
|
+
h1 { font-size: 2rem; color: #0f3460; margin-bottom: 4px; }
|
|
269
|
+
h1 small { font-size: 1rem; font-weight: 400; color: #666; }
|
|
270
|
+
h2 { font-size: 1.2rem; color: #16213e; margin: 0 0 6px; }
|
|
271
|
+
.meta { color: #666; font-size: 0.9rem; margin-bottom: 32px; }
|
|
272
|
+
.meta strong { color: #333; }
|
|
273
|
+
.stats {
|
|
274
|
+
display: grid;
|
|
275
|
+
grid-template-columns: repeat(auto-fit, minmax(155px, 1fr));
|
|
276
|
+
gap: 14px; margin-bottom: 32px;
|
|
277
|
+
}
|
|
278
|
+
.stat-card {
|
|
279
|
+
background: white; border-radius: 10px; padding: 18px 14px;
|
|
280
|
+
box-shadow: 0 2px 8px rgba(0,0,0,0.08); text-align: center;
|
|
281
|
+
}
|
|
282
|
+
.stat-card .num { font-size: 2rem; font-weight: 700; line-height: 1; }
|
|
283
|
+
.stat-card .lbl { color: #444; font-size: 0.82rem; margin-top: 6px; font-weight: 600; }
|
|
284
|
+
.stat-card .hint { color: #999; font-size: 0.75rem; margin-top: 3px; font-style: italic; }
|
|
285
|
+
.num-neutral { color: #0f3460; }
|
|
286
|
+
.num-ok { color: #38a169; }
|
|
287
|
+
.num-warn { color: #d69e2e; }
|
|
288
|
+
.num-err { color: #e53e3e; }
|
|
289
|
+
.section-header { margin: 40px 0 12px; }
|
|
290
|
+
.legend {
|
|
291
|
+
font-size: 0.875rem; color: #555;
|
|
292
|
+
background: #f8f9ff; border-left: 4px solid #0f3460;
|
|
293
|
+
padding: 10px 14px; border-radius: 0 6px 6px 0; margin: 8px 0 14px;
|
|
294
|
+
}
|
|
295
|
+
.legend code { background: #e8ebff; padding: 1px 5px; border-radius: 3px; font-size: 0.85em; }
|
|
296
|
+
table {
|
|
297
|
+
width: 100%; border-collapse: collapse; background: white;
|
|
298
|
+
border-radius: 10px; overflow: hidden;
|
|
299
|
+
box-shadow: 0 2px 8px rgba(0,0,0,0.08); margin-bottom: 8px;
|
|
300
|
+
}
|
|
301
|
+
th {
|
|
302
|
+
background: #0f3460; color: white; padding: 11px 14px;
|
|
303
|
+
text-align: left; font-size: 0.84rem; font-weight: 600;
|
|
304
|
+
}
|
|
305
|
+
td { padding: 9px 14px; border-bottom: 1px solid #f0f0f0; font-size: 0.84rem; vertical-align: top; }
|
|
306
|
+
tr:last-child td { border-bottom: none; }
|
|
307
|
+
tr:hover td { background: #f8f9ff; }
|
|
308
|
+
.center { text-align: center; }
|
|
309
|
+
.text-cell { max-width: 260px; word-break: break-word; }
|
|
310
|
+
code.path { color: #555; font-size: 0.81rem; }
|
|
311
|
+
code.key { color: #0f3460; font-size: 0.81rem; }
|
|
312
|
+
.overflow-row { text-align: center; color: #888; font-style: italic; padding: 12px; }
|
|
313
|
+
.badge {
|
|
314
|
+
display: inline-block; padding: 2px 8px; border-radius: 10px;
|
|
315
|
+
font-size: 0.75rem; font-weight: 600; white-space: nowrap;
|
|
316
|
+
}
|
|
317
|
+
.badge-blue { background: #ebf4ff; color: #2b6cb0; }
|
|
318
|
+
.badge-red { background: #fff5f5; color: #c53030; }
|
|
319
|
+
.badge-green { background: #f0fff4; color: #276749; }
|
|
320
|
+
.badge-orange { background: #fffaf0; color: #c05621; }
|
|
321
|
+
.badge-grey { background: #f7f7f7; color: #555; }
|
|
322
|
+
.info-box {
|
|
323
|
+
background: #ebf8ff; border-left: 4px solid #3182ce;
|
|
324
|
+
padding: 14px 18px; border-radius: 0 8px 8px 0;
|
|
325
|
+
margin: 0 0 28px; font-size: 0.9rem;
|
|
326
|
+
}
|
|
327
|
+
.info-box ul { margin: 8px 0 0 18px; padding: 0; }
|
|
328
|
+
.info-box li { margin-bottom: 4px; }
|
|
329
|
+
.success-box { background: #f0fff4; border-left-color: #38a169; }
|
|
330
|
+
.empty-state { color: #888; font-style: italic; font-size: 0.88rem; padding: 8px 0; }
|
|
331
|
+
footer {
|
|
332
|
+
margin-top: 48px; padding-top: 16px;
|
|
333
|
+
border-top: 1px solid #ddd; color: #aaa;
|
|
334
|
+
font-size: 0.8rem; text-align: center;
|
|
335
|
+
}
|
|
336
|
+
`;
|