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'; connect-src 'self' https://quickslice-production-4b57.up.railway.app wss://quickslice-production-4b57.up.railway.app https://public.api.bsky.app https://cloudflare-dns.com; img-src 'self' https: data:;"
9 />
10 <title>{ Lexicon Publisher }</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 a {
89 color: var(--accent);
90 }
91
92 a:visited {
93 color: var(--accent);
94 }
95
96 /* Actor Autocomplete */
97 qs-actor-autocomplete {
98 --qs-input-bg: var(--bg-input);
99 --qs-input-border: var(--border);
100 --qs-input-border-focus: var(--accent);
101 --qs-input-text: var(--text-primary);
102 --qs-input-placeholder: var(--text-secondary);
103 --qs-input-padding: 0.5rem 0.75rem;
104 --qs-menu-bg: var(--bg-card);
105 --qs-menu-border: var(--border);
106 --qs-menu-shadow: rgba(0, 0, 0, 0.3);
107 --qs-item-hover: var(--bg-input);
108 --qs-item-text: var(--text-primary);
109 --qs-item-secondary: var(--text-secondary);
110 flex: 1;
111 }
112
113 /* Sections */
114 .section {
115 background: var(--bg-card);
116 border-radius: 0.5rem;
117 padding: 1rem;
118 margin-bottom: 1rem;
119 border: 1px solid var(--border);
120 box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
121 }
122
123 .section-header {
124 display: flex;
125 justify-content: space-between;
126 align-items: center;
127 margin-bottom: 0.75rem;
128 }
129
130 .section-title {
131 font-weight: 600;
132 font-size: 0.875rem;
133 text-transform: uppercase;
134 letter-spacing: 0.05em;
135 color: var(--text-secondary);
136 }
137
138 /* Buttons */
139 .btn {
140 padding: 0.5rem 1rem;
141 border: none;
142 border-radius: 0.375rem;
143 font-size: 0.875rem;
144 font-weight: 500;
145 cursor: pointer;
146 transition:
147 background-color 0.15s,
148 opacity 0.15s;
149 }
150
151 .btn-primary {
152 background: var(--accent);
153 color: #ffffff;
154 }
155
156 .btn-primary:hover {
157 background: var(--accent-hover);
158 }
159
160 .btn-secondary {
161 background: var(--bg-card);
162 color: var(--text-primary);
163 border: 1px solid var(--border);
164 }
165
166 .btn-secondary:hover {
167 background: var(--bg-primary);
168 }
169
170 .btn-small {
171 padding: 0.25rem 0.5rem;
172 font-size: 0.75rem;
173 }
174
175 .btn-danger {
176 background: var(--error-bg);
177 color: var(--error-text);
178 border: 1px solid var(--error-border);
179 }
180
181 .btn-danger:hover {
182 background: #fee2e2;
183 }
184
185 .btn:disabled {
186 opacity: 0.5;
187 cursor: not-allowed;
188 }
189
190 /* Editors */
191 .editor-card {
192 background: var(--bg-input);
193 border: 1px solid var(--border);
194 border-radius: 0.375rem;
195 margin-bottom: 0.75rem;
196 }
197
198 .editor-header {
199 display: flex;
200 justify-content: space-between;
201 align-items: center;
202 padding: 0.5rem 0.75rem;
203 border-bottom: 1px solid var(--border);
204 background: var(--bg-card);
205 border-radius: 0.375rem 0.375rem 0 0;
206 }
207
208 .editor-label {
209 font-size: 0.75rem;
210 color: var(--text-secondary);
211 font-weight: 500;
212 }
213
214 textarea {
215 width: 100%;
216 min-height: 200px;
217 padding: 0.75rem;
218 background: var(--bg-input);
219 border: none;
220 color: var(--text-primary);
221 font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
222 font-size: 0.8125rem;
223 resize: vertical;
224 border-radius: 0 0 0.375rem 0.375rem;
225 }
226
227 textarea:focus {
228 outline: none;
229 background: #ffffff;
230 }
231
232 textarea::placeholder {
233 color: var(--text-secondary);
234 opacity: 0.6;
235 }
236
237 input[type="text"] {
238 flex: 1;
239 padding: 0.5rem 0.75rem;
240 background: var(--bg-input);
241 border: 1px solid var(--border);
242 border-radius: 0.375rem;
243 color: var(--text-primary);
244 font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
245 font-size: 0.8125rem;
246 }
247
248 input[type="text"]:focus {
249 outline: none;
250 border-color: var(--border-focus);
251 background: #ffffff;
252 }
253
254 input[type="text"]::placeholder {
255 color: var(--text-secondary);
256 opacity: 0.6;
257 }
258
259 /* Results */
260 .result {
261 padding: 1rem;
262 border-radius: 0.375rem;
263 font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
264 font-size: 0.8125rem;
265 white-space: pre-wrap;
266 word-break: break-word;
267 }
268
269 .result-success {
270 background: var(--success-bg);
271 border: 1px solid var(--success-border);
272 color: var(--success-text);
273 }
274
275 .result-error {
276 background: var(--error-bg);
277 border: 1px solid var(--error-border);
278 color: var(--error-text);
279 }
280
281 .result:empty {
282 display: none;
283 }
284
285 /* Button row */
286 .button-row {
287 display: flex;
288 gap: 0.5rem;
289 margin-top: 0.75rem;
290 }
291
292 .hidden {
293 display: none !important;
294 }
295
296 /* Drop Zone */
297 .drop-zone {
298 border: 2px dashed var(--border);
299 border-radius: 0.5rem;
300 padding: 2rem;
301 text-align: center;
302 margin-bottom: 1rem;
303 transition: all 0.2s ease;
304 cursor: pointer;
305 }
306
307 .drop-zone:hover {
308 border-color: var(--accent);
309 background: var(--bg-input);
310 }
311
312 .drop-zone.drag-over {
313 border-color: var(--accent);
314 background: rgba(0, 102, 204, 0.05);
315 }
316
317 .drop-zone-icon {
318 font-size: 2rem;
319 margin-bottom: 0.5rem;
320 }
321
322 .drop-zone-text {
323 color: var(--text-secondary);
324 font-size: 0.875rem;
325 }
326
327 .drop-zone-text strong {
328 color: var(--accent);
329 }
330
331 .drop-zone-hint {
332 color: var(--text-secondary);
333 font-size: 0.75rem;
334 margin-top: 0.25rem;
335 opacity: 0.8;
336 }
337
338 .drop-zone-example {
339 color: var(--text-secondary);
340 font-size: 0.7rem;
341 margin-top: 0.5rem;
342 opacity: 0.7;
343 }
344
345 .drop-zone-example pre {
346 font-family: monospace;
347 font-size: 0.65rem;
348 line-height: 1.3;
349 }
350
351 .drop-zone-example a {
352 color: var(--text-secondary);
353 }
354
355 .loading-spinner {
356 display: inline-block;
357 width: 1rem;
358 height: 1rem;
359 border: 2px solid var(--border);
360 border-top-color: var(--accent);
361 border-radius: 50%;
362 animation: spin 0.8s linear infinite;
363 margin-right: 0.5rem;
364 vertical-align: middle;
365 }
366
367 @keyframes spin {
368 to {
369 transform: rotate(360deg);
370 }
371 }
372
373 /* Collapsible editors */
374 .editor-card.collapsed textarea {
375 display: none;
376 }
377
378 .collapse-toggle {
379 background: none;
380 border: none;
381 cursor: pointer;
382 padding: 0.25rem;
383 color: var(--text-secondary);
384 font-size: 0.75rem;
385 display: flex;
386 align-items: center;
387 gap: 0.25rem;
388 }
389
390 .collapse-toggle:hover {
391 color: var(--text-primary);
392 }
393
394 .collapse-all-row {
395 display: flex;
396 justify-content: flex-end;
397 margin-bottom: 0.5rem;
398 gap: 0.5rem;
399 }
400
401 /* Modal */
402 .modal-overlay {
403 position: fixed;
404 top: 0;
405 left: 0;
406 right: 0;
407 bottom: 0;
408 background: rgba(0, 0, 0, 0.5);
409 display: flex;
410 align-items: center;
411 justify-content: center;
412 z-index: 1000;
413 }
414
415 .modal {
416 background: var(--bg-card);
417 border-radius: 0.5rem;
418 padding: 1.5rem;
419 width: 90%;
420 max-width: 400px;
421 box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
422 }
423
424 .modal-header {
425 display: flex;
426 justify-content: space-between;
427 align-items: center;
428 margin-bottom: 1rem;
429 }
430
431 .modal-header h3 {
432 font-size: 1.125rem;
433 font-weight: 600;
434 }
435
436 .btn-close {
437 background: none;
438 border: none;
439 font-size: 1.5rem;
440 cursor: pointer;
441 color: var(--text-secondary);
442 line-height: 1;
443 }
444
445 .btn-close:hover {
446 color: var(--text-primary);
447 }
448
449 .form-group {
450 margin-bottom: 1rem;
451 }
452
453 .form-group label {
454 display: block;
455 font-size: 0.875rem;
456 font-weight: 500;
457 margin-bottom: 0.25rem;
458 }
459
460 .form-group input {
461 width: 100%;
462 padding: 0.5rem 0.75rem;
463 border: 1px solid var(--border);
464 border-radius: 0.375rem;
465 font-size: 0.875rem;
466 }
467
468 .form-group input:focus {
469 outline: none;
470 border-color: var(--border-focus);
471 }
472
473 /* User status in header */
474 .user-status {
475 display: flex;
476 align-items: center;
477 gap: 0.75rem;
478 font-size: 0.875rem;
479 }
480
481 .user-handle {
482 color: var(--text-secondary);
483 }
484
485 .btn-text {
486 background: none;
487 border: none;
488 color: var(--accent);
489 cursor: pointer;
490 font-size: 0.875rem;
491 padding: 0;
492 }
493
494 .btn-text:hover {
495 text-decoration: underline;
496 }
497
498 /* Warning box */
499 .warning-box {
500 background: #fef3c7;
501 border: 1px solid #fcd34d;
502 border-radius: 0.375rem;
503 padding: 0.75rem;
504 font-size: 0.875rem;
505 color: #92400e;
506 }
507
508 /* Published lexicon cards */
509 .published-card {
510 background: var(--bg-input);
511 border: 1px solid var(--border);
512 border-radius: 0.375rem;
513 padding: 0.75rem;
514 margin-bottom: 0.5rem;
515 }
516
517 .published-header {
518 display: flex;
519 justify-content: space-between;
520 align-items: center;
521 }
522
523 .published-nsid {
524 font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
525 font-size: 0.875rem;
526 font-weight: 500;
527 }
528
529 .published-meta {
530 font-size: 0.75rem;
531 color: var(--text-secondary);
532 margin-top: 0.25rem;
533 }
534
535 /* DNS instructions */
536 .dns-card {
537 background: var(--bg-input);
538 border: 1px solid var(--border);
539 border-radius: 0.375rem;
540 padding: 1rem;
541 margin-bottom: 0.75rem;
542 }
543
544 .dns-domain {
545 font-weight: 600;
546 font-size: 1rem;
547 margin-bottom: 0.75rem;
548 color: var(--accent);
549 display: flex;
550 align-items: center;
551 gap: 0.5rem;
552 }
553
554 .dns-status {
555 font-size: 1.1rem;
556 font-weight: 700;
557 }
558
559 .dns-verified {
560 color: #22c55e;
561 }
562
563 .dns-not-found {
564 color: #ef4444;
565 }
566
567 .dns-card-verified {
568 border-color: #22c55e;
569 background: rgba(34, 197, 94, 0.05);
570 }
571
572 .dns-record {
573 background: var(--bg-card);
574 border: 1px solid var(--border);
575 border-radius: 0.25rem;
576 padding: 0.75rem;
577 margin-bottom: 0.75rem;
578 }
579
580 .dns-row {
581 display: flex;
582 align-items: center;
583 gap: 0.5rem;
584 margin-bottom: 0.25rem;
585 }
586
587 .dns-row:last-child {
588 margin-bottom: 0;
589 }
590
591 .dns-label {
592 font-size: 0.75rem;
593 color: var(--text-secondary);
594 width: 3rem;
595 }
596
597 .dns-value {
598 font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
599 font-size: 0.8125rem;
600 flex: 1;
601 }
602
603 .dns-lexicons {
604 font-size: 0.875rem;
605 }
606
607 .dns-lexicons ul {
608 margin: 0.25rem 0 0 1.25rem;
609 padding: 0;
610 }
611
612 .dns-lexicons li {
613 font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
614 font-size: 0.8125rem;
615 }
616
617 .dns-note {
618 background: #dbeafe;
619 border: 1px solid #93c5fd;
620 border-radius: 0.375rem;
621 padding: 0.75rem;
622 font-size: 0.875rem;
623 color: #1e40af;
624 }
625
626 /* Step wizard styles */
627 .step {
628 background: var(--bg-card);
629 border-radius: 0.5rem;
630 margin-bottom: 0.75rem;
631 border: 1px solid var(--border);
632 box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
633 }
634
635 .step-header {
636 display: flex;
637 justify-content: space-between;
638 align-items: center;
639 padding: 1rem;
640 cursor: pointer;
641 user-select: none;
642 }
643
644 .step-header:hover {
645 background: var(--bg-input);
646 }
647
648 .step-header-left {
649 display: flex;
650 align-items: center;
651 gap: 0.75rem;
652 }
653
654 .step-number {
655 width: 1.75rem;
656 height: 1.75rem;
657 border-radius: 50%;
658 display: flex;
659 align-items: center;
660 justify-content: center;
661 font-size: 0.875rem;
662 font-weight: 600;
663 background: var(--border);
664 color: var(--text-secondary);
665 }
666
667 .step-title {
668 font-weight: 600;
669 font-size: 0.9375rem;
670 }
671
672 .step-summary {
673 font-size: 0.875rem;
674 color: var(--text-secondary);
675 }
676
677 .step-content {
678 padding: 1rem;
679 border-top: 1px solid var(--border);
680 }
681
682 /* Step states */
683 .step.step-current .step-number {
684 background: var(--accent);
685 color: #ffffff;
686 }
687
688 .step.step-current .step-header {
689 cursor: default;
690 }
691
692 .step.step-current .step-header:hover {
693 background: transparent;
694 }
695
696 .step.step-completed .step-number {
697 background: var(--success-text);
698 color: #ffffff;
699 }
700
701 .step.step-completed .step-number::after {
702 content: '✓';
703 }
704
705 .step.step-completed .step-number span {
706 display: none;
707 }
708
709 .step.step-future {
710 opacity: 0.5;
711 }
712
713 .step.step-future .step-header {
714 cursor: not-allowed;
715 }
716
717 .step.step-future .step-header:hover {
718 background: transparent;
719 }
720
721 .step.step-collapsed .step-content {
722 display: none;
723 }
724
725 .step.step-collapsed .step-header {
726 border-bottom: none;
727 }
728 </style>
729 </head>
730 <body>
731 <div id="app">
732 <header>
733 <div>
734 <h1>
735 <span style="color: var(--accent)">{</span> Lexicon Publisher
736 <span style="color: var(--accent)">}</span>
737 </h1>
738 <p class="tagline">
739 Publish your AT Protocol Lexicon schemas
740 </p>
741 </div>
742 </header>
743 <div style="text-align: center; margin-bottom: 1.5rem; font-size: 0.875rem; color: var(--text-secondary);">
744 Learn more:
745 <a href="https://atproto.com/specs/lexicon#lexicon-publication-and-resolution" target="_blank" rel="noopener">AT Protocol Spec</a>
746 ·
747 <a href="https://nickthesick.com/blog/Publishing+ATProto+Lexicons" target="_blank" rel="noopener">Publishing Guide</a>
748 ·
749 🐛 <a href="https://tools.slices.network/bugs?ns=network.slices" target="_blank" rel="noopener">Report a bug</a>
750 </div>
751 <main id="steps-container">
752 </main>
753 <footer style="text-align: center; padding: 2rem 1rem; color: var(--text-secondary); font-size: 0.875rem;">
754 <svg width="24" height="24" viewBox="0 0 128 128" style="vertical-align: middle; margin-right: 0.5rem;">
755 <defs>
756 <linearGradient id="board1" x1="0%" y1="0%" x2="100%" y2="100%">
757 <stop offset="0%" stop-color="#FF6347" />
758 <stop offset="100%" stop-color="#FF4500" />
759 </linearGradient>
760 <linearGradient id="board2" x1="0%" y1="0%" x2="100%" y2="100%">
761 <stop offset="0%" stop-color="#00CED1" />
762 <stop offset="100%" stop-color="#4682B4" />
763 </linearGradient>
764 </defs>
765 <g transform="translate(64, 64)">
766 <ellipse cx="0" cy="-28" rx="50" ry="20" fill="url(#board1)" />
767 <ellipse cx="0" cy="0" rx="60" ry="20" fill="url(#board2)" />
768 <ellipse cx="0" cy="28" rx="40" ry="20" fill="#32CD32" />
769 </g>
770 </svg>
771 © 2025 slices.network
772 </footer>
773 </div>
774 <script src="https://cdn.jsdelivr.net/gh/bigmoves/honk@deaa420/dist/honk.min.js" integrity="sha384-c6ZE86yow2uS6DWDABjcuBgp4d9kDleBTyI7eDxjwdgqvl92jM4iFZ+mK3FuxKTk" crossorigin="anonymous"></script>
775 <script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js" integrity="sha384-+mbV2IY1Zk/X1p/nWllGySJSUN8uMs+gUAN10Or95UBH0fpj6GfKgPmgC5EXieXG" crossorigin="anonymous"></script>
776 <!-- TODO: Pin quickslice to a specific version/commit instead of @main -->
777 <script src="https://cdn.jsdelivr.net/gh/bigmoves/quickslice@main/quickslice-client-js/dist/quickslice-client.min.js" crossorigin="anonymous"></script>
778 <script src="https://cdn.jsdelivr.net/gh/bigmoves/elements@v0.1.0/dist/elements.min.js" integrity="sha384-ZT1CwqnY+9K7mM18To0R9U17zu1pEQUJLncM59n3QlwKcGeHI944R8knY+Alh2eA" crossorigin="anonymous"></script>
779 <script>
780 // =============================================================================
781 // CONSTANTS
782 // =============================================================================
783
784 const SERVER_URL = "https://quickslice-production-4b57.up.railway.app";
785 const CLIENT_ID = "client_0ygtMYHz-rzUtCMgvhigcA";
786 const SCOPE = "atproto repo:com.atproto.lexicon.schema";
787
788 // =============================================================================
789 // STATE
790 // =============================================================================
791
792 const state = {
793 // Existing lexicon state
794 lexicons: [{ id: 1, value: "", collapsed: false }],
795 nextLexiconId: 2,
796 result: null,
797
798 // New: Auth state
799 client: null,
800 viewer: null, // { did, handle }
801
802 // New: Published lexicons from user's PDS
803 publishedLexicons: [],
804
805 // New: Publish workflow
806 validationPassed: false,
807 conflicts: [], // [{ nsid, existingOwner, isSameOwner }]
808 publishResults: [], // [{ nsid, success, error }]
809
810 // New: DNS instructions
811 dnsInstructions: [], // [{ domain, record, lexicons }]
812
813 // New: External dependency check
814 missingDependencies: [], // [nsid, ...]
815
816 // Publish result (separate from validation result)
817 publishResult: null,
818
819 // Loading states
820 isValidating: false,
821 isPublishing: false,
822
823 // Step wizard state
824 currentStep: 1, // 1=SignIn, 2=Define, 3=Publish, 4=DNS, 5=Published
825 stepCompleted: {
826 1: false, // Sign In
827 2: false, // Define Lexicons
828 3: false, // Publish
829 4: false, // Verify DNS (non-blocking)
830 },
831 };
832
833 // =============================================================================
834 // STEP NAVIGATION
835 // =============================================================================
836
837 function goToStep(stepNumber) {
838 if (stepNumber < 1 || stepNumber > 5) return;
839
840 // Can only go to completed steps or the next available step
841 const maxAllowedStep = getMaxAllowedStep();
842 if (stepNumber > maxAllowedStep) return;
843
844 state.currentStep = stepNumber;
845 renderAllSteps();
846 }
847
848 function getMaxAllowedStep() {
849 // Can go to step N+1 if step N is completed
850 for (let i = 1; i <= 4; i++) {
851 if (!state.stepCompleted[i]) return i;
852 }
853 return 5;
854 }
855
856 function completeStep(stepNumber) {
857 state.stepCompleted[stepNumber] = true;
858 state.currentStep = Math.min(stepNumber + 1, 5);
859 renderAllSteps();
860 }
861
862 function resetStepsFrom(stepNumber) {
863 // Reset this step and all downstream steps
864 for (let i = stepNumber; i <= 4; i++) {
865 state.stepCompleted[i] = false;
866 }
867 // Clear downstream state
868 if (stepNumber <= 2) {
869 state.validationPassed = false;
870 state.conflicts = [];
871 state.missingDependencies = [];
872 state.result = null;
873 }
874 if (stepNumber <= 3) {
875 state.publishResult = null;
876 state.publishResults = [];
877 }
878 }
879
880 function getStepSummary(stepNumber) {
881 switch (stepNumber) {
882 case 1:
883 return state.viewer ? `@${state.viewer.handle}` : '';
884 case 2:
885 const validCount = state.lexicons.filter(l => l.value.trim()).length;
886 return `${validCount} lexicon${validCount !== 1 ? 's' : ''} ready`;
887 case 3:
888 const publishedCount = state.publishResults.filter(r => r.success).length;
889 return `Published ${publishedCount} lexicon${publishedCount !== 1 ? 's' : ''}`;
890 case 4:
891 const verifiedCount = state.dnsInstructions.filter(d => d.verified === true).length;
892 const totalDns = state.dnsInstructions.length;
893 if (totalDns === 0) return 'No DNS needed';
894 if (verifiedCount === totalDns) return 'DNS verified';
895 return `${verifiedCount}/${totalDns} domains verified`;
896 default:
897 return '';
898 }
899 }
900
901 // =============================================================================
902 // PLACEHOLDERS
903 // =============================================================================
904
905 const LEXICON_PLACEHOLDER = `{
906 "lexicon": 1,
907 "id": "com.example.myRecord",
908 "defs": {
909 "main": {
910 "type": "record",
911 "record": {
912 "type": "object",
913 "required": ["status", "createdAt"],
914 "properties": {
915 "status": {
916 "type": "string",
917 "maxLength": 100
918 },
919 "createdAt": {
920 "type": "string",
921 "format": "datetime"
922 }
923 }
924 }
925 }
926 }
927}`;
928
929 // =============================================================================
930 // HELPERS
931 // =============================================================================
932
933 function esc(str) {
934 const d = document.createElement("div");
935 d.textContent = str || "";
936 return d.innerHTML;
937 }
938
939 function escapeAttr(str) {
940 return (str || "")
941 .replace(/&/g, "&")
942 .replace(/"/g, """)
943 .replace(/</g, "<")
944 .replace(/>/g, ">");
945 }
946
947 function unwrapHonkResult(result) {
948 if (typeof result.isOk === "function") {
949 return { ok: result.isOk(), value: result[0] };
950 }
951 return { ok: true, value: result };
952 }
953
954 function formatHonkError(err) {
955 if (!err) return "Unknown error";
956 if (typeof err === "string") return err;
957 if (err.message) {
958 return err.path ? `${err.message} (at ${err.path})` : err.message;
959 }
960
961 // Handle Gleam Dict/Map structure (HAMT)
962 // Structure: { root: { array: [{ k: nsid, v: { head: errorMsg, tail: {} } }] }, size: n }
963 if (err.root && err.root.array && Array.isArray(err.root.array)) {
964 const errors = [];
965 for (const entry of err.root.array) {
966 if (entry.v) {
967 // Extract errors from linked list structure (head/tail)
968 let current = entry.v;
969 while (current && current.head) {
970 errors.push(current.head);
971 current = current.tail;
972 }
973 }
974 }
975 if (errors.length > 0) {
976 return errors.join("\n");
977 }
978 }
979
980 // Handle simple linked list structure (head/tail without root)
981 if (err.head) {
982 const errors = [];
983 let current = err;
984 while (current && current.head) {
985 errors.push(current.head);
986 current = current.tail;
987 }
988 if (errors.length > 0) {
989 return errors.join("\n");
990 }
991 }
992
993 return JSON.stringify(err, null, 2);
994 }
995
996 // =============================================================================
997 // OAUTH
998 // =============================================================================
999
1000 async function initClient() {
1001 if (!state.client) {
1002 state.client = await QuicksliceClient.createQuicksliceClient({
1003 server: SERVER_URL,
1004 clientId: CLIENT_ID,
1005 scope: SCOPE,
1006 });
1007 }
1008 return state.client;
1009 }
1010
1011 function showLoginModal() {
1012 const existing = document.getElementById("login-modal");
1013 if (existing) existing.remove();
1014
1015 const modal = document.createElement("div");
1016 modal.id = "login-modal";
1017 modal.className = "modal-overlay";
1018 modal.innerHTML = `
1019 <div class="modal">
1020 <div class="modal-header">
1021 <h3>Sign In</h3>
1022 <button class="btn-close" onclick="closeLoginModal()">×</button>
1023 </div>
1024 <form onsubmit="handleLogin(event)">
1025 <div class="form-group">
1026 <label for="login-handle">Your handle</label>
1027 <input type="text" id="login-handle" placeholder="you.bsky.social" required />
1028 </div>
1029 <button type="submit" class="btn btn-primary" style="width: 100%">Sign In</button>
1030 </form>
1031 </div>
1032 `;
1033 document.body.appendChild(modal);
1034 document.getElementById("login-handle").focus();
1035 }
1036
1037 function closeLoginModal() {
1038 const modal = document.getElementById("login-modal");
1039 if (modal) modal.remove();
1040 }
1041
1042 async function handleLogin(event) {
1043 event.preventDefault();
1044 const handle = document.getElementById("login-handle").value.trim();
1045 if (!handle) return;
1046
1047 try {
1048 await initClient();
1049 await state.client.loginWithRedirect({ handle });
1050 } catch (err) {
1051 console.error("Login error:", err);
1052 state.result = { success: false, message: "Login failed: " + err.message };
1053 renderAllSteps();
1054 }
1055 }
1056
1057 function handleLogout() {
1058 if (state.client) {
1059 state.client.logout();
1060 }
1061 window.location.reload();
1062 }
1063
1064 // =============================================================================
1065 // RENDERING
1066 // =============================================================================
1067
1068 function renderStep(stepNumber, title, contentHtml) {
1069 const isCompleted = state.stepCompleted[stepNumber];
1070 const isCurrent = state.currentStep === stepNumber;
1071 const isFuture = stepNumber > getMaxAllowedStep();
1072 const isCollapsed = !isCurrent;
1073
1074 const classes = ['step'];
1075 if (isCompleted) classes.push('step-completed');
1076 if (isCurrent) classes.push('step-current');
1077 if (isFuture) classes.push('step-future');
1078 if (isCollapsed) classes.push('step-collapsed');
1079
1080 const summary = isCompleted ? getStepSummary(stepNumber) : '';
1081 const clickHandler = !isCurrent && !isFuture ? `onclick="goToStep(${stepNumber})"` : '';
1082
1083 return `
1084 <div class="${classes.join(' ')}" data-step="${stepNumber}">
1085 <div class="step-header" ${clickHandler}>
1086 <div class="step-header-left">
1087 <div class="step-number"><span>${stepNumber}</span></div>
1088 <span class="step-title">${esc(title)}</span>
1089 </div>
1090 ${summary ? `<span class="step-summary">${esc(summary)}</span>` : ''}
1091 </div>
1092 <div class="step-content">
1093 ${contentHtml}
1094 </div>
1095 </div>
1096 `;
1097 }
1098
1099 function renderAllSteps() {
1100 const container = document.getElementById('steps-container');
1101 if (!container) return;
1102
1103 container.innerHTML = [
1104 renderStep(1, 'Sign In', renderSignInContent()),
1105 renderStep(2, 'Define Lexicons', renderDefineLexiconsContent()),
1106 renderStep(3, 'Publish', renderPublishContent()),
1107 renderStep(4, 'Verify DNS', renderDnsContent()),
1108 renderStep(5, 'Your Published Lexicons', renderPublishedLexiconsContent()),
1109 ].join('');
1110
1111 setupAutocomplete();
1112 }
1113
1114 function renderSignInContent() {
1115 if (state.viewer) {
1116 // Already signed in - show minimal confirmation
1117 return `
1118 <p style="color: var(--text-secondary);">
1119 Signed in as <strong>@${esc(state.viewer.handle)}</strong>
1120 </p>
1121 <div class="button-row">
1122 <button class="btn btn-secondary" onclick="handleLogout()">Sign Out</button>
1123 </div>
1124 `;
1125 }
1126
1127 return `
1128 <p style="color: var(--text-secondary); margin-bottom: 1rem;">
1129 Sign in with your AT Protocol handle to publish lexicons to your PDS.
1130 </p>
1131 <form onsubmit="handleStepLogin(event)" style="display: flex; gap: 0.5rem; align-items: flex-start;">
1132 <qs-actor-autocomplete id="handle-autocomplete" placeholder="you.bsky.social"></qs-actor-autocomplete>
1133 <input type="hidden" id="step-login-handle" />
1134 <button type="submit" class="btn btn-primary">Sign In</button>
1135 </form>
1136 `;
1137 }
1138
1139 function setupAutocomplete() {
1140 const autocomplete = document.getElementById('handle-autocomplete');
1141 if (!autocomplete) return;
1142
1143 autocomplete.addEventListener('qs-select', (e) => {
1144 document.getElementById('step-login-handle').value = e.detail.actor.handle;
1145 });
1146 autocomplete.addEventListener('input', () => {
1147 document.getElementById('step-login-handle').value = autocomplete.value;
1148 });
1149 }
1150
1151 async function handleStepLogin(event) {
1152 event.preventDefault();
1153 const handle = document.getElementById('step-login-handle').value.trim() ||
1154 document.getElementById('handle-autocomplete')?.value?.trim();
1155 if (!handle) return;
1156
1157 try {
1158 await initClient();
1159 await state.client.loginWithRedirect({ handle });
1160 } catch (err) {
1161 console.error('Login error:', err);
1162 state.result = { success: false, message: 'Login failed: ' + err.message };
1163 renderAllSteps();
1164 }
1165 }
1166
1167 function renderDefineLexiconsContent() {
1168 const getLexiconLabel = (lex, idx) => {
1169 try {
1170 const obj = JSON.parse(lex.value);
1171 if (obj.id) return obj.id;
1172 } catch (e) {}
1173 return `Lexicon ${idx + 1}`;
1174 };
1175
1176 const editorsHtml = state.lexicons
1177 .map(
1178 (lex, idx) => `
1179 <div class="editor-card ${lex.collapsed ? "collapsed" : ""}" data-lexicon-id="${lex.id}">
1180 <div class="editor-header">
1181 <div style="display: flex; align-items: center; gap: 0.5rem;">
1182 <button class="collapse-toggle" onclick="toggleLexicon(${lex.id})">
1183 ${lex.collapsed ? "▶" : "▼"}
1184 </button>
1185 <span class="editor-label">${esc(getLexiconLabel(lex, idx))}</span>
1186 </div>
1187 <button
1188 class="btn btn-danger btn-small"
1189 onclick="removeLexicon(${lex.id})"
1190 ${state.lexicons.length === 1 ? "disabled" : ""}
1191 >Remove</button>
1192 </div>
1193 <textarea
1194 placeholder="${escapeAttr(LEXICON_PLACEHOLDER)}"
1195 onchange="handleLexiconChange(${lex.id}, this.value)"
1196 oninput="handleLexiconChange(${lex.id}, this.value)"
1197 >${esc(lex.value)}</textarea>
1198 </div>
1199 `,
1200 )
1201 .join("");
1202
1203 const hasMultiple = state.lexicons.length > 1;
1204 const allCollapsed = state.lexicons.every((l) => l.collapsed);
1205 const collapseAllRow = hasMultiple
1206 ? `<div class="collapse-all-row">
1207 <button class="btn btn-secondary btn-small" onclick="toggleAllLexicons(${allCollapsed ? "false" : "true"})">
1208 ${allCollapsed ? "Expand All" : "Collapse All"}
1209 </button>
1210 </div>`
1211 : "";
1212
1213 const resultHtml = state.result && state.currentStep === 2 ? `
1214 <div class="result ${state.result.success ? 'result-success' : 'result-error'}" style="margin-top: 0.75rem;">${state.result.success ? '✓' : '✗'} ${esc(state.result.message)}</div>
1215 ${state.missingDependencies.length > 0 ? `
1216 <div class="warning-box" style="margin-top: 0.75rem;">
1217 <strong>Missing external dependencies:</strong>
1218 <ul style="margin: 0.5rem 0 0 1.25rem;">
1219 ${state.missingDependencies.map(d => `<li>${esc(d)}</li>`).join('')}
1220 </ul>
1221 </div>
1222 ` : ''}
1223 ` : '';
1224
1225 return `
1226 <div
1227 class="drop-zone"
1228 id="drop-zone"
1229 ondragover="handleDragOver(event)"
1230 ondragleave="handleDragLeave(event)"
1231 ondrop="handleDrop(event)"
1232 onclick="triggerFileInput()"
1233 >
1234 <div class="drop-zone-icon">📦</div>
1235 <div class="drop-zone-text">
1236 <strong>Drop a .zip file of your lexicons</strong> or click to browse
1237 </div>
1238 <div class="drop-zone-hint">Supports nested folders with .json lexicon files</div>
1239 <div class="drop-zone-example">
1240<pre style="text-align: left; margin: 0.75rem auto 0; display: inline-block;">lexicons/
1241├── com/
1242│ └── example/
1243│ ├── actor/
1244│ │ └── profile.json
1245│ └── feed/
1246│ └── post.json</pre>
1247 </div>
1248 </div>
1249 <div style="display: flex; justify-content: center; margin-bottom: 1rem; color: var(--text-secondary);">
1250 See <a href="https://github.com/bluesky-social/atproto/tree/main/lexicons" target="_blank" rel="noopener">bluesky-social/atproto/lexicons</a> for a typical structure
1251 </div>
1252 <input type="file" id="file-input" accept=".zip" onchange="handleFileSelect(event)" style="display: none;" />
1253 ${collapseAllRow}
1254 ${editorsHtml}
1255 <div class="button-row">
1256 <button class="btn btn-secondary" onclick="addLexicon()" ${state.isValidating ? 'disabled' : ''}>+ Add Lexicon</button>
1257 <button class="btn btn-primary" onclick="continueToPublish()" ${state.isValidating ? 'disabled' : ''}>
1258 ${state.isValidating ? '<span class="loading-spinner"></span> Validating...' : 'Continue to Publish'}
1259 </button>
1260 </div>
1261 ${state.publishedLexicons.length > 0 ? `
1262 <div style="text-align: center; margin-top: 0.75rem;">
1263 <a href="#" onclick="goToPublishedStep(); return false;" style="color: var(--text-secondary); font-size: 0.875rem;">
1264 or view your published lexicons
1265 </a>
1266 </div>
1267 ` : ''}
1268 ${resultHtml}
1269 `;
1270 }
1271
1272 function goToPublishedStep() {
1273 // Skip to step 5 to view published lexicons
1274 state.stepCompleted[2] = true;
1275 state.stepCompleted[3] = true;
1276 state.stepCompleted[4] = true;
1277 state.currentStep = 5;
1278 renderAllSteps();
1279 }
1280
1281 function handleLexiconChange(id, value) {
1282 const lex = state.lexicons.find((l) => l.id === id);
1283 if (lex) {
1284 lex.value = value;
1285 // Reset downstream steps when editing
1286 if (state.stepCompleted[2]) {
1287 resetStepsFrom(2);
1288 }
1289 }
1290 }
1291
1292 async function continueToPublish() {
1293 // Validate lexicons first
1294 state.result = null;
1295 state.validationPassed = false;
1296 state.conflicts = [];
1297 state.missingDependencies = [];
1298 state.isValidating = true;
1299 renderAllSteps();
1300
1301 const parseResult = parseLexicons();
1302 if (parseResult.error) {
1303 state.result = { success: false, message: parseResult.error };
1304 state.isValidating = false;
1305 renderAllSteps();
1306 return;
1307 }
1308
1309 try {
1310 const lexiconList = honk.toList(parseResult.lexicons);
1311 const result = honk.validate(lexiconList);
1312 const unwrapped = unwrapHonkResult(result);
1313
1314 if (!unwrapped.ok) {
1315 state.result = {
1316 success: false,
1317 message: `Validation failed: ${formatHonkError(unwrapped.value)}`,
1318 };
1319 state.isValidating = false;
1320 renderAllSteps();
1321 return;
1322 }
1323
1324 const count = parseResult.lexicons.length;
1325 state.result = { success: true, message: `${count} lexicon${count !== 1 ? 's' : ''} valid` };
1326 state.validationPassed = true;
1327
1328 // Check external dependencies and conflicts
1329 if (state.viewer) {
1330 const userDomain = getUserDomain(state.viewer.handle);
1331 const externalRefs = extractExternalRefs(parseResult.lexicons, userDomain);
1332 const { missing } = await checkExternalDependencies(externalRefs);
1333 state.missingDependencies = missing;
1334 await checkConflicts();
1335 }
1336
1337 state.isValidating = false;
1338 // Complete step 2 and move to step 3
1339 completeStep(2);
1340 } catch (e) {
1341 state.result = {
1342 success: false,
1343 message: `Validation error: ${e.message}`,
1344 };
1345 state.isValidating = false;
1346 renderAllSteps();
1347 }
1348 }
1349
1350 function renderPublishContent() {
1351 const { publishable, excluded } = getPublishableLexicons();
1352 const userDomain = state.viewer ? getUserDomain(state.viewer.handle) : '';
1353
1354 // Build conflict lookup
1355 const conflictMap = {};
1356 for (const c of state.conflicts) {
1357 conflictMap[c.nsid] = c;
1358 }
1359
1360 // Categorize lexicons
1361 const willPublish = []; // new or update (same owner)
1362 const blocked = []; // different owner conflicts
1363
1364 for (const p of publishable) {
1365 const conflict = conflictMap[p.nsid];
1366 if (conflict && !conflict.isSameOwner) {
1367 blocked.push({ nsid: p.nsid, owner: conflict.existingOwner });
1368 } else {
1369 willPublish.push({
1370 nsid: p.nsid,
1371 isUpdate: conflict && conflict.isSameOwner
1372 });
1373 }
1374 }
1375
1376 const publishCount = willPublish.length;
1377 const canPublish = publishCount > 0;
1378
1379 const resultHtml = state.publishResult ? `
1380 <div class="result ${state.publishResult.success ? 'result-success' : 'result-error'}" style="margin-top: 0.75rem;">${state.publishResult.success ? '✓' : '✗'} ${esc(state.publishResult.message)}</div>
1381 ` : '';
1382
1383 // Unified lexicon list
1384 const listItems = [];
1385
1386 // Publishable (new or update)
1387 for (const item of willPublish) {
1388 const status = item.isUpdate ? 'update' : 'new';
1389 listItems.push(`<li><span style="color: var(--success);">✓</span> ${esc(item.nsid)} <span style="opacity: 0.6;">(${status})</span></li>`);
1390 }
1391
1392 // Excluded (wrong domain)
1393 for (const item of excluded) {
1394 listItems.push(`<li><span style="opacity: 0.5;">−</span> <span style="opacity: 0.6;">${esc(item.nsid)}</span> <span style="opacity: 0.5;">(${esc(getRootDomain(item.domain))})</span></li>`);
1395 }
1396
1397 // Blocked (different owner)
1398 for (const item of blocked) {
1399 listItems.push(`<li><span style="color: var(--error);">✗</span> ${esc(item.nsid)} <span style="opacity: 0.6;">owned by @${esc(item.owner)}</span></li>`);
1400 }
1401
1402 const lexiconList = listItems.length > 0 ? `
1403 <ul style="margin: 0 0 0.75rem 0; padding-left: 0; list-style: none; font-family: ui-monospace, SFMono-Regular, monospace; font-size: 0.875rem;">
1404 ${listItems.join('')}
1405 </ul>
1406 ` : '';
1407
1408 return `
1409 <p style="color: var(--text-secondary); margin-bottom: 0.75rem;">
1410 ${canPublish
1411 ? `Publish ${publishCount} lexicon${publishCount !== 1 ? 's' : ''} to @${esc(state.viewer?.handle || '')}'s PDS.`
1412 : `No lexicons to publish for ${esc(userDomain)}.`}
1413 </p>
1414 ${lexiconList}
1415 <div class="button-row">
1416 <button class="btn btn-primary" onclick="publishAndContinue()" ${canPublish && !state.isPublishing ? '' : 'disabled'}>
1417 ${state.isPublishing ? '<span class="loading-spinner"></span> Publishing...' : 'Publish to PDS'}
1418 </button>
1419 </div>
1420 ${resultHtml}
1421 `;
1422 }
1423
1424 async function publishAndContinue() {
1425 if (!state.viewer || !state.client) return;
1426
1427 state.publishResults = [];
1428 state.publishResult = null;
1429 state.isPublishing = true;
1430 renderAllSteps();
1431
1432 const { publishable: lexiconsToPublish } = getPublishableLexicons();
1433
1434 if (lexiconsToPublish.length === 0) {
1435 state.publishResult = { success: false, message: "No valid lexicons to publish" };
1436 state.isPublishing = false;
1437 renderAllSteps();
1438 return;
1439 }
1440
1441 // Publish each lexicon
1442 for (const { nsid, schema } of lexiconsToPublish) {
1443 try {
1444 const ownedConflict = state.conflicts.find(c => c.nsid === nsid && c.isSameOwner);
1445 const input = {
1446 id: nsid,
1447 defs: schema.defs,
1448 lexicon: schema.lexicon || 1,
1449 ...(schema.description && { description: schema.description }),
1450 };
1451
1452 if (ownedConflict) {
1453 await state.client.mutate(`
1454 mutation UpdateSchema($input: ComAtprotoLexiconSchemaInput!, $rkey: String!) {
1455 updateComAtprotoLexiconSchema(input: $input, rkey: $rkey) {
1456 uri
1457 }
1458 }
1459 `, { rkey: nsid, input });
1460 state.publishResults.push({ nsid, success: true, updated: true });
1461 } else {
1462 await state.client.mutate(`
1463 mutation CreateSchema($input: ComAtprotoLexiconSchemaInput!, $rkey: String!) {
1464 createComAtprotoLexiconSchema(input: $input, rkey: $rkey) {
1465 uri
1466 }
1467 }
1468 `, { rkey: nsid, input });
1469 state.publishResults.push({ nsid, success: true });
1470 }
1471 } catch (err) {
1472 state.publishResults.push({ nsid, success: false, error: err.message });
1473 }
1474 }
1475
1476 // Generate DNS instructions
1477 generateDnsInstructions(lexiconsToPublish.map(l => l.nsid));
1478
1479 // Refresh published lexicons list
1480 await fetchPublishedLexicons();
1481
1482 // Show results
1483 const successCount = state.publishResults.filter(r => r.success).length;
1484 const failCount = state.publishResults.filter(r => !r.success).length;
1485
1486 state.isPublishing = false;
1487
1488 if (failCount === 0) {
1489 state.publishResult = { success: true, message: `Published ${successCount} lexicon${successCount !== 1 ? 's' : ''}` };
1490 // Complete step 3 and move forward
1491 completeStep(3);
1492 } else {
1493 const errors = state.publishResults.filter(r => !r.success).map(r => `${r.nsid}: ${r.error}`).join('\n');
1494 state.publishResult = { success: false, message: `Published ${successCount}, failed ${failCount}:\n${errors}` };
1495 renderAllSteps();
1496 }
1497 }
1498
1499 function renderDnsContent() {
1500 if (state.dnsInstructions.length === 0) {
1501 return `
1502 <p style="color: var(--text-secondary);">
1503 No DNS records required. Your lexicons are published!
1504 </p>
1505 <div class="button-row">
1506 <button class="btn btn-primary" onclick="skipDnsAndContinue()">View Published Lexicons</button>
1507 </div>
1508 `;
1509 }
1510
1511 const cards = state.dnsInstructions.map(instr => {
1512 let statusIcon = '';
1513 if (instr.verified === true) {
1514 statusIcon = '<span class="dns-status dns-verified" title="Verified">✓</span>';
1515 } else if (instr.verified === false) {
1516 statusIcon = '<span class="dns-status dns-not-found" title="Not found">✗</span>';
1517 }
1518
1519 return `
1520 <div class="dns-card ${instr.verified === true ? 'dns-card-verified' : ''}">
1521 <div class="dns-domain">${esc(instr.domain)} ${statusIcon}</div>
1522 <div class="dns-record">
1523 <div class="dns-row">
1524 <span class="dns-label">Type:</span>
1525 <span class="dns-value">TXT</span>
1526 </div>
1527 <div class="dns-row">
1528 <span class="dns-label">Name:</span>
1529 <span class="dns-value">${esc(instr.recordName)}</span>
1530 <button class="btn btn-secondary btn-small" onclick="copyToClipboard('${escapeAttr(instr.recordName)}')">Copy</button>
1531 </div>
1532 <div class="dns-row">
1533 <span class="dns-label">Value:</span>
1534 <span class="dns-value">${esc(instr.recordValue)}</span>
1535 <button class="btn btn-secondary btn-small" onclick="copyToClipboard('${escapeAttr(instr.recordValue)}')">Copy</button>
1536 </div>
1537 </div>
1538 <div class="dns-lexicons">
1539 <strong>Lexicons:</strong>
1540 <ul>
1541 ${instr.lexicons.map(l => `<li>${esc(l)}</li>`).join('')}
1542 </ul>
1543 </div>
1544 </div>
1545 `;
1546 }).join('');
1547
1548 const allVerified = state.dnsInstructions.every(d => d.verified === true);
1549
1550 return `
1551 <div class="dns-note" style="margin-bottom: 0.75rem;">
1552 Add these TXT records to your DNS provider. You must control these domains to complete lexicon resolution.
1553 </div>
1554 ${cards}
1555 <div class="button-row">
1556 <button class="btn btn-secondary" onclick="verifyDnsAndRender()">Verify DNS</button>
1557 <button class="btn btn-primary" onclick="continueToDone()">
1558 ${allVerified ? 'Continue' : 'Skip for Now'}
1559 </button>
1560 </div>
1561 `;
1562 }
1563
1564 async function verifyDnsAndRender() {
1565 await verifyDnsRecords();
1566 renderAllSteps();
1567 }
1568
1569 function skipDnsAndContinue() {
1570 // Mark DNS step as complete (non-blocking) and continue
1571 state.stepCompleted[4] = true;
1572 state.currentStep = 5;
1573 renderAllSteps();
1574 }
1575
1576 function continueToDone() {
1577 // Mark DNS step as complete and move to final step
1578 state.stepCompleted[4] = true;
1579 state.currentStep = 5;
1580 renderAllSteps();
1581 }
1582
1583 function renderPublishedLexiconsContent() {
1584 if (state.publishedLexicons.length === 0) {
1585 return `
1586 <p style="color: var(--text-secondary);">
1587 You haven't published any lexicons yet.
1588 </p>
1589 `;
1590 }
1591
1592 const lexiconCards = state.publishedLexicons.map(lex => `
1593 <div class="published-card">
1594 <div class="published-header">
1595 <span class="published-nsid">${esc(lex.rkey)}</span>
1596 <button class="btn btn-danger btn-small" onclick="deleteLexiconAndRender('${escapeAttr(lex.rkey)}')">
1597 Delete
1598 </button>
1599 </div>
1600 </div>
1601 `).join('');
1602
1603 return `
1604 <p style="color: var(--text-secondary); margin-bottom: 0.75rem;">
1605 You have ${state.publishedLexicons.length} published lexicon${state.publishedLexicons.length !== 1 ? 's' : ''}.
1606 </p>
1607 ${lexiconCards}
1608 <div class="button-row" style="margin-top: 1rem;">
1609 <button class="btn btn-primary" onclick="goToDefineStep()">Add or Update Lexicons</button>
1610 </div>
1611 `;
1612 }
1613
1614 function goToDefineStep() {
1615 // Reset steps 2-4 but keep step 1 complete (still signed in)
1616 resetStepsFrom(2);
1617 state.currentStep = 2;
1618 renderAllSteps();
1619 }
1620
1621 async function deleteLexiconAndRender(rkey) {
1622 if (!confirm(`Delete lexicon ${rkey}?`)) return;
1623
1624 try {
1625 await state.client.mutate(`
1626 mutation DeleteSchema($rkey: String!) {
1627 deleteComAtprotoLexiconSchema(rkey: $rkey) {
1628 uri
1629 }
1630 }
1631 `, { rkey });
1632
1633 state.publishedLexicons = state.publishedLexicons.filter(l => l.rkey !== rkey);
1634
1635 // Update DNS instructions if needed
1636 if (state.publishedLexicons.length > 0) {
1637 generateDnsInstructions(state.publishedLexicons.map(l => l.rkey));
1638 } else {
1639 state.dnsInstructions = [];
1640 }
1641
1642 renderAllSteps();
1643 } catch (err) {
1644 alert(`Failed to delete: ${err.message}`);
1645 }
1646 }
1647
1648 async function fetchPublishedLexicons() {
1649 if (!state.viewer || !state.client) return;
1650
1651 try {
1652 const result = await state.client.query(`
1653 query {
1654 viewer {
1655 comAtprotoLexiconSchemaByDid(first: 100) {
1656 edges {
1657 node {
1658 uri
1659 defs
1660 lexicon
1661 indexedAt
1662 }
1663 }
1664 }
1665 }
1666 }
1667 `);
1668
1669 state.publishedLexicons = (result?.viewer?.comAtprotoLexiconSchemaByDid?.edges || [])
1670 .map(e => {
1671 const uri = e.node.uri;
1672 const rkey = uri.split('/').pop();
1673 return {
1674 uri,
1675 rkey,
1676 defs: e.node.defs,
1677 lexicon: e.node.lexicon,
1678 indexedAt: e.node.indexedAt,
1679 };
1680 });
1681
1682 // Generate DNS instructions from published lexicons
1683 if (state.publishedLexicons.length > 0) {
1684 generateDnsInstructions(state.publishedLexicons.map(l => l.rkey));
1685 }
1686 } catch (err) {
1687 console.error("Failed to fetch published lexicons:", err);
1688 }
1689 }
1690
1691 function getDomainAuthority(nsid) {
1692 // "buzz.bookhive.defs" → "bookhive.buzz"
1693 // "buzz.bookhive.actor.profile" → "actor.bookhive.buzz"
1694 const parts = nsid.split(".");
1695 parts.pop(); // remove name (last segment)
1696 return parts.reverse().join(".");
1697 }
1698
1699 function getUserDomain(handle) {
1700 // "bigmoves.bookhive.buzz" → "bookhive.buzz"
1701 // "chad.bsky.social" → "bsky.social"
1702 const parts = handle.split(".");
1703 if (parts.length >= 2) {
1704 return parts.slice(-2).join(".");
1705 }
1706 return handle;
1707 }
1708
1709 function getRootDomain(domain) {
1710 // "richtext.bsky.app" → "bsky.app"
1711 // "actor.bsky.app" → "bsky.app"
1712 const parts = domain.split(".");
1713 if (parts.length >= 2) {
1714 return parts.slice(-2).join(".");
1715 }
1716 return domain;
1717 }
1718
1719 function canPublishLexicon(nsid) {
1720 if (!state.viewer) return false;
1721 const userDomain = getUserDomain(state.viewer.handle);
1722 const lexiconDomain = getDomainAuthority(nsid);
1723 // Check if lexicon domain ends with user's domain
1724 return lexiconDomain === userDomain || lexiconDomain.endsWith("." + userDomain);
1725 }
1726
1727 function getPublishableLexicons() {
1728 const publishable = [];
1729 const excluded = [];
1730
1731 for (const lex of state.lexicons) {
1732 const trimmed = lex.value.trim();
1733 if (!trimmed) continue;
1734 try {
1735 const obj = JSON.parse(trimmed);
1736 if (obj.id) {
1737 if (canPublishLexicon(obj.id)) {
1738 publishable.push({ nsid: obj.id, schema: obj });
1739 } else {
1740 excluded.push({ nsid: obj.id, domain: getDomainAuthority(obj.id) });
1741 }
1742 }
1743 } catch (e) {}
1744 }
1745
1746 return { publishable, excluded };
1747 }
1748
1749 function extractExternalRefs(lexicons, userDomain) {
1750 const refs = new Set();
1751
1752 function walk(obj) {
1753 if (!obj || typeof obj !== 'object') return;
1754 if (obj.ref && typeof obj.ref === 'string') {
1755 const nsid = obj.ref.split('#')[0];
1756 if (!nsid) return; // Skip local refs like #main
1757 const domain = getDomainAuthority(nsid);
1758 if (domain !== userDomain && !domain.endsWith('.' + userDomain)) {
1759 refs.add(nsid);
1760 }
1761 }
1762 for (const val of Object.values(obj)) walk(val);
1763 }
1764
1765 for (const lex of lexicons) walk(lex);
1766 return [...refs];
1767 }
1768
1769 async function checkExternalDependencies(externalNsids) {
1770 if (externalNsids.length === 0) return { found: [], missing: [] };
1771
1772 try {
1773 const result = await state.client.query(`
1774 query CheckDeps($nsids: [String!]!) {
1775 comAtprotoLexiconSchema(
1776 first: 100,
1777 where: { id: { in: $nsids } }
1778 ) {
1779 edges {
1780 node { id }
1781 }
1782 }
1783 }
1784 `, { nsids: externalNsids });
1785
1786 const foundIds = (result?.comAtprotoLexiconSchema?.edges || [])
1787 .map(e => e.node.id);
1788
1789 return {
1790 found: externalNsids.filter(n => foundIds.includes(n)),
1791 missing: externalNsids.filter(n => !foundIds.includes(n)),
1792 };
1793 } catch (err) {
1794 console.error("External dependency check failed:", err);
1795 return { found: [], missing: [] };
1796 }
1797 }
1798
1799 function generateDnsInstructions(nsids) {
1800 if (!state.viewer) return;
1801
1802 const byDomain = {};
1803
1804 for (const nsid of nsids) {
1805 const domain = getDomainAuthority(nsid);
1806 if (!byDomain[domain]) {
1807 byDomain[domain] = [];
1808 }
1809 byDomain[domain].push(nsid);
1810 }
1811
1812 state.dnsInstructions = Object.entries(byDomain).map(([domain, lexicons]) => ({
1813 domain,
1814 recordName: `_lexicon.${domain}`,
1815 recordValue: `did=${state.viewer.did}`,
1816 lexicons,
1817 verified: null, // null = not checked, true = verified, false = not found
1818 }));
1819 }
1820
1821 async function resolveDNS(name, type = 'TXT') {
1822 const url = new URL('https://cloudflare-dns.com/dns-query');
1823 url.searchParams.set('name', name);
1824 url.searchParams.set('type', type);
1825
1826 const response = await fetch(url, {
1827 headers: { 'Accept': 'application/dns-json' }
1828 });
1829
1830 return response.json();
1831 }
1832
1833 async function verifyDnsRecords() {
1834 if (!state.viewer || state.dnsInstructions.length === 0) return;
1835
1836 const expectedValue = `did=${state.viewer.did}`;
1837
1838 for (const instr of state.dnsInstructions) {
1839 try {
1840 const result = await resolveDNS(instr.recordName, 'TXT');
1841 const answers = result.Answer || [];
1842 // TXT records come quoted, check if any match
1843 const found = answers.some(a => {
1844 const txt = (a.data || '').replace(/^"|"$/g, '');
1845 return txt === expectedValue;
1846 });
1847 instr.verified = found;
1848 } catch (err) {
1849 console.error(`DNS lookup failed for ${instr.recordName}:`, err);
1850 instr.verified = false;
1851 }
1852 }
1853 }
1854
1855 function copyToClipboard(text) {
1856 navigator.clipboard.writeText(text).catch(err => {
1857 console.error('Copy failed:', err);
1858 });
1859 }
1860
1861 // =============================================================================
1862 // STATE UPDATES
1863 // =============================================================================
1864
1865 function addLexicon() {
1866 state.lexicons.push({ id: state.nextLexiconId++, value: "", collapsed: false });
1867 renderAllSteps();
1868 }
1869
1870 function toggleLexicon(id) {
1871 const lex = state.lexicons.find((l) => l.id === id);
1872 if (lex) {
1873 lex.collapsed = !lex.collapsed;
1874 renderAllSteps();
1875 }
1876 }
1877
1878 function toggleAllLexicons(collapse) {
1879 state.lexicons.forEach((lex) => {
1880 lex.collapsed = collapse;
1881 });
1882 renderAllSteps();
1883 }
1884
1885 function removeLexicon(id) {
1886 if (state.lexicons.length <= 1) return;
1887 state.lexicons = state.lexicons.filter((l) => l.id !== id);
1888 renderAllSteps();
1889 }
1890
1891 // =============================================================================
1892 // VALIDATION
1893 // =============================================================================
1894
1895 function parseLexicons() {
1896 const parsed = [];
1897 for (let i = 0; i < state.lexicons.length; i++) {
1898 const lex = state.lexicons[i];
1899 const trimmed = lex.value.trim();
1900
1901 if (!trimmed) {
1902 return {
1903 error: `Lexicon ${i + 1}: Empty - please enter a lexicon schema`,
1904 };
1905 }
1906
1907 // Validate JSON syntax first
1908 try {
1909 const obj = JSON.parse(trimmed);
1910 if (Array.isArray(obj)) {
1911 return {
1912 error: `Lexicon ${i + 1}: Expected a single lexicon object, not an array. Use "+ Add Lexicon" for multiple.`,
1913 };
1914 }
1915 } catch (e) {
1916 return { error: `Lexicon ${i + 1}: Invalid JSON - ${e.message}` };
1917 }
1918
1919 // Parse to Gleam Json type
1920 const parseResult = honk.parse_json_string(trimmed);
1921 const unwrapped = unwrapHonkResult(parseResult);
1922 if (!unwrapped.ok) {
1923 return { error: `Lexicon ${i + 1}: ${formatHonkError(unwrapped.value)}` };
1924 }
1925 parsed.push(unwrapped.value);
1926 }
1927 return { lexicons: parsed };
1928 }
1929
1930 async function checkConflicts() {
1931 state.conflicts = [];
1932
1933 // Only check conflicts for publishable lexicons (ones the user can actually publish)
1934 const { publishable } = getPublishableLexicons();
1935 const nsids = publishable.map(p => p.nsid);
1936
1937 if (nsids.length === 0) return;
1938
1939 try {
1940 // Batch query for all NSIDs at once
1941 const result = await state.client.query(`
1942 query CheckConflicts($nsids: [String!]!) {
1943 comAtprotoLexiconSchema(
1944 where: { id: { in: $nsids } }
1945 first: 100
1946 ) {
1947 edges {
1948 node {
1949 id
1950 uri
1951 did
1952 actorHandle
1953 }
1954 }
1955 }
1956 }
1957 `, { nsids });
1958
1959 const matches = result?.comAtprotoLexiconSchema?.edges || [];
1960 for (const match of matches) {
1961 const nsid = match.node.id;
1962 const existingDid = match.node.did;
1963 const existingHandle = match.node.actorHandle || existingDid;
1964 const isSameOwner = state.viewer && existingDid === state.viewer.did;
1965
1966 state.conflicts.push({
1967 nsid,
1968 existingOwner: existingHandle,
1969 existingDid,
1970 isSameOwner,
1971 });
1972 }
1973 } catch (err) {
1974 console.error("Conflict check failed:", err);
1975 }
1976 }
1977
1978 // =============================================================================
1979 // ZIP FILE HANDLING
1980 // =============================================================================
1981
1982 function handleDragOver(event) {
1983 event.preventDefault();
1984 event.stopPropagation();
1985 event.currentTarget.classList.add("drag-over");
1986 }
1987
1988 function handleDragLeave(event) {
1989 event.preventDefault();
1990 event.stopPropagation();
1991 event.currentTarget.classList.remove("drag-over");
1992 }
1993
1994 function handleDrop(event) {
1995 event.preventDefault();
1996 event.stopPropagation();
1997 event.currentTarget.classList.remove("drag-over");
1998
1999 const files = event.dataTransfer.files;
2000 if (files.length > 0) {
2001 processZipFile(files[0]);
2002 }
2003 }
2004
2005 function triggerFileInput() {
2006 document.getElementById("file-input").click();
2007 }
2008
2009 function handleFileSelect(event) {
2010 const files = event.target.files;
2011 if (files.length > 0) {
2012 processZipFile(files[0]);
2013 }
2014 // Reset input so the same file can be selected again
2015 event.target.value = "";
2016 }
2017
2018 async function processZipFile(file) {
2019 if (!file.name.toLowerCase().endsWith(".zip")) {
2020 state.result = {
2021 success: false,
2022 message: `Invalid file type: ${file.name}. Please select a .zip file.`,
2023 };
2024 renderAllSteps();
2025 return;
2026 }
2027
2028 const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
2029 if (file.size > MAX_FILE_SIZE) {
2030 state.result = {
2031 success: false,
2032 message: `File too large: ${(file.size / 1024 / 1024).toFixed(1)}MB. Maximum size is 10MB.`,
2033 };
2034 renderAllSteps();
2035 return;
2036 }
2037
2038 if (typeof JSZip === "undefined") {
2039 state.result = {
2040 success: false,
2041 message: "JSZip library failed to load. Check your internet connection.",
2042 };
2043 renderAllSteps();
2044 return;
2045 }
2046
2047 // Show loading state
2048 const dropZone = document.getElementById("drop-zone");
2049 if (dropZone) {
2050 dropZone.innerHTML = `
2051 <div class="drop-zone-icon"><span class="loading-spinner"></span></div>
2052 <div class="drop-zone-text">Processing ${esc(file.name)}...</div>
2053 `;
2054 }
2055
2056 try {
2057 const zip = await JSZip.loadAsync(file);
2058 const jsonFiles = [];
2059
2060 // Find all .json files in the zip (including nested folders)
2061 // Skip macOS metadata files (__MACOSX) and hidden files (._*)
2062 zip.forEach((relativePath, zipEntry) => {
2063 if (
2064 !zipEntry.dir &&
2065 relativePath.toLowerCase().endsWith(".json") &&
2066 !relativePath.startsWith("__MACOSX/") &&
2067 !relativePath.split("/").some((part) => part.startsWith("._"))
2068 ) {
2069 jsonFiles.push({ path: relativePath, entry: zipEntry });
2070 }
2071 });
2072
2073 if (jsonFiles.length === 0) {
2074 state.result = {
2075 success: false,
2076 message: `No .json files found in ${file.name}`,
2077 };
2078 renderAllSteps();
2079 return;
2080 }
2081
2082 // Extract and parse all JSON files
2083 const lexicons = [];
2084 const errors = [];
2085
2086 for (const { path, entry } of jsonFiles) {
2087 try {
2088 const content = await entry.async("string");
2089 // Validate it's valid JSON
2090 JSON.parse(content);
2091 lexicons.push({ path, content });
2092 } catch (e) {
2093 errors.push(`${path}: ${e.message}`);
2094 }
2095 }
2096
2097 if (lexicons.length === 0) {
2098 state.result = {
2099 success: false,
2100 message: `All JSON files had parse errors:\n${errors.join("\n")}`,
2101 };
2102 renderAllSteps();
2103 return;
2104 }
2105
2106 // Replace current lexicons with extracted ones (collapsed by default)
2107 state.lexicons = lexicons.map((lex, idx) => ({
2108 id: state.nextLexiconId + idx,
2109 value: lex.content,
2110 collapsed: true,
2111 }));
2112 state.nextLexiconId += lexicons.length;
2113
2114 const successMsg = `Loaded ${lexicons.length} lexicon${lexicons.length === 1 ? "" : "s"} from ${file.name}`;
2115 const errorMsg =
2116 errors.length > 0
2117 ? `\n\nSkipped ${errors.length} file${errors.length === 1 ? "" : "s"}:\n${errors.join("\n")}`
2118 : "";
2119
2120 state.result = {
2121 success: true,
2122 message: successMsg + errorMsg,
2123 };
2124
2125 renderAllSteps();
2126 } catch (e) {
2127 state.result = {
2128 success: false,
2129 message: `Failed to read zip file: ${e.message}`,
2130 };
2131 renderAllSteps();
2132 }
2133 }
2134
2135 // =============================================================================
2136 // INIT
2137 // =============================================================================
2138
2139 async function init() {
2140 if (typeof honk === "undefined") {
2141 document.getElementById("steps-container").innerHTML = `
2142 <div class="result result-error" style="margin: 1rem;">✗ Failed to load honk library from CDN. Check your internet connection.</div>
2143 `;
2144 return;
2145 }
2146
2147 // Handle OAuth callback
2148 if (window.location.search.includes("code=")) {
2149 try {
2150 await initClient();
2151 await state.client.handleRedirectCallback();
2152 window.history.replaceState({}, "", window.location.pathname);
2153 } catch (err) {
2154 console.error("OAuth callback error:", err);
2155 state.result = { success: false, message: "Authentication failed: " + err.message };
2156 }
2157 } else {
2158 try {
2159 await initClient();
2160 } catch (err) {
2161 console.error("Failed to initialize client:", err);
2162 }
2163 }
2164
2165 // Check if authenticated and fetch viewer + published lexicons
2166 if (state.client) {
2167 try {
2168 const isLoggedIn = await state.client.isAuthenticated();
2169 if (isLoggedIn) {
2170 const result = await state.client.query(`
2171 query {
2172 viewer {
2173 did
2174 handle
2175 comAtprotoLexiconSchemaByDid(first: 100) {
2176 edges {
2177 node {
2178 uri
2179 defs
2180 lexicon
2181 indexedAt
2182 }
2183 }
2184 }
2185 }
2186 }
2187 `);
2188 if (result?.viewer) {
2189 state.viewer = { did: result.viewer.did, handle: result.viewer.handle };
2190 state.publishedLexicons = (result.viewer.comAtprotoLexiconSchemaByDid?.edges || [])
2191 .map(e => {
2192 const uri = e.node.uri;
2193 const rkey = uri.split('/').pop();
2194 return {
2195 uri,
2196 rkey,
2197 defs: e.node.defs,
2198 lexicon: e.node.lexicon,
2199 indexedAt: e.node.indexedAt,
2200 };
2201 });
2202
2203 // Generate DNS instructions from published lexicons
2204 if (state.publishedLexicons.length > 0) {
2205 generateDnsInstructions(state.publishedLexicons.map(l => l.rkey));
2206 }
2207
2208 // Complete step 1 since we're signed in
2209 state.stepCompleted[1] = true;
2210
2211 // If returning user has published lexicons, skip to step 5
2212 if (state.publishedLexicons.length > 0) {
2213 state.stepCompleted[2] = true;
2214 state.stepCompleted[3] = true;
2215 state.stepCompleted[4] = true;
2216 state.currentStep = 5;
2217 } else {
2218 state.currentStep = 2;
2219 }
2220 }
2221 }
2222 } catch (err) {
2223 console.error("Auth check error:", err);
2224 }
2225 }
2226
2227 renderAllSteps();
2228 }
2229
2230 window.addEventListener("DOMContentLoaded", init);
2231 </script>
2232 </body>
2233</html>