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; 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, "&")
368 .replace(/"/g, """)
369 .replace(/</g, "<")
370 .replace(/>/g, ">");
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>