Tools for the Atmosphere tools.slices.network
quickslice atproto html

feat: implement lexicon and record validation with honk

+326 -1
+326 -1
lexicon-validator.html
··· 273 273 </div> 274 274 <script src="https://cdn.jsdelivr.net/gh/bigmoves/honk@main/dist/honk.min.js"></script> 275 275 <script> 276 - // JavaScript will be added in subsequent tasks 276 + // ============================================================================= 277 + // STATE 278 + // ============================================================================= 279 + 280 + const state = { 281 + lexicons: [{ id: 1, value: "" }], 282 + nextLexiconId: 2, 283 + record: "", 284 + nsid: "", 285 + result: null 286 + }; 287 + 288 + // ============================================================================= 289 + // PLACEHOLDERS 290 + // ============================================================================= 291 + 292 + const LEXICON_PLACEHOLDER = `{ 293 + "lexicon": 1, 294 + "id": "com.example.myRecord", 295 + "defs": { 296 + "main": { 297 + "type": "record", 298 + "record": { 299 + "type": "object", 300 + "required": ["status", "createdAt"], 301 + "properties": { 302 + "status": { 303 + "type": "string", 304 + "maxLength": 100 305 + }, 306 + "createdAt": { 307 + "type": "string", 308 + "format": "datetime" 309 + } 310 + } 311 + } 312 + } 313 + } 314 + }`; 315 + 316 + const RECORD_PLACEHOLDER = `{ 317 + "status": "Hello world", 318 + "createdAt": "2025-01-15T12:00:00Z" 319 + }`; 320 + 321 + // ============================================================================= 322 + // HELPERS 323 + // ============================================================================= 324 + 325 + function esc(str) { 326 + const d = document.createElement("div"); 327 + d.textContent = str || ""; 328 + return d.innerHTML; 329 + } 330 + 331 + function escapeAttr(str) { 332 + return (str || "") 333 + .replace(/&/g, "&amp;") 334 + .replace(/"/g, "&quot;") 335 + .replace(/</g, "&lt;") 336 + .replace(/>/g, "&gt;"); 337 + } 338 + 339 + function unwrapHonkResult(result) { 340 + if (typeof result.isOk === "function") { 341 + return { ok: result.isOk(), value: result.isOk() ? result.unwrap() : result.unwrapError() }; 342 + } 343 + if ("Ok" in result) { 344 + return { ok: true, value: result.Ok }; 345 + } 346 + if ("Error" in result) { 347 + return { ok: false, value: result.Error }; 348 + } 349 + return { ok: true, value: result }; 350 + } 351 + 352 + function formatHonkError(err) { 353 + if (!err) return "Unknown error"; 354 + if (typeof err === "string") return err; 355 + if (err.message) { 356 + return err.path ? `${err.message} (at ${err.path})` : err.message; 357 + } 358 + return JSON.stringify(err, null, 2); 359 + } 360 + 361 + // ============================================================================= 362 + // RENDERING 363 + // ============================================================================= 364 + 365 + function renderLexiconsSection() { 366 + const section = document.getElementById("lexicons-section"); 367 + 368 + const editorsHtml = state.lexicons.map((lex, idx) => ` 369 + <div class="editor-card" data-lexicon-id="${lex.id}"> 370 + <div class="editor-header"> 371 + <span class="editor-label">Lexicon ${idx + 1}</span> 372 + <button 373 + class="btn btn-danger btn-small" 374 + onclick="removeLexicon(${lex.id})" 375 + ${state.lexicons.length === 1 ? "disabled" : ""} 376 + >Remove</button> 377 + </div> 378 + <textarea 379 + placeholder="${escapeAttr(LEXICON_PLACEHOLDER)}" 380 + onchange="updateLexicon(${lex.id}, this.value)" 381 + oninput="updateLexicon(${lex.id}, this.value)" 382 + >${esc(lex.value)}</textarea> 383 + </div> 384 + `).join(""); 385 + 386 + section.innerHTML = ` 387 + <div class="section-header"> 388 + <span class="section-title">Lexicons</span> 389 + </div> 390 + ${editorsHtml} 391 + <div class="button-row"> 392 + <button class="btn btn-secondary" onclick="addLexicon()">+ Add Lexicon</button> 393 + <button class="btn btn-primary" onclick="validateLexicons()">Validate Lexicons</button> 394 + </div> 395 + `; 396 + } 397 + 398 + function renderRecordSection() { 399 + const section = document.getElementById("record-section"); 400 + 401 + section.innerHTML = ` 402 + <div class="section-header"> 403 + <span class="section-title">Record</span> 404 + </div> 405 + <div class="nsid-row"> 406 + <span class="nsid-label">NSID:</span> 407 + <input 408 + type="text" 409 + id="nsid-input" 410 + placeholder="com.example.myRecord" 411 + value="${escapeAttr(state.nsid)}" 412 + oninput="updateNsid(this.value)" 413 + /> 414 + </div> 415 + <div class="editor-card"> 416 + <div class="editor-header"> 417 + <span class="editor-label">Record Data</span> 418 + </div> 419 + <textarea 420 + id="record-input" 421 + placeholder="${escapeAttr(RECORD_PLACEHOLDER)}" 422 + onchange="updateRecord(this.value)" 423 + oninput="updateRecord(this.value)" 424 + >${esc(state.record)}</textarea> 425 + </div> 426 + <div class="button-row"> 427 + <button class="btn btn-primary" onclick="validateRecord()">Validate Record</button> 428 + </div> 429 + `; 430 + } 431 + 432 + function renderResult() { 433 + const section = document.getElementById("results-section"); 434 + 435 + if (!state.result) { 436 + section.innerHTML = ""; 437 + return; 438 + } 439 + 440 + const cls = state.result.success ? "result-success" : "result-error"; 441 + const icon = state.result.success ? "✓" : "✗"; 442 + 443 + section.innerHTML = ` 444 + <div class="result ${cls}">${icon} ${esc(state.result.message)}</div> 445 + `; 446 + } 447 + 448 + // ============================================================================= 449 + // STATE UPDATES 450 + // ============================================================================= 451 + 452 + function addLexicon() { 453 + state.lexicons.push({ id: state.nextLexiconId++, value: "" }); 454 + renderLexiconsSection(); 455 + } 456 + 457 + function removeLexicon(id) { 458 + if (state.lexicons.length <= 1) return; 459 + state.lexicons = state.lexicons.filter(l => l.id !== id); 460 + renderLexiconsSection(); 461 + } 462 + 463 + function updateLexicon(id, value) { 464 + const lex = state.lexicons.find(l => l.id === id); 465 + if (lex) lex.value = value; 466 + } 467 + 468 + function updateRecord(value) { 469 + state.record = value; 470 + } 471 + 472 + function updateNsid(value) { 473 + state.nsid = value; 474 + } 475 + 476 + // ============================================================================= 477 + // VALIDATION 478 + // ============================================================================= 479 + 480 + function parseLexicons() { 481 + const parsed = []; 482 + for (let i = 0; i < state.lexicons.length; i++) { 483 + const lex = state.lexicons[i]; 484 + const trimmed = lex.value.trim(); 485 + 486 + if (!trimmed) { 487 + return { error: `Lexicon ${i + 1}: Empty - please enter a lexicon schema` }; 488 + } 489 + 490 + try { 491 + const obj = JSON.parse(trimmed); 492 + if (Array.isArray(obj)) { 493 + return { error: `Lexicon ${i + 1}: Expected a single lexicon object, not an array. Use "+ Add Lexicon" for multiple.` }; 494 + } 495 + parsed.push(obj); 496 + } catch (e) { 497 + return { error: `Lexicon ${i + 1}: Invalid JSON - ${e.message}` }; 498 + } 499 + } 500 + return { lexicons: parsed }; 501 + } 502 + 503 + function validateLexicons() { 504 + state.result = null; 505 + 506 + const parseResult = parseLexicons(); 507 + if (parseResult.error) { 508 + state.result = { success: false, message: parseResult.error }; 509 + renderResult(); 510 + return; 511 + } 512 + 513 + try { 514 + const result = honk.validate(parseResult.lexicons); 515 + const unwrapped = unwrapHonkResult(result); 516 + 517 + if (unwrapped.ok) { 518 + const count = parseResult.lexicons.length; 519 + const noun = count === 1 ? "lexicon" : "lexicons"; 520 + state.result = { success: true, message: `${count} ${noun} valid` }; 521 + } else { 522 + state.result = { success: false, message: `Validation failed: ${formatHonkError(unwrapped.value)}` }; 523 + } 524 + } catch (e) { 525 + state.result = { success: false, message: `Validation error: ${e.message}` }; 526 + } 527 + 528 + renderResult(); 529 + } 530 + 531 + function validateRecord() { 532 + state.result = null; 533 + 534 + const nsid = state.nsid.trim(); 535 + if (!nsid) { 536 + state.result = { success: false, message: "NSID is required for record validation" }; 537 + renderResult(); 538 + return; 539 + } 540 + 541 + if (!honk.is_valid_nsid(nsid)) { 542 + state.result = { success: false, message: `Invalid NSID format: "${nsid}"` }; 543 + renderResult(); 544 + return; 545 + } 546 + 547 + const parseResult = parseLexicons(); 548 + if (parseResult.error) { 549 + state.result = { success: false, message: parseResult.error }; 550 + renderResult(); 551 + return; 552 + } 553 + 554 + const recordTrimmed = state.record.trim(); 555 + if (!recordTrimmed) { 556 + state.result = { success: false, message: "Record data is required" }; 557 + renderResult(); 558 + return; 559 + } 560 + 561 + let recordData; 562 + try { 563 + recordData = JSON.parse(recordTrimmed); 564 + } catch (e) { 565 + state.result = { success: false, message: `Record: Invalid JSON - ${e.message}` }; 566 + renderResult(); 567 + return; 568 + } 569 + 570 + try { 571 + const result = honk.validate_record(parseResult.lexicons, nsid, recordData); 572 + const unwrapped = unwrapHonkResult(result); 573 + 574 + if (unwrapped.ok) { 575 + state.result = { success: true, message: `Record valid against ${nsid}` }; 576 + } else { 577 + state.result = { success: false, message: `Record invalid: ${formatHonkError(unwrapped.value)}` }; 578 + } 579 + } catch (e) { 580 + state.result = { success: false, message: `Validation error: ${e.message}` }; 581 + } 582 + 583 + renderResult(); 584 + } 585 + 586 + // ============================================================================= 587 + // INIT 588 + // ============================================================================= 589 + 590 + function init() { 591 + if (typeof honk === "undefined") { 592 + document.getElementById("results-section").innerHTML = ` 593 + <div class="result result-error">✗ Failed to load honk library from CDN. Check your internet connection.</div> 594 + `; 595 + return; 596 + } 597 + renderLexiconsSection(); 598 + renderRecordSection(); 599 + } 600 + 601 + window.addEventListener("DOMContentLoaded", init); 277 602 </script> 278 603 </body> 279 604 </html>