@hydration-audit/dashboard 0.2.4 → 0.2.5
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/dist/index.cjs +42 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.html +459 -0
- package/dist/index.mjs +42 -0
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -59,6 +59,41 @@ async function startDashboard(reportPath, port = 4173) {
|
|
|
59
59
|
}
|
|
60
60
|
const currentDir = import_node_path.default.dirname((0, import_node_url.fileURLToPath)(import_meta.url));
|
|
61
61
|
const staticDir = import_node_fs.default.existsSync(import_node_path.default.join(currentDir, "app")) ? import_node_path.default.join(currentDir, "app") : currentDir;
|
|
62
|
+
let currentImageReport = null;
|
|
63
|
+
const imageReportPath = import_node_path.default.join(import_node_path.default.dirname(absoluteReportPath), ".image-audit-report.json");
|
|
64
|
+
let imageWatcher = null;
|
|
65
|
+
const watchImageReport = () => {
|
|
66
|
+
if (imageWatcher) return;
|
|
67
|
+
try {
|
|
68
|
+
if (import_node_fs.default.existsSync(imageReportPath)) {
|
|
69
|
+
imageWatcher = import_node_fs.default.watch(imageReportPath, () => {
|
|
70
|
+
try {
|
|
71
|
+
tryReadImageReport();
|
|
72
|
+
for (const client of wss.clients) {
|
|
73
|
+
if (client.readyState === 1) {
|
|
74
|
+
client.send(JSON.stringify({ type: "image-update", report: currentImageReport }));
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
} catch {
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
} catch {
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
function tryReadImageReport() {
|
|
85
|
+
try {
|
|
86
|
+
if (import_node_fs.default.existsSync(imageReportPath)) {
|
|
87
|
+
const content = import_node_fs.default.readFileSync(imageReportPath, "utf-8");
|
|
88
|
+
currentImageReport = JSON.parse(content);
|
|
89
|
+
watchImageReport();
|
|
90
|
+
} else {
|
|
91
|
+
currentImageReport = null;
|
|
92
|
+
}
|
|
93
|
+
} catch {
|
|
94
|
+
currentImageReport = null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
62
97
|
const server = import_node_http.default.createServer((req, res) => {
|
|
63
98
|
const url = req.url ?? "/";
|
|
64
99
|
if (url === "/api/report") {
|
|
@@ -66,6 +101,12 @@ async function startDashboard(reportPath, port = 4173) {
|
|
|
66
101
|
res.end(JSON.stringify(currentReport ?? { error: "No report loaded" }));
|
|
67
102
|
return;
|
|
68
103
|
}
|
|
104
|
+
if (url === "/api/image-report") {
|
|
105
|
+
tryReadImageReport();
|
|
106
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
107
|
+
res.end(JSON.stringify(currentImageReport ?? { error: "No image report found" }));
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
69
110
|
let filePath = import_node_path.default.join(staticDir, url === "/" ? "index.html" : url);
|
|
70
111
|
const ext = import_node_path.default.extname(filePath);
|
|
71
112
|
if (!ext) {
|
|
@@ -99,6 +140,7 @@ async function startDashboard(reportPath, port = 4173) {
|
|
|
99
140
|
} catch {
|
|
100
141
|
console.warn("Could not watch report file for changes");
|
|
101
142
|
}
|
|
143
|
+
tryReadImageReport();
|
|
102
144
|
return new Promise((resolve) => {
|
|
103
145
|
server.listen(port, () => {
|
|
104
146
|
const url = `http://localhost:${port}`;
|
package/dist/index.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts","../src/server.ts"],"sourcesContent":["export { startDashboard } from './server.js';\n","import http from 'node:http';\nimport fs from 'node:fs';\nimport path from 'node:path';\nimport { fileURLToPath } from 'node:url';\nimport { WebSocketServer } from 'ws';\nimport type { AnalysisReport } from '@hydration-audit/core';\n\nconst MIME_TYPES: Record<string, string> = {\n '.html': 'text/html',\n '.js': 'application/javascript',\n '.css': 'text/css',\n '.json': 'application/json',\n '.svg': 'image/svg+xml',\n};\n\n/**\n * Start the dashboard server.\n * Serves a static SPA and provides a WebSocket for live report updates.\n */\nexport async function startDashboard(\n reportPath: string,\n port = 4173,\n): Promise<{ url: string; close: () => void }> {\n const absoluteReportPath = path.resolve(reportPath);\n\n // Read the initial report\n let currentReport: AnalysisReport | null = null;\n try {\n const content = fs.readFileSync(absoluteReportPath, 'utf-8');\n currentReport = JSON.parse(content);\n } catch {\n console.warn(`Could not read report at ${absoluteReportPath}`);\n }\n\n const currentDir = path.dirname(fileURLToPath(import.meta.url));\n const staticDir = fs.existsSync(path.join(currentDir, 'app'))\n ? path.join(currentDir, 'app')\n : currentDir;\n\n const server = http.createServer((req, res) => {\n const url = req.url ?? '/';\n\n // API endpoint for report data\n if (url === '/api/report') {\n res.writeHead(200, { 'Content-Type': 'application/json' });\n res.end(JSON.stringify(currentReport ?? { error: 'No report loaded' }));\n return;\n }\n\n // Serve static files\n let filePath = path.join(staticDir, url === '/' ? 'index.html' : url);\n const ext = path.extname(filePath);\n\n // SPA fallback — serve index.html for non-file routes\n if (!ext) {\n filePath = path.join(staticDir, 'index.html');\n }\n\n try {\n const content = fs.readFileSync(filePath);\n const mime = MIME_TYPES[path.extname(filePath)] ?? 'text/plain';\n res.writeHead(200, { 'Content-Type': mime });\n res.end(content);\n } catch {\n res.writeHead(404);\n res.end('Not found');\n }\n });\n\n // WebSocket for live reload\n const wss = new WebSocketServer({ server });\n\n // Watch report file for changes\n let watcher: fs.FSWatcher | null = null;\n try {\n watcher = fs.watch(absoluteReportPath, () => {\n try {\n const content = fs.readFileSync(absoluteReportPath, 'utf-8');\n currentReport = JSON.parse(content);\n // Notify all connected clients\n for (const client of wss.clients) {\n if (client.readyState === 1) {\n client.send(JSON.stringify({ type: 'update', report: currentReport }));\n }\n }\n } catch {\n // Ignore parse errors during write\n }\n });\n } catch {\n console.warn('Could not watch report file for changes');\n }\n\n return new Promise((resolve) => {\n server.listen(port, () => {\n const url = `http://localhost:${port}`;\n console.log(`\\n Dashboard running at ${url}\\n`);\n resolve({\n url,\n close: () => {\n watcher?.close();\n wss.close();\n server.close();\n },\n });\n });\n });\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACAA,uBAAiB;AACjB,qBAAe;AACf,uBAAiB;AACjB,sBAA8B;AAC9B,gBAAgC;AAJhC;AAOA,IAAM,aAAqC;AAAA,EACzC,SAAS;AAAA,EACT,OAAO;AAAA,EACP,QAAQ;AAAA,EACR,SAAS;AAAA,EACT,QAAQ;AACV;AAMA,eAAsB,eACpB,YACA,OAAO,MACsC;AAC7C,QAAM,qBAAqB,iBAAAA,QAAK,QAAQ,UAAU;AAGlD,MAAI,gBAAuC;AAC3C,MAAI;AACF,UAAM,UAAU,eAAAC,QAAG,aAAa,oBAAoB,OAAO;AAC3D,oBAAgB,KAAK,MAAM,OAAO;AAAA,EACpC,QAAQ;AACN,YAAQ,KAAK,4BAA4B,kBAAkB,EAAE;AAAA,EAC/D;AAEA,QAAM,aAAa,iBAAAD,QAAK,YAAQ,+BAAc,YAAY,GAAG,CAAC;AAC9D,QAAM,YAAY,eAAAC,QAAG,WAAW,iBAAAD,QAAK,KAAK,YAAY,KAAK,CAAC,IACxD,iBAAAA,QAAK,KAAK,YAAY,KAAK,IAC3B;AAEJ,QAAM,SAAS,
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/server.ts"],"sourcesContent":["export { startDashboard } from './server.js';\n","import http from 'node:http';\nimport fs from 'node:fs';\nimport path from 'node:path';\nimport { fileURLToPath } from 'node:url';\nimport { WebSocketServer } from 'ws';\nimport type { AnalysisReport } from '@hydration-audit/core';\n\nconst MIME_TYPES: Record<string, string> = {\n '.html': 'text/html',\n '.js': 'application/javascript',\n '.css': 'text/css',\n '.json': 'application/json',\n '.svg': 'image/svg+xml',\n};\n\n/**\n * Start the dashboard server.\n * Serves a static SPA and provides a WebSocket for live report updates.\n */\nexport async function startDashboard(\n reportPath: string,\n port = 4173,\n): Promise<{ url: string; close: () => void }> {\n const absoluteReportPath = path.resolve(reportPath);\n\n // Read the initial report\n let currentReport: AnalysisReport | null = null;\n try {\n const content = fs.readFileSync(absoluteReportPath, 'utf-8');\n currentReport = JSON.parse(content);\n } catch {\n console.warn(`Could not read report at ${absoluteReportPath}`);\n }\n\n const currentDir = path.dirname(fileURLToPath(import.meta.url));\n const staticDir = fs.existsSync(path.join(currentDir, 'app'))\n ? path.join(currentDir, 'app')\n : currentDir;\n\n let currentImageReport: any = null;\n const imageReportPath = path.join(path.dirname(absoluteReportPath), '.image-audit-report.json');\n let imageWatcher: fs.FSWatcher | null = null;\n\n const watchImageReport = () => {\n if (imageWatcher) return;\n try {\n if (fs.existsSync(imageReportPath)) {\n imageWatcher = fs.watch(imageReportPath, () => {\n try {\n tryReadImageReport();\n for (const client of wss.clients) {\n if (client.readyState === 1) {\n client.send(JSON.stringify({ type: 'image-update', report: currentImageReport }));\n }\n }\n } catch {}\n });\n }\n } catch {}\n };\n\n function tryReadImageReport() {\n try {\n if (fs.existsSync(imageReportPath)) {\n const content = fs.readFileSync(imageReportPath, 'utf-8');\n currentImageReport = JSON.parse(content);\n watchImageReport();\n } else {\n currentImageReport = null;\n }\n } catch {\n currentImageReport = null;\n }\n }\n\n const server = http.createServer((req, res) => {\n const url = req.url ?? '/';\n\n // API endpoint for report data\n if (url === '/api/report') {\n res.writeHead(200, { 'Content-Type': 'application/json' });\n res.end(JSON.stringify(currentReport ?? { error: 'No report loaded' }));\n return;\n }\n\n if (url === '/api/image-report') {\n tryReadImageReport();\n res.writeHead(200, { 'Content-Type': 'application/json' });\n res.end(JSON.stringify(currentImageReport ?? { error: 'No image report found' }));\n return;\n }\n\n // Serve static files\n let filePath = path.join(staticDir, url === '/' ? 'index.html' : url);\n const ext = path.extname(filePath);\n\n // SPA fallback — serve index.html for non-file routes\n if (!ext) {\n filePath = path.join(staticDir, 'index.html');\n }\n\n try {\n const content = fs.readFileSync(filePath);\n const mime = MIME_TYPES[path.extname(filePath)] ?? 'text/plain';\n res.writeHead(200, { 'Content-Type': mime });\n res.end(content);\n } catch {\n res.writeHead(404);\n res.end('Not found');\n }\n });\n\n // WebSocket for live reload\n const wss = new WebSocketServer({ server });\n\n // Watch report file for changes\n let watcher: fs.FSWatcher | null = null;\n try {\n watcher = fs.watch(absoluteReportPath, () => {\n try {\n const content = fs.readFileSync(absoluteReportPath, 'utf-8');\n currentReport = JSON.parse(content);\n // Notify all connected clients\n for (const client of wss.clients) {\n if (client.readyState === 1) {\n client.send(JSON.stringify({ type: 'update', report: currentReport }));\n }\n }\n } catch {\n // Ignore parse errors during write\n }\n });\n } catch {\n console.warn('Could not watch report file for changes');\n }\n\n tryReadImageReport();\n\n return new Promise((resolve) => {\n server.listen(port, () => {\n const url = `http://localhost:${port}`;\n console.log(`\\n Dashboard running at ${url}\\n`);\n resolve({\n url,\n close: () => {\n watcher?.close();\n wss.close();\n server.close();\n },\n });\n });\n });\n}\n"],"mappings":"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","names":["path","fs","http"]}
|
package/dist/index.html
CHANGED
|
@@ -812,6 +812,66 @@
|
|
|
812
812
|
.view.active {
|
|
813
813
|
display: block;
|
|
814
814
|
}
|
|
815
|
+
|
|
816
|
+
/* Images & Media View Styling */
|
|
817
|
+
.img-preview-thumb {
|
|
818
|
+
width: 48px;
|
|
819
|
+
height: 48px;
|
|
820
|
+
object-fit: cover;
|
|
821
|
+
border-radius: 4px;
|
|
822
|
+
background: var(--surface-active);
|
|
823
|
+
border: 1px solid var(--border);
|
|
824
|
+
}
|
|
825
|
+
.img-preview-placeholder {
|
|
826
|
+
width: 48px;
|
|
827
|
+
height: 48px;
|
|
828
|
+
border-radius: 4px;
|
|
829
|
+
background: var(--surface-active);
|
|
830
|
+
border: 1px solid var(--border);
|
|
831
|
+
display: flex;
|
|
832
|
+
align-items: center;
|
|
833
|
+
justify-content: center;
|
|
834
|
+
color: var(--text-muted);
|
|
835
|
+
font-size: 0.7rem;
|
|
836
|
+
font-weight: 600;
|
|
837
|
+
text-transform: uppercase;
|
|
838
|
+
}
|
|
839
|
+
.cdn-badge {
|
|
840
|
+
display: inline-block;
|
|
841
|
+
padding: 2px 6px;
|
|
842
|
+
border-radius: 4px;
|
|
843
|
+
font-size: 0.75rem;
|
|
844
|
+
font-weight: 600;
|
|
845
|
+
}
|
|
846
|
+
.cdn-badge.yes {
|
|
847
|
+
background: var(--green-alpha);
|
|
848
|
+
color: var(--green);
|
|
849
|
+
}
|
|
850
|
+
.cdn-badge.no {
|
|
851
|
+
background: var(--yellow-alpha);
|
|
852
|
+
color: var(--yellow);
|
|
853
|
+
}
|
|
854
|
+
.issue-badge-inline {
|
|
855
|
+
display: inline-block;
|
|
856
|
+
padding: 1px 6px;
|
|
857
|
+
border-radius: 4px;
|
|
858
|
+
font-size: 0.7rem;
|
|
859
|
+
font-weight: 700;
|
|
860
|
+
text-transform: uppercase;
|
|
861
|
+
margin-top: 2px;
|
|
862
|
+
}
|
|
863
|
+
.issue-badge-inline.error {
|
|
864
|
+
background: var(--red-alpha);
|
|
865
|
+
color: var(--red);
|
|
866
|
+
}
|
|
867
|
+
.issue-badge-inline.warning {
|
|
868
|
+
background: var(--yellow-alpha);
|
|
869
|
+
color: var(--yellow);
|
|
870
|
+
}
|
|
871
|
+
.issue-badge-inline.info {
|
|
872
|
+
background: var(--blue-alpha);
|
|
873
|
+
color: var(--blue);
|
|
874
|
+
}
|
|
815
875
|
</style>
|
|
816
876
|
</head>
|
|
817
877
|
<body>
|
|
@@ -825,6 +885,7 @@
|
|
|
825
885
|
|
|
826
886
|
<script type="module">
|
|
827
887
|
let report = null;
|
|
888
|
+
let imageReport = null;
|
|
828
889
|
let activeTab = 'overview';
|
|
829
890
|
|
|
830
891
|
// Filtering and search state
|
|
@@ -833,6 +894,9 @@
|
|
|
833
894
|
let directiveFilter = 'all';
|
|
834
895
|
let severityFilter = 'all';
|
|
835
896
|
|
|
897
|
+
let imagePageFilter = 'all';
|
|
898
|
+
let imageSeverityFilter = 'all';
|
|
899
|
+
|
|
836
900
|
// Sorting state
|
|
837
901
|
let sortColumn = 'gzip';
|
|
838
902
|
let sortAsc = false;
|
|
@@ -845,6 +909,17 @@
|
|
|
845
909
|
try {
|
|
846
910
|
const res = await fetch('/api/report');
|
|
847
911
|
report = await res.json();
|
|
912
|
+
|
|
913
|
+
try {
|
|
914
|
+
const imgRes = await fetch('/api/image-report');
|
|
915
|
+
const imgData = await imgRes.json();
|
|
916
|
+
if (imgData && !imgData.error) {
|
|
917
|
+
imageReport = imgData;
|
|
918
|
+
}
|
|
919
|
+
} catch (e) {
|
|
920
|
+
console.warn('No image report available', e);
|
|
921
|
+
}
|
|
922
|
+
|
|
848
923
|
renderLayout();
|
|
849
924
|
connectWS();
|
|
850
925
|
} catch (e) {
|
|
@@ -866,6 +941,9 @@
|
|
|
866
941
|
if (data.type === 'update') {
|
|
867
942
|
report = data.report;
|
|
868
943
|
updateMetricsAndViews();
|
|
944
|
+
} else if (data.type === 'image-update') {
|
|
945
|
+
imageReport = data.report;
|
|
946
|
+
updateMetricsAndViews();
|
|
869
947
|
}
|
|
870
948
|
};
|
|
871
949
|
ws.onclose = () => setTimeout(connectWS, 3000);
|
|
@@ -923,6 +1001,10 @@
|
|
|
923
1001
|
<span>Issues</span>
|
|
924
1002
|
<span class="nav-badge" id="badge-issues-count" style="${totalIssues > 0 ? 'background:var(--yellow-alpha);color:var(--yellow);' : ''}">${totalIssues}</span>
|
|
925
1003
|
</button>
|
|
1004
|
+
<button class="nav-item ${activeTab === 'images' ? 'active' : ''}" data-tab="images" id="nav-item-images" style="display: none;">
|
|
1005
|
+
<span>Images & Media</span>
|
|
1006
|
+
<span class="nav-badge" id="badge-images-issues-count">0</span>
|
|
1007
|
+
</button>
|
|
926
1008
|
</div>
|
|
927
1009
|
|
|
928
1010
|
<div class="status">
|
|
@@ -1020,6 +1102,35 @@
|
|
|
1020
1102
|
|
|
1021
1103
|
<div class="issues-list" id="issues-list-container"></div>
|
|
1022
1104
|
</div>
|
|
1105
|
+
|
|
1106
|
+
<!-- Images View -->
|
|
1107
|
+
<div class="view ${activeTab === 'images' ? 'active' : ''}" id="view-images">
|
|
1108
|
+
<div class="header">
|
|
1109
|
+
<div class="header-title">
|
|
1110
|
+
<h2>Images & Media Audit</h2>
|
|
1111
|
+
<p>Verify image dimensions, sizes, formats, broken assets, and CDN delivery.</p>
|
|
1112
|
+
</div>
|
|
1113
|
+
</div>
|
|
1114
|
+
<div class="stats-grid" id="images-stats" style="margin-bottom: 1.5rem;"></div>
|
|
1115
|
+
|
|
1116
|
+
<div class="filter-bar" id="images-filter-bar">
|
|
1117
|
+
<div class="filter-group">
|
|
1118
|
+
<span class="filter-label">Filter Page:</span>
|
|
1119
|
+
<select id="images-page-select" class="search-input" style="width: auto; max-width: 300px; padding: 0.35rem 0.75rem;">
|
|
1120
|
+
<option value="all">All Pages</option>
|
|
1121
|
+
</select>
|
|
1122
|
+
</div>
|
|
1123
|
+
<div class="filter-group" style="margin-left: auto;">
|
|
1124
|
+
<span class="filter-label">Filter Severity:</span>
|
|
1125
|
+
<button class="filter-pill active" id="btn-img-severity-all">All</button>
|
|
1126
|
+
<button class="filter-pill" id="btn-img-severity-error">Errors</button>
|
|
1127
|
+
<button class="filter-pill" id="btn-img-severity-warning">Warnings</button>
|
|
1128
|
+
<button class="filter-pill" id="btn-img-severity-info">Info</button>
|
|
1129
|
+
</div>
|
|
1130
|
+
</div>
|
|
1131
|
+
|
|
1132
|
+
<div id="images-report-container" style="display:flex; flex-direction:column; gap:2rem;"></div>
|
|
1133
|
+
</div>
|
|
1023
1134
|
</div>
|
|
1024
1135
|
|
|
1025
1136
|
<!-- Detail Inspector Slide-out Drawer -->
|
|
@@ -1036,6 +1147,26 @@
|
|
|
1036
1147
|
`;
|
|
1037
1148
|
|
|
1038
1149
|
document.getElementById('app-root').replaceChildren(container);
|
|
1150
|
+
|
|
1151
|
+
// Show/hide Images nav item
|
|
1152
|
+
if (imageReport) {
|
|
1153
|
+
const navItemImages = document.getElementById('nav-item-images');
|
|
1154
|
+
if (navItemImages) {
|
|
1155
|
+
navItemImages.style.display = 'flex';
|
|
1156
|
+
const totalImgIssues = imageReport.totals.totalIssues;
|
|
1157
|
+
const imgBadge = document.getElementById('badge-images-issues-count');
|
|
1158
|
+
if (imgBadge) {
|
|
1159
|
+
imgBadge.textContent = totalImgIssues;
|
|
1160
|
+
if (totalImgIssues > 0) {
|
|
1161
|
+
imgBadge.style.background = 'var(--yellow-alpha)';
|
|
1162
|
+
imgBadge.style.color = 'var(--yellow)';
|
|
1163
|
+
} else {
|
|
1164
|
+
imgBadge.style.background = 'var(--surface-hover)';
|
|
1165
|
+
imgBadge.style.color = 'var(--text-secondary)';
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1039
1170
|
|
|
1040
1171
|
// Wire sidebar tabs switching
|
|
1041
1172
|
document.querySelectorAll('.sidebar .nav-item').forEach(btn => {
|
|
@@ -1050,6 +1181,8 @@
|
|
|
1050
1181
|
// Re-trigger layout sizing on tab changes (specifically for Treemap resizing)
|
|
1051
1182
|
if (activeTab === 'overview') {
|
|
1052
1183
|
setTimeout(renderTreemap, 50);
|
|
1184
|
+
} else if (activeTab === 'images') {
|
|
1185
|
+
renderImagesView();
|
|
1053
1186
|
}
|
|
1054
1187
|
});
|
|
1055
1188
|
});
|
|
@@ -1087,6 +1220,33 @@
|
|
|
1087
1220
|
});
|
|
1088
1221
|
});
|
|
1089
1222
|
|
|
1223
|
+
// Wire images filters
|
|
1224
|
+
const pageSelect = document.getElementById('images-page-select');
|
|
1225
|
+
if (pageSelect) {
|
|
1226
|
+
pageSelect.addEventListener('change', (e) => {
|
|
1227
|
+
imagePageFilter = e.target.value;
|
|
1228
|
+
renderImagesView();
|
|
1229
|
+
});
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
const imgSeverityButtons = {
|
|
1233
|
+
all: document.getElementById('btn-img-severity-all'),
|
|
1234
|
+
error: document.getElementById('btn-img-severity-error'),
|
|
1235
|
+
warning: document.getElementById('btn-img-severity-warning'),
|
|
1236
|
+
info: document.getElementById('btn-img-severity-info'),
|
|
1237
|
+
};
|
|
1238
|
+
|
|
1239
|
+
Object.entries(imgSeverityButtons).forEach(([sev, btn]) => {
|
|
1240
|
+
if (btn) {
|
|
1241
|
+
btn.addEventListener('click', () => {
|
|
1242
|
+
imageSeverityFilter = sev;
|
|
1243
|
+
Object.values(imgSeverityButtons).forEach(b => { if (b) b.classList.remove('active'); });
|
|
1244
|
+
btn.classList.add('active');
|
|
1245
|
+
renderImagesView();
|
|
1246
|
+
});
|
|
1247
|
+
}
|
|
1248
|
+
});
|
|
1249
|
+
|
|
1090
1250
|
// Sort handlers
|
|
1091
1251
|
document.querySelectorAll('#islands-table th').forEach(th => {
|
|
1092
1252
|
th.addEventListener('click', () => {
|
|
@@ -1132,6 +1292,30 @@
|
|
|
1132
1292
|
issuesBadge.style.color = 'var(--text-secondary)';
|
|
1133
1293
|
}
|
|
1134
1294
|
|
|
1295
|
+
if (imageReport) {
|
|
1296
|
+
const navItemImages = document.getElementById('nav-item-images');
|
|
1297
|
+
if (navItemImages) {
|
|
1298
|
+
navItemImages.style.display = 'flex';
|
|
1299
|
+
const totalImgIssues = imageReport.totals.totalIssues;
|
|
1300
|
+
const imgBadge = document.getElementById('badge-images-issues-count');
|
|
1301
|
+
if (imgBadge) {
|
|
1302
|
+
imgBadge.textContent = totalImgIssues;
|
|
1303
|
+
if (totalImgIssues > 0) {
|
|
1304
|
+
imgBadge.style.background = 'var(--yellow-alpha)';
|
|
1305
|
+
imgBadge.style.color = 'var(--yellow)';
|
|
1306
|
+
} else {
|
|
1307
|
+
imgBadge.style.background = 'var(--surface-hover)';
|
|
1308
|
+
imgBadge.style.color = 'var(--text-secondary)';
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
renderImagesStats();
|
|
1313
|
+
populateImagesPageSelect();
|
|
1314
|
+
if (activeTab === 'images') {
|
|
1315
|
+
renderImagesView();
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1135
1319
|
// Re-populate sections
|
|
1136
1320
|
renderOverviewStats();
|
|
1137
1321
|
renderTreemap();
|
|
@@ -1148,6 +1332,281 @@
|
|
|
1148
1332
|
}
|
|
1149
1333
|
}
|
|
1150
1334
|
|
|
1335
|
+
// Populate the Images View Metrics cards
|
|
1336
|
+
function renderImagesStats() {
|
|
1337
|
+
const statsGrid = document.getElementById('images-stats');
|
|
1338
|
+
if (!statsGrid || !imageReport) return;
|
|
1339
|
+
|
|
1340
|
+
const t = imageReport.totals;
|
|
1341
|
+
const issuesClass = t.issuesBySeverity.error > 0 ? 'red' : t.issuesBySeverity.warning > 0 ? 'yellow' : 'green';
|
|
1342
|
+
|
|
1343
|
+
statsGrid.innerHTML = `
|
|
1344
|
+
<div class="stat-card">
|
|
1345
|
+
<div class="stat-label">Total Images</div>
|
|
1346
|
+
<div class="stat-value">${t.totalImages}</div>
|
|
1347
|
+
</div>
|
|
1348
|
+
<div class="stat-card">
|
|
1349
|
+
<div class="stat-label">Total Attachments</div>
|
|
1350
|
+
<div class="stat-value">${t.totalAttachments}</div>
|
|
1351
|
+
</div>
|
|
1352
|
+
<div class="stat-card">
|
|
1353
|
+
<div class="stat-label">Total Issues</div>
|
|
1354
|
+
<div class="stat-value ${issuesClass}">${t.totalIssues}</div>
|
|
1355
|
+
</div>
|
|
1356
|
+
<div class="stat-card">
|
|
1357
|
+
<div class="stat-label">Severity Breakdown</div>
|
|
1358
|
+
<div style="margin-top: 0.5rem; display: flex; flex-direction: column; gap: 2px; font-size: 0.8rem; font-weight: 500;">
|
|
1359
|
+
<div style="display:flex; justify-content:space-between; color: var(--red);">
|
|
1360
|
+
<span>Errors:</span> <strong>${t.issuesBySeverity.error}</strong>
|
|
1361
|
+
</div>
|
|
1362
|
+
<div style="display:flex; justify-content:space-between; color: var(--yellow);">
|
|
1363
|
+
<span>Warnings:</span> <strong>${t.issuesBySeverity.warning}</strong>
|
|
1364
|
+
</div>
|
|
1365
|
+
<div style="display:flex; justify-content:space-between; color: var(--blue);">
|
|
1366
|
+
<span>Info:</span> <strong>${t.issuesBySeverity.info}</strong>
|
|
1367
|
+
</div>
|
|
1368
|
+
</div>
|
|
1369
|
+
</div>
|
|
1370
|
+
`;
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
// Populate the Images Page Select Dropdown
|
|
1374
|
+
function populateImagesPageSelect() {
|
|
1375
|
+
const pageSelect = document.getElementById('images-page-select');
|
|
1376
|
+
if (!pageSelect || !imageReport) return;
|
|
1377
|
+
|
|
1378
|
+
const currentVal = pageSelect.value || 'all';
|
|
1379
|
+
|
|
1380
|
+
pageSelect.innerHTML = `<option value="all">All Pages (${imageReport.pages.length})</option>`;
|
|
1381
|
+
|
|
1382
|
+
for (const page of imageReport.pages) {
|
|
1383
|
+
const opt = document.createElement('option');
|
|
1384
|
+
opt.value = page.route;
|
|
1385
|
+
const totalPageIssues = page.images.reduce((sum, img) => sum + img.issues.length, 0) +
|
|
1386
|
+
page.attachments.reduce((sum, att) => sum + att.issues.length, 0);
|
|
1387
|
+
opt.textContent = `${page.route} (${page.images.length} images, ${totalPageIssues} issues)`;
|
|
1388
|
+
pageSelect.appendChild(opt);
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
if (Array.from(pageSelect.options).some(o => o.value === currentVal)) {
|
|
1392
|
+
pageSelect.value = currentVal;
|
|
1393
|
+
imagePageFilter = currentVal;
|
|
1394
|
+
} else {
|
|
1395
|
+
pageSelect.value = 'all';
|
|
1396
|
+
imagePageFilter = 'all';
|
|
1397
|
+
}
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
// Render list of audited images and attachments page-by-page
|
|
1401
|
+
function renderImagesView() {
|
|
1402
|
+
const container = document.getElementById('images-report-container');
|
|
1403
|
+
if (!container || !imageReport) return;
|
|
1404
|
+
|
|
1405
|
+
let pagesToRender = imageReport.pages;
|
|
1406
|
+
if (imagePageFilter !== 'all') {
|
|
1407
|
+
pagesToRender = pagesToRender.filter(p => p.route === imagePageFilter);
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
let html = '';
|
|
1411
|
+
|
|
1412
|
+
for (const page of pagesToRender) {
|
|
1413
|
+
const filteredImages = page.images.filter(img => {
|
|
1414
|
+
if (imageSeverityFilter === 'all') return true;
|
|
1415
|
+
return img.issues.some(iss => iss.severity === imageSeverityFilter);
|
|
1416
|
+
});
|
|
1417
|
+
|
|
1418
|
+
const filteredAttachments = page.attachments.filter(att => {
|
|
1419
|
+
if (imageSeverityFilter === 'all') return true;
|
|
1420
|
+
return att.issues.some(iss => iss.severity === imageSeverityFilter);
|
|
1421
|
+
});
|
|
1422
|
+
|
|
1423
|
+
if (filteredImages.length === 0 && filteredAttachments.length === 0) {
|
|
1424
|
+
continue;
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
const totalIssuesOnPage = filteredImages.reduce((sum, img) => sum + img.issues.length, 0) +
|
|
1428
|
+
filteredAttachments.reduce((sum, att) => sum + att.issues.length, 0);
|
|
1429
|
+
|
|
1430
|
+
const pageErrors = filteredImages.reduce((sum, img) => sum + img.issues.filter(i => i.severity === 'error').length, 0) +
|
|
1431
|
+
filteredAttachments.reduce((sum, att) => sum + att.issues.filter(i => i.severity === 'error').length, 0);
|
|
1432
|
+
|
|
1433
|
+
const pageWarnings = filteredImages.reduce((sum, img) => sum + img.issues.filter(i => i.severity === 'warning').length, 0) +
|
|
1434
|
+
filteredAttachments.reduce((sum, att) => sum + att.issues.filter(i => i.severity === 'warning').length, 0);
|
|
1435
|
+
|
|
1436
|
+
const pageBadgeClass = pageErrors > 0 ? 'error' : pageWarnings > 0 ? 'warn' : 'zero';
|
|
1437
|
+
|
|
1438
|
+
html += `
|
|
1439
|
+
<div class="card" style="margin-bottom:1.5rem; gap:1rem;">
|
|
1440
|
+
<div style="display:flex; justify-content:space-between; align-items:center; border-bottom:1px solid var(--border); padding-bottom:0.75rem;">
|
|
1441
|
+
<div>
|
|
1442
|
+
<h3 style="margin-bottom:0; font-size:1.15rem; color:var(--text);">${page.route}</h3>
|
|
1443
|
+
<span style="font-size:0.8rem; color:var(--text-muted); font-family:'JetBrains Mono', monospace;">${page.htmlFile}</span>
|
|
1444
|
+
</div>
|
|
1445
|
+
<div style="display:flex; align-items:center; gap:0.5rem;">
|
|
1446
|
+
<span style="font-size:0.8rem; color:var(--text-secondary);">${page.images.length} images, ${page.attachments.length} attachments</span>
|
|
1447
|
+
<span class="issues-count ${pageBadgeClass}">${totalIssuesOnPage}</span>
|
|
1448
|
+
</div>
|
|
1449
|
+
</div>
|
|
1450
|
+
|
|
1451
|
+
${filteredImages.length > 0 ? `
|
|
1452
|
+
<div>
|
|
1453
|
+
<h4 style="font-size:0.9rem; font-weight:600; color:var(--text-secondary); margin-bottom:0.75rem; text-transform:uppercase; letter-spacing:0.05em;">Images</h4>
|
|
1454
|
+
<div class="table-wrapper">
|
|
1455
|
+
<table>
|
|
1456
|
+
<thead>
|
|
1457
|
+
<tr>
|
|
1458
|
+
<th style="width: 80px;">Preview</th>
|
|
1459
|
+
<th>Resource URL / tag</th>
|
|
1460
|
+
<th>File Size</th>
|
|
1461
|
+
<th>Dimensions (Natural / HTML)</th>
|
|
1462
|
+
<th>CDN</th>
|
|
1463
|
+
<th>Diagnostics</th>
|
|
1464
|
+
</tr>
|
|
1465
|
+
</thead>
|
|
1466
|
+
<tbody>
|
|
1467
|
+
${filteredImages.map(img => {
|
|
1468
|
+
const isBroken = img.isBroken;
|
|
1469
|
+
const isRemote = img.src.startsWith('http://') || img.src.startsWith('https://');
|
|
1470
|
+
|
|
1471
|
+
let previewHtml = '';
|
|
1472
|
+
if (isBroken) {
|
|
1473
|
+
previewHtml = `<div class="img-preview-placeholder" style="color:var(--red); border-color:var(--red-alpha);">Broken</div>`;
|
|
1474
|
+
} else if (isRemote) {
|
|
1475
|
+
previewHtml = `<img src="${img.src}" class="img-preview-thumb" onerror="this.style.display='none'; this.nextElementSibling.style.display='flex';" />
|
|
1476
|
+
<div class="img-preview-placeholder" style="display:none;">Remote</div>`;
|
|
1477
|
+
} else if (img.src.startsWith('data:')) {
|
|
1478
|
+
previewHtml = `<img src="${img.src}" class="img-preview-thumb" />`;
|
|
1479
|
+
} else {
|
|
1480
|
+
previewHtml = `<div class="img-preview-placeholder">Local</div>`;
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
const displaySize = img.fileSize ? formatBytes(img.fileSize) : 'Unknown';
|
|
1484
|
+
const sizeClass = img.fileSize && img.fileSize > 100 * 1024 ? 'color: var(--yellow); font-weight:600;' : '';
|
|
1485
|
+
|
|
1486
|
+
const naturalDims = img.naturalWidth && img.naturalHeight ? `${img.naturalWidth}x${img.naturalHeight}` : 'Unknown';
|
|
1487
|
+
const displayDims = img.displayWidth && img.displayHeight ? `${img.displayWidth}x${img.displayHeight}` : 'Not set';
|
|
1488
|
+
|
|
1489
|
+
let dimsClass = '';
|
|
1490
|
+
if (img.naturalWidth && img.displayWidth && img.naturalWidth > img.displayWidth * 1.5) {
|
|
1491
|
+
dimsClass = 'color: var(--yellow); font-weight:600;';
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
const issuesListHtml = img.issues.map(iss => `
|
|
1495
|
+
<div style="margin-top: 4px; display: flex; align-items: flex-start; gap: 4px;">
|
|
1496
|
+
<span class="issue-badge-inline ${iss.severity}">${iss.severity}</span>
|
|
1497
|
+
<div style="font-size:0.85rem;">
|
|
1498
|
+
<strong>${iss.message}</strong>
|
|
1499
|
+
<div style="color: var(--text-muted); font-size: 0.75rem; margin-top:2px;">Recommendation: ${iss.recommendation}</div>
|
|
1500
|
+
</div>
|
|
1501
|
+
</div>
|
|
1502
|
+
`).join('');
|
|
1503
|
+
|
|
1504
|
+
return `
|
|
1505
|
+
<tr>
|
|
1506
|
+
<td>${previewHtml}</td>
|
|
1507
|
+
<td>
|
|
1508
|
+
<div style="font-weight:600; word-break:break-all; max-width:320px;" title="${img.src}">
|
|
1509
|
+
${isRemote ? `<a href="${img.src}" target="_blank" style="color:var(--blue); text-decoration:none;">${pathBasename(img.src)}</a>` : pathBasename(img.src)}
|
|
1510
|
+
</div>
|
|
1511
|
+
<div style="font-size:0.75rem; color:var(--text-muted); font-family:'JetBrains Mono', monospace; margin-top:2px;"><${img.tagName}></div>
|
|
1512
|
+
${isRemote ? `<div style="font-size:0.75rem; color:var(--text-muted); max-width:320px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">${img.src}</div>` : `<div style="font-size:0.75rem; color:var(--text-muted);">${img.src}</div>`}
|
|
1513
|
+
</td>
|
|
1514
|
+
<td style="${sizeClass}">${displaySize}</td>
|
|
1515
|
+
<td style="${dimsClass}">
|
|
1516
|
+
<div>Nat: ${naturalDims}</div>
|
|
1517
|
+
<div style="font-size:0.75rem; color:var(--text-muted);">HTML: ${displayDims}</div>
|
|
1518
|
+
</td>
|
|
1519
|
+
<td>
|
|
1520
|
+
<span class="cdn-badge ${img.isCdn ? 'yes' : 'no'}">${img.isCdn ? 'CDN' : 'No CDN'}</span>
|
|
1521
|
+
</td>
|
|
1522
|
+
<td>
|
|
1523
|
+
${img.issues.length === 0
|
|
1524
|
+
? `<span style="color:var(--green); font-size:0.85rem; font-weight:500;">✓ Healthy</span>`
|
|
1525
|
+
: `<div style="display:flex; flex-direction:column; gap:6px;">${issuesListHtml}</div>`}
|
|
1526
|
+
</td>
|
|
1527
|
+
</tr>
|
|
1528
|
+
`;
|
|
1529
|
+
}).join('')}
|
|
1530
|
+
</tbody>
|
|
1531
|
+
</table>
|
|
1532
|
+
</div>
|
|
1533
|
+
</div>
|
|
1534
|
+
` : ''}
|
|
1535
|
+
|
|
1536
|
+
${filteredAttachments.length > 0 ? `
|
|
1537
|
+
<div style="margin-top:0.75rem;">
|
|
1538
|
+
<h4 style="font-size:0.9rem; font-weight:600; color:var(--text-secondary); margin-bottom:0.75rem; text-transform:uppercase; letter-spacing:0.05em;">Media & File Attachments</h4>
|
|
1539
|
+
<div class="table-wrapper">
|
|
1540
|
+
<table>
|
|
1541
|
+
<thead>
|
|
1542
|
+
<tr>
|
|
1543
|
+
<th>Resource URL / href</th>
|
|
1544
|
+
<th>Tag</th>
|
|
1545
|
+
<th>CDN</th>
|
|
1546
|
+
<th>Status</th>
|
|
1547
|
+
<th>Diagnostics</th>
|
|
1548
|
+
</tr>
|
|
1549
|
+
</thead>
|
|
1550
|
+
<tbody>
|
|
1551
|
+
${filteredAttachments.map(att => {
|
|
1552
|
+
const isRemote = att.href.startsWith('http://') || att.href.startsWith('https://');
|
|
1553
|
+
const isBroken = att.isBroken;
|
|
1554
|
+
|
|
1555
|
+
const issuesListHtml = att.issues.map(iss => `
|
|
1556
|
+
<div style="margin-top: 4px; display: flex; align-items: flex-start; gap: 4px;">
|
|
1557
|
+
<span class="issue-badge-inline ${iss.severity}">${iss.severity}</span>
|
|
1558
|
+
<div style="font-size:0.85rem;">
|
|
1559
|
+
<strong>${iss.message}</strong>
|
|
1560
|
+
<div style="color: var(--text-muted); font-size: 0.75rem; margin-top:2px;">Recommendation: ${iss.recommendation}</div>
|
|
1561
|
+
</div>
|
|
1562
|
+
</div>
|
|
1563
|
+
`).join('');
|
|
1564
|
+
|
|
1565
|
+
return `
|
|
1566
|
+
<tr>
|
|
1567
|
+
<td>
|
|
1568
|
+
<div style="font-weight:600; word-break:break-all; max-width:400px;" title="${att.href}">
|
|
1569
|
+
${isRemote ? `<a href="${att.href}" target="_blank" style="color:var(--blue); text-decoration:none;">${pathBasename(att.href)}</a>` : pathBasename(att.href)}
|
|
1570
|
+
</div>
|
|
1571
|
+
<div style="font-size:0.75rem; color:var(--text-muted); word-break:break-all; max-width:400px; margin-top:2px;">${att.href}</div>
|
|
1572
|
+
</td>
|
|
1573
|
+
<td><span style="font-family:'JetBrains Mono', monospace; font-size:0.85rem;"><${att.tagName}></span></td>
|
|
1574
|
+
<td>
|
|
1575
|
+
<span class="cdn-badge ${att.isCdn ? 'yes' : 'no'}">${att.isCdn ? 'CDN' : 'No CDN'}</span>
|
|
1576
|
+
</td>
|
|
1577
|
+
<td>
|
|
1578
|
+
${isBroken
|
|
1579
|
+
? `<span style="color:var(--red); font-weight:600;">Broken Link</span>`
|
|
1580
|
+
: `<span style="color:var(--green); font-weight:600;">Active</span>`}
|
|
1581
|
+
</td>
|
|
1582
|
+
<td>
|
|
1583
|
+
${att.issues.length === 0
|
|
1584
|
+
? `<span style="color:var(--green); font-size:0.85rem; font-weight:500;">✓ Healthy</span>`
|
|
1585
|
+
: `<div style="display:flex; flex-direction:column; gap:6px;">${issuesListHtml}</div>`}
|
|
1586
|
+
</td>
|
|
1587
|
+
</tr>
|
|
1588
|
+
`;
|
|
1589
|
+
}).join('')}
|
|
1590
|
+
</tbody>
|
|
1591
|
+
</table>
|
|
1592
|
+
</div>
|
|
1593
|
+
</div>
|
|
1594
|
+
` : ''}
|
|
1595
|
+
</div>
|
|
1596
|
+
`;
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
if (!html) {
|
|
1600
|
+
container.innerHTML = `
|
|
1601
|
+
<div style="text-align:center; color:var(--text-muted); padding:4rem 0;">
|
|
1602
|
+
No media assets found matching the filter criteria.
|
|
1603
|
+
</div>
|
|
1604
|
+
`;
|
|
1605
|
+
} else {
|
|
1606
|
+
container.innerHTML = html;
|
|
1607
|
+
}
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1151
1610
|
// Populate the Overview Metrics cards
|
|
1152
1611
|
function renderOverviewStats() {
|
|
1153
1612
|
const statsGrid = document.getElementById('overview-stats');
|
package/dist/index.mjs
CHANGED
|
@@ -22,6 +22,41 @@ async function startDashboard(reportPath, port = 4173) {
|
|
|
22
22
|
}
|
|
23
23
|
const currentDir = path.dirname(fileURLToPath(import.meta.url));
|
|
24
24
|
const staticDir = fs.existsSync(path.join(currentDir, "app")) ? path.join(currentDir, "app") : currentDir;
|
|
25
|
+
let currentImageReport = null;
|
|
26
|
+
const imageReportPath = path.join(path.dirname(absoluteReportPath), ".image-audit-report.json");
|
|
27
|
+
let imageWatcher = null;
|
|
28
|
+
const watchImageReport = () => {
|
|
29
|
+
if (imageWatcher) return;
|
|
30
|
+
try {
|
|
31
|
+
if (fs.existsSync(imageReportPath)) {
|
|
32
|
+
imageWatcher = fs.watch(imageReportPath, () => {
|
|
33
|
+
try {
|
|
34
|
+
tryReadImageReport();
|
|
35
|
+
for (const client of wss.clients) {
|
|
36
|
+
if (client.readyState === 1) {
|
|
37
|
+
client.send(JSON.stringify({ type: "image-update", report: currentImageReport }));
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
} catch {
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
} catch {
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
function tryReadImageReport() {
|
|
48
|
+
try {
|
|
49
|
+
if (fs.existsSync(imageReportPath)) {
|
|
50
|
+
const content = fs.readFileSync(imageReportPath, "utf-8");
|
|
51
|
+
currentImageReport = JSON.parse(content);
|
|
52
|
+
watchImageReport();
|
|
53
|
+
} else {
|
|
54
|
+
currentImageReport = null;
|
|
55
|
+
}
|
|
56
|
+
} catch {
|
|
57
|
+
currentImageReport = null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
25
60
|
const server = http.createServer((req, res) => {
|
|
26
61
|
const url = req.url ?? "/";
|
|
27
62
|
if (url === "/api/report") {
|
|
@@ -29,6 +64,12 @@ async function startDashboard(reportPath, port = 4173) {
|
|
|
29
64
|
res.end(JSON.stringify(currentReport ?? { error: "No report loaded" }));
|
|
30
65
|
return;
|
|
31
66
|
}
|
|
67
|
+
if (url === "/api/image-report") {
|
|
68
|
+
tryReadImageReport();
|
|
69
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
70
|
+
res.end(JSON.stringify(currentImageReport ?? { error: "No image report found" }));
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
32
73
|
let filePath = path.join(staticDir, url === "/" ? "index.html" : url);
|
|
33
74
|
const ext = path.extname(filePath);
|
|
34
75
|
if (!ext) {
|
|
@@ -62,6 +103,7 @@ async function startDashboard(reportPath, port = 4173) {
|
|
|
62
103
|
} catch {
|
|
63
104
|
console.warn("Could not watch report file for changes");
|
|
64
105
|
}
|
|
106
|
+
tryReadImageReport();
|
|
65
107
|
return new Promise((resolve) => {
|
|
66
108
|
server.listen(port, () => {
|
|
67
109
|
const url = `http://localhost:${port}`;
|
package/dist/index.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/server.ts"],"sourcesContent":["import http from 'node:http';\nimport fs from 'node:fs';\nimport path from 'node:path';\nimport { fileURLToPath } from 'node:url';\nimport { WebSocketServer } from 'ws';\nimport type { AnalysisReport } from '@hydration-audit/core';\n\nconst MIME_TYPES: Record<string, string> = {\n '.html': 'text/html',\n '.js': 'application/javascript',\n '.css': 'text/css',\n '.json': 'application/json',\n '.svg': 'image/svg+xml',\n};\n\n/**\n * Start the dashboard server.\n * Serves a static SPA and provides a WebSocket for live report updates.\n */\nexport async function startDashboard(\n reportPath: string,\n port = 4173,\n): Promise<{ url: string; close: () => void }> {\n const absoluteReportPath = path.resolve(reportPath);\n\n // Read the initial report\n let currentReport: AnalysisReport | null = null;\n try {\n const content = fs.readFileSync(absoluteReportPath, 'utf-8');\n currentReport = JSON.parse(content);\n } catch {\n console.warn(`Could not read report at ${absoluteReportPath}`);\n }\n\n const currentDir = path.dirname(fileURLToPath(import.meta.url));\n const staticDir = fs.existsSync(path.join(currentDir, 'app'))\n ? path.join(currentDir, 'app')\n : currentDir;\n\n const server = http.createServer((req, res) => {\n const url = req.url ?? '/';\n\n // API endpoint for report data\n if (url === '/api/report') {\n res.writeHead(200, { 'Content-Type': 'application/json' });\n res.end(JSON.stringify(currentReport ?? { error: 'No report loaded' }));\n return;\n }\n\n // Serve static files\n let filePath = path.join(staticDir, url === '/' ? 'index.html' : url);\n const ext = path.extname(filePath);\n\n // SPA fallback — serve index.html for non-file routes\n if (!ext) {\n filePath = path.join(staticDir, 'index.html');\n }\n\n try {\n const content = fs.readFileSync(filePath);\n const mime = MIME_TYPES[path.extname(filePath)] ?? 'text/plain';\n res.writeHead(200, { 'Content-Type': mime });\n res.end(content);\n } catch {\n res.writeHead(404);\n res.end('Not found');\n }\n });\n\n // WebSocket for live reload\n const wss = new WebSocketServer({ server });\n\n // Watch report file for changes\n let watcher: fs.FSWatcher | null = null;\n try {\n watcher = fs.watch(absoluteReportPath, () => {\n try {\n const content = fs.readFileSync(absoluteReportPath, 'utf-8');\n currentReport = JSON.parse(content);\n // Notify all connected clients\n for (const client of wss.clients) {\n if (client.readyState === 1) {\n client.send(JSON.stringify({ type: 'update', report: currentReport }));\n }\n }\n } catch {\n // Ignore parse errors during write\n }\n });\n } catch {\n console.warn('Could not watch report file for changes');\n }\n\n return new Promise((resolve) => {\n server.listen(port, () => {\n const url = `http://localhost:${port}`;\n console.log(`\\n Dashboard running at ${url}\\n`);\n resolve({\n url,\n close: () => {\n watcher?.close();\n wss.close();\n server.close();\n },\n });\n });\n });\n}\n"],"mappings":";AAAA,OAAO,UAAU;AACjB,OAAO,QAAQ;AACf,OAAO,UAAU;AACjB,SAAS,qBAAqB;AAC9B,SAAS,uBAAuB;AAGhC,IAAM,aAAqC;AAAA,EACzC,SAAS;AAAA,EACT,OAAO;AAAA,EACP,QAAQ;AAAA,EACR,SAAS;AAAA,EACT,QAAQ;AACV;AAMA,eAAsB,eACpB,YACA,OAAO,MACsC;AAC7C,QAAM,qBAAqB,KAAK,QAAQ,UAAU;AAGlD,MAAI,gBAAuC;AAC3C,MAAI;AACF,UAAM,UAAU,GAAG,aAAa,oBAAoB,OAAO;AAC3D,oBAAgB,KAAK,MAAM,OAAO;AAAA,EACpC,QAAQ;AACN,YAAQ,KAAK,4BAA4B,kBAAkB,EAAE;AAAA,EAC/D;AAEA,QAAM,aAAa,KAAK,QAAQ,cAAc,YAAY,GAAG,CAAC;AAC9D,QAAM,YAAY,GAAG,WAAW,KAAK,KAAK,YAAY,KAAK,CAAC,IACxD,KAAK,KAAK,YAAY,KAAK,IAC3B;AAEJ,QAAM,SAAS,KAAK,aAAa,CAAC,KAAK,QAAQ;AAC7C,UAAM,MAAM,IAAI,OAAO;AAGvB,QAAI,QAAQ,eAAe;AACzB,UAAI,UAAU,KAAK,EAAE,gBAAgB,mBAAmB,CAAC;AACzD,UAAI,IAAI,KAAK,UAAU,iBAAiB,EAAE,OAAO,mBAAmB,CAAC,CAAC;AACtE;AAAA,IACF;AAGA,QAAI,WAAW,KAAK,KAAK,WAAW,QAAQ,MAAM,eAAe,GAAG;AACpE,UAAM,MAAM,KAAK,QAAQ,QAAQ;AAGjC,QAAI,CAAC,KAAK;AACR,iBAAW,KAAK,KAAK,WAAW,YAAY;AAAA,IAC9C;AAEA,QAAI;AACF,YAAM,UAAU,GAAG,aAAa,QAAQ;AACxC,YAAM,OAAO,WAAW,KAAK,QAAQ,QAAQ,CAAC,KAAK;AACnD,UAAI,UAAU,KAAK,EAAE,gBAAgB,KAAK,CAAC;AAC3C,UAAI,IAAI,OAAO;AAAA,IACjB,QAAQ;AACN,UAAI,UAAU,GAAG;AACjB,UAAI,IAAI,WAAW;AAAA,IACrB;AAAA,EACF,CAAC;AAGD,QAAM,MAAM,IAAI,gBAAgB,EAAE,OAAO,CAAC;AAG1C,MAAI,UAA+B;AACnC,MAAI;AACF,cAAU,GAAG,MAAM,oBAAoB,MAAM;AAC3C,UAAI;AACF,cAAM,UAAU,GAAG,aAAa,oBAAoB,OAAO;AAC3D,wBAAgB,KAAK,MAAM,OAAO;AAElC,mBAAW,UAAU,IAAI,SAAS;AAChC,cAAI,OAAO,eAAe,GAAG;AAC3B,mBAAO,KAAK,KAAK,UAAU,EAAE,MAAM,UAAU,QAAQ,cAAc,CAAC,CAAC;AAAA,UACvE;AAAA,QACF;AAAA,MACF,QAAQ;AAAA,MAER;AAAA,IACF,CAAC;AAAA,EACH,QAAQ;AACN,YAAQ,KAAK,yCAAyC;AAAA,EACxD;AAEA,SAAO,IAAI,QAAQ,CAAC,YAAY;AAC9B,WAAO,OAAO,MAAM,MAAM;AACxB,YAAM,MAAM,oBAAoB,IAAI;AACpC,cAAQ,IAAI;AAAA,yBAA4B,GAAG;AAAA,CAAI;AAC/C,cAAQ;AAAA,QACN;AAAA,QACA,OAAO,MAAM;AACX,mBAAS,MAAM;AACf,cAAI,MAAM;AACV,iBAAO,MAAM;AAAA,QACf;AAAA,MACF,CAAC;AAAA,IACH,CAAC;AAAA,EACH,CAAC;AACH;","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/server.ts"],"sourcesContent":["import http from 'node:http';\nimport fs from 'node:fs';\nimport path from 'node:path';\nimport { fileURLToPath } from 'node:url';\nimport { WebSocketServer } from 'ws';\nimport type { AnalysisReport } from '@hydration-audit/core';\n\nconst MIME_TYPES: Record<string, string> = {\n '.html': 'text/html',\n '.js': 'application/javascript',\n '.css': 'text/css',\n '.json': 'application/json',\n '.svg': 'image/svg+xml',\n};\n\n/**\n * Start the dashboard server.\n * Serves a static SPA and provides a WebSocket for live report updates.\n */\nexport async function startDashboard(\n reportPath: string,\n port = 4173,\n): Promise<{ url: string; close: () => void }> {\n const absoluteReportPath = path.resolve(reportPath);\n\n // Read the initial report\n let currentReport: AnalysisReport | null = null;\n try {\n const content = fs.readFileSync(absoluteReportPath, 'utf-8');\n currentReport = JSON.parse(content);\n } catch {\n console.warn(`Could not read report at ${absoluteReportPath}`);\n }\n\n const currentDir = path.dirname(fileURLToPath(import.meta.url));\n const staticDir = fs.existsSync(path.join(currentDir, 'app'))\n ? path.join(currentDir, 'app')\n : currentDir;\n\n let currentImageReport: any = null;\n const imageReportPath = path.join(path.dirname(absoluteReportPath), '.image-audit-report.json');\n let imageWatcher: fs.FSWatcher | null = null;\n\n const watchImageReport = () => {\n if (imageWatcher) return;\n try {\n if (fs.existsSync(imageReportPath)) {\n imageWatcher = fs.watch(imageReportPath, () => {\n try {\n tryReadImageReport();\n for (const client of wss.clients) {\n if (client.readyState === 1) {\n client.send(JSON.stringify({ type: 'image-update', report: currentImageReport }));\n }\n }\n } catch {}\n });\n }\n } catch {}\n };\n\n function tryReadImageReport() {\n try {\n if (fs.existsSync(imageReportPath)) {\n const content = fs.readFileSync(imageReportPath, 'utf-8');\n currentImageReport = JSON.parse(content);\n watchImageReport();\n } else {\n currentImageReport = null;\n }\n } catch {\n currentImageReport = null;\n }\n }\n\n const server = http.createServer((req, res) => {\n const url = req.url ?? '/';\n\n // API endpoint for report data\n if (url === '/api/report') {\n res.writeHead(200, { 'Content-Type': 'application/json' });\n res.end(JSON.stringify(currentReport ?? { error: 'No report loaded' }));\n return;\n }\n\n if (url === '/api/image-report') {\n tryReadImageReport();\n res.writeHead(200, { 'Content-Type': 'application/json' });\n res.end(JSON.stringify(currentImageReport ?? { error: 'No image report found' }));\n return;\n }\n\n // Serve static files\n let filePath = path.join(staticDir, url === '/' ? 'index.html' : url);\n const ext = path.extname(filePath);\n\n // SPA fallback — serve index.html for non-file routes\n if (!ext) {\n filePath = path.join(staticDir, 'index.html');\n }\n\n try {\n const content = fs.readFileSync(filePath);\n const mime = MIME_TYPES[path.extname(filePath)] ?? 'text/plain';\n res.writeHead(200, { 'Content-Type': mime });\n res.end(content);\n } catch {\n res.writeHead(404);\n res.end('Not found');\n }\n });\n\n // WebSocket for live reload\n const wss = new WebSocketServer({ server });\n\n // Watch report file for changes\n let watcher: fs.FSWatcher | null = null;\n try {\n watcher = fs.watch(absoluteReportPath, () => {\n try {\n const content = fs.readFileSync(absoluteReportPath, 'utf-8');\n currentReport = JSON.parse(content);\n // Notify all connected clients\n for (const client of wss.clients) {\n if (client.readyState === 1) {\n client.send(JSON.stringify({ type: 'update', report: currentReport }));\n }\n }\n } catch {\n // Ignore parse errors during write\n }\n });\n } catch {\n console.warn('Could not watch report file for changes');\n }\n\n tryReadImageReport();\n\n return new Promise((resolve) => {\n server.listen(port, () => {\n const url = `http://localhost:${port}`;\n console.log(`\\n Dashboard running at ${url}\\n`);\n resolve({\n url,\n close: () => {\n watcher?.close();\n wss.close();\n server.close();\n },\n });\n });\n });\n}\n"],"mappings":"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","names":[]}
|