Tools for the Atmosphere tools.slices.network
quickslice atproto html
at feat/lexicon-validator 795 lines 22 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; 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 </style> 292 </head> 293 <body> 294 <div id="app"> 295 <header> 296 <h1><span style="color: var(--accent)">{</span> Lexicon Validator <span style="color: var(--accent)">}</span></h1> 297 <p class="tagline"> 298 Powered by <a href="https://hexdocs.pm/honk/index.html" target="_blank">honk</a> 299 </p> 300 </header> 301 <main> 302 <div id="lexicons-section" class="section"></div> 303 <div id="record-section" class="section"></div> 304 <div id="results-section"></div> 305 </main> 306 </div> 307 <script src="https://cdn.jsdelivr.net/gh/bigmoves/honk@deaa420/dist/honk.min.js"></script> 308 <script> 309 // ============================================================================= 310 // STATE 311 // ============================================================================= 312 313 const state = { 314 lexicons: [{ id: 1, value: "" }], 315 nextLexiconId: 2, 316 record: "", 317 nsid: "", 318 availableNsids: [], 319 result: null, 320 }; 321 322 // ============================================================================= 323 // PLACEHOLDERS 324 // ============================================================================= 325 326 const LEXICON_PLACEHOLDER = `{ 327 "lexicon": 1, 328 "id": "com.example.myRecord", 329 "defs": { 330 "main": { 331 "type": "record", 332 "record": { 333 "type": "object", 334 "required": ["status", "createdAt"], 335 "properties": { 336 "status": { 337 "type": "string", 338 "maxLength": 100 339 }, 340 "createdAt": { 341 "type": "string", 342 "format": "datetime" 343 } 344 } 345 } 346 } 347 } 348}`; 349 350 const RECORD_PLACEHOLDER = `{ 351 "status": "Hello world", 352 "createdAt": "2025-01-15T12:00:00Z" 353}`; 354 355 // ============================================================================= 356 // HELPERS 357 // ============================================================================= 358 359 function esc(str) { 360 const d = document.createElement("div"); 361 d.textContent = str || ""; 362 return d.innerHTML; 363 } 364 365 function escapeAttr(str) { 366 return (str || "") 367 .replace(/&/g, "&amp;") 368 .replace(/"/g, "&quot;") 369 .replace(/</g, "&lt;") 370 .replace(/>/g, "&gt;"); 371 } 372 373 function unwrapHonkResult(result) { 374 if (typeof result.isOk === "function") { 375 return { ok: result.isOk(), value: result[0] }; 376 } 377 return { ok: true, value: result }; 378 } 379 380 function formatHonkError(err) { 381 if (!err) return "Unknown error"; 382 if (typeof err === "string") return err; 383 if (err.message) { 384 return err.path ? `${err.message} (at ${err.path})` : err.message; 385 } 386 return JSON.stringify(err, null, 2); 387 } 388 389 // ============================================================================= 390 // RENDERING 391 // ============================================================================= 392 393 function renderLexiconsSection() { 394 const section = document.getElementById("lexicons-section"); 395 396 const editorsHtml = state.lexicons 397 .map( 398 (lex, idx) => ` 399 <div class="editor-card" data-lexicon-id="${lex.id}"> 400 <div class="editor-header"> 401 <span class="editor-label">Lexicon ${idx + 1}</span> 402 <button 403 class="btn btn-danger btn-small" 404 onclick="removeLexicon(${lex.id})" 405 ${state.lexicons.length === 1 ? "disabled" : ""} 406 >Remove</button> 407 </div> 408 <textarea 409 placeholder="${escapeAttr(LEXICON_PLACEHOLDER)}" 410 onchange="updateLexicon(${lex.id}, this.value)" 411 oninput="updateLexicon(${lex.id}, this.value)" 412 >${esc(lex.value)}</textarea> 413 </div> 414 `, 415 ) 416 .join(""); 417 418 section.innerHTML = ` 419 <div class="section-header"> 420 <span class="section-title">Lexicons</span> 421 </div> 422 ${editorsHtml} 423 <div class="button-row"> 424 <button class="btn btn-secondary" onclick="addLexicon()">+ Add Lexicon</button> 425 <button class="btn btn-primary" onclick="validateLexicons()">Validate Lexicons</button> 426 </div> 427 `; 428 } 429 430 function renderRecordSection() { 431 const section = document.getElementById("record-section"); 432 433 const optionsHtml = 434 state.availableNsids.length === 0 435 ? '<option value="" disabled selected>Enter a lexicon with a record type</option>' 436 : [ 437 `<option value="" disabled ${!state.nsid ? "selected" : ""}>Select a record type</option>`, 438 ] 439 .concat( 440 state.availableNsids.map( 441 (nsid) => 442 `<option value="${escapeAttr(nsid)}" ${state.nsid === nsid ? "selected" : ""}>${esc(nsid)}</option>`, 443 ), 444 ) 445 .join(""); 446 447 section.innerHTML = ` 448 <div class="section-header"> 449 <span class="section-title">Record</span> 450 </div> 451 <div class="nsid-row"> 452 <span class="nsid-label">NSID:</span> 453 <select 454 id="nsid-select" 455 onchange="updateNsid(this.value)" 456 ${state.availableNsids.length === 0 ? "disabled" : ""} 457 >${optionsHtml}</select> 458 </div> 459 <div class="editor-card"> 460 <div class="editor-header"> 461 <span class="editor-label">Record Data</span> 462 <button 463 class="btn btn-secondary btn-small" 464 onclick="resetRecordTemplate()" 465 ${!state.nsid ? "disabled" : ""} 466 >Reset</button> 467 </div> 468 <textarea 469 id="record-input" 470 placeholder="${escapeAttr(RECORD_PLACEHOLDER)}" 471 onchange="updateRecord(this.value)" 472 oninput="updateRecord(this.value)" 473 >${esc(state.record)}</textarea> 474 </div> 475 <div class="button-row"> 476 <button class="btn btn-primary" onclick="validateRecord()">Validate Record</button> 477 </div> 478 `; 479 } 480 481 function renderResult() { 482 const section = document.getElementById("results-section"); 483 484 if (!state.result) { 485 section.innerHTML = ""; 486 return; 487 } 488 489 const cls = state.result.success ? "result-success" : "result-error"; 490 const icon = state.result.success ? "✓" : "✗"; 491 492 section.innerHTML = ` 493 <div class="result ${cls}">${icon} ${esc(state.result.message)}</div> 494 `; 495 } 496 497 // ============================================================================= 498 // STATE UPDATES 499 // ============================================================================= 500 501 function addLexicon() { 502 state.lexicons.push({ id: state.nextLexiconId++, value: "" }); 503 renderLexiconsSection(); 504 } 505 506 function removeLexicon(id) { 507 if (state.lexicons.length <= 1) return; 508 state.lexicons = state.lexicons.filter((l) => l.id !== id); 509 renderLexiconsSection(); 510 } 511 512 function updateLexicon(id, value) { 513 const lex = state.lexicons.find((l) => l.id === id); 514 if (lex) lex.value = value; 515 updateAvailableNsids(); 516 } 517 518 function updateAvailableNsids() { 519 const nsids = []; 520 for (const lex of state.lexicons) { 521 const trimmed = lex.value.trim(); 522 if (!trimmed) continue; 523 try { 524 const obj = JSON.parse(trimmed); 525 if (obj.id && obj.defs?.main?.type === "record") { 526 nsids.push(obj.id); 527 } 528 } catch (e) { 529 // Invalid JSON, skip 530 } 531 } 532 state.availableNsids = nsids; 533 renderRecordSection(); 534 } 535 536 function updateRecord(value) { 537 state.record = value; 538 } 539 540 function resetRecordTemplate() { 541 if (state.nsid) { 542 const template = generateRecordTemplate(state.nsid); 543 if (template) { 544 state.record = template; 545 renderRecordSection(); 546 } 547 } 548 } 549 550 function updateNsid(value) { 551 state.nsid = value; 552 if (value) { 553 const template = generateRecordTemplate(value); 554 if (template) { 555 state.record = template; 556 } 557 } 558 renderRecordSection(); 559 } 560 561 function generateRecordTemplate(nsid) { 562 for (const lex of state.lexicons) { 563 const trimmed = lex.value.trim(); 564 if (!trimmed) continue; 565 try { 566 const obj = JSON.parse(trimmed); 567 if (obj.id === nsid && obj.defs?.main?.type === "record") { 568 const record = obj.defs.main.record; 569 if (record?.type === "object" && record.properties) { 570 const template = {}; 571 const required = record.required || []; 572 for (const field of required) { 573 const prop = record.properties[field]; 574 if (prop) { 575 template[field] = getDefaultValue(prop); 576 } 577 } 578 return JSON.stringify(template, null, 2); 579 } 580 } 581 } catch (e) { 582 // Invalid JSON, skip 583 } 584 } 585 return null; 586 } 587 588 function getDefaultValue(prop) { 589 switch (prop.type) { 590 case "string": 591 if (prop.format === "datetime") return new Date().toISOString(); 592 if (prop.format === "uri") return "https://example.com"; 593 if (prop.format === "at-uri") return "at://did:plc:example/app.bsky.feed.post/abc123"; 594 if (prop.format === "did") return "did:plc:example"; 595 if (prop.format === "handle") return "user.example.com"; 596 if (prop.format === "cid") return "bafyreib..."; 597 if (prop.const) return prop.const; 598 if (prop.enum) return prop.enum[0]; 599 return ""; 600 case "integer": 601 return prop.minimum ?? prop.default ?? 0; 602 case "boolean": 603 return prop.default ?? false; 604 case "array": 605 return []; 606 case "object": 607 return {}; 608 case "blob": 609 return { $type: "blob", ref: { $link: "" }, mimeType: "", size: 0 }; 610 default: 611 return null; 612 } 613 } 614 615 // ============================================================================= 616 // VALIDATION 617 // ============================================================================= 618 619 function parseLexicons() { 620 const parsed = []; 621 for (let i = 0; i < state.lexicons.length; i++) { 622 const lex = state.lexicons[i]; 623 const trimmed = lex.value.trim(); 624 625 if (!trimmed) { 626 return { 627 error: `Lexicon ${i + 1}: Empty - please enter a lexicon schema`, 628 }; 629 } 630 631 // Validate JSON syntax first 632 try { 633 const obj = JSON.parse(trimmed); 634 if (Array.isArray(obj)) { 635 return { 636 error: `Lexicon ${i + 1}: Expected a single lexicon object, not an array. Use "+ Add Lexicon" for multiple.`, 637 }; 638 } 639 } catch (e) { 640 return { error: `Lexicon ${i + 1}: Invalid JSON - ${e.message}` }; 641 } 642 643 // Parse to Gleam Json type 644 const parseResult = honk.parse_json_string(trimmed); 645 const unwrapped = unwrapHonkResult(parseResult); 646 if (!unwrapped.ok) { 647 return { error: `Lexicon ${i + 1}: ${formatHonkError(unwrapped.value)}` }; 648 } 649 parsed.push(unwrapped.value); 650 } 651 return { lexicons: parsed }; 652 } 653 654 function validateLexicons() { 655 state.result = null; 656 657 const parseResult = parseLexicons(); 658 if (parseResult.error) { 659 state.result = { success: false, message: parseResult.error }; 660 renderResult(); 661 return; 662 } 663 664 try { 665 // Convert JS array to Gleam List 666 const lexiconList = honk.toList(parseResult.lexicons); 667 const result = honk.validate(lexiconList); 668 const unwrapped = unwrapHonkResult(result); 669 670 if (unwrapped.ok) { 671 const count = parseResult.lexicons.length; 672 const noun = count === 1 ? "lexicon" : "lexicons"; 673 state.result = { success: true, message: `${count} ${noun} valid` }; 674 } else { 675 state.result = { 676 success: false, 677 message: `Validation failed: ${formatHonkError(unwrapped.value)}`, 678 }; 679 } 680 } catch (e) { 681 state.result = { 682 success: false, 683 message: `Validation error: ${e.message}`, 684 }; 685 } 686 687 renderResult(); 688 } 689 690 function validateRecord() { 691 state.result = null; 692 693 const nsid = state.nsid.trim(); 694 if (!nsid) { 695 state.result = { 696 success: false, 697 message: "NSID is required for record validation", 698 }; 699 renderResult(); 700 return; 701 } 702 703 if (!honk.is_valid_nsid(nsid)) { 704 state.result = { 705 success: false, 706 message: `Invalid NSID format: "${nsid}"`, 707 }; 708 renderResult(); 709 return; 710 } 711 712 const parseResult = parseLexicons(); 713 if (parseResult.error) { 714 state.result = { success: false, message: parseResult.error }; 715 renderResult(); 716 return; 717 } 718 719 const recordTrimmed = state.record.trim(); 720 if (!recordTrimmed) { 721 state.result = { success: false, message: "Record data is required" }; 722 renderResult(); 723 return; 724 } 725 726 // Validate JSON syntax first 727 try { 728 JSON.parse(recordTrimmed); 729 } catch (e) { 730 state.result = { 731 success: false, 732 message: `Record: Invalid JSON - ${e.message}`, 733 }; 734 renderResult(); 735 return; 736 } 737 738 // Parse record to Gleam Json type 739 const recordParseResult = honk.parse_json_string(recordTrimmed); 740 const recordUnwrapped = unwrapHonkResult(recordParseResult); 741 if (!recordUnwrapped.ok) { 742 state.result = { 743 success: false, 744 message: `Record: ${formatHonkError(recordUnwrapped.value)}`, 745 }; 746 renderResult(); 747 return; 748 } 749 750 try { 751 // Convert JS array to Gleam List 752 const lexiconList = honk.toList(parseResult.lexicons); 753 const result = honk.validate_record(lexiconList, nsid, recordUnwrapped.value); 754 const unwrapped = unwrapHonkResult(result); 755 756 if (unwrapped.ok) { 757 state.result = { 758 success: true, 759 message: `Record valid against ${nsid}`, 760 }; 761 } else { 762 state.result = { 763 success: false, 764 message: `Record invalid: ${formatHonkError(unwrapped.value)}`, 765 }; 766 } 767 } catch (e) { 768 state.result = { 769 success: false, 770 message: `Validation error: ${e.message}`, 771 }; 772 } 773 774 renderResult(); 775 } 776 777 // ============================================================================= 778 // INIT 779 // ============================================================================= 780 781 function init() { 782 if (typeof honk === "undefined") { 783 document.getElementById("results-section").innerHTML = ` 784 <div class="result result-error">✗ Failed to load honk library from CDN. Check your internet connection.</div> 785 `; 786 return; 787 } 788 renderLexiconsSection(); 789 renderRecordSection(); 790 } 791 792 window.addEventListener("DOMContentLoaded", init); 793 </script> 794 </body> 795</html>