@hydration-audit/dashboard 0.2.3 → 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 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}`;
@@ -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,iBAAAE,QAAK,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,iBAAAF,QAAK,KAAK,WAAW,QAAQ,MAAM,eAAe,GAAG;AACpE,UAAM,MAAM,iBAAAA,QAAK,QAAQ,QAAQ;AAGjC,QAAI,CAAC,KAAK;AACR,iBAAW,iBAAAA,QAAK,KAAK,WAAW,YAAY;AAAA,IAC9C;AAEA,QAAI;AACF,YAAM,UAAU,eAAAC,QAAG,aAAa,QAAQ;AACxC,YAAM,OAAO,WAAW,iBAAAD,QAAK,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,0BAAgB,EAAE,OAAO,CAAC;AAG1C,MAAI,UAA+B;AACnC,MAAI;AACF,cAAU,eAAAC,QAAG,MAAM,oBAAoB,MAAM;AAC3C,UAAI;AACF,cAAM,UAAU,eAAAA,QAAG,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":["path","fs","http"]}
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;">&lt;${img.tagName}&gt;</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;">&lt;${att.tagName}&gt;</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}`;
@@ -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":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hydration-audit/dashboard",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
4
4
  "description": "Web dashboard for visualizing JavaScript hydration costs",
5
5
  "type": "module",
6
6
  "exports": {
@@ -21,7 +21,7 @@
21
21
  "preact": "^10.24.0",
22
22
  "d3-hierarchy": "^3.1.2",
23
23
  "ws": "^8.18.0",
24
- "@hydration-audit/core": "0.2.3"
24
+ "@hydration-audit/core": "0.2.4"
25
25
  },
26
26
  "devDependencies": {
27
27
  "@preact/preset-vite": "^2.9.0",