@tinyviber/a2h 0.1.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.
Files changed (162) hide show
  1. package/README.md +229 -0
  2. package/bin/a2h.js +7 -0
  3. package/dist/actions/engine.d.ts +40 -0
  4. package/dist/actions/engine.d.ts.map +1 -0
  5. package/dist/actions/engine.js +227 -0
  6. package/dist/actions/engine.js.map +1 -0
  7. package/dist/actions/policy.d.ts +8 -0
  8. package/dist/actions/policy.d.ts.map +1 -0
  9. package/dist/actions/policy.js +55 -0
  10. package/dist/actions/policy.js.map +1 -0
  11. package/dist/cli/index.d.ts +2 -0
  12. package/dist/cli/index.d.ts.map +1 -0
  13. package/dist/cli/index.js +258 -0
  14. package/dist/cli/index.js.map +1 -0
  15. package/dist/cli/init.d.ts +8 -0
  16. package/dist/cli/init.d.ts.map +1 -0
  17. package/dist/cli/init.js +69 -0
  18. package/dist/cli/init.js.map +1 -0
  19. package/dist/index.d.ts +26 -0
  20. package/dist/index.d.ts.map +1 -0
  21. package/dist/index.js +66 -0
  22. package/dist/index.js.map +1 -0
  23. package/dist/ir/build.d.ts +8 -0
  24. package/dist/ir/build.d.ts.map +1 -0
  25. package/dist/ir/build.js +397 -0
  26. package/dist/ir/build.js.map +1 -0
  27. package/dist/ir/enrich.d.ts +21 -0
  28. package/dist/ir/enrich.d.ts.map +1 -0
  29. package/dist/ir/enrich.js +177 -0
  30. package/dist/ir/enrich.js.map +1 -0
  31. package/dist/ir/identity.d.ts +3 -0
  32. package/dist/ir/identity.d.ts.map +1 -0
  33. package/dist/ir/identity.js +91 -0
  34. package/dist/ir/identity.js.map +1 -0
  35. package/dist/ir/scoring.d.ts +7 -0
  36. package/dist/ir/scoring.d.ts.map +1 -0
  37. package/dist/ir/scoring.js +88 -0
  38. package/dist/ir/scoring.js.map +1 -0
  39. package/dist/parsers/code.d.ts +3 -0
  40. package/dist/parsers/code.d.ts.map +1 -0
  41. package/dist/parsers/code.js +17 -0
  42. package/dist/parsers/code.js.map +1 -0
  43. package/dist/parsers/diff.d.ts +11 -0
  44. package/dist/parsers/diff.d.ts.map +1 -0
  45. package/dist/parsers/diff.js +125 -0
  46. package/dist/parsers/diff.js.map +1 -0
  47. package/dist/parsers/json.d.ts +3 -0
  48. package/dist/parsers/json.d.ts.map +1 -0
  49. package/dist/parsers/json.js +16 -0
  50. package/dist/parsers/json.js.map +1 -0
  51. package/dist/parsers/log.d.ts +3 -0
  52. package/dist/parsers/log.d.ts.map +1 -0
  53. package/dist/parsers/log.js +105 -0
  54. package/dist/parsers/log.js.map +1 -0
  55. package/dist/parsers/markdown.d.ts +15 -0
  56. package/dist/parsers/markdown.d.ts.map +1 -0
  57. package/dist/parsers/markdown.js +89 -0
  58. package/dist/parsers/markdown.js.map +1 -0
  59. package/dist/parsers/text.d.ts +24 -0
  60. package/dist/parsers/text.d.ts.map +1 -0
  61. package/dist/parsers/text.js +98 -0
  62. package/dist/parsers/text.js.map +1 -0
  63. package/dist/presentation/blocks.d.ts +14 -0
  64. package/dist/presentation/blocks.d.ts.map +1 -0
  65. package/dist/presentation/blocks.js +87 -0
  66. package/dist/presentation/blocks.js.map +1 -0
  67. package/dist/presentation/present.d.ts +12 -0
  68. package/dist/presentation/present.d.ts.map +1 -0
  69. package/dist/presentation/present.js +115 -0
  70. package/dist/presentation/present.js.map +1 -0
  71. package/dist/providers/mock.d.ts +3 -0
  72. package/dist/providers/mock.d.ts.map +1 -0
  73. package/dist/providers/mock.js +171 -0
  74. package/dist/providers/mock.js.map +1 -0
  75. package/dist/providers/registry.d.ts +12 -0
  76. package/dist/providers/registry.d.ts.map +1 -0
  77. package/dist/providers/registry.js +20 -0
  78. package/dist/providers/registry.js.map +1 -0
  79. package/dist/providers/types.d.ts +64 -0
  80. package/dist/providers/types.d.ts.map +1 -0
  81. package/dist/providers/types.js +40 -0
  82. package/dist/providers/types.js.map +1 -0
  83. package/dist/renderer/assets/index.html +41 -0
  84. package/dist/renderer/assets/js/actions.js +205 -0
  85. package/dist/renderer/assets/js/api.js +75 -0
  86. package/dist/renderer/assets/js/artifacts.js +422 -0
  87. package/dist/renderer/assets/js/blocks.js +301 -0
  88. package/dist/renderer/assets/js/bus.js +21 -0
  89. package/dist/renderer/assets/js/cards.js +137 -0
  90. package/dist/renderer/assets/js/dom.js +61 -0
  91. package/dist/renderer/assets/js/format.js +118 -0
  92. package/dist/renderer/assets/js/main.js +125 -0
  93. package/dist/renderer/assets/js/nav.js +80 -0
  94. package/dist/renderer/assets/js/router.js +64 -0
  95. package/dist/renderer/assets/js/store.js +69 -0
  96. package/dist/renderer/assets/js/views.js +356 -0
  97. package/dist/renderer/assets/styles.css +856 -0
  98. package/dist/renderer/content.d.ts +11 -0
  99. package/dist/renderer/content.d.ts.map +1 -0
  100. package/dist/renderer/content.js +114 -0
  101. package/dist/renderer/content.js.map +1 -0
  102. package/dist/scanner/classify.d.ts +17 -0
  103. package/dist/scanner/classify.d.ts.map +1 -0
  104. package/dist/scanner/classify.js +117 -0
  105. package/dist/scanner/classify.js.map +1 -0
  106. package/dist/scanner/git.d.ts +3 -0
  107. package/dist/scanner/git.d.ts.map +1 -0
  108. package/dist/scanner/git.js +43 -0
  109. package/dist/scanner/git.js.map +1 -0
  110. package/dist/scanner/ignore.d.ts +22 -0
  111. package/dist/scanner/ignore.d.ts.map +1 -0
  112. package/dist/scanner/ignore.js +57 -0
  113. package/dist/scanner/ignore.js.map +1 -0
  114. package/dist/scanner/scan.d.ts +8 -0
  115. package/dist/scanner/scan.d.ts.map +1 -0
  116. package/dist/scanner/scan.js +108 -0
  117. package/dist/scanner/scan.js.map +1 -0
  118. package/dist/security/boundary.d.ts +19 -0
  119. package/dist/security/boundary.d.ts.map +1 -0
  120. package/dist/security/boundary.js +59 -0
  121. package/dist/security/boundary.js.map +1 -0
  122. package/dist/security/urlPolicy.d.ts +4 -0
  123. package/dist/security/urlPolicy.d.ts.map +1 -0
  124. package/dist/security/urlPolicy.js +56 -0
  125. package/dist/security/urlPolicy.js.map +1 -0
  126. package/dist/security/workspaceRead.d.ts +25 -0
  127. package/dist/security/workspaceRead.d.ts.map +1 -0
  128. package/dist/security/workspaceRead.js +103 -0
  129. package/dist/security/workspaceRead.js.map +1 -0
  130. package/dist/semantics/load.d.ts +11 -0
  131. package/dist/semantics/load.d.ts.map +1 -0
  132. package/dist/semantics/load.js +537 -0
  133. package/dist/semantics/load.js.map +1 -0
  134. package/dist/semantics/resolve.d.ts +37 -0
  135. package/dist/semantics/resolve.d.ts.map +1 -0
  136. package/dist/semantics/resolve.js +211 -0
  137. package/dist/semantics/resolve.js.map +1 -0
  138. package/dist/semantics/types.d.ts +158 -0
  139. package/dist/semantics/types.d.ts.map +1 -0
  140. package/dist/semantics/types.js +3 -0
  141. package/dist/semantics/types.js.map +1 -0
  142. package/dist/server/server.d.ts +17 -0
  143. package/dist/server/server.d.ts.map +1 -0
  144. package/dist/server/server.js +510 -0
  145. package/dist/server/server.js.map +1 -0
  146. package/dist/server/workspace.d.ts +53 -0
  147. package/dist/server/workspace.d.ts.map +1 -0
  148. package/dist/server/workspace.js +211 -0
  149. package/dist/server/workspace.js.map +1 -0
  150. package/dist/types.d.ts +570 -0
  151. package/dist/types.d.ts.map +1 -0
  152. package/dist/types.js +19 -0
  153. package/dist/types.js.map +1 -0
  154. package/dist/util/path.d.ts +11 -0
  155. package/dist/util/path.d.ts.map +1 -0
  156. package/dist/util/path.js +35 -0
  157. package/dist/util/path.js.map +1 -0
  158. package/dist/util/safeRead.d.ts +38 -0
  159. package/dist/util/safeRead.d.ts.map +1 -0
  160. package/dist/util/safeRead.js +117 -0
  161. package/dist/util/safeRead.js.map +1 -0
  162. package/package.json +40 -0
@@ -0,0 +1,422 @@
1
+ // Artifact rendering: the reading surfaces a human uses to inspect content.
2
+ // Content is always inserted as text (never innerHTML) except for markdown,
3
+ // which the server has already rendered with escaping enabled.
4
+
5
+ import { el, append } from './dom.js';
6
+ import { KIND_LABEL, fmtBytes, fmtStamp, kindLabel, relTime, shortPath } from './format.js';
7
+ import { loadArtifact } from './api.js';
8
+ import { hrefFor } from './router.js';
9
+ import { renderBlocks, tagRow } from './blocks.js';
10
+ import { renderActions } from './actions.js';
11
+ import { findTask } from './store.js';
12
+
13
+ const CODE_PREVIEW = 600;
14
+ const DIFF_PREVIEW = 500;
15
+
16
+ export function renderArtifactView(payload) {
17
+ const wrap = el('div', {});
18
+ wrap.appendChild(artifactHeader(payload));
19
+ wrap.appendChild(renderBody(payload));
20
+
21
+ if (payload.blocks && payload.blocks.length) {
22
+ const extras = el('div', { class: 'artifact-extras' });
23
+ append(extras, renderBlocks(payload.blocks, { renderActions }, { card: true }));
24
+ wrap.appendChild(extras);
25
+ }
26
+
27
+ const relations = renderRelations(payload);
28
+ if (relations) wrap.appendChild(relations);
29
+
30
+ return wrap;
31
+ }
32
+
33
+ function artifactHeader(payload) {
34
+ const head = el('div', { class: 'artifact-head' });
35
+ head.appendChild(
36
+ el('button', { class: 'back-link', text: '← Overview', onclick: () => { location.hash = '#/'; } }),
37
+ );
38
+ head.appendChild(el('h1', { class: 'artifact-title', text: payload.title }));
39
+ if (payload.path) head.appendChild(el('p', { class: 'artifact-path', text: payload.path }));
40
+
41
+ const meta = [];
42
+ meta.push(kindLabel(payload.kind));
43
+ if (payload.meta && payload.meta.size != null) meta.push(fmtBytes(payload.meta.size));
44
+ if (payload.meta && payload.meta.mtimeMs) meta.push(relTime(payload.meta.mtimeMs));
45
+ if (payload.meta && payload.meta.lineCount != null) meta.push(`${payload.meta.lineCount} lines`);
46
+ if (payload.meta && payload.meta.fileCount != null) {
47
+ meta.push(`${payload.meta.fileCount} file${payload.meta.fileCount === 1 ? '' : 's'}`);
48
+ }
49
+ if (payload.meta && payload.meta.language) meta.push(payload.meta.language);
50
+ head.appendChild(el('p', { class: 'artifact-meta', text: meta.join(' · ') }));
51
+
52
+ const facts = el('p', { class: 'artifact-meta artifact-provenance' });
53
+ if (payload.kind && KIND_LABEL[payload.kind] == null) {
54
+ facts.appendChild(el('span', { class: 'provenance-item', text: `role: ${payload.kind}` }));
55
+ }
56
+ if (payload.taskId) {
57
+ facts.appendChild(
58
+ el('a', { class: 'provenance-link', href: hrefFor('task', payload.taskId), text: `task: ${taskTitle(payload.taskId)}` }),
59
+ );
60
+ }
61
+ if (payload.blocksNote) facts.appendChild(el('span', { text: payload.blocksNote }));
62
+ if (facts.childNodes.length) head.appendChild(facts);
63
+
64
+ if (payload.tags && payload.tags.length) head.appendChild(tagRow(payload.tags));
65
+ return head;
66
+ }
67
+
68
+ function taskTitle(taskId) {
69
+ const task = findTask(taskId);
70
+ return task ? task.title : taskId;
71
+ }
72
+
73
+ function renderBody(payload) {
74
+ const box = el('div', { class: 'artifact-body' });
75
+ const content = payload.content;
76
+
77
+ switch (content && content.type) {
78
+ case 'markdown': {
79
+ const prose = el('div', { class: 'prose' });
80
+ prose.innerHTML = content.html || '';
81
+ box.appendChild(prose);
82
+ break;
83
+ }
84
+ case 'code':
85
+ box.appendChild(renderCode(content));
86
+ break;
87
+ case 'diff':
88
+ box.appendChild(renderDiff(content));
89
+ break;
90
+ case 'json':
91
+ box.appendChild(renderJson(content));
92
+ break;
93
+ case 'log':
94
+ box.appendChild(renderLog(content, payload.path));
95
+ break;
96
+ case 'image':
97
+ box.appendChild(renderImage(content, payload));
98
+ break;
99
+ case 'file':
100
+ box.appendChild(renderFile(content));
101
+ break;
102
+ default:
103
+ box.appendChild(el('div', { class: 'notice', text: 'Unsupported content type.' }));
104
+ }
105
+ return box;
106
+ }
107
+
108
+ // --------------------------------------------------------------- code view
109
+
110
+ function renderCode(content) {
111
+ const box = el('div', {});
112
+ const bar = el('div', { class: 'bar' }, [
113
+ el('span', { text: content.language || 'text' }),
114
+ el('span', { text: `${content.totalLines} lines` }),
115
+ ]);
116
+ if (content.truncated) bar.appendChild(el('span', { class: 'flag-warn', text: 'truncated' }));
117
+ box.appendChild(bar);
118
+
119
+ const lines = content.raw ? content.raw.split('\n') : [];
120
+ if (lines.length && lines[lines.length - 1] === '') lines.pop();
121
+
122
+ const wrap = el('div', { class: 'code-wrap' });
123
+ if (lines.length > CODE_PREVIEW) {
124
+ wrap.appendChild(codeLines(lines.slice(0, CODE_PREVIEW), 0));
125
+ wrap.appendChild(
126
+ el('div', { class: 'bar' }, [
127
+ el('button', {
128
+ class: 'btn',
129
+ text: `Show all ${lines.length} lines`,
130
+ onclick: (event) => {
131
+ const parent = event.currentTarget.closest('.code-wrap');
132
+ parent.textContent = '';
133
+ parent.appendChild(codeLines(lines, 0));
134
+ },
135
+ }),
136
+ ]),
137
+ );
138
+ } else {
139
+ wrap.appendChild(codeLines(lines, 0));
140
+ }
141
+ box.appendChild(wrap);
142
+ return box;
143
+ }
144
+
145
+ function codeLines(lines, startNo) {
146
+ const frag = document.createDocumentFragment();
147
+ for (let i = 0; i < lines.length; i++) {
148
+ frag.appendChild(
149
+ el('div', { class: 'code-line' }, [
150
+ el('span', { class: 'ln', text: String(startNo + i + 1) }),
151
+ el('span', { class: 'body', text: lines[i] }),
152
+ ]),
153
+ );
154
+ }
155
+ return frag;
156
+ }
157
+
158
+ // --------------------------------------------------------------- diff view
159
+
160
+ function renderDiff(content) {
161
+ const box = el('div', {});
162
+ const files = content.files || [];
163
+ const bar = el('div', { class: 'bar' }, [
164
+ el('span', { text: `${files.length} file${files.length === 1 ? '' : 's'}` }),
165
+ ]);
166
+ if (content.truncated) bar.appendChild(el('span', { class: 'flag-warn', text: 'truncated' }));
167
+ box.appendChild(bar);
168
+
169
+ let totalLines = 0;
170
+ for (const file of files) for (const hunk of file.hunks) totalLines += hunk.lines.length;
171
+
172
+ const body = el('div', { class: 'diff-wrap' });
173
+ if (totalLines > DIFF_PREVIEW) {
174
+ body.appendChild(diffFiles(files, DIFF_PREVIEW));
175
+ box.appendChild(body);
176
+ box.appendChild(
177
+ el('div', { class: 'bar' }, [
178
+ el('button', {
179
+ class: 'btn',
180
+ text: `Show full diff (${totalLines} lines)`,
181
+ onclick: (event) => {
182
+ const target = event.currentTarget;
183
+ const parent = target.closest('.artifact-body');
184
+ const wrap = parent.querySelector('.diff-wrap');
185
+ wrap.textContent = '';
186
+ wrap.appendChild(diffFiles(files, Infinity));
187
+ target.parentNode.remove();
188
+ },
189
+ }),
190
+ ]),
191
+ );
192
+ } else {
193
+ body.appendChild(diffFiles(files, Infinity));
194
+ box.appendChild(body);
195
+ }
196
+ return box;
197
+ }
198
+
199
+ function diffFiles(files, budget) {
200
+ const frag = document.createDocumentFragment();
201
+ let remaining = budget;
202
+ for (const file of files) {
203
+ if (remaining <= 0) break;
204
+ const fileBox = el('div', { class: 'diff-file' }, [
205
+ el('div', { class: 'diff-file-head' }, [
206
+ el('span', { class: 'path', text: file.path }),
207
+ el('span', { class: 'diff-status', text: file.status }),
208
+ el('span', { class: 'diff-stat add', text: `+${file.added}` }),
209
+ el('span', { class: 'diff-stat del', text: `-${file.removed}` }),
210
+ ]),
211
+ ]);
212
+ for (const hunk of file.hunks) {
213
+ if (remaining <= 0) break;
214
+ fileBox.appendChild(el('div', { class: 'diff-hunk-header', text: hunk.header }));
215
+ for (const line of hunk.lines) {
216
+ if (remaining <= 0) break;
217
+ fileBox.appendChild(diffLineRow(line));
218
+ remaining -= 1;
219
+ }
220
+ }
221
+ frag.appendChild(fileBox);
222
+ }
223
+ return frag;
224
+ }
225
+
226
+ function diffLineRow(line) {
227
+ let cls = 'diff-line';
228
+ if (line.type === 'add') cls += ' add';
229
+ else if (line.type === 'del') cls += ' del';
230
+ return el('div', { class: cls }, [
231
+ el('span', { class: 'ln old', text: line.oldNo != null ? String(line.oldNo) : '' }),
232
+ el('span', { class: 'ln new', text: line.newNo != null ? String(line.newNo) : '' }),
233
+ el('span', { class: 'body', text: line.text }),
234
+ ]);
235
+ }
236
+
237
+ // ---------------------------------------------------------------- log view
238
+
239
+ function renderLog(content, path) {
240
+ const box = el('div', {});
241
+ const head = el('div', { class: 'log-head' }, [
242
+ el('span', { text: `${content.totalLines} lines total` }),
243
+ ]);
244
+ if (content.hasErrors) head.appendChild(el('span', { class: 'log-flag error', text: 'errors' }));
245
+ if (content.hasWarnings) head.appendChild(el('span', { class: 'log-flag warn', text: 'warnings' }));
246
+ head.appendChild(
247
+ el('button', {
248
+ class: 'btn',
249
+ text: 'Show full log',
250
+ onclick: async (event) => {
251
+ const button = event.currentTarget;
252
+ button.disabled = true;
253
+ button.textContent = 'Loading…';
254
+ try {
255
+ const payload = await loadArtifact(path, 'full');
256
+ const c = payload.content;
257
+ const wrap = box.querySelector('.log-wrap');
258
+ wrap.textContent = '';
259
+ wrap.appendChild(logLines(c.tail, 0, c.errorLines));
260
+ button.remove();
261
+ if (c.truncated) head.appendChild(el('span', { class: 'log-flag warn', text: 'truncated' }));
262
+ } catch {
263
+ button.disabled = false;
264
+ button.textContent = 'Show full log';
265
+ }
266
+ },
267
+ }),
268
+ );
269
+ box.appendChild(head);
270
+
271
+ const wrap = el('div', { class: 'log-wrap' });
272
+ wrap.appendChild(logLines(content.tail, content.totalLines - content.tail.length, content.errorLines));
273
+ box.appendChild(wrap);
274
+ return box;
275
+ }
276
+
277
+ function logLines(lines, startNo, errorLines) {
278
+ const errorSet = new Set(errorLines || []);
279
+ const frag = document.createDocumentFragment();
280
+ for (let i = 0; i < lines.length; i++) {
281
+ const lineNo = startNo + i;
282
+ const cls = errorSet.has(lineNo) ? 'log-line is-error' : 'log-line';
283
+ frag.appendChild(
284
+ el('div', { class: cls }, [
285
+ el('span', { class: 'ln', text: String(lineNo + 1) }),
286
+ el('span', { class: 'body', text: lines[i] }),
287
+ ]),
288
+ );
289
+ }
290
+ return frag;
291
+ }
292
+
293
+ // --------------------------------------------------------------- json view
294
+
295
+ function renderJson(content) {
296
+ const box = el('div', {});
297
+ const bar = el('div', { class: 'bar' }, [
298
+ el('span', { text: content.valid ? 'valid JSON' : 'invalid JSON' }),
299
+ ]);
300
+ if (content.truncated) bar.appendChild(el('span', { class: 'flag-warn', text: 'truncated' }));
301
+ box.appendChild(bar);
302
+
303
+ const wrap = el('div', { class: 'json-wrap' });
304
+ const tree = () => {
305
+ wrap.textContent = '';
306
+ if (content.valid) wrap.appendChild(jsonNode(content.data, 0));
307
+ else wrap.appendChild(el('pre', { class: 'raw', text: content.raw }));
308
+ };
309
+ const raw = () => {
310
+ wrap.textContent = '';
311
+ wrap.appendChild(el('pre', { class: 'raw', text: content.raw }));
312
+ };
313
+
314
+ let showingRaw = false;
315
+ bar.appendChild(
316
+ el('button', {
317
+ class: 'btn',
318
+ text: 'Raw',
319
+ onclick: (event) => {
320
+ showingRaw = !showingRaw;
321
+ event.currentTarget.textContent = showingRaw ? 'Tree' : 'Raw';
322
+ if (showingRaw) raw();
323
+ else tree();
324
+ },
325
+ }),
326
+ );
327
+
328
+ tree();
329
+ box.appendChild(wrap);
330
+ return box;
331
+ }
332
+
333
+ function jsonNode(value, depth) {
334
+ if (depth > 12) return el('span', { class: 'json-collapsed', text: '…' });
335
+ if (value === null) return el('span', { class: 'json-null', text: 'null' });
336
+
337
+ const type = typeof value;
338
+ if (type === 'string') return el('span', { class: 'json-str', text: `"${value}"` });
339
+ if (type === 'number') return el('span', { class: 'json-num', text: String(value) });
340
+ if (type === 'boolean') return el('span', { class: 'json-bool', text: String(value) });
341
+ if (type !== 'object') return el('span', { class: 'json-null', text: String(value) });
342
+
343
+ const isArray = Array.isArray(value);
344
+ const keys = Object.keys(value);
345
+ const details = el('details', { class: 'json-row' });
346
+ const summary = el('summary', {});
347
+ summary.appendChild(el('span', { class: 'json-collapsed', text: isArray ? `Array(${keys.length}) ` : '{ ' }));
348
+ summary.appendChild(
349
+ el('span', {
350
+ class: 'json-collapsed',
351
+ text: keys.length ? keys.slice(0, 3).join(', ') + (keys.length > 3 ? ', …' : '') : '',
352
+ }),
353
+ );
354
+ if (!isArray) summary.appendChild(el('span', { class: 'json-collapsed', text: ' }' }));
355
+ details.appendChild(summary);
356
+
357
+ const MAX = 200;
358
+ for (const key of keys.slice(0, MAX)) {
359
+ const row = el('div', { class: 'json-child' });
360
+ if (!isArray) row.appendChild(el('span', { class: 'json-key', text: `${key}: ` }));
361
+ row.appendChild(jsonNode(value[key], depth + 1));
362
+ details.appendChild(row);
363
+ }
364
+ if (keys.length > MAX) {
365
+ details.appendChild(el('div', { class: 'json-child json-collapsed', text: `… ${keys.length - MAX} more` }));
366
+ }
367
+ return details;
368
+ }
369
+
370
+ // -------------------------------------------------------------- image view
371
+
372
+ function renderImage(content, payload) {
373
+ const wrap = el('div', { class: 'image-wrap' });
374
+ wrap.appendChild(el('img', { src: content.url, alt: payload.title, loading: 'lazy' }));
375
+ const meta = [];
376
+ if (payload.meta && payload.meta.imageWidth) meta.push(`${payload.meta.imageWidth} × ${payload.meta.imageHeight}`);
377
+ if (payload.meta && payload.meta.size != null) meta.push(fmtBytes(payload.meta.size));
378
+ if (payload.path) meta.push(payload.path);
379
+ wrap.appendChild(el('div', { class: 'image-meta', text: meta.join(' · ') }));
380
+ return wrap;
381
+ }
382
+
383
+ // --------------------------------------------------------------- file view
384
+
385
+ function renderFile(content) {
386
+ const wrap = el('div', { class: 'prose' });
387
+ if (content.note) {
388
+ wrap.appendChild(el('p', { class: 'notice', text: content.note }));
389
+ return wrap;
390
+ }
391
+ if (content.isBinary) {
392
+ wrap.appendChild(el('p', { class: 'notice', text: 'Binary file — no text preview available.' }));
393
+ return wrap;
394
+ }
395
+ if (content.text != null && content.text !== '') {
396
+ wrap.appendChild(el('pre', { class: 'raw', text: content.text }));
397
+ if (content.truncated) wrap.appendChild(el('p', { class: 'notice', text: 'Preview truncated.' }));
398
+ } else {
399
+ wrap.appendChild(el('p', { class: 'empty', text: 'Empty file.' }));
400
+ }
401
+ return wrap;
402
+ }
403
+
404
+ // -------------------------------------------------------------- relations
405
+
406
+ function renderRelations(payload) {
407
+ const relations = payload.relations;
408
+ if (!relations || relations.length === 0) return null;
409
+ const box = el('div', { class: 'relations' }, [el('h2', { class: 'section-title', text: 'Related' })]);
410
+ const list = el('ul', { class: 'block-list' });
411
+ for (const relation of relations) {
412
+ list.appendChild(
413
+ el('li', { class: 'block-list-item' }, [
414
+ el('a', { class: 'block-list-title link', href: hrefFor('artifact', relation.target), text: shortPath(relation.target) }),
415
+ el('span', { class: 'relation-kind', text: relation.kind }),
416
+ relation.note ? el('span', { class: 'muted', text: relation.note }) : null,
417
+ ]),
418
+ );
419
+ }
420
+ box.appendChild(list);
421
+ return box;
422
+ }