Tools for the Atmosphere
tools.slices.network
quickslice
atproto
html
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 <meta
7 http-equiv="Content-Security-Policy"
8 content="default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com; style-src 'self' 'unsafe-inline';"
9 />
10 <title>{ Lexicon Validator }</title>
11 <style>
12 /* CSS Reset */
13 *,
14 *::before,
15 *::after {
16 box-sizing: border-box;
17 }
18 * {
19 margin: 0;
20 }
21 body {
22 line-height: 1.5;
23 -webkit-font-smoothing: antialiased;
24 }
25 input,
26 button,
27 textarea {
28 font: inherit;
29 }
30
31 /* Light Theme */
32 :root {
33 --bg-primary: #f5f5f5;
34 --bg-card: #ffffff;
35 --bg-input: #fafafa;
36 --text-primary: #1a1a1a;
37 --text-secondary: #666666;
38 --accent: #0066cc;
39 --accent-hover: #0052a3;
40 --border: #e0e0e0;
41 --border-focus: #0066cc;
42 --error-bg: #fef2f2;
43 --error-border: #fca5a5;
44 --error-text: #dc2626;
45 --success-bg: #f0fdf4;
46 --success-border: #86efac;
47 --success-text: #16a34a;
48 }
49
50 body {
51 font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
52 background: var(--bg-primary);
53 color: var(--text-primary);
54 min-height: 100vh;
55 padding: 2rem 1rem;
56 }
57
58 #app {
59 max-width: 700px;
60 margin: 0 auto;
61 }
62
63 header {
64 text-align: center;
65 margin-bottom: 2rem;
66 }
67
68 header h1 {
69 font-size: 2rem;
70 color: var(--text-primary);
71 margin-bottom: 0.25rem;
72 }
73
74 .tagline {
75 color: var(--text-secondary);
76 font-size: 0.875rem;
77 }
78
79 .tagline a {
80 color: var(--accent);
81 text-decoration: none;
82 }
83
84 .tagline a:hover {
85 text-decoration: underline;
86 }
87
88 /* Sections */
89 .section {
90 background: var(--bg-card);
91 border-radius: 0.5rem;
92 padding: 1rem;
93 margin-bottom: 1rem;
94 border: 1px solid var(--border);
95 box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
96 }
97
98 .section-header {
99 display: flex;
100 justify-content: space-between;
101 align-items: center;
102 margin-bottom: 0.75rem;
103 }
104
105 .section-title {
106 font-weight: 600;
107 font-size: 0.875rem;
108 text-transform: uppercase;
109 letter-spacing: 0.05em;
110 color: var(--text-secondary);
111 }
112
113 /* Buttons */
114 .btn {
115 padding: 0.5rem 1rem;
116 border: none;
117 border-radius: 0.375rem;
118 font-size: 0.875rem;
119 font-weight: 500;
120 cursor: pointer;
121 transition:
122 background-color 0.15s,
123 opacity 0.15s;
124 }
125
126 .btn-primary {
127 background: var(--accent);
128 color: #ffffff;
129 }
130
131 .btn-primary:hover {
132 background: var(--accent-hover);
133 }
134
135 .btn-secondary {
136 background: var(--bg-card);
137 color: var(--text-primary);
138 border: 1px solid var(--border);
139 }
140
141 .btn-secondary:hover {
142 background: var(--bg-primary);
143 }
144
145 .btn-small {
146 padding: 0.25rem 0.5rem;
147 font-size: 0.75rem;
148 }
149
150 .btn-danger {
151 background: var(--error-bg);
152 color: var(--error-text);
153 border: 1px solid var(--error-border);
154 }
155
156 .btn-danger:hover {
157 background: #fee2e2;
158 }
159
160 .btn:disabled {
161 opacity: 0.5;
162 cursor: not-allowed;
163 }
164
165 /* Editors */
166 .editor-card {
167 background: var(--bg-input);
168 border: 1px solid var(--border);
169 border-radius: 0.375rem;
170 margin-bottom: 0.75rem;
171 }
172
173 .editor-header {
174 display: flex;
175 justify-content: space-between;
176 align-items: center;
177 padding: 0.5rem 0.75rem;
178 border-bottom: 1px solid var(--border);
179 background: var(--bg-card);
180 border-radius: 0.375rem 0.375rem 0 0;
181 }
182
183 .editor-label {
184 font-size: 0.75rem;
185 color: var(--text-secondary);
186 font-weight: 500;
187 }
188
189 textarea {
190 width: 100%;
191 min-height: 200px;
192 padding: 0.75rem;
193 background: var(--bg-input);
194 border: none;
195 color: var(--text-primary);
196 font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
197 font-size: 0.8125rem;
198 resize: vertical;
199 border-radius: 0 0 0.375rem 0.375rem;
200 }
201
202 textarea:focus {
203 outline: none;
204 background: #ffffff;
205 }
206
207 textarea::placeholder {
208 color: var(--text-secondary);
209 opacity: 0.6;
210 }
211
212 /* NSID Input */
213 .nsid-row {
214 display: flex;
215 align-items: center;
216 gap: 0.75rem;
217 margin-bottom: 0.75rem;
218 }
219
220 .nsid-label {
221 font-size: 0.875rem;
222 color: var(--text-secondary);
223 white-space: nowrap;
224 }
225
226 input[type="text"],
227 select {
228 flex: 1;
229 padding: 0.5rem 0.75rem;
230 background: var(--bg-input);
231 border: 1px solid var(--border);
232 border-radius: 0.375rem;
233 color: var(--text-primary);
234 font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
235 font-size: 0.8125rem;
236 }
237
238 input[type="text"]:focus,
239 select:focus {
240 outline: none;
241 border-color: var(--border-focus);
242 background: #ffffff;
243 }
244
245 input[type="text"]::placeholder {
246 color: var(--text-secondary);
247 opacity: 0.6;
248 }
249
250 select:disabled {
251 opacity: 0.6;
252 cursor: not-allowed;
253 }
254
255 /* Results */
256 .result {
257 padding: 1rem;
258 border-radius: 0.375rem;
259 font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
260 font-size: 0.8125rem;
261 white-space: pre-wrap;
262 word-break: break-word;
263 }
264
265 .result-success {
266 background: var(--success-bg);
267 border: 1px solid var(--success-border);
268 color: var(--success-text);
269 }
270
271 .result-error {
272 background: var(--error-bg);
273 border: 1px solid var(--error-border);
274 color: var(--error-text);
275 }
276
277 .result:empty {
278 display: none;
279 }
280
281 /* Button row */
282 .button-row {
283 display: flex;
284 gap: 0.5rem;
285 margin-top: 0.75rem;
286 }
287
288 .hidden {
289 display: none !important;
290 }
291
292 /* Drop Zone */
293 .drop-zone {
294 border: 2px dashed var(--border);
295 border-radius: 0.5rem;
296 padding: 2rem;
297 text-align: center;
298 margin-bottom: 1rem;
299 transition: all 0.2s ease;
300 cursor: pointer;
301 }
302
303 .drop-zone:hover {
304 border-color: var(--accent);
305 background: var(--bg-input);
306 }
307
308 .drop-zone.drag-over {
309 border-color: var(--accent);
310 background: rgba(0, 102, 204, 0.05);
311 }
312
313 .drop-zone-icon {
314 font-size: 2rem;
315 margin-bottom: 0.5rem;
316 }
317
318 .drop-zone-text {
319 color: var(--text-secondary);
320 font-size: 0.875rem;
321 }
322
323 .drop-zone-text strong {
324 color: var(--accent);
325 }
326
327 .drop-zone-hint {
328 color: var(--text-secondary);
329 font-size: 0.75rem;
330 margin-top: 0.25rem;
331 opacity: 0.8;
332 }
333
334 .loading-spinner {
335 display: inline-block;
336 width: 1rem;
337 height: 1rem;
338 border: 2px solid var(--border);
339 border-top-color: var(--accent);
340 border-radius: 50%;
341 animation: spin 0.8s linear infinite;
342 margin-right: 0.5rem;
343 vertical-align: middle;
344 }
345
346 @keyframes spin {
347 to {
348 transform: rotate(360deg);
349 }
350 }
351
352 /* Collapsible editors */
353 .editor-card.collapsed textarea {
354 display: none;
355 }
356
357 .collapse-toggle {
358 background: none;
359 border: none;
360 cursor: pointer;
361 padding: 0.25rem;
362 color: var(--text-secondary);
363 font-size: 0.75rem;
364 display: flex;
365 align-items: center;
366 gap: 0.25rem;
367 }
368
369 .collapse-toggle:hover {
370 color: var(--text-primary);
371 }
372
373 .collapse-all-row {
374 display: flex;
375 justify-content: flex-end;
376 margin-bottom: 0.5rem;
377 gap: 0.5rem;
378 }
379 </style>
380 </head>
381 <body>
382 <div id="app">
383 <header>
384 <h1>
385 <span style="color: var(--accent)">{</span> Lexicon Validator
386 <span style="color: var(--accent)">}</span>
387 </h1>
388 <p class="tagline">
389 Powered by <a href="https://hexdocs.pm/honk/index.html" target="_blank">honk</a>
390 </p>
391 </header>
392 <main>
393 <div id="lexicons-section" class="section"></div>
394 <div id="record-section" class="section"></div>
395 <div id="results-section"></div>
396 </main>
397 </div>
398 <script src="https://cdn.jsdelivr.net/gh/bigmoves/honk@deaa420/dist/honk.min.js"></script>
399 <script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
400 <script>
401 // =============================================================================
402 // STATE
403 // =============================================================================
404
405 const state = {
406 lexicons: [{ id: 1, value: "", collapsed: false }],
407 nextLexiconId: 2,
408 record: "",
409 nsid: "",
410 availableNsids: [],
411 result: null,
412 };
413
414 // =============================================================================
415 // PLACEHOLDERS
416 // =============================================================================
417
418 const LEXICON_PLACEHOLDER = `{
419 "lexicon": 1,
420 "id": "com.example.myRecord",
421 "defs": {
422 "main": {
423 "type": "record",
424 "record": {
425 "type": "object",
426 "required": ["status", "createdAt"],
427 "properties": {
428 "status": {
429 "type": "string",
430 "maxLength": 100
431 },
432 "createdAt": {
433 "type": "string",
434 "format": "datetime"
435 }
436 }
437 }
438 }
439 }
440}`;
441
442 const RECORD_PLACEHOLDER = `{
443 "status": "Hello world",
444 "createdAt": "2025-01-15T12:00:00Z"
445}`;
446
447 // =============================================================================
448 // HELPERS
449 // =============================================================================
450
451 function esc(str) {
452 const d = document.createElement("div");
453 d.textContent = str || "";
454 return d.innerHTML;
455 }
456
457 function escapeAttr(str) {
458 return (str || "")
459 .replace(/&/g, "&")
460 .replace(/"/g, """)
461 .replace(/</g, "<")
462 .replace(/>/g, ">");
463 }
464
465 function unwrapHonkResult(result) {
466 if (typeof result.isOk === "function") {
467 return { ok: result.isOk(), value: result[0] };
468 }
469 return { ok: true, value: result };
470 }
471
472 function formatHonkError(err) {
473 if (!err) return "Unknown error";
474 if (typeof err === "string") return err;
475 if (err.message) {
476 return err.path ? `${err.message} (at ${err.path})` : err.message;
477 }
478
479 // Handle Gleam Dict/Map structure (HAMT)
480 // Structure: { root: { array: [{ k: nsid, v: { head: errorMsg, tail: {} } }] }, size: n }
481 if (err.root && err.root.array && Array.isArray(err.root.array)) {
482 const errors = [];
483 for (const entry of err.root.array) {
484 if (entry.v) {
485 // Extract errors from linked list structure (head/tail)
486 let current = entry.v;
487 while (current && current.head) {
488 errors.push(current.head);
489 current = current.tail;
490 }
491 }
492 }
493 if (errors.length > 0) {
494 return errors.join("\n");
495 }
496 }
497
498 // Handle simple linked list structure (head/tail without root)
499 if (err.head) {
500 const errors = [];
501 let current = err;
502 while (current && current.head) {
503 errors.push(current.head);
504 current = current.tail;
505 }
506 if (errors.length > 0) {
507 return errors.join("\n");
508 }
509 }
510
511 return JSON.stringify(err, null, 2);
512 }
513
514 // =============================================================================
515 // RENDERING
516 // =============================================================================
517
518 function renderLexiconsSection() {
519 const section = document.getElementById("lexicons-section");
520
521 const getLexiconLabel = (lex, idx) => {
522 try {
523 const obj = JSON.parse(lex.value);
524 if (obj.id) return obj.id;
525 } catch (e) {}
526 return `Lexicon ${idx + 1}`;
527 };
528
529 const editorsHtml = state.lexicons
530 .map(
531 (lex, idx) => `
532 <div class="editor-card ${lex.collapsed ? "collapsed" : ""}" data-lexicon-id="${lex.id}">
533 <div class="editor-header">
534 <div style="display: flex; align-items: center; gap: 0.5rem;">
535 <button class="collapse-toggle" onclick="toggleLexicon(${lex.id})">
536 ${lex.collapsed ? "▶" : "▼"}
537 </button>
538 <span class="editor-label">${esc(getLexiconLabel(lex, idx))}</span>
539 </div>
540 <button
541 class="btn btn-danger btn-small"
542 onclick="removeLexicon(${lex.id})"
543 ${state.lexicons.length === 1 ? "disabled" : ""}
544 >Remove</button>
545 </div>
546 <textarea
547 placeholder="${escapeAttr(LEXICON_PLACEHOLDER)}"
548 onchange="updateLexicon(${lex.id}, this.value)"
549 oninput="updateLexicon(${lex.id}, this.value)"
550 >${esc(lex.value)}</textarea>
551 </div>
552 `,
553 )
554 .join("");
555
556 const hasMultiple = state.lexicons.length > 1;
557 const allCollapsed = state.lexicons.every((l) => l.collapsed);
558 const collapseAllRow = hasMultiple
559 ? `<div class="collapse-all-row">
560 <button class="btn btn-secondary btn-small" onclick="toggleAllLexicons(${allCollapsed ? "false" : "true"})">
561 ${allCollapsed ? "Expand All" : "Collapse All"}
562 </button>
563 </div>`
564 : "";
565
566 section.innerHTML = `
567 <div class="section-header">
568 <span class="section-title">Lexicons</span>
569 </div>
570 <div
571 class="drop-zone"
572 id="drop-zone"
573 ondragover="handleDragOver(event)"
574 ondragleave="handleDragLeave(event)"
575 ondrop="handleDrop(event)"
576 onclick="triggerFileInput()"
577 >
578 <div class="drop-zone-icon">📦</div>
579 <div class="drop-zone-text">
580 <strong>Drop a .zip file</strong> or click to browse
581 </div>
582 <div class="drop-zone-hint">Supports nested folders with .json lexicon files</div>
583 </div>
584 <input type="file" id="file-input" accept=".zip" onchange="handleFileSelect(event)" style="display: none;" />
585 ${collapseAllRow}
586 ${editorsHtml}
587 <div class="button-row">
588 <button class="btn btn-secondary" onclick="addLexicon()">+ Add Lexicon</button>
589 <button class="btn btn-primary" onclick="validateLexicons()">Validate Lexicons</button>
590 </div>
591 `;
592 }
593
594 function renderRecordSection() {
595 const section = document.getElementById("record-section");
596
597 const optionsHtml =
598 state.availableNsids.length === 0
599 ? '<option value="" disabled selected>Enter a lexicon with a record type</option>'
600 : [
601 `<option value="" disabled ${!state.nsid ? "selected" : ""}>Select a record type</option>`,
602 ]
603 .concat(
604 state.availableNsids.map(
605 (nsid) =>
606 `<option value="${escapeAttr(nsid)}" ${state.nsid === nsid ? "selected" : ""}>${esc(nsid)}</option>`,
607 ),
608 )
609 .join("");
610
611 section.innerHTML = `
612 <div class="section-header">
613 <span class="section-title">Record</span>
614 </div>
615 <div class="nsid-row">
616 <span class="nsid-label">NSID:</span>
617 <select
618 id="nsid-select"
619 onchange="updateNsid(this.value)"
620 ${state.availableNsids.length === 0 ? "disabled" : ""}
621 >${optionsHtml}</select>
622 </div>
623 <div class="editor-card">
624 <div class="editor-header">
625 <span class="editor-label">Record Data</span>
626 <button
627 class="btn btn-secondary btn-small"
628 onclick="resetRecordTemplate()"
629 ${!state.nsid ? "disabled" : ""}
630 >Reset</button>
631 </div>
632 <textarea
633 id="record-input"
634 placeholder="${escapeAttr(RECORD_PLACEHOLDER)}"
635 onchange="updateRecord(this.value)"
636 oninput="updateRecord(this.value)"
637 >${esc(state.record)}</textarea>
638 </div>
639 <div class="button-row">
640 <button class="btn btn-primary" onclick="validateRecord()">Validate Record</button>
641 </div>
642 `;
643 }
644
645 function renderResult() {
646 const section = document.getElementById("results-section");
647
648 if (!state.result) {
649 section.innerHTML = "";
650 return;
651 }
652
653 const cls = state.result.success ? "result-success" : "result-error";
654 const icon = state.result.success ? "✓" : "✗";
655
656 section.innerHTML = `
657 <div class="result ${cls}">${icon} ${esc(state.result.message)}</div>
658 `;
659 }
660
661 // =============================================================================
662 // STATE UPDATES
663 // =============================================================================
664
665 function addLexicon() {
666 state.lexicons.push({ id: state.nextLexiconId++, value: "", collapsed: false });
667 renderLexiconsSection();
668 }
669
670 function toggleLexicon(id) {
671 const lex = state.lexicons.find((l) => l.id === id);
672 if (lex) {
673 lex.collapsed = !lex.collapsed;
674 renderLexiconsSection();
675 }
676 }
677
678 function toggleAllLexicons(collapse) {
679 state.lexicons.forEach((lex) => {
680 lex.collapsed = collapse;
681 });
682 renderLexiconsSection();
683 }
684
685 function removeLexicon(id) {
686 if (state.lexicons.length <= 1) return;
687 state.lexicons = state.lexicons.filter((l) => l.id !== id);
688 renderLexiconsSection();
689 updateAvailableNsids();
690 }
691
692 function updateLexicon(id, value) {
693 const lex = state.lexicons.find((l) => l.id === id);
694 if (lex) lex.value = value;
695 updateAvailableNsids();
696 }
697
698 function updateAvailableNsids() {
699 const nsids = [];
700 for (const lex of state.lexicons) {
701 const trimmed = lex.value.trim();
702 if (!trimmed) continue;
703 try {
704 const obj = JSON.parse(trimmed);
705 if (obj.id && obj.defs?.main?.type === "record") {
706 nsids.push(obj.id);
707 }
708 } catch (e) {
709 // Invalid JSON, skip
710 }
711 }
712 state.availableNsids = nsids;
713 // Clear selected NSID if it's no longer available
714 if (state.nsid && !nsids.includes(state.nsid)) {
715 state.nsid = "";
716 state.record = "";
717 }
718 renderRecordSection();
719 }
720
721 function updateRecord(value) {
722 state.record = value;
723 }
724
725 function resetRecordTemplate() {
726 if (state.nsid) {
727 const template = generateRecordTemplate(state.nsid);
728 if (template) {
729 state.record = template;
730 renderRecordSection();
731 }
732 }
733 }
734
735 function updateNsid(value) {
736 state.nsid = value;
737 if (value) {
738 const template = generateRecordTemplate(value);
739 if (template) {
740 state.record = template;
741 }
742 }
743 renderRecordSection();
744 }
745
746 function generateRecordTemplate(nsid) {
747 for (const lex of state.lexicons) {
748 const trimmed = lex.value.trim();
749 if (!trimmed) continue;
750 try {
751 const obj = JSON.parse(trimmed);
752 if (obj.id === nsid && obj.defs?.main?.type === "record") {
753 const record = obj.defs.main.record;
754 if (record?.type === "object" && record.properties) {
755 const template = {};
756 const required = record.required || [];
757 for (const field of required) {
758 const prop = record.properties[field];
759 if (prop) {
760 template[field] = getDefaultValue(prop);
761 }
762 }
763 return JSON.stringify(template, null, 2);
764 }
765 }
766 } catch (e) {
767 // Invalid JSON, skip
768 }
769 }
770 return null;
771 }
772
773 function getDefaultValue(prop) {
774 switch (prop.type) {
775 case "string":
776 if (prop.format === "datetime") return new Date().toISOString();
777 if (prop.format === "uri") return "https://example.com";
778 if (prop.format === "at-uri") return "at://did:plc:example/app.bsky.feed.post/abc123";
779 if (prop.format === "did") return "did:plc:example";
780 if (prop.format === "handle") return "user.example.com";
781 if (prop.format === "cid") return "bafyreib...";
782 if (prop.const) return prop.const;
783 if (prop.enum) return prop.enum[0];
784 return "";
785 case "integer":
786 return prop.minimum ?? prop.default ?? 0;
787 case "boolean":
788 return prop.default ?? false;
789 case "array":
790 return [];
791 case "object":
792 return {};
793 case "blob":
794 return { $type: "blob", ref: { $link: "" }, mimeType: "", size: 0 };
795 default:
796 return null;
797 }
798 }
799
800 // =============================================================================
801 // VALIDATION
802 // =============================================================================
803
804 function parseLexicons() {
805 const parsed = [];
806 for (let i = 0; i < state.lexicons.length; i++) {
807 const lex = state.lexicons[i];
808 const trimmed = lex.value.trim();
809
810 if (!trimmed) {
811 return {
812 error: `Lexicon ${i + 1}: Empty - please enter a lexicon schema`,
813 };
814 }
815
816 // Validate JSON syntax first
817 try {
818 const obj = JSON.parse(trimmed);
819 if (Array.isArray(obj)) {
820 return {
821 error: `Lexicon ${i + 1}: Expected a single lexicon object, not an array. Use "+ Add Lexicon" for multiple.`,
822 };
823 }
824 } catch (e) {
825 return { error: `Lexicon ${i + 1}: Invalid JSON - ${e.message}` };
826 }
827
828 // Parse to Gleam Json type
829 const parseResult = honk.parse_json_string(trimmed);
830 const unwrapped = unwrapHonkResult(parseResult);
831 if (!unwrapped.ok) {
832 return { error: `Lexicon ${i + 1}: ${formatHonkError(unwrapped.value)}` };
833 }
834 parsed.push(unwrapped.value);
835 }
836 return { lexicons: parsed };
837 }
838
839 function validateLexicons() {
840 state.result = null;
841
842 const parseResult = parseLexicons();
843 if (parseResult.error) {
844 state.result = { success: false, message: parseResult.error };
845 renderResult();
846 return;
847 }
848
849 try {
850 // Convert JS array to Gleam List
851 const lexiconList = honk.toList(parseResult.lexicons);
852 const result = honk.validate(lexiconList);
853 const unwrapped = unwrapHonkResult(result);
854
855 if (unwrapped.ok) {
856 const count = parseResult.lexicons.length;
857 const noun = count === 1 ? "lexicon" : "lexicons";
858 state.result = { success: true, message: `${count} ${noun} valid` };
859 } else {
860 state.result = {
861 success: false,
862 message: `Validation failed: ${formatHonkError(unwrapped.value)}`,
863 };
864 }
865 } catch (e) {
866 state.result = {
867 success: false,
868 message: `Validation error: ${e.message}`,
869 };
870 }
871
872 renderResult();
873 }
874
875 function validateRecord() {
876 state.result = null;
877
878 const nsid = state.nsid.trim();
879 if (!nsid) {
880 state.result = {
881 success: false,
882 message: "NSID is required for record validation",
883 };
884 renderResult();
885 return;
886 }
887
888 if (!honk.is_valid_nsid(nsid)) {
889 state.result = {
890 success: false,
891 message: `Invalid NSID format: "${nsid}"`,
892 };
893 renderResult();
894 return;
895 }
896
897 const parseResult = parseLexicons();
898 if (parseResult.error) {
899 state.result = { success: false, message: parseResult.error };
900 renderResult();
901 return;
902 }
903
904 const recordTrimmed = state.record.trim();
905 if (!recordTrimmed) {
906 state.result = { success: false, message: "Record data is required" };
907 renderResult();
908 return;
909 }
910
911 // Validate JSON syntax first
912 try {
913 JSON.parse(recordTrimmed);
914 } catch (e) {
915 state.result = {
916 success: false,
917 message: `Record: Invalid JSON - ${e.message}`,
918 };
919 renderResult();
920 return;
921 }
922
923 // Parse record to Gleam Json type
924 const recordParseResult = honk.parse_json_string(recordTrimmed);
925 const recordUnwrapped = unwrapHonkResult(recordParseResult);
926 if (!recordUnwrapped.ok) {
927 state.result = {
928 success: false,
929 message: `Record: ${formatHonkError(recordUnwrapped.value)}`,
930 };
931 renderResult();
932 return;
933 }
934
935 try {
936 // Convert JS array to Gleam List
937 const lexiconList = honk.toList(parseResult.lexicons);
938 const result = honk.validate_record(lexiconList, nsid, recordUnwrapped.value);
939 const unwrapped = unwrapHonkResult(result);
940
941 if (unwrapped.ok) {
942 state.result = {
943 success: true,
944 message: `Record valid against ${nsid}`,
945 };
946 } else {
947 state.result = {
948 success: false,
949 message: `Record invalid: ${formatHonkError(unwrapped.value)}`,
950 };
951 }
952 } catch (e) {
953 state.result = {
954 success: false,
955 message: `Validation error: ${e.message}`,
956 };
957 }
958
959 renderResult();
960 }
961
962 // =============================================================================
963 // ZIP FILE HANDLING
964 // =============================================================================
965
966 function handleDragOver(event) {
967 event.preventDefault();
968 event.stopPropagation();
969 event.currentTarget.classList.add("drag-over");
970 }
971
972 function handleDragLeave(event) {
973 event.preventDefault();
974 event.stopPropagation();
975 event.currentTarget.classList.remove("drag-over");
976 }
977
978 function handleDrop(event) {
979 event.preventDefault();
980 event.stopPropagation();
981 event.currentTarget.classList.remove("drag-over");
982
983 const files = event.dataTransfer.files;
984 if (files.length > 0) {
985 processZipFile(files[0]);
986 }
987 }
988
989 function triggerFileInput() {
990 document.getElementById("file-input").click();
991 }
992
993 function handleFileSelect(event) {
994 const files = event.target.files;
995 if (files.length > 0) {
996 processZipFile(files[0]);
997 }
998 // Reset input so the same file can be selected again
999 event.target.value = "";
1000 }
1001
1002 async function processZipFile(file) {
1003 if (!file.name.toLowerCase().endsWith(".zip")) {
1004 state.result = {
1005 success: false,
1006 message: `Invalid file type: ${file.name}. Please select a .zip file.`,
1007 };
1008 renderResult();
1009 return;
1010 }
1011
1012 if (typeof JSZip === "undefined") {
1013 state.result = {
1014 success: false,
1015 message: "JSZip library failed to load. Check your internet connection.",
1016 };
1017 renderResult();
1018 return;
1019 }
1020
1021 // Show loading state
1022 const dropZone = document.getElementById("drop-zone");
1023 if (dropZone) {
1024 dropZone.innerHTML = `
1025 <div class="drop-zone-icon"><span class="loading-spinner"></span></div>
1026 <div class="drop-zone-text">Processing ${esc(file.name)}...</div>
1027 `;
1028 }
1029
1030 try {
1031 const zip = await JSZip.loadAsync(file);
1032 const jsonFiles = [];
1033
1034 // Find all .json files in the zip (including nested folders)
1035 // Skip macOS metadata files (__MACOSX) and hidden files (._*)
1036 zip.forEach((relativePath, zipEntry) => {
1037 if (
1038 !zipEntry.dir &&
1039 relativePath.toLowerCase().endsWith(".json") &&
1040 !relativePath.startsWith("__MACOSX/") &&
1041 !relativePath.split("/").some((part) => part.startsWith("._"))
1042 ) {
1043 jsonFiles.push({ path: relativePath, entry: zipEntry });
1044 }
1045 });
1046
1047 if (jsonFiles.length === 0) {
1048 state.result = {
1049 success: false,
1050 message: `No .json files found in ${file.name}`,
1051 };
1052 renderLexiconsSection();
1053 renderResult();
1054 return;
1055 }
1056
1057 // Extract and parse all JSON files
1058 const lexicons = [];
1059 const errors = [];
1060
1061 for (const { path, entry } of jsonFiles) {
1062 try {
1063 const content = await entry.async("string");
1064 // Validate it's valid JSON
1065 JSON.parse(content);
1066 lexicons.push({ path, content });
1067 } catch (e) {
1068 errors.push(`${path}: ${e.message}`);
1069 }
1070 }
1071
1072 if (lexicons.length === 0) {
1073 state.result = {
1074 success: false,
1075 message: `All JSON files had parse errors:\n${errors.join("\n")}`,
1076 };
1077 renderLexiconsSection();
1078 renderResult();
1079 return;
1080 }
1081
1082 // Replace current lexicons with extracted ones (collapsed by default)
1083 state.lexicons = lexicons.map((lex, idx) => ({
1084 id: state.nextLexiconId + idx,
1085 value: lex.content,
1086 collapsed: true,
1087 }));
1088 state.nextLexiconId += lexicons.length;
1089
1090 const successMsg = `Loaded ${lexicons.length} lexicon${lexicons.length === 1 ? "" : "s"} from ${file.name}`;
1091 const errorMsg =
1092 errors.length > 0
1093 ? `\n\nSkipped ${errors.length} file${errors.length === 1 ? "" : "s"}:\n${errors.join("\n")}`
1094 : "";
1095
1096 state.result = {
1097 success: true,
1098 message: successMsg + errorMsg,
1099 };
1100
1101 renderLexiconsSection();
1102 updateAvailableNsids();
1103 renderResult();
1104 } catch (e) {
1105 state.result = {
1106 success: false,
1107 message: `Failed to read zip file: ${e.message}`,
1108 };
1109 renderLexiconsSection();
1110 renderResult();
1111 }
1112 }
1113
1114 // =============================================================================
1115 // INIT
1116 // =============================================================================
1117
1118 function init() {
1119 if (typeof honk === "undefined") {
1120 document.getElementById("results-section").innerHTML = `
1121 <div class="result result-error">✗ Failed to load honk library from CDN. Check your internet connection.</div>
1122 `;
1123 return;
1124 }
1125 renderLexiconsSection();
1126 renderRecordSection();
1127 }
1128
1129 window.addEventListener("DOMContentLoaded", init);
1130 </script>
1131 </body>
1132</html>