Tools for the Atmosphere tools.slices.network
quickslice atproto html
at main 1132 lines 33 kB view raw
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, "&amp;") 460 .replace(/"/g, "&quot;") 461 .replace(/</g, "&lt;") 462 .replace(/>/g, "&gt;"); 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>