artifacty 0.10.8 → 0.11.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.
@@ -21,6 +21,20 @@ for (const container of document.querySelectorAll("[data-artifacty-code-viewer]"
21
21
  enhanceCodeViewer(container);
22
22
  }
23
23
 
24
+ for (const codeEl of document.querySelectorAll("pre > code[class*='language-']")) {
25
+ enhanceMarkdownCodeBlock(codeEl);
26
+ }
27
+
28
+ enhanceMermaidEmbeds();
29
+
30
+ for (const section of document.querySelectorAll("[data-artifact-csv]")) {
31
+ enhanceCsvViewer(section);
32
+ }
33
+
34
+ for (const section of document.querySelectorAll("[data-artifact-sarif]")) {
35
+ enhanceSarifViewer(section);
36
+ }
37
+
24
38
  function enhanceCodeViewer(container) {
25
39
  const source = container.querySelector("textarea")?.value || "";
26
40
  const fallback = container.querySelector(".artifact-code-fallback");
@@ -65,3 +79,366 @@ function languageExtension(language) {
65
79
  }
66
80
  return [];
67
81
  }
82
+
83
+ // --- Markdown/notebook embedded rendering (roadmap section 14 and 15) -----
84
+ //
85
+ // The server always renders fenced code blocks as escaped
86
+ // `<pre><code class="language-x">` (see markdownToHtml/renderMarkdownCodeFence
87
+ // in src/lib/render.js), so the page is already correct without JS. This
88
+ // only upgrades that markup to read-only CodeMirror highlighting.
89
+
90
+ function enhanceMarkdownCodeBlock(codeEl) {
91
+ const pre = codeEl.closest("pre");
92
+ if (!pre || !pre.isConnected) {
93
+ return;
94
+ }
95
+ const match = /language-([a-zA-Z0-9+#_.-]+)/.exec(codeEl.className || "");
96
+ const language = match ? match[1] : "";
97
+ const source = codeEl.textContent || "";
98
+
99
+ const mount = document.createElement("div");
100
+ mount.className = "artifact-doc-codemirror";
101
+
102
+ new EditorView({
103
+ doc: source,
104
+ parent: mount,
105
+ extensions: [
106
+ basicSetup,
107
+ EditorState.readOnly.of(true),
108
+ EditorView.editable.of(false),
109
+ EditorView.lineWrapping,
110
+ viewerTheme,
111
+ languageExtension(language)
112
+ ]
113
+ });
114
+
115
+ pre.replaceWith(mount);
116
+ }
117
+
118
+ // ```mermaid``` fences render server-side as a placeholder
119
+ // (`[data-artifacty-mermaid]`) carrying the base64-encoded sandboxed iframe
120
+ // srcdoc that whole-document Mermaid artifacts already use. Each placeholder
121
+ // is upgraded into a real iframe lazily, only once it scrolls into view, to
122
+ // avoid paying Mermaid's render cost for diagrams the viewer never sees.
123
+ function enhanceMermaidEmbeds() {
124
+ const placeholders = document.querySelectorAll("[data-artifacty-mermaid]");
125
+ if (placeholders.length === 0) {
126
+ return;
127
+ }
128
+
129
+ if (!("IntersectionObserver" in window)) {
130
+ placeholders.forEach(mountMermaidEmbed);
131
+ return;
132
+ }
133
+
134
+ const observer = new IntersectionObserver((entries) => {
135
+ for (const entry of entries) {
136
+ if (entry.isIntersecting) {
137
+ observer.unobserve(entry.target);
138
+ mountMermaidEmbed(entry.target);
139
+ }
140
+ }
141
+ });
142
+
143
+ placeholders.forEach((placeholder) => observer.observe(placeholder));
144
+ }
145
+
146
+ function mountMermaidEmbed(placeholder) {
147
+ const encoded = placeholder.dataset.mermaidSrcdoc;
148
+ if (!encoded) {
149
+ return;
150
+ }
151
+ let srcdoc;
152
+ try {
153
+ // atob() yields a Latin-1 byte string; the srcdoc was UTF-8 encoded
154
+ // before being base64'd (render.js), so it must be decoded as UTF-8
155
+ // here too or non-ASCII labels (e.g. Korean) come out as mojibake.
156
+ const bytes = Uint8Array.from(atob(encoded), (c) => c.charCodeAt(0));
157
+ srcdoc = new TextDecoder().decode(bytes);
158
+ } catch {
159
+ return;
160
+ }
161
+ const iframe = document.createElement("iframe");
162
+ iframe.className = "artifact-frame artifact-mermaid-frame";
163
+ iframe.setAttribute("sandbox", "allow-scripts");
164
+ iframe.srcdoc = srcdoc;
165
+ placeholder.replaceChildren(iframe);
166
+ delete placeholder.dataset.artifactyMermaid;
167
+ }
168
+
169
+ // --- CSV sort, filter, and visible row count (roadmap section 7) ---------
170
+ //
171
+ // Progressive enhancement: the table is already fully rendered server-side
172
+ // with bounded rows/columns (see renderCsv in src/lib/render.js). Without
173
+ // this script the table still displays correctly; this only adds
174
+ // client-side sort/filter over the data already in the DOM.
175
+
176
+ function enhanceCsvViewer(section) {
177
+ const table = section.querySelector("[data-csv-table]");
178
+ const headerRow = table?.querySelector("thead tr");
179
+ const headerCells = headerRow ? Array.from(headerRow.querySelectorAll("th[data-csv-col]")) : [];
180
+ const tbody = table?.querySelector("tbody");
181
+ const rows = tbody ? Array.from(tbody.querySelectorAll("tr[data-csv-row]")) : [];
182
+ if (!table || !tbody || headerCells.length === 0 || rows.length === 0) {
183
+ return;
184
+ }
185
+
186
+ const status = document.createElement("p");
187
+ status.className = "artifact-csv-status";
188
+ status.hidden = true;
189
+ const note = section.querySelector(".artifact-csv-note");
190
+ if (note) {
191
+ note.insertAdjacentElement("afterend", status);
192
+ } else {
193
+ table.insertAdjacentElement("beforebegin", status);
194
+ }
195
+
196
+ let sortColumn = -1;
197
+ let sortDirection = "";
198
+
199
+ const sortButtons = headerCells.map((th, index) => {
200
+ const label = th.textContent;
201
+ th.textContent = "";
202
+ const button = document.createElement("button");
203
+ button.type = "button";
204
+ button.className = "csv-sort-btn";
205
+ button.append(document.createTextNode(label));
206
+ const indicator = document.createElement("span");
207
+ indicator.className = "csv-sort-indicator";
208
+ button.append(indicator);
209
+ th.append(button);
210
+ button.addEventListener("click", () => {
211
+ if (sortColumn === index) {
212
+ sortDirection = sortDirection === "asc" ? "desc" : sortDirection === "desc" ? "" : "asc";
213
+ } else {
214
+ sortColumn = index;
215
+ sortDirection = "asc";
216
+ }
217
+ updateSortIndicators();
218
+ apply();
219
+ });
220
+ return { button, indicator };
221
+ });
222
+
223
+ function updateSortIndicators() {
224
+ sortButtons.forEach(({ indicator }, index) => {
225
+ indicator.textContent = index === sortColumn && sortDirection
226
+ ? (sortDirection === "asc" ? " ▲" : " ▼")
227
+ : "";
228
+ });
229
+ }
230
+
231
+ const filterRow = document.createElement("tr");
232
+ filterRow.className = "csv-filter-row";
233
+ const filterInputs = headerCells.map((_headerCell, index) => {
234
+ const cell = document.createElement("th");
235
+ const input = document.createElement("input");
236
+ input.type = "text";
237
+ input.className = "csv-filter-input";
238
+ input.placeholder = "Filter";
239
+ input.setAttribute("aria-label", `Filter column ${index + 1}`);
240
+ input.addEventListener("input", apply);
241
+ cell.append(input);
242
+ filterRow.append(cell);
243
+ return input;
244
+ });
245
+ headerRow.insertAdjacentElement("afterend", filterRow);
246
+
247
+ function cellValue(row, index) {
248
+ return (row.children[index]?.textContent || "").trim();
249
+ }
250
+
251
+ function compareValues(a, b) {
252
+ const trimmedA = a.trim();
253
+ const trimmedB = b.trim();
254
+ const numericA = trimmedA !== "" && Number.isFinite(Number(trimmedA));
255
+ const numericB = trimmedB !== "" && Number.isFinite(Number(trimmedB));
256
+ if (numericA && numericB) {
257
+ return Number(trimmedA) - Number(trimmedB);
258
+ }
259
+ return a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" });
260
+ }
261
+
262
+ function apply() {
263
+ const activeFilters = filterInputs
264
+ .map((input, index) => ({ index, term: input.value.trim().toLowerCase() }))
265
+ .filter((entry) => entry.term);
266
+
267
+ let visible = rows.filter((row) =>
268
+ activeFilters.every((entry) => cellValue(row, entry.index).toLowerCase().includes(entry.term))
269
+ );
270
+
271
+ if (sortColumn >= 0 && sortDirection) {
272
+ const direction = sortDirection === "asc" ? 1 : -1;
273
+ visible = visible.slice().sort((a, b) =>
274
+ direction * compareValues(cellValue(a, sortColumn), cellValue(b, sortColumn))
275
+ );
276
+ }
277
+
278
+ const visibleSet = new Set(visible);
279
+ const fragment = document.createDocumentFragment();
280
+ rows.forEach((row) => {
281
+ row.hidden = !visibleSet.has(row);
282
+ });
283
+ visible.forEach((row) => fragment.append(row));
284
+ rows.forEach((row) => {
285
+ if (!visibleSet.has(row)) {
286
+ fragment.append(row);
287
+ }
288
+ });
289
+ tbody.append(fragment);
290
+
291
+ status.hidden = false;
292
+ status.textContent = `${visible.length} of ${rows.length} rows shown`;
293
+ }
294
+
295
+ apply();
296
+ }
297
+
298
+ // --- SARIF level filter, rule filter, and sort (roadmap section 7) -------
299
+
300
+ function enhanceSarifViewer(section) {
301
+ const table = section.querySelector("[data-sarif-table]");
302
+ const tbody = table?.querySelector("tbody");
303
+ const rows = tbody ? Array.from(tbody.querySelectorAll("tr[data-sarif-result]")) : [];
304
+ if (!table || !tbody || rows.length === 0) {
305
+ return;
306
+ }
307
+
308
+ const controls = document.createElement("div");
309
+ controls.className = "sarif-controls";
310
+
311
+ const chipsWrap = document.createElement("div");
312
+ chipsWrap.className = "sarif-level-chips";
313
+ const activeLevels = new Set();
314
+ ["error", "warning", "note", "none"].forEach((level) => {
315
+ const chip = document.createElement("button");
316
+ chip.type = "button";
317
+ chip.className = "sarif-chip";
318
+ chip.textContent = level;
319
+ chip.setAttribute("aria-pressed", "false");
320
+ chip.addEventListener("click", () => {
321
+ if (activeLevels.has(level)) {
322
+ activeLevels.delete(level);
323
+ chip.classList.remove("active");
324
+ chip.setAttribute("aria-pressed", "false");
325
+ } else {
326
+ activeLevels.add(level);
327
+ chip.classList.add("active");
328
+ chip.setAttribute("aria-pressed", "true");
329
+ }
330
+ apply();
331
+ });
332
+ chipsWrap.append(chip);
333
+ });
334
+
335
+ const ruleInput = document.createElement("input");
336
+ ruleInput.type = "text";
337
+ ruleInput.className = "sarif-rule-filter";
338
+ ruleInput.placeholder = "Filter by rule id";
339
+ ruleInput.setAttribute("aria-label", "Filter by rule id");
340
+ ruleInput.addEventListener("input", apply);
341
+
342
+ controls.append(chipsWrap, ruleInput);
343
+
344
+ const note = section.querySelector(".artifact-sarif-note");
345
+ if (note) {
346
+ note.insertAdjacentElement("afterend", controls);
347
+ } else {
348
+ table.insertAdjacentElement("beforebegin", controls);
349
+ }
350
+
351
+ const status = document.createElement("p");
352
+ status.className = "artifact-sarif-status";
353
+ status.hidden = true;
354
+ controls.insertAdjacentElement("afterend", status);
355
+
356
+ const sortHeaders = Array.from(table.querySelectorAll("th[data-sarif-sort]"));
357
+ let sortKey = "";
358
+ let sortDirection = "";
359
+ const levelRank = { error: 0, warning: 1, note: 2, none: 3 };
360
+
361
+ const sortButtons = sortHeaders.map((th) => {
362
+ const key = th.dataset.sarifSort;
363
+ const label = th.textContent;
364
+ th.textContent = "";
365
+ const button = document.createElement("button");
366
+ button.type = "button";
367
+ button.className = "csv-sort-btn";
368
+ button.append(document.createTextNode(label));
369
+ const indicator = document.createElement("span");
370
+ indicator.className = "csv-sort-indicator";
371
+ button.append(indicator);
372
+ th.append(button);
373
+ button.addEventListener("click", () => {
374
+ if (sortKey === key) {
375
+ sortDirection = sortDirection === "asc" ? "desc" : sortDirection === "desc" ? "" : "asc";
376
+ } else {
377
+ sortKey = key;
378
+ sortDirection = "asc";
379
+ }
380
+ updateIndicators();
381
+ apply();
382
+ });
383
+ return { key, indicator };
384
+ });
385
+
386
+ function updateIndicators() {
387
+ sortButtons.forEach(({ key, indicator }) => {
388
+ indicator.textContent = key === sortKey && sortDirection
389
+ ? (sortDirection === "asc" ? " ▲" : " ▼")
390
+ : "";
391
+ });
392
+ }
393
+
394
+ function compareRows(a, b) {
395
+ if (sortKey === "level") {
396
+ const rankA = levelRank[a.dataset.level] ?? 9;
397
+ const rankB = levelRank[b.dataset.level] ?? 9;
398
+ return rankA - rankB;
399
+ }
400
+ if (sortKey === "rule") {
401
+ return (a.dataset.rule || "").localeCompare(b.dataset.rule || "", undefined, { numeric: true, sensitivity: "base" });
402
+ }
403
+ if (sortKey === "location") {
404
+ return (a.dataset.location || "").localeCompare(b.dataset.location || "", undefined, { numeric: true, sensitivity: "base" });
405
+ }
406
+ return 0;
407
+ }
408
+
409
+ function apply() {
410
+ const ruleTerm = ruleInput.value.trim().toLowerCase();
411
+ let visible = rows.filter((row) => {
412
+ if (activeLevels.size > 0 && !activeLevels.has(row.dataset.level)) {
413
+ return false;
414
+ }
415
+ if (ruleTerm && !(row.dataset.rule || "").toLowerCase().includes(ruleTerm)) {
416
+ return false;
417
+ }
418
+ return true;
419
+ });
420
+
421
+ if (sortKey && sortDirection) {
422
+ const direction = sortDirection === "asc" ? 1 : -1;
423
+ visible = visible.slice().sort((a, b) => direction * compareRows(a, b));
424
+ }
425
+
426
+ const visibleSet = new Set(visible);
427
+ const fragment = document.createDocumentFragment();
428
+ rows.forEach((row) => {
429
+ row.hidden = !visibleSet.has(row);
430
+ });
431
+ visible.forEach((row) => fragment.append(row));
432
+ rows.forEach((row) => {
433
+ if (!visibleSet.has(row)) {
434
+ fragment.append(row);
435
+ }
436
+ });
437
+ tbody.append(fragment);
438
+
439
+ status.hidden = false;
440
+ status.textContent = `${visible.length} of ${rows.length} results shown`;
441
+ }
442
+
443
+ apply();
444
+ }