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.
- package/CLAUDE.md +1 -1
- package/README.md +260 -5
- package/docs/artifact-schema-v1.md +187 -1
- package/docs/central-team-deployment-design.md +3 -1
- package/docs/integrations.md +9 -1
- package/docs/mcp-public-api.md +103 -9
- package/docs/release-checklist.md +1 -1
- package/docs/roadmap-design.md +977 -0
- package/docs/sarif-csv-artifact-plan.md +42 -7
- package/docs/threat-model.md +217 -2
- package/package.json +2 -1
- package/src/cli.js +550 -43
- package/src/client/viewer.js +377 -0
- package/src/lib/backup.js +326 -15
- package/src/lib/converters.js +99 -5
- package/src/lib/csv.js +51 -0
- package/src/lib/diff.js +656 -0
- package/src/lib/doctor.js +88 -2
- package/src/lib/embeddings.js +407 -0
- package/src/lib/events.js +183 -0
- package/src/lib/i18n.js +290 -2
- package/src/lib/listing.js +46 -0
- package/src/lib/openapi.js +614 -0
- package/src/lib/render.js +1604 -160
- package/src/lib/retention-form.js +31 -0
- package/src/lib/retention.js +499 -0
- package/src/lib/sarif-csv-export.js +290 -0
- package/src/lib/schemas.js +461 -0
- package/src/lib/security.js +226 -1
- package/src/lib/sse.js +52 -0
- package/src/lib/storage.js +2935 -305
- package/src/lib/webhooks.js +356 -0
- package/src/mcp-server.js +756 -25
- package/src/server.js +1529 -98
package/src/client/viewer.js
CHANGED
|
@@ -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
|
+
}
|