cloudfrontize 1.1.7 → 1.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.
Files changed (33) hide show
  1. package/README.md +141 -83
  2. package/dist/cli.js +36 -32
  3. package/dist/ui/app.js +399 -0
  4. package/dist/ui/favicons/android-icon-144x144.png +0 -0
  5. package/dist/ui/favicons/android-icon-192x192.png +0 -0
  6. package/dist/ui/favicons/android-icon-36x36.png +0 -0
  7. package/dist/ui/favicons/android-icon-48x48.png +0 -0
  8. package/dist/ui/favicons/android-icon-72x72.png +0 -0
  9. package/dist/ui/favicons/android-icon-96x96.png +0 -0
  10. package/dist/ui/favicons/apple-icon-114x114.png +0 -0
  11. package/dist/ui/favicons/apple-icon-120x120.png +0 -0
  12. package/dist/ui/favicons/apple-icon-144x144.png +0 -0
  13. package/dist/ui/favicons/apple-icon-152x152.png +0 -0
  14. package/dist/ui/favicons/apple-icon-180x180.png +0 -0
  15. package/dist/ui/favicons/apple-icon-57x57.png +0 -0
  16. package/dist/ui/favicons/apple-icon-60x60.png +0 -0
  17. package/dist/ui/favicons/apple-icon-72x72.png +0 -0
  18. package/dist/ui/favicons/apple-icon-76x76.png +0 -0
  19. package/dist/ui/favicons/apple-icon-precomposed.png +0 -0
  20. package/dist/ui/favicons/apple-icon.png +0 -0
  21. package/dist/ui/favicons/browserconfig.xml +11 -0
  22. package/dist/ui/favicons/favicon-16x16.png +0 -0
  23. package/dist/ui/favicons/favicon-32x32.png +0 -0
  24. package/dist/ui/favicons/favicon-96x96.png +0 -0
  25. package/dist/ui/favicons/favicon.ico +0 -0
  26. package/dist/ui/favicons/manifest.json +41 -0
  27. package/dist/ui/favicons/ms-icon-144x144.png +0 -0
  28. package/dist/ui/favicons/ms-icon-150x150.png +0 -0
  29. package/dist/ui/favicons/ms-icon-310x310.png +0 -0
  30. package/dist/ui/favicons/ms-icon-70x70.png +0 -0
  31. package/dist/ui/index.html +102 -0
  32. package/dist/ui/style.css +639 -0
  33. package/package.json +1 -1
package/dist/ui/app.js ADDED
@@ -0,0 +1,399 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * CloudFrontize Developer UI - Client Logic
5
+ */
6
+
7
+ document.addEventListener('DOMContentLoaded', () => {
8
+ const requestFeed = document.getElementById('request-feed');
9
+ const portDisplay = document.getElementById('current-port');
10
+
11
+ // Tab Switching
12
+ const tabBtns = document.querySelectorAll('.tab-btn');
13
+ const tabContents = document.querySelectorAll('.tab-content');
14
+
15
+ tabBtns.forEach(btn => {
16
+ btn.addEventListener('click', () => {
17
+ tabBtns.forEach(b => b.classList.remove('active'));
18
+ tabContents.forEach(c => c.classList.remove('active'));
19
+
20
+ btn.classList.add('active');
21
+ document.getElementById(btn.dataset.tab).classList.add('active');
22
+ });
23
+ });
24
+
25
+ // Filtering Pulse
26
+ const filterBtns = document.querySelectorAll('.btn-filter');
27
+ filterBtns.forEach(btn => {
28
+ btn.addEventListener('click', () => {
29
+ filterBtns.forEach(b => b.classList.remove('active'));
30
+ btn.classList.add('active');
31
+
32
+ const type = btn.dataset.type;
33
+ const cards = document.querySelectorAll('.request-card');
34
+ cards.forEach(card => {
35
+ if (!type || type === 'all') {
36
+ card.style.display = 'block';
37
+ } else if (type === 'rewrite') {
38
+ card.style.display = card.classList.contains('is-rewrite') ? 'block' : 'none';
39
+ } else if (type === 'violation') {
40
+ card.style.display = card.classList.contains('has-violation') ? 'block' : 'none';
41
+ }
42
+ });
43
+ });
44
+ });
45
+
46
+ // Real-time Feed via SSE
47
+ const evtSource = new EventSource('/events');
48
+
49
+ evtSource.onmessage = (event) => {
50
+ const data = JSON.parse(event.data);
51
+ if (data.type === 'init') {
52
+ portDisplay.textContent = data.port;
53
+ // Wipe all local state for a clean slate
54
+ requestFeed.innerHTML = '<div class="empty-state"><p>Waiting for requests...</p></div>';
55
+ loadHeaderState(data.headerState);
56
+
57
+ // Reset "Dirty" state
58
+ markClean();
59
+
60
+ // Reset filters to "All"
61
+ const allFilterBtn = document.querySelector('.btn-filter[data-type="all"]');
62
+ if (allFilterBtn) {
63
+ filterBtns.forEach(b => b.classList.remove('active'));
64
+ allFilterBtn.classList.add('active');
65
+ }
66
+ } else if (data.type === 'request') {
67
+ addRequestToFeed(data.request);
68
+ }
69
+ };
70
+
71
+ evtSource.onerror = () => {
72
+ console.error("SSE Connection lost.");
73
+ };
74
+
75
+ const sidebar = document.querySelector('.sidebar');
76
+ const applyBtn = document.getElementById('apply-headers');
77
+ let isDirty = false;
78
+
79
+ function markDirty() {
80
+ if (isDirty) return;
81
+ isDirty = true;
82
+ applyBtn.classList.add('dirty');
83
+ applyBtn.innerText = 'Save Changes';
84
+ }
85
+
86
+ function markClean() {
87
+ isDirty = false;
88
+ applyBtn.classList.remove('dirty');
89
+ applyBtn.innerText = 'Applied';
90
+ }
91
+
92
+ // Header Management
93
+ function createHeaderRow(key = '', value = '', isDelete = false) {
94
+ const row = document.createElement('div');
95
+ row.className = 'header-row' + (isDelete ? ' suppressed' : '');
96
+ row.innerHTML = `
97
+ <div class="row-main">
98
+ <input type="text" placeholder="Header Name" value="${key}" class="hdr-key">
99
+ <input type="text" placeholder="Value" value="${value}" class="hdr-val" ${isDelete ? 'disabled' : ''}>
100
+ </div>
101
+ <div class="row-actions">
102
+ <button class="btn-toggle-action ${isDelete ? '' : 'active'}" title="${isDelete ? 'Set: Override/Inject' : 'Suppress: Explicitly Delete'}">
103
+ <span class="material-icons">${isDelete ? 'do_not_disturb_on' : 'verified'}</span>
104
+ </button>
105
+ <button class="btn-remove" title="Remove"><span class="material-icons">delete</span></button>
106
+ </div>
107
+ `;
108
+
109
+ const actionBtn = row.querySelector('.btn-toggle-action');
110
+ const valInput = row.querySelector('.hdr-val');
111
+ const keyInput = row.querySelector('.hdr-key');
112
+
113
+ const handleChange = () => markDirty();
114
+ keyInput.oninput = handleChange;
115
+ valInput.oninput = handleChange;
116
+
117
+ actionBtn.onclick = () => {
118
+ const nowActive = actionBtn.classList.toggle('active');
119
+ const isSuppressed = !nowActive;
120
+
121
+ row.classList.toggle('suppressed', isSuppressed);
122
+ actionBtn.title = isSuppressed ? 'Set: Override/Inject' : 'Suppress: Explicitly Delete';
123
+ actionBtn.innerHTML = `<span class="material-icons">${isSuppressed ? 'do_not_disturb_on' : 'verified'}</span>`;
124
+ valInput.disabled = isSuppressed;
125
+ markDirty();
126
+ };
127
+
128
+ row.querySelector('.btn-remove').onclick = () => {
129
+ row.remove();
130
+ markDirty();
131
+ };
132
+ return row;
133
+ }
134
+
135
+ document.getElementById('add-req-header').onclick = () => {
136
+ document.getElementById('req-header-list').appendChild(createHeaderRow());
137
+ markDirty();
138
+ };
139
+
140
+ document.getElementById('add-res-header').onclick = () => {
141
+ document.getElementById('res-header-list').appendChild(createHeaderRow());
142
+ markDirty();
143
+ };
144
+
145
+ // Presets
146
+ document.querySelectorAll('.btn-preset').forEach(btn => {
147
+ btn.onclick = () => {
148
+ const listId = btn.dataset.type === 'request' ? 'req-header-list' : 'res-header-list';
149
+ const list = document.getElementById(listId);
150
+ list.appendChild(createHeaderRow(btn.dataset.header, btn.dataset.value));
151
+ markDirty();
152
+ };
153
+ });
154
+
155
+ document.getElementById('reset-headers').onclick = () => {
156
+ if (confirm('Reset all overrides to file defaults?')) {
157
+ loadHeaderState({}); // We would fetch defaults if we had them saved, but for now we clear
158
+ markDirty();
159
+ }
160
+ };
161
+
162
+ // Save State
163
+ applyBtn.onclick = async () => {
164
+ const state = { request: {}, response: {} };
165
+ let hasValidationError = false;
166
+
167
+ const collect = (listId, target) => {
168
+ document.querySelectorAll(`#${listId} .header-row`).forEach(row => {
169
+ const keyInput = row.querySelector('.hdr-key');
170
+ const valInput = row.querySelector('.hdr-val');
171
+ const k = keyInput.value.trim();
172
+ const v = valInput.value.trim();
173
+ const isSuppressed = row.classList.contains('suppressed');
174
+
175
+ if (!k) {
176
+ // Any dangling row must have a name, or it's a validation error
177
+ keyInput.classList.add('input-error');
178
+ hasValidationError = true;
179
+ return;
180
+ }
181
+
182
+ keyInput.classList.remove('input-error');
183
+ target[k] = isSuppressed ? null : v;
184
+ });
185
+ };
186
+
187
+ collect('req-header-list', state.request);
188
+ collect('res-header-list', state.response);
189
+
190
+ if (hasValidationError) {
191
+ applyBtn.innerText = '🛑 Fix Header Names';
192
+ applyBtn.classList.add('error-pulse');
193
+ setTimeout(() => {
194
+ applyBtn.innerText = isDirty ? 'Save Changes' : 'Applied';
195
+ applyBtn.classList.remove('error-pulse');
196
+ }, 3000);
197
+ return;
198
+ }
199
+
200
+ try {
201
+ const res = await fetch('/headers', {
202
+ method: 'POST',
203
+ headers: { 'Content-Type': 'application/json' },
204
+ body: JSON.stringify(state)
205
+ });
206
+ if (res.ok) {
207
+ markClean();
208
+ applyBtn.innerText = '✅ Applied';
209
+ applyBtn.classList.add('success');
210
+ setTimeout(() => {
211
+ applyBtn.innerText = 'Applied';
212
+ applyBtn.classList.remove('success');
213
+ }, 2000);
214
+ }
215
+ } catch (err) {
216
+ alert('🛑 Failed to save headers.');
217
+ }
218
+ };
219
+
220
+ function loadHeaderState(state) {
221
+ const reqList = document.getElementById('req-header-list');
222
+ const resList = document.getElementById('res-header-list');
223
+ reqList.innerHTML = '';
224
+ resList.innerHTML = '';
225
+
226
+ Object.entries(state.request || {}).forEach(([k, v]) => reqList.appendChild(createHeaderRow(k, v, v === null)));
227
+ Object.entries(state.response || {}).forEach(([k, v]) => resList.appendChild(createHeaderRow(k, v, v === null)));
228
+ }
229
+
230
+ function addRequestToFeed(req) {
231
+ if (document.querySelector('.empty-state')) {
232
+ requestFeed.innerHTML = '';
233
+ }
234
+
235
+ const card = document.createElement('div');
236
+ card.className = 'request-card';
237
+ card.id = `req-${req.id}`;
238
+ if (req.violation) card.classList.add('has-violation');
239
+ if (req.steps && req.steps.length > 1) card.classList.add('is-rewrite');
240
+
241
+ const statusClass = req.status >= 500 ? 'status-error' : (req.status >= 400 ? 'status-warn' : 'status-ok');
242
+
243
+ card.innerHTML = `
244
+ <div class="card-header">
245
+ <div style="display: flex; align-items: center; gap: 0.75rem">
246
+ <span class="card-id">#${req.id.slice(0, 8)}</span>
247
+ <span class="card-method">${req.method}</span>
248
+ <span class="card-uri">${req.path}</span>
249
+ </div>
250
+ <div style="display: flex; align-items: center; gap: 1rem">
251
+ <span class="card-status ${statusClass}">${req.status}</span>
252
+ <span class="expand-icon">expand_more</span>
253
+ </div>
254
+ </div>
255
+ <div class="card-body-mini">
256
+ <div class="rewrite-path">${req.steps.length > 1 ? `↳ ${req.steps[req.steps.length - 1].uri}` : ''}</div>
257
+ <div class="performance-mini">${req.cpu.toFixed(2)}ms</div>
258
+ </div>
259
+ <div class="card-details" style="display: none;">
260
+ <div class="loading-spinner">Loading deep inspection data...</div>
261
+ </div>
262
+ `;
263
+
264
+ card.querySelector('.expand-icon').onclick = (e) => {
265
+ e.stopPropagation();
266
+ toggleRequestDetails(req.id, card);
267
+ };
268
+ requestFeed.prepend(card);
269
+
270
+ if (requestFeed.children.length > 50) {
271
+ requestFeed.removeChild(requestFeed.lastChild);
272
+ }
273
+ }
274
+
275
+ async function toggleRequestDetails(id, card) {
276
+ const detailsEl = card.querySelector('.card-details');
277
+ const iconEl = card.querySelector('.expand-icon');
278
+ const isExpanded = detailsEl.style.display === 'block';
279
+
280
+ if (isExpanded) {
281
+ detailsEl.style.display = 'none';
282
+ card.classList.remove('expanded');
283
+ iconEl.innerText = 'expand_more';
284
+ return;
285
+ }
286
+
287
+ detailsEl.style.display = 'block';
288
+ card.classList.add('expanded');
289
+ iconEl.innerText = 'expand_less';
290
+
291
+ if (detailsEl.innerHTML.includes('Loading deep inspection data...')) {
292
+ try {
293
+ const res = await fetch(`/request/${id}`);
294
+ const data = await res.json();
295
+ renderDetails(detailsEl, data);
296
+ } catch (err) {
297
+ detailsEl.innerHTML = `<div class="error">Failed to load details: ${err.message}</div>`;
298
+ }
299
+ }
300
+ }
301
+
302
+ function renderDetails(el, data) {
303
+ el.innerHTML = `
304
+ <div class="detail-nav">
305
+ <div class="nav-item active" data-pane="trace">Trace</div>
306
+ <div class="nav-item" data-pane="headers">Headers</div>
307
+ <div class="nav-item" data-pane="body">Body</div>
308
+ </div>
309
+
310
+ <div class="detail-pane active" id="pane-trace-${data.id}">
311
+ <div class="detail-grid">
312
+ <div class="detail-section">
313
+ <h4>Pipeline Evolution</h4>
314
+ <div class="shadow-rewrite">
315
+ ${data.steps.map(s => `
316
+ <div class="step">
317
+ <span>${s.uri}</span>
318
+ <span class="step-label">${s.label}</span>
319
+ </div>
320
+ `).join('')}
321
+ </div>
322
+ ${data.violation ? `
323
+ <div class="fidelity-alert">
324
+ <span>🛑</span>
325
+ <div>
326
+ <strong>Fidelity Violation:</strong> ${data.violation}
327
+ </div>
328
+ </div>
329
+ ` : ''}
330
+ </div>
331
+ </div>
332
+ </div>
333
+
334
+ <div class="detail-pane" id="pane-headers-${data.id}">
335
+ <div class="header-lifecycle">
336
+ <div class="lifecycle-col">
337
+ <h5>Request lifecycle (Viewer → Origin)</h5>
338
+ <div class="header-diff">
339
+ ${renderHeaderDiff(data.headers.request.viewer, data.headers.request.origin)}
340
+ </div>
341
+ </div>
342
+ <div class="lifecycle-col">
343
+ <h5>Response lifecycle (Origin → Viewer)</h5>
344
+ <div class="header-diff">
345
+ ${renderHeaderDiff(data.headers.response.origin, data.headers.response.viewer)}
346
+ </div>
347
+ </div>
348
+ </div>
349
+ </div>
350
+
351
+ <div class="detail-pane" id="pane-body-${data.id}">
352
+ <div class="detail-section">
353
+ <h4>Body Snippet (First 1KB)</h4>
354
+ <pre class="body-snippet">${data.bodySnippet || '(No body content buffered)'}</pre>
355
+ </div>
356
+ </div>
357
+ `;
358
+
359
+ // Wire up internal tabs
360
+ el.querySelectorAll('.nav-item').forEach(nav => {
361
+ nav.onclick = (e) => {
362
+ e.stopPropagation();
363
+ el.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
364
+ el.querySelectorAll('.detail-pane').forEach(p => p.classList.remove('active'));
365
+
366
+ nav.classList.add('active');
367
+ el.querySelector(`#pane-${nav.dataset.pane}-${data.id}`).classList.add('active');
368
+ };
369
+ });
370
+ }
371
+
372
+ function renderHeaderDiff(incoming, final) {
373
+ const allKeys = new Set([...Object.keys(incoming), ...Object.keys(final)]);
374
+ let html = '<table class="diff-table">';
375
+
376
+ [...allKeys].sort().forEach(key => {
377
+ const val1 = incoming[key];
378
+ const val2 = final[key];
379
+
380
+ let rowClass = '';
381
+ if (val1 === undefined) rowClass = 'h-added';
382
+ else if (val2 === undefined) rowClass = 'h-removed';
383
+ else if (JSON.stringify(val1) !== JSON.stringify(val2)) rowClass = 'h-modified';
384
+
385
+ html += `
386
+ <tr class="${rowClass}">
387
+ <td class="h-key">${key}</td>
388
+ <td class="h-val">${val2 !== undefined ? val2 : '<span class="deleted">REMOVED</span>'}</td>
389
+ </tr>
390
+ `;
391
+ });
392
+
393
+ return html + '</table>';
394
+ }
395
+
396
+ document.getElementById('clear-feed').onclick = () => {
397
+ requestFeed.innerHTML = '<div class="empty-state"><p>Waiting for requests...</p></div>';
398
+ };
399
+ });
Binary file
@@ -0,0 +1,11 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <browserconfig>
3
+ <msapplication>
4
+ <tile>
5
+ <square70x70logo src="/favicons/ms-icon-70x70.png"/>
6
+ <square150x150logo src="/favicons/ms-icon-150x150.png"/>
7
+ <square310x310logo src="/favicons/ms-icon-310x310.png"/>
8
+ <TileColor>#ffffff</TileColor>
9
+ </tile>
10
+ </msapplication>
11
+ </browserconfig>
Binary file
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "App",
3
+ "icons": [
4
+ {
5
+ "src": "/favicons/android-icon-36x36.png",
6
+ "sizes": "36x36",
7
+ "type": "image/png",
8
+ "density": "0.75"
9
+ },
10
+ {
11
+ "src": "/favicons/android-icon-48x48.png",
12
+ "sizes": "48x48",
13
+ "type": "image/png",
14
+ "density": "1.0"
15
+ },
16
+ {
17
+ "src": "/favicons/android-icon-72x72.png",
18
+ "sizes": "72x72",
19
+ "type": "image/png",
20
+ "density": "1.5"
21
+ },
22
+ {
23
+ "src": "/favicons/android-icon-96x96.png",
24
+ "sizes": "96x96",
25
+ "type": "image/png",
26
+ "density": "2.0"
27
+ },
28
+ {
29
+ "src": "/favicons/android-icon-144x144.png",
30
+ "sizes": "144x144",
31
+ "type": "image/png",
32
+ "density": "3.0"
33
+ },
34
+ {
35
+ "src": "/favicons/android-icon-192x192.png",
36
+ "sizes": "192x192",
37
+ "type": "image/png",
38
+ "density": "4.0"
39
+ }
40
+ ]
41
+ }
@@ -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>CloudFrontize Developer Dashboard</title>
7
+
8
+ <link rel="icon" type="image/png" sizes="32x32" href="/favicons/favicon-32x32.png">
9
+ <link rel="icon" type="image/png" sizes="16x16" href="/favicons/favicon-16x16.png">
10
+ <link rel="shortcut icon" href="/favicons/favicon.ico">
11
+
12
+ <link rel="apple-touch-icon" sizes="180x180" href="/favicons/apple-icon-180x180.png">
13
+
14
+ <link rel="manifest" href="/favicons/manifest.json">
15
+ <meta name="theme-color" content="#1a1a1a"> <meta name="msapplication-config" content="/favicons/browserconfig.xml">
16
+ <meta name="msapplication-TileColor" content="#1a1a1a">
17
+
18
+ <link rel="stylesheet" href="style.css">
19
+ <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
20
+ </head>
21
+ <body class="dark-mode">
22
+ <header>
23
+ <div class="logo">
24
+ <span class="cloud">☁️</span>
25
+ <h1>CloudFrontize <span class="badge">PRO</span></h1>
26
+ </div>
27
+ <div class="status-bar">
28
+ <div class="status-item">
29
+ <span class="label">Server:</span>
30
+ <span class="value success">Running</span>
31
+ </div>
32
+ <div class="status-item">
33
+ <span class="label">Port:</span>
34
+ <span id="current-port" class="value">3000</span>
35
+ </div>
36
+ </div>
37
+ </header>
38
+
39
+ <main>
40
+ <aside class="sidebar">
41
+ <section class="controls">
42
+ <h2>Header Intelligence</h2>
43
+ <div class="control-group">
44
+ <h3>Simulation Presets</h3>
45
+ <div class="preset-grid">
46
+ <button class="btn-preset" data-type="request" data-header="CloudFront-Is-Mobile-Viewer" data-value="true">Mobile</button>
47
+ <button class="btn-preset" data-type="request" data-header="CloudFront-Viewer-Country" data-value="US">USA (US)</button>
48
+ <button class="btn-preset" data-type="request" data-header="CloudFront-Viewer-Country" data-value="ES">Spain (ES)</button>
49
+ <button class="btn-preset" data-type="response" data-header="X-Cache-Status" data-value="MISS">Cache MISS</button>
50
+ </div>
51
+ </div>
52
+
53
+ <div class="control-group">
54
+ <div class="tabs">
55
+ <button class="tab-btn active" data-tab="request-overrides">Viewer Request</button>
56
+ <button class="tab-btn" data-tab="response-overrides">Origin Response</button>
57
+ </div>
58
+
59
+ <div id="request-overrides" class="tab-content active">
60
+ <div class="header-list" id="req-header-list">
61
+ <!-- Dynamic Header Rows -->
62
+ </div>
63
+ <button class="btn-add" id="add-req-header">+ Add Header</button>
64
+ </div>
65
+
66
+ <div id="response-overrides" class="tab-content">
67
+ <div class="header-list" id="res-header-list">
68
+ <!-- Dynamic Header Rows -->
69
+ </div>
70
+ <button class="btn-add" id="add-res-header">+ Add Header</button>
71
+ </div>
72
+ </div>
73
+
74
+ <div class="actions">
75
+ <button class="btn-primary" id="apply-headers">Apply Changes</button>
76
+ <button class="btn-secondary" id="reset-headers">Reset to File Defaults</button>
77
+ </div>
78
+ </section>
79
+ </aside>
80
+
81
+ <section class="content">
82
+ <div class="feed-header">
83
+ <h2>Request Pulse</h2>
84
+ <div class="filters">
85
+ <button class="btn-filter active">All</button>
86
+ <button class="btn-filter" data-type="rewrite">Rewrites</button>
87
+ <button class="btn-filter" data-type="violation">Violations</button>
88
+ </div>
89
+ <button class="btn-clear" id="clear-feed">Clear</button>
90
+ </div>
91
+ <div class="request-feed" id="request-feed">
92
+ <!-- Real-time Request Cards -->
93
+ <div class="empty-state">
94
+ <p>Waiting for requests...</p>
95
+ </div>
96
+ </div>
97
+ </section>
98
+ </main>
99
+
100
+ <script src="app.js"></script>
101
+ </body>
102
+ </html>