sourcefire 0.2.0__py3-none-any.whl

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.
@@ -0,0 +1,414 @@
1
+ /* ============================================================
2
+ Sourcefire — Frontend
3
+ by Athar Wani
4
+ ============================================================ */
5
+
6
+ // ── State ────────────────────────────────────────────────────
7
+ var currentMode = 'debug';
8
+ var chatHistory = [];
9
+ var isStreaming = false;
10
+
11
+ // ── DOM ──────────────────────────────────────────────────────
12
+ var chat = document.getElementById('chat');
13
+ var queryInput = document.getElementById('query-input');
14
+ var sendBtn = document.getElementById('send-btn');
15
+ var indexStatus = document.getElementById('index-status');
16
+ var langBadge = document.getElementById('lang-badge');
17
+ var modelSelect = document.getElementById('model-select');
18
+ var statsEl = document.getElementById('retrieval-stats');
19
+ var sourceModal = document.getElementById('source-modal');
20
+ var sourceViewer = document.getElementById('source-viewer');
21
+ var sourceTitle = document.getElementById('source-modal-title');
22
+ var sourceClose = document.getElementById('source-modal-close');
23
+ var sourceBack = sourceModal ? sourceModal.querySelector('.source-modal__backdrop') : null;
24
+
25
+ // ── Mode Switching ───────────────────────────────────────────
26
+ document.querySelectorAll('.mode-pill').forEach(function(pill) {
27
+ pill.addEventListener('click', function() {
28
+ document.querySelectorAll('.mode-pill').forEach(function(p) {
29
+ p.classList.remove('mode-pill--active');
30
+ p.setAttribute('aria-selected', 'false');
31
+ });
32
+ pill.classList.add('mode-pill--active');
33
+ pill.setAttribute('aria-selected', 'true');
34
+ currentMode = pill.dataset.mode;
35
+ });
36
+ });
37
+
38
+ // ── Send ─────────────────────────────────────────────────────
39
+ sendBtn.addEventListener('click', sendQuery);
40
+ queryInput.addEventListener('keydown', function(e) {
41
+ if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); sendQuery(); }
42
+ });
43
+
44
+ // Auto-resize textarea
45
+ queryInput.addEventListener('input', function() {
46
+ this.style.height = 'auto';
47
+ this.style.height = Math.min(this.scrollHeight, 200) + 'px';
48
+ });
49
+
50
+ // ── Send Query ───────────────────────────────────────────────
51
+ async function sendQuery() {
52
+ var query = queryInput.value.trim();
53
+ if (!query || isStreaming) return;
54
+
55
+ isStreaming = true;
56
+ sendBtn.disabled = true;
57
+ queryInput.value = '';
58
+ queryInput.style.height = 'auto';
59
+
60
+ // Remove welcome
61
+ var welcome = chat.querySelector('.welcome');
62
+ if (welcome) welcome.remove();
63
+
64
+ // User message
65
+ appendMsg('user', query);
66
+
67
+ // Assistant container with status timeline
68
+ var container = document.createElement('div');
69
+ container.className = 'msg msg--assistant';
70
+
71
+ var timeline = document.createElement('div');
72
+ timeline.className = 'status-timeline';
73
+ container.appendChild(timeline);
74
+
75
+ var thinkingEl = createThinking();
76
+ container.appendChild(thinkingEl);
77
+
78
+ var contentEl = document.createElement('div');
79
+ contentEl.className = 'msg__content';
80
+ contentEl.style.display = 'none';
81
+ container.appendChild(contentEl);
82
+
83
+ chat.appendChild(container);
84
+ scrollToBottom();
85
+
86
+ var rawText = '';
87
+
88
+ try {
89
+ var response = await fetch('/api/query', {
90
+ method: 'POST',
91
+ headers: { 'Content-Type': 'application/json' },
92
+ body: JSON.stringify({
93
+ query: query,
94
+ mode: currentMode,
95
+ model: modelSelect.value,
96
+ history: chatHistory.slice(-10),
97
+ }),
98
+ });
99
+
100
+ if (!response.ok) throw new Error('HTTP ' + response.status);
101
+
102
+ var reader = response.body.getReader();
103
+ var decoder = new TextDecoder();
104
+ var buffer = '';
105
+
106
+ while (true) {
107
+ var chunk = await reader.read();
108
+ if (chunk.done) break;
109
+
110
+ buffer += decoder.decode(chunk.value, { stream: true });
111
+ var lines = buffer.split('\n');
112
+ buffer = lines.pop();
113
+
114
+ for (var i = 0; i < lines.length; i++) {
115
+ var trimmed = lines[i].trim();
116
+ if (!trimmed.startsWith('data: ')) continue;
117
+ var jsonStr = trimmed.slice(6);
118
+ if (!jsonStr) continue;
119
+
120
+ var event;
121
+ try { event = JSON.parse(jsonStr); } catch(e) { continue; }
122
+
123
+ if (event.type === 'status') {
124
+ handleStatus(timeline, event);
125
+ } else if (event.type === 'token') {
126
+ if (thinkingEl.parentNode) thinkingEl.remove();
127
+ contentEl.style.display = '';
128
+ rawText += event.content;
129
+ contentEl.textContent = rawText;
130
+ scrollToBottom();
131
+ } else if (event.type === 'done') {
132
+ if (thinkingEl.parentNode) thinkingEl.remove();
133
+ contentEl.style.display = '';
134
+ addStatusTag(timeline, 'done', 'done');
135
+ finalizeMsg(contentEl, rawText);
136
+ updateStats(event.stats, event.sources);
137
+ chatHistory.push({ role: 'user', content: query });
138
+ chatHistory.push({ role: 'assistant', content: rawText });
139
+ scrollToBottom();
140
+ } else if (event.type === 'error') {
141
+ if (thinkingEl.parentNode) thinkingEl.remove();
142
+ contentEl.style.display = '';
143
+ contentEl.textContent = 'Error: ' + event.content;
144
+ addStatusTag(timeline, 'error', 'error');
145
+ scrollToBottom();
146
+ }
147
+ }
148
+ }
149
+ } catch (err) {
150
+ if (thinkingEl.parentNode) thinkingEl.remove();
151
+ contentEl.style.display = '';
152
+ contentEl.textContent = 'Connection error: ' + err.message;
153
+ addStatusTag(timeline, 'error', 'failed');
154
+ } finally {
155
+ isStreaming = false;
156
+ sendBtn.disabled = false;
157
+ queryInput.focus();
158
+ }
159
+ }
160
+
161
+ // ── Status Handling ──────────────────────────────────────────
162
+ // One "live" tag that updates in-place, plus pinned tool/context tags.
163
+ function handleStatus(timeline, event) {
164
+ var stage = event.stage;
165
+ var live = timeline.querySelector('.status-tag--live');
166
+
167
+ if (stage === 'retrieving') {
168
+ setLiveTag(timeline, live, '\u26A1', 'retrieving...');
169
+ } else if (stage === 'context_found') {
170
+ // Replace live tag with a static context tag
171
+ removeLive(timeline);
172
+ pinTag(timeline, 'context', '\u25A0', event.chunks + ' chunks \u00B7 ' + event.files + ' files');
173
+ } else if (stage === 'thinking') {
174
+ setLiveTag(timeline, live, '\u26A1', 'thinking...');
175
+ } else if (stage === 'generating') {
176
+ setLiveTag(timeline, live, '\u26A1', 'generating...');
177
+ } else if (stage === 'tool_call') {
178
+ removeLive(timeline);
179
+ pinTag(timeline, 'tool', '\u2699', event.tool);
180
+ } else if (stage === 'tool_done') {
181
+ // tool tag already pinned, nothing to do
182
+ }
183
+ }
184
+
185
+ function setLiveTag(timeline, existing, icon, label) {
186
+ if (existing) {
187
+ // Update in-place
188
+ existing.lastChild.textContent = label;
189
+ existing.querySelector('.status-tag__icon').textContent = icon;
190
+ } else {
191
+ var tag = createTag('active', icon, label);
192
+ tag.classList.add('status-tag--live');
193
+ timeline.appendChild(tag);
194
+ }
195
+ scrollToBottom();
196
+ }
197
+
198
+ function removeLive(timeline) {
199
+ var live = timeline.querySelector('.status-tag--live');
200
+ if (live) live.remove();
201
+ }
202
+
203
+ function pinTag(timeline, type, icon, label) {
204
+ timeline.appendChild(createTag(type, icon, label));
205
+ scrollToBottom();
206
+ }
207
+
208
+ function addStatusTag(timeline, type, label) {
209
+ // Used for final done/error
210
+ removeLive(timeline);
211
+ var icons = { 'done': '\u2713', 'error': '\u2717' };
212
+ timeline.appendChild(createTag(type, icons[type] || '\u25CF', label));
213
+ scrollToBottom();
214
+ }
215
+
216
+ function createTag(type, iconText, label) {
217
+ var tag = document.createElement('span');
218
+ tag.className = 'status-tag';
219
+ if (type === 'active') tag.className += ' status-tag--active';
220
+ else if (type === 'done') tag.className += ' status-tag--done';
221
+ else if (type === 'tool') tag.className += ' status-tag--tool';
222
+ else if (type === 'context') tag.className += ' status-tag--context';
223
+ else if (type === 'error') tag.className += ' status-tag--error';
224
+
225
+ var icon = document.createElement('span');
226
+ icon.className = 'status-tag__icon';
227
+ icon.textContent = iconText;
228
+ tag.appendChild(icon);
229
+ tag.appendChild(document.createTextNode(label));
230
+ return tag;
231
+ }
232
+
233
+ // ── Finalize Message ─────────────────────────────────────────
234
+ function finalizeMsg(el, rawText) {
235
+ // Safe: innerHTML set from renderMarkdown which processes our own markdown text
236
+ el.innerHTML = renderMarkdown(rawText);
237
+ el.querySelectorAll('pre code').forEach(function(block) {
238
+ hljs.highlightElement(block);
239
+ });
240
+ attachFileRefListeners(el);
241
+ }
242
+
243
+ // ── Markdown Rendering ───────────────────────────────────────
244
+ var _FILE_LINK_RE = /\[([^\]]+)\]\(file:\/\/([^)]+)\)/g;
245
+ var _PLACEHOLDER_RE = /\u200B\u200BFILEREF\u200B([^\u200B]+)\u200B([^\u200B]+)\u200B\u200B/g;
246
+
247
+ function renderMarkdown(text) {
248
+ var preprocessed = text.replace(_FILE_LINK_RE, function(match, linkText, path) {
249
+ if (path.startsWith('/')) path = path.substring(1);
250
+ return '\u200B\u200BFILEREF\u200B' + path + '\u200B' + linkText + '\u200B\u200B';
251
+ });
252
+ var html = marked.parse(preprocessed, { breaks: true, gfm: true });
253
+ html = html.replace(_PLACEHOLDER_RE, function(match, path, linkText) {
254
+ return '<a class="file-ref" href="#" data-path="' + escapeHtml(path) + '" title="View source">' + escapeHtml(linkText) + '</a>';
255
+ });
256
+ return html;
257
+ }
258
+
259
+ // ── File Ref Listeners ───────────────────────────────────────
260
+ var FILE_PATH_PATTERN = /\b[\w][\w./\\-]*\/[\w./\\-]+\.(?:py|js|jsx|ts|tsx|dart|go|rs|java|kt|swift|rb|php|c|cpp|cc|cxx|h|hpp|hxx|hh|yaml|yml|json|toml|md|html|css|sql|sh|proto|graphql|cmake)\b|\b[\w-]+\.(?:py|js|ts|dart|go|rs|java|c|cpp|h|hpp|yaml|yml|json|toml|md)\b/g;
261
+
262
+ function attachFileRefListeners(el) {
263
+ el.querySelectorAll('a').forEach(function(a) {
264
+ var path = '';
265
+ if (a.dataset.path) {
266
+ path = a.dataset.path;
267
+ } else {
268
+ var href = a.getAttribute('href') || '';
269
+ if (href.startsWith('file://')) {
270
+ path = decodeURIComponent(href.replace('file://', ''));
271
+ if (path.startsWith('/')) path = path.substring(1);
272
+ } else if (href.match(/^[\w][\w./\\-]+\.(?:py|js|ts|dart|go|rs|java|c|cpp|h|hpp|yaml|yml|json|md)$/)) {
273
+ path = href;
274
+ }
275
+ }
276
+ if (path) {
277
+ a.classList.add('file-ref');
278
+ a.removeAttribute('target');
279
+ (function(p) { a.addEventListener('click', function(e) { e.preventDefault(); loadSource(p); }); })(path);
280
+ }
281
+ });
282
+
283
+ // Fallback: plain text paths
284
+ var walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, {
285
+ acceptNode: function(node) {
286
+ var parent = node.parentElement;
287
+ while (parent && parent !== el) {
288
+ if (parent.tagName === 'CODE' || parent.tagName === 'PRE' || parent.tagName === 'A') return NodeFilter.FILTER_REJECT;
289
+ parent = parent.parentElement;
290
+ }
291
+ FILE_PATH_PATTERN.lastIndex = 0;
292
+ return FILE_PATH_PATTERN.test(node.textContent) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP;
293
+ },
294
+ });
295
+ var textNodes = []; var n;
296
+ while ((n = walker.nextNode())) textNodes.push(n);
297
+
298
+ for (var t = 0; t < textNodes.length; t++) {
299
+ var textNode = textNodes[t];
300
+ FILE_PATH_PATTERN.lastIndex = 0;
301
+ var text = textNode.textContent;
302
+ var frag = document.createDocumentFragment();
303
+ var lastIdx = 0; var m;
304
+ while ((m = FILE_PATH_PATTERN.exec(text)) !== null) {
305
+ if (m.index > lastIdx) frag.appendChild(document.createTextNode(text.slice(lastIdx, m.index)));
306
+ var a = document.createElement('a');
307
+ a.className = 'file-ref'; a.dataset.path = m[0]; a.textContent = m[0]; a.href = '#'; a.title = 'View source';
308
+ (function(p) { a.addEventListener('click', function(e) { e.preventDefault(); loadSource(p); }); })(m[0]);
309
+ frag.appendChild(a);
310
+ lastIdx = m.index + m[0].length;
311
+ }
312
+ if (lastIdx < text.length) frag.appendChild(document.createTextNode(text.slice(lastIdx)));
313
+ textNode.parentNode.replaceChild(frag, textNode);
314
+ }
315
+ }
316
+
317
+ // ── Helpers ──────────────────────────────────────────────────
318
+ function appendMsg(role, content) {
319
+ var el = document.createElement('div');
320
+ el.className = 'msg msg--' + role;
321
+ el.textContent = content;
322
+ chat.appendChild(el);
323
+ scrollToBottom();
324
+ }
325
+
326
+ function createThinking() {
327
+ var el = document.createElement('div');
328
+ el.className = 'thinking';
329
+ for (var i = 0; i < 3; i++) { var d = document.createElement('span'); d.className = 'thinking__dot'; el.appendChild(d); }
330
+ return el;
331
+ }
332
+
333
+ function scrollToBottom() { chat.scrollTop = chat.scrollHeight; }
334
+
335
+ function escapeHtml(s) {
336
+ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
337
+ }
338
+
339
+ function updateStats(stats, sources) {
340
+ if (!stats && !sources) { statsEl.textContent = ''; return; }
341
+ var c = sources ? sources.length : 0;
342
+ var used = stats ? stats.chunks_used : c;
343
+ statsEl.textContent = used + ' chunks \u00B7 ' + (stats ? stats.total_estimated : '?') + ' tokens est.';
344
+ }
345
+
346
+ // ── Source Viewer ────────────────────────────────────────────
347
+ function openModal() { if (sourceModal) sourceModal.hidden = false; }
348
+ function closeModal() { if (sourceModal) sourceModal.hidden = true; }
349
+
350
+ if (sourceClose) sourceClose.addEventListener('click', closeModal);
351
+ if (sourceBack) sourceBack.addEventListener('click', closeModal);
352
+ document.addEventListener('keydown', function(e) { if (e.key === 'Escape') closeModal(); });
353
+
354
+ async function loadSource(path) {
355
+ if (!sourceViewer || !sourceModal) return;
356
+ sourceTitle.textContent = path;
357
+ sourceViewer.textContent = 'Loading\u2026';
358
+ openModal();
359
+ try {
360
+ var response = await fetch('/api/sources?path=' + encodeURIComponent(path));
361
+ if (!response.ok) throw new Error('HTTP ' + response.status);
362
+ var data = await response.json();
363
+ var lang = data.language || 'text';
364
+ var highlighted;
365
+ try { highlighted = hljs.highlight(data.content, { language: lang }).value; }
366
+ catch(e) { try { highlighted = hljs.highlightAuto(data.content).value; } catch(e2) { highlighted = escapeHtml(data.content); } }
367
+ sourceViewer.textContent = '';
368
+ var pre = document.createElement('pre');
369
+ var code = document.createElement('code');
370
+ // Safe: highlighted is from hljs processing backend-validated local file content
371
+ code.innerHTML = formatLines(highlighted);
372
+ pre.appendChild(code);
373
+ sourceViewer.appendChild(pre);
374
+ } catch(err) {
375
+ sourceViewer.textContent = 'Could not load ' + path + ': ' + err.message;
376
+ }
377
+ }
378
+
379
+ function formatLines(html) {
380
+ var lines = html.split('\n');
381
+ var out = '';
382
+ for (var i = 0; i < lines.length; i++) {
383
+ out += '<div class="source-line"><span class="source-line__num">' + (i+1) + '</span><span class="source-line__code">' + (lines[i]||' ') + '</span></div>';
384
+ }
385
+ return out;
386
+ }
387
+
388
+ // ── Status Polling ───────────────────────────────────────────
389
+ async function pollStatus() {
390
+ try {
391
+ var res = await fetch('/api/status');
392
+ if (!res.ok) throw new Error();
393
+ var data = await res.json();
394
+ if (data.index_status === 'ready') {
395
+ indexStatus.textContent = data.files_indexed + ' files';
396
+ indexStatus.classList.add('is-ready');
397
+ } else {
398
+ indexStatus.textContent = 'indexing';
399
+ setTimeout(pollStatus, 3000);
400
+ }
401
+ if (data.language && data.language !== 'generic') {
402
+ langBadge.textContent = data.language;
403
+ }
404
+ } catch(e) {
405
+ indexStatus.textContent = 'offline';
406
+ indexStatus.classList.add('is-error');
407
+ }
408
+ }
409
+
410
+ // ── Init ─────────────────────────────────────────────────────
411
+ document.addEventListener('DOMContentLoaded', function() {
412
+ pollStatus();
413
+ queryInput.focus();
414
+ });
@@ -0,0 +1,102 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Sourcefire</title>
7
+
8
+ <!-- Fonts -->
9
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
10
+ <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,400;0,500;0,600;1,400&display=swap" rel="stylesheet" />
11
+ <link href="https://api.fontshare.com/v2/css?f[]=clash-display@500,600,700&f[]=satoshi@400,500,700&display=swap" rel="stylesheet" />
12
+
13
+ <!-- highlight.js -->
14
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/atom-one-dark.min.css" />
15
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
16
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/dart.min.js"></script>
17
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/go.min.js"></script>
18
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/rust.min.js"></script>
19
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/kotlin.min.js"></script>
20
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/c.min.js"></script>
21
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/cpp.min.js"></script>
22
+
23
+ <!-- marked.js -->
24
+ <script src="https://cdn.jsdelivr.net/npm/marked@12.0.0/marked.min.js"></script>
25
+
26
+ <link rel="stylesheet" href="/static/styles.css" />
27
+ </head>
28
+ <body>
29
+
30
+ <!-- ▸ Top Bar -->
31
+ <header class="topbar">
32
+ <div class="topbar__left">
33
+ <span class="topbar__logo">source<span class="topbar__logo--accent">fire</span> <sub class="topbar__logo--sub">by cravv</sub></span>
34
+ <span id="lang-badge" class="lang-badge"></span>
35
+ </div>
36
+ <div class="topbar__right">
37
+ <span id="index-status" class="index-badge">indexing</span>
38
+ <select id="model-select" class="model-select">
39
+ <option value="gemini-3.1-flash-lite-preview">flash</option>
40
+ <option value="gemini-3.1-pro-preview">pro</option>
41
+ </select>
42
+ </div>
43
+ </header>
44
+
45
+ <!-- ▸ Chat -->
46
+ <main class="main">
47
+ <div id="chat" class="chat" role="log" aria-live="polite">
48
+ <div class="welcome">
49
+ <div class="welcome__icon">&#9632;</div>
50
+ <h1 class="welcome__title">source<span class="welcome__title--accent">fire</span> <sub class="welcome__title--sub">by cravv</sub></h1>
51
+ <p class="welcome__sub">Paste an error. Describe a feature. Ask anything about your codebase.</p>
52
+ <div class="welcome__modes">
53
+ <span class="welcome__mode"><span class="dot dot--debug"></span> Debug &mdash; trace errors</span>
54
+ <span class="welcome__mode"><span class="dot dot--feature"></span> Feature &mdash; plan new code</span>
55
+ <span class="welcome__mode"><span class="dot dot--explain"></span> Explain &mdash; understand code</span>
56
+ </div>
57
+ </div>
58
+ </div>
59
+ </main>
60
+
61
+ <!-- ▸ Input -->
62
+ <footer class="inputbar">
63
+ <div class="inputbar__inner">
64
+ <div class="mode-pills" role="tablist">
65
+ <button class="mode-pill mode-pill--active" role="tab" aria-selected="true" data-mode="debug">
66
+ <span class="dot dot--debug"></span> Debug
67
+ </button>
68
+ <button class="mode-pill" role="tab" aria-selected="false" data-mode="feature">
69
+ <span class="dot dot--feature"></span> Feature
70
+ </button>
71
+ <button class="mode-pill" role="tab" aria-selected="false" data-mode="explain">
72
+ <span class="dot dot--explain"></span> Explain
73
+ </button>
74
+ </div>
75
+ <div class="input-row">
76
+ <textarea id="query-input" class="query-textarea" rows="1" placeholder="Ask about the codebase..." aria-label="Query"></textarea>
77
+ <button id="send-btn" class="send-btn" aria-label="Send">
78
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg>
79
+ </button>
80
+ </div>
81
+ <div class="inputbar__footer">
82
+ <span id="retrieval-stats" class="retrieval-stats"></span>
83
+ <span class="credit">built by Athar Wani at Cravv HQ</span>
84
+ </div>
85
+ </div>
86
+ </footer>
87
+
88
+ <!-- ▸ Source Viewer Modal -->
89
+ <div id="source-modal" class="source-modal" hidden>
90
+ <div class="source-modal__backdrop"></div>
91
+ <div class="source-modal__panel">
92
+ <div class="source-modal__header">
93
+ <span id="source-modal-title" class="source-modal__title"></span>
94
+ <button id="source-modal-close" class="source-modal__close" aria-label="Close">&times;</button>
95
+ </div>
96
+ <div id="source-viewer" class="source-modal__body"></div>
97
+ </div>
98
+ </div>
99
+
100
+ <script src="/static/app.js"></script>
101
+ </body>
102
+ </html>