artifacty 0.1.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/lib/render.js CHANGED
@@ -2,17 +2,26 @@ import { EDITOR_CLIENT_PATH, VIEWER_CLIENT_PATH, editorImportMapJson } from "./e
2
2
  import { createI18n, DEFAULT_LOCALE, editorMessages, localizedHref, switchLocaleHref } from "./i18n.js";
3
3
  import { ARTIFACT_FORMATS, ARTIFACT_TYPES } from "./storage.js";
4
4
 
5
- export function renderDashboard({ artifacts, baseUrl, filters = {}, locale = DEFAULT_LOCALE, currentPath = "/" }) {
5
+ export function renderDashboard({ artifacts, baseUrl, filters = {}, pagination, locale = DEFAULT_LOCALE, currentPath = "/" }) {
6
6
  const view = viewContext(locale, currentPath);
7
+ const total = pagination?.total ?? artifacts.length;
8
+ const start = artifacts.length ? (pagination?.offset ?? 0) + 1 : 0;
9
+ const end = artifacts.length ? (pagination?.offset ?? 0) + artifacts.length : 0;
10
+ const searchBackend = pagination?.search?.backend;
11
+ const pager = renderDashboardPager({ pagination, filters, view });
7
12
  const rows = artifacts
8
13
  .map((artifact) => {
9
14
  const tags = artifact.tags.map((tag) => `<span class="tag">${escapeHtml(tag)}</span>`).join("");
10
15
  const status = artifact.archivedAt ? statusBadge("archived") : "";
16
+ const snippet = artifact.searchSnippet
17
+ ? `<span class="row-snippet">${escapeHtml(artifact.searchSnippet)}</span>`
18
+ : "";
11
19
  return `
12
20
  <a class="artifact-row" href="${view.href(`/artifacts/${encodeURIComponent(artifact.id)}`)}">
13
21
  <span class="row-main">
14
22
  <strong>${escapeHtml(artifact.title)}</strong>
15
23
  <span>${escapeHtml(artifact.id)}</span>
24
+ ${snippet}
16
25
  </span>
17
26
  <span>${escapeHtml(artifact.sourceAgent)}</span>
18
27
  ${typeBadge(artifact.artifactType || "document")}
@@ -44,10 +53,12 @@ export function renderDashboard({ artifacts, baseUrl, filters = {}, locale = DEF
44
53
  </header>
45
54
  <main class="dashboard">
46
55
  <section class="toolbar">
47
- <span>${view.text("dashboard.count", { count: artifacts.length })}</span>
56
+ <span>${view.text("dashboard.range", { start, end, total })}</span>
57
+ ${searchBackend ? `<span>${view.text("dashboard.searchBackend", { backend: searchBackend })}</span>` : ""}
48
58
  </section>
49
59
  <form class="filter-form" method="get" action="/">
50
60
  ${localeInput(view.locale)}
61
+ ${filters.limit ? `<input type="hidden" name="limit" value="${escapeAttribute(filters.limit)}">` : ""}
51
62
  <input name="q" value="${escapeAttribute(filters.query || "")}" placeholder="${view.attr("filter.search")}">
52
63
  <input name="tag" value="${escapeAttribute(filters.tag || "")}" placeholder="${view.attr("filter.tag")}">
53
64
  <input name="sourceAgent" value="${escapeAttribute(filters.sourceAgent || "")}" placeholder="${view.attr("filter.source")}">
@@ -58,12 +69,52 @@ export function renderDashboard({ artifacts, baseUrl, filters = {}, locale = DEF
58
69
  <section class="artifact-list">
59
70
  ${rows || `<div class="empty">${view.text("dashboard.empty")}</div>`}
60
71
  </section>
72
+ ${pager}
61
73
  </main>
62
74
  `,
63
75
  locale: view.locale
64
76
  });
65
77
  }
66
78
 
79
+ function renderDashboardPager({ pagination, filters, view }) {
80
+ if (!pagination || pagination.total <= pagination.limit) {
81
+ return "";
82
+ }
83
+
84
+ const previous = pagination.previousOffset === null
85
+ ? `<span class="pager-disabled">${view.text("dashboard.previous")}</span>`
86
+ : `<a href="${view.href(dashboardPageHref(filters, pagination.previousOffset))}">${view.text("dashboard.previous")}</a>`;
87
+ const next = pagination.nextOffset === null
88
+ ? `<span class="pager-disabled">${view.text("dashboard.next")}</span>`
89
+ : `<a href="${view.href(dashboardPageHref(filters, pagination.nextOffset))}">${view.text("dashboard.next")}</a>`;
90
+
91
+ return `<nav class="pager" aria-label="Pagination">${previous}${next}</nav>`;
92
+ }
93
+
94
+ function dashboardPageHref(filters, offset) {
95
+ const params = new URLSearchParams();
96
+ if (filters.query) {
97
+ params.set("q", filters.query);
98
+ }
99
+ if (filters.tag) {
100
+ params.set("tag", filters.tag);
101
+ }
102
+ if (filters.sourceAgent) {
103
+ params.set("sourceAgent", filters.sourceAgent);
104
+ }
105
+ if (filters.includeArchived) {
106
+ params.set("includeArchived", "true");
107
+ }
108
+ if (filters.limit) {
109
+ params.set("limit", filters.limit);
110
+ }
111
+ if (offset > 0) {
112
+ params.set("offset", String(offset));
113
+ }
114
+ const query = params.toString();
115
+ return query ? `/?${query}` : "/";
116
+ }
117
+
67
118
  export function renderArtifactFormPage({ mode, baseUrl, artifact, version, content, authToken = "", locale = DEFAULT_LOCALE, currentPath = "/" }) {
68
119
  const view = viewContext(locale, currentPath);
69
120
  const isEdit = mode === "edit";
@@ -163,6 +214,8 @@ export function renderImportArtifactPage({ baseUrl, authToken = "", locale = DEF
163
214
  <option value="claude">Claude</option>
164
215
  <option value="codex">Codex</option>
165
216
  <option value="gemini">Gemini</option>
217
+ <option value="copilot">GitHub Copilot</option>
218
+ <option value="cursor">Cursor</option>
166
219
  <option value="generic">Generic</option>
167
220
  </select>
168
221
  </label>
@@ -208,8 +261,10 @@ export function renderArtifactPage({ artifact, version, content, baseUrl, authTo
208
261
  const rendered = renderContent(version.format, content, version.metadata || {}, {
209
262
  reactFrameUrl: reactRendererEnabled()
210
263
  ? view.href(`/artifacts/${encodeURIComponent(artifact.id)}/react-frame?version=${version.version}`)
211
- : ""
264
+ : "",
265
+ rawUrl: view.href(`/artifacts/${encodeURIComponent(artifact.id)}/raw?version=${version.version}`)
212
266
  });
267
+ const viewClass = artifactViewClass({ artifact, version });
213
268
  const needsViewerScript = version.format === "code";
214
269
  const rawUrl = `/artifacts/${encodeURIComponent(artifact.id)}/raw?version=${version.version}`;
215
270
  const archiveAction = artifact.archivedAt ? "restore" : "archive";
@@ -231,7 +286,7 @@ export function renderArtifactPage({ artifact, version, content, baseUrl, authTo
231
286
  ${languageSwitcher(view)}
232
287
  </nav>
233
288
  </header>
234
- <main class="artifact-view">
289
+ <main class="${viewClass}">
235
290
  <section class="meta-strip">
236
291
  ${formatBadge(version.format)}
237
292
  ${typeBadge(artifact.artifactType || "document")}
@@ -256,6 +311,16 @@ export function renderArtifactPage({ artifact, version, content, baseUrl, authTo
256
311
  });
257
312
  }
258
313
 
314
+ function artifactViewClass({ artifact, version }) {
315
+ const wideFormats = new Set(["html", "svg", "mermaid", "react", "sarif", "csv", "image", "video"]);
316
+ const wideTypes = new Set(["dashboard", "design-option", "diff-walkthrough"]);
317
+ const classes = ["artifact-view"];
318
+ if (wideFormats.has(version.format) || wideTypes.has(artifact.artifactType)) {
319
+ classes.push("artifact-view-wide");
320
+ }
321
+ return classes.join(" ");
322
+ }
323
+
259
324
  export function renderDiffPage({ artifact, fromVersion, toVersion, fromContent, toContent, diffRows, baseUrl, locale = DEFAULT_LOCALE, currentPath = "/" }) {
260
325
  const view = viewContext(locale, currentPath);
261
326
  const title = view.raw("diff.title", { title: artifact.title });
@@ -342,6 +407,22 @@ export function renderContent(format, content, metadata = {}, options = {}) {
342
407
  return `<pre class="artifact-code"><code>${escapeHtml(formatJson(content))}</code></pre>`;
343
408
  }
344
409
 
410
+ if (format === "sarif") {
411
+ return renderSarif(content);
412
+ }
413
+
414
+ if (format === "csv") {
415
+ return renderCsv(content);
416
+ }
417
+
418
+ if (format === "image") {
419
+ return renderMediaArtifact("image", content, metadata, options);
420
+ }
421
+
422
+ if (format === "video") {
423
+ return renderMediaArtifact("video", content, metadata, options);
424
+ }
425
+
345
426
  if (format === "code") {
346
427
  const language = metadata.language || metadata.artifactyImport?.language || "";
347
428
  return `<section class="artifact-code-viewer" data-artifacty-code-viewer data-language="${escapeAttribute(language)}">
@@ -598,6 +679,9 @@ export function pageShell({ title, body, head = "", afterBody = "", locale = DEF
598
679
  width: min(1180px, calc(100vw - 32px));
599
680
  margin: 28px auto 72px;
600
681
  }
682
+ .artifact-view-wide {
683
+ width: min(1760px, calc(100vw - 24px));
684
+ }
601
685
  .toolbar {
602
686
  font-family: var(--mono);
603
687
  font-size: 12px;
@@ -672,6 +756,34 @@ export function pageShell({ title, body, head = "", afterBody = "", locale = DEF
672
756
  background: var(--panel);
673
757
  box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
674
758
  }
759
+ .pager {
760
+ display: flex;
761
+ justify-content: flex-end;
762
+ gap: 8px;
763
+ margin-top: 14px;
764
+ font-family: var(--mono);
765
+ font-size: 12.5px;
766
+ }
767
+ .pager a,
768
+ .pager-disabled {
769
+ display: inline-flex;
770
+ min-height: 34px;
771
+ align-items: center;
772
+ justify-content: center;
773
+ padding: 5px 12px;
774
+ border: 1px solid var(--line-2);
775
+ border-radius: 8px;
776
+ background: var(--panel);
777
+ color: var(--muted);
778
+ }
779
+ .pager a:hover {
780
+ border-color: var(--accent);
781
+ color: var(--text);
782
+ text-decoration: none;
783
+ }
784
+ .pager-disabled {
785
+ opacity: 0.55;
786
+ }
675
787
  .editor-form {
676
788
  display: grid;
677
789
  gap: 16px;
@@ -856,6 +968,13 @@ export function pageShell({ title, body, head = "", afterBody = "", locale = DEF
856
968
  font-size: 12px;
857
969
  color: var(--faint);
858
970
  }
971
+ .row-main .row-snippet {
972
+ color: var(--muted);
973
+ white-space: normal;
974
+ overflow: visible;
975
+ text-overflow: clip;
976
+ overflow-wrap: anywhere;
977
+ }
859
978
  .artifact-row > span:not(.row-main):not(.tags):not(.badge) {
860
979
  font-family: var(--mono);
861
980
  font-size: 12.5px;
@@ -905,6 +1024,8 @@ export function pageShell({ title, body, head = "", afterBody = "", locale = DEF
905
1024
  .badge.t-diagram { --bh: #0ea5e9; }
906
1025
  .badge.t-component { --bh: #7c3aed; }
907
1026
  .badge.t-snippet { --bh: #64748b; }
1027
+ .badge.t-analysis-report { --bh: #dc2626; }
1028
+ .badge.t-table { --bh: #0891b2; }
908
1029
  .badge.t-unknown { --bh: #94a3b8; }
909
1030
  .badge.f-html { --bh: #e0795b; }
910
1031
  .badge.f-markdown { --bh: #3b82f6; }
@@ -914,6 +1035,10 @@ export function pageShell({ title, body, head = "", afterBody = "", locale = DEF
914
1035
  .badge.f-svg { --bh: #0ea5e9; }
915
1036
  .badge.f-mermaid { --bh: #14b8a6; }
916
1037
  .badge.f-react { --bh: #7c3aed; }
1038
+ .badge.f-sarif { --bh: #dc2626; }
1039
+ .badge.f-csv { --bh: #0891b2; }
1040
+ .badge.f-image { --bh: #f59e0b; }
1041
+ .badge.f-video { --bh: #db2777; }
917
1042
  .badge.s-archived { --bh: #94a3b8; }
918
1043
  .empty {
919
1044
  padding: 28px;
@@ -1076,6 +1201,105 @@ export function pageShell({ title, body, head = "", afterBody = "", locale = DEF
1076
1201
  }
1077
1202
  .artifact-table .align-center { text-align: center; }
1078
1203
  .artifact-table .align-right { text-align: right; }
1204
+ .artifact-csv,
1205
+ .artifact-sarif {
1206
+ display: grid;
1207
+ gap: 14px;
1208
+ }
1209
+ .artifact-csv-note,
1210
+ .artifact-sarif-note {
1211
+ margin: 0;
1212
+ padding: 10px 12px;
1213
+ border: 1px solid var(--line);
1214
+ border-radius: 8px;
1215
+ background: var(--panel);
1216
+ color: var(--muted);
1217
+ font-size: 13px;
1218
+ }
1219
+ .artifact-summary-grid {
1220
+ display: grid;
1221
+ grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
1222
+ gap: 10px;
1223
+ }
1224
+ .summary-card {
1225
+ display: grid;
1226
+ gap: 3px;
1227
+ min-height: 72px;
1228
+ padding: 12px;
1229
+ border: 1px solid var(--line);
1230
+ border-radius: 8px;
1231
+ background: var(--panel);
1232
+ }
1233
+ .summary-card span {
1234
+ color: var(--muted);
1235
+ font-family: var(--mono);
1236
+ font-size: 11.5px;
1237
+ text-transform: uppercase;
1238
+ letter-spacing: 0.06em;
1239
+ }
1240
+ .summary-card strong {
1241
+ font-size: 20px;
1242
+ line-height: 1.2;
1243
+ }
1244
+ .sarif-level {
1245
+ display: inline-flex;
1246
+ min-width: 68px;
1247
+ justify-content: center;
1248
+ padding: 2px 8px;
1249
+ border-radius: 999px;
1250
+ background: color-mix(in srgb, var(--bh, var(--muted)) 14%, transparent);
1251
+ color: color-mix(in srgb, var(--bh, var(--muted)) 70%, var(--text));
1252
+ font-family: var(--mono);
1253
+ font-size: 12px;
1254
+ }
1255
+ .sarif-level.error { --bh: #dc2626; }
1256
+ .sarif-level.warning { --bh: #d97706; }
1257
+ .sarif-level.note { --bh: #2563eb; }
1258
+ .sarif-level.none { --bh: #64748b; }
1259
+ .artifact-raw-details {
1260
+ border: 1px solid var(--line);
1261
+ border-radius: 12px;
1262
+ background: var(--panel);
1263
+ overflow: hidden;
1264
+ }
1265
+ .artifact-raw-details summary {
1266
+ padding: 10px 14px;
1267
+ cursor: pointer;
1268
+ color: var(--muted);
1269
+ font-family: var(--mono);
1270
+ font-size: 12.5px;
1271
+ }
1272
+ .artifact-raw-details .artifact-code {
1273
+ border: 0;
1274
+ border-top: 1px solid var(--line);
1275
+ border-radius: 0;
1276
+ }
1277
+ .artifact-media {
1278
+ display: grid;
1279
+ gap: 12px;
1280
+ margin: 0;
1281
+ padding: 14px;
1282
+ border: 1px solid var(--line);
1283
+ border-radius: 12px;
1284
+ background: var(--panel);
1285
+ box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
1286
+ }
1287
+ .artifact-media img,
1288
+ .artifact-media video {
1289
+ display: block;
1290
+ width: 100%;
1291
+ max-height: 76vh;
1292
+ object-fit: contain;
1293
+ border-radius: 8px;
1294
+ background: var(--panel-2);
1295
+ }
1296
+ .artifact-media figcaption,
1297
+ .artifact-media-note {
1298
+ color: var(--muted);
1299
+ font-family: var(--mono);
1300
+ font-size: 12.5px;
1301
+ overflow-wrap: anywhere;
1302
+ }
1079
1303
  .artifact-code {
1080
1304
  padding: 22px;
1081
1305
  overflow: auto;
@@ -1512,6 +1736,270 @@ function alignmentAttribute(alignment) {
1512
1736
  return alignment ? ` class="align-${alignment}"` : "";
1513
1737
  }
1514
1738
 
1739
+ const CSV_RENDER_ROW_LIMIT = 1000;
1740
+ const CSV_RENDER_COLUMN_LIMIT = 80;
1741
+ const SARIF_RENDER_RESULT_LIMIT = 500;
1742
+
1743
+ function renderMediaArtifact(format, content, metadata = {}, options = {}) {
1744
+ const mimeType = mediaMimeType(metadata, format);
1745
+ const source = options.rawUrl || mediaDataUrl(content, mimeType);
1746
+ if (!source) {
1747
+ return `<section class="artifact-media-note">
1748
+ Media preview is unavailable because this artifact does not contain valid base64 media content.
1749
+ </section>
1750
+ <pre class="artifact-code"><code>${escapeHtml(content)}</code></pre>`;
1751
+ }
1752
+
1753
+ const label = [
1754
+ mimeType || format,
1755
+ metadata.encoding ? `encoding: ${metadata.encoding}` : "",
1756
+ metadata.originalEncoding ? `source: ${metadata.originalEncoding}` : ""
1757
+ ].filter(Boolean).join(" · ");
1758
+
1759
+ if (format === "video") {
1760
+ return `<figure class="artifact-media artifact-video">
1761
+ <video controls preload="metadata" src="${escapeAttribute(source)}"></video>
1762
+ <figcaption>${escapeHtml(label || "video artifact")}</figcaption>
1763
+ </figure>`;
1764
+ }
1765
+
1766
+ return `<figure class="artifact-media artifact-image">
1767
+ <img src="${escapeAttribute(source)}" alt="Image artifact">
1768
+ <figcaption>${escapeHtml(label || "image artifact")}</figcaption>
1769
+ </figure>`;
1770
+ }
1771
+
1772
+ function mediaMimeType(metadata = {}, format) {
1773
+ const explicit = String(metadata.mimeType || "").toLowerCase();
1774
+ if (explicit.startsWith("image/") || explicit.startsWith("video/")) {
1775
+ return explicit;
1776
+ }
1777
+ return format === "video" ? "video/mp4" : "image/png";
1778
+ }
1779
+
1780
+ function mediaDataUrl(content, mimeType) {
1781
+ const value = String(content || "").trim();
1782
+ if (/^data:[^;,]+;base64,/i.test(value)) {
1783
+ return value;
1784
+ }
1785
+ const base64 = value.replace(/\s+/g, "");
1786
+ if (!/^[A-Za-z0-9+/]+={0,2}$/.test(base64)) {
1787
+ return "";
1788
+ }
1789
+ return `data:${mimeType};base64,${base64}`;
1790
+ }
1791
+
1792
+ function renderCsv(content) {
1793
+ const parsed = parseCsv(content);
1794
+ if (!parsed.ok) {
1795
+ return `<section class="artifact-csv">
1796
+ <p class="artifact-csv-note">CSV parsing failed: ${escapeHtml(parsed.error)}. Showing escaped source.</p>
1797
+ <pre class="artifact-code"><code>${escapeHtml(content)}</code></pre>
1798
+ </section>`;
1799
+ }
1800
+
1801
+ if (parsed.rows.length === 0) {
1802
+ return `<section class="artifact-csv"><p class="artifact-csv-note">Empty CSV artifact.</p></section>`;
1803
+ }
1804
+
1805
+ const columnCount = Math.max(...parsed.rows.map((row) => row.length));
1806
+ const visibleColumns = Math.min(columnCount, CSV_RENDER_COLUMN_LIMIT);
1807
+ const header = parsed.rows[0];
1808
+ const bodyRows = parsed.rows.slice(1, CSV_RENDER_ROW_LIMIT + 1);
1809
+ const rowTruncated = parsed.rows.length - 1 > CSV_RENDER_ROW_LIMIT;
1810
+ const columnTruncated = columnCount > CSV_RENDER_COLUMN_LIMIT;
1811
+ const headerHtml = Array.from({ length: visibleColumns }, (_, index) =>
1812
+ `<th>${escapeHtml(header[index] || `Column ${index + 1}`)}</th>`
1813
+ ).join("");
1814
+ const bodyHtml = bodyRows.map((row) =>
1815
+ `<tr>${Array.from({ length: visibleColumns }, (_, index) =>
1816
+ `<td>${escapeHtml(row[index] || "")}</td>`
1817
+ ).join("")}</tr>`
1818
+ ).join("\n");
1819
+ const notes = [
1820
+ `${parsed.rows.length - 1} data rows`,
1821
+ `${columnCount} columns`,
1822
+ rowTruncated ? `showing first ${CSV_RENDER_ROW_LIMIT} rows` : "",
1823
+ columnTruncated ? `showing first ${CSV_RENDER_COLUMN_LIMIT} columns` : ""
1824
+ ].filter(Boolean).join(" · ");
1825
+
1826
+ return `<section class="artifact-csv">
1827
+ <p class="artifact-csv-note">${escapeHtml(notes)}</p>
1828
+ <div class="artifact-table-scroll">
1829
+ <table class="artifact-table">
1830
+ <thead><tr>${headerHtml}</tr></thead>
1831
+ <tbody>${bodyHtml}</tbody>
1832
+ </table>
1833
+ </div>
1834
+ </section>`;
1835
+ }
1836
+
1837
+ function parseCsv(content) {
1838
+ const text = String(content || "");
1839
+ const rows = [];
1840
+ let row = [];
1841
+ let cell = "";
1842
+ let inQuotes = false;
1843
+
1844
+ for (let index = 0; index < text.length; index += 1) {
1845
+ const char = text[index];
1846
+ if (char === "\"") {
1847
+ if (inQuotes && text[index + 1] === "\"") {
1848
+ cell += "\"";
1849
+ index += 1;
1850
+ } else {
1851
+ inQuotes = !inQuotes;
1852
+ }
1853
+ } else if (char === "," && !inQuotes) {
1854
+ row.push(cell);
1855
+ cell = "";
1856
+ } else if ((char === "\n" || char === "\r") && !inQuotes) {
1857
+ if (char === "\r" && text[index + 1] === "\n") {
1858
+ index += 1;
1859
+ }
1860
+ row.push(cell);
1861
+ if (row.length > 1 || row[0] !== "") {
1862
+ rows.push(row);
1863
+ }
1864
+ row = [];
1865
+ cell = "";
1866
+ } else {
1867
+ cell += char;
1868
+ }
1869
+ }
1870
+
1871
+ if (inQuotes) {
1872
+ return { ok: false, error: "unterminated quoted field", rows: [] };
1873
+ }
1874
+ row.push(cell);
1875
+ if (row.length > 1 || row[0] !== "") {
1876
+ rows.push(row);
1877
+ }
1878
+ return { ok: true, rows };
1879
+ }
1880
+
1881
+ function renderSarif(content) {
1882
+ let sarif;
1883
+ try {
1884
+ sarif = JSON.parse(content);
1885
+ } catch {
1886
+ return `<section class="artifact-sarif">
1887
+ <p class="artifact-sarif-note">Invalid SARIF JSON. Showing escaped source.</p>
1888
+ <pre class="artifact-code"><code>${escapeHtml(content)}</code></pre>
1889
+ </section>`;
1890
+ }
1891
+
1892
+ if (!isSarifDocument(sarif)) {
1893
+ return `<section class="artifact-sarif">
1894
+ <p class="artifact-sarif-note">This JSON does not match the expected SARIF top-level shape. Showing formatted JSON.</p>
1895
+ <pre class="artifact-code"><code>${escapeHtml(formatJson(content))}</code></pre>
1896
+ </section>`;
1897
+ }
1898
+
1899
+ const results = collectSarifResults(sarif);
1900
+ const counts = countBy(results, (result) => result.level);
1901
+ const visibleResults = results.slice(0, SARIF_RENDER_RESULT_LIMIT);
1902
+ const rowHtml = visibleResults.map((result) => `<tr>
1903
+ <td><span class="sarif-level ${escapeAttribute(result.level)}">${escapeHtml(result.level)}</span></td>
1904
+ <td>${escapeHtml(result.ruleId)}</td>
1905
+ <td>${escapeHtml(result.message)}</td>
1906
+ <td>${escapeHtml(result.location)}</td>
1907
+ <td>${escapeHtml(result.tool)}</td>
1908
+ </tr>`).join("\n");
1909
+ const note = results.length > SARIF_RENDER_RESULT_LIMIT
1910
+ ? `Showing first ${SARIF_RENDER_RESULT_LIMIT} of ${results.length} results.`
1911
+ : `${results.length} results.`;
1912
+
1913
+ return `<section class="artifact-sarif">
1914
+ <div class="artifact-summary-grid">
1915
+ ${summaryCard("Runs", sarif.runs.length)}
1916
+ ${summaryCard("Results", results.length)}
1917
+ ${summaryCard("Errors", counts.error || 0)}
1918
+ ${summaryCard("Warnings", counts.warning || 0)}
1919
+ ${summaryCard("Notes", counts.note || 0)}
1920
+ </div>
1921
+ <p class="artifact-sarif-note">${escapeHtml(note)}</p>
1922
+ <div class="artifact-table-scroll">
1923
+ <table class="artifact-table">
1924
+ <thead><tr><th>Level</th><th>Rule</th><th>Message</th><th>Location</th><th>Tool</th></tr></thead>
1925
+ <tbody>${rowHtml}</tbody>
1926
+ </table>
1927
+ </div>
1928
+ <details class="artifact-raw-details">
1929
+ <summary>Raw SARIF JSON</summary>
1930
+ <pre class="artifact-code"><code>${escapeHtml(formatJson(content))}</code></pre>
1931
+ </details>
1932
+ </section>`;
1933
+ }
1934
+
1935
+ function isSarifDocument(value) {
1936
+ return value &&
1937
+ typeof value === "object" &&
1938
+ !Array.isArray(value) &&
1939
+ Array.isArray(value.runs) &&
1940
+ (typeof value.version === "string" || String(value.$schema || "").toLowerCase().includes("sarif"));
1941
+ }
1942
+
1943
+ function collectSarifResults(sarif) {
1944
+ return sarif.runs.flatMap((run) => {
1945
+ const tool = run?.tool?.driver?.name || run?.tool?.driver?.fullName || "unknown";
1946
+ const rules = new Map();
1947
+ for (const [index, rule] of (run?.tool?.driver?.rules || []).entries()) {
1948
+ if (rule?.id) {
1949
+ rules.set(rule.id, rule);
1950
+ }
1951
+ rules.set(index, rule);
1952
+ }
1953
+ return (run?.results || []).map((result) => {
1954
+ const rule = rules.get(result.ruleId) || rules.get(result.ruleIndex);
1955
+ return {
1956
+ level: normalizeSarifLevel(result.level || rule?.defaultConfiguration?.level),
1957
+ ruleId: result.ruleId || rule?.id || (Number.isInteger(result.ruleIndex) ? `#${result.ruleIndex}` : "unknown"),
1958
+ message: sarifMessage(result.message) || sarifMessage(rule?.shortDescription) || sarifMessage(rule?.fullDescription) || "",
1959
+ location: sarifLocation(result),
1960
+ tool
1961
+ };
1962
+ });
1963
+ });
1964
+ }
1965
+
1966
+ function normalizeSarifLevel(value) {
1967
+ const normalized = String(value || "warning").toLowerCase();
1968
+ return ["error", "warning", "note", "none"].includes(normalized) ? normalized : "warning";
1969
+ }
1970
+
1971
+ function sarifMessage(message) {
1972
+ if (!message || typeof message !== "object") {
1973
+ return "";
1974
+ }
1975
+ return String(message.text || message.markdown || "").trim();
1976
+ }
1977
+
1978
+ function sarifLocation(result) {
1979
+ const physical = result?.locations?.[0]?.physicalLocation;
1980
+ const uri = physical?.artifactLocation?.uri || physical?.artifactLocation?.uriBaseId || "";
1981
+ const region = physical?.region || {};
1982
+ const line = Number.isInteger(region.startLine) ? region.startLine : "";
1983
+ const column = Number.isInteger(region.startColumn) ? region.startColumn : "";
1984
+ return [
1985
+ uri || "unknown",
1986
+ line ? `:${line}` : "",
1987
+ column ? `:${column}` : ""
1988
+ ].join("");
1989
+ }
1990
+
1991
+ function countBy(values, keyFn) {
1992
+ return values.reduce((counts, value) => {
1993
+ const key = keyFn(value);
1994
+ counts[key] = (counts[key] || 0) + 1;
1995
+ return counts;
1996
+ }, {});
1997
+ }
1998
+
1999
+ function summaryCard(label, value) {
2000
+ return `<div class="summary-card"><span>${escapeHtml(label)}</span><strong>${escapeHtml(value)}</strong></div>`;
2001
+ }
2002
+
1515
2003
  function formatJson(content) {
1516
2004
  try {
1517
2005
  return JSON.stringify(JSON.parse(content), null, 2);