1import { Nsid } from "@atcute/lexicons";
2import { useLocation, useNavigate } from "@solidjs/router";
3import { createEffect, For, Show } from "solid-js";
4import { resolveLexiconAuthority } from "../utils/api.js";
5
6interface LexiconSchema {
7 lexicon: number;
8 id: string;
9 description?: string;
10 defs: {
11 [key: string]: LexiconDef;
12 };
13}
14
15interface LexiconDef {
16 type: string;
17 description?: string;
18 key?: string;
19 record?: LexiconObject;
20 parameters?: LexiconObject;
21 input?: { encoding: string; schema?: LexiconObject };
22 output?: { encoding: string; schema?: LexiconObject };
23 errors?: Array<{ name: string; description?: string }>;
24 properties?: { [key: string]: LexiconProperty };
25 required?: string[];
26 nullable?: string[];
27 maxLength?: number;
28 minLength?: number;
29 maxGraphemes?: number;
30 minGraphemes?: number;
31 items?: LexiconProperty;
32 refs?: string[];
33 closed?: boolean;
34 enum?: string[];
35 const?: string;
36 default?: any;
37 minimum?: number;
38 maximum?: number;
39 accept?: string[];
40 maxSize?: number;
41 knownValues?: string[];
42 format?: string;
43}
44
45interface LexiconObject {
46 type: string;
47 description?: string;
48 ref?: string;
49 refs?: string[];
50 closed?: boolean;
51 properties?: { [key: string]: LexiconProperty };
52 required?: string[];
53 nullable?: string[];
54}
55
56interface LexiconProperty {
57 type: string;
58 description?: string;
59 ref?: string;
60 refs?: string[];
61 closed?: boolean;
62 format?: string;
63 items?: LexiconProperty;
64 minLength?: number;
65 maxLength?: number;
66 maxGraphemes?: number;
67 minGraphemes?: number;
68 minimum?: number;
69 maximum?: number;
70 enum?: string[];
71 const?: string | boolean | number;
72 default?: any;
73 knownValues?: string[];
74 accept?: string[];
75 maxSize?: number;
76}
77
78const TypeBadge = (props: { type: string; format?: string; refType?: string }) => {
79 const navigate = useNavigate();
80 const displayType =
81 props.refType ? props.refType.replace(/^#/, "")
82 : props.format ? `${props.type}:${props.format}`
83 : props.type;
84
85 const isLocalRef = () => props.refType?.startsWith("#");
86 const isExternalRef = () => props.refType && !props.refType.startsWith("#");
87
88 const handleClick = async () => {
89 if (isLocalRef()) {
90 const defName = props.refType!.slice(1);
91 window.history.replaceState(null, "", `#schema:${defName}`);
92 const element = document.getElementById(`def-${defName}`);
93 if (element) {
94 element.scrollIntoView({ behavior: "instant", block: "start" });
95 }
96 } else if (isExternalRef()) {
97 try {
98 const [nsid, anchor] = props.refType!.split("#");
99 const authority = await resolveLexiconAuthority(nsid as Nsid);
100
101 const hash = anchor ? `#schema:${anchor}` : "#schema";
102 navigate(`/at://${authority}/com.atproto.lexicon.schema/${nsid}${hash}`);
103 } catch (err) {
104 console.error("Failed to resolve lexicon authority:", err);
105 }
106 }
107 };
108
109 return (
110 <>
111 <Show when={props.refType}>
112 <button
113 type="button"
114 onClick={handleClick}
115 class="inline-block cursor-pointer truncate rounded bg-blue-100 px-1.5 py-0.5 font-mono text-xs text-blue-800 hover:bg-blue-200 hover:underline active:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-300 dark:hover:bg-blue-900/50 dark:active:bg-blue-900/50"
116 >
117 {displayType}
118 </button>
119 </Show>
120 <Show when={!props.refType}>
121 <span class="inline-block rounded bg-blue-100 px-1.5 py-0.5 font-mono text-xs text-blue-800 dark:bg-blue-900/30 dark:text-blue-300">
122 {displayType}
123 </span>
124 </Show>
125 </>
126 );
127};
128
129const UnionBadges = (props: { refs: string[] }) => (
130 <div class="flex flex-wrap gap-2">
131 <For each={props.refs}>{(refType) => <TypeBadge type="union" refType={refType} />}</For>
132 </div>
133);
134
135const ConstraintsList = (props: { property: LexiconProperty }) => (
136 <div class="flex flex-wrap gap-x-4 gap-y-1 text-xs text-neutral-500 dark:text-neutral-400">
137 <Show when={props.property.minLength !== undefined}>
138 <span>minLength: {props.property.minLength}</span>
139 </Show>
140 <Show when={props.property.maxLength !== undefined}>
141 <span>maxLength: {props.property.maxLength}</span>
142 </Show>
143 <Show when={props.property.maxGraphemes !== undefined}>
144 <span>maxGraphemes: {props.property.maxGraphemes}</span>
145 </Show>
146 <Show when={props.property.minGraphemes !== undefined}>
147 <span>minGraphemes: {props.property.minGraphemes}</span>
148 </Show>
149 <Show when={props.property.minimum !== undefined}>
150 <span>min: {props.property.minimum}</span>
151 </Show>
152 <Show when={props.property.maximum !== undefined}>
153 <span>max: {props.property.maximum}</span>
154 </Show>
155 <Show when={props.property.maxSize !== undefined}>
156 <span>maxSize: {props.property.maxSize}</span>
157 </Show>
158 <Show when={props.property.accept}>
159 <span>accept: [{props.property.accept!.join(", ")}]</span>
160 </Show>
161 <Show when={props.property.enum}>
162 <span>enum: [{props.property.enum!.join(", ")}]</span>
163 </Show>
164 <Show when={props.property.const}>
165 <span>const: {props.property.const?.toString()}</span>
166 </Show>
167 <Show when={props.property.default !== undefined}>
168 <span>default: {JSON.stringify(props.property.default)}</span>
169 </Show>
170 <Show when={props.property.knownValues}>
171 <span>knownValues: [{props.property.knownValues!.join(", ")}]</span>
172 </Show>
173 <Show when={props.property.closed}>
174 <span>closed: true</span>
175 </Show>
176 </div>
177);
178
179const PropertyRow = (props: {
180 name: string;
181 property: LexiconProperty;
182 required?: boolean;
183 hideNameType?: boolean;
184}) => {
185 const hasConstraints = (property: LexiconProperty) =>
186 property.minLength !== undefined ||
187 property.maxLength !== undefined ||
188 property.maxGraphemes !== undefined ||
189 property.minGraphemes !== undefined ||
190 property.minimum !== undefined ||
191 property.maximum !== undefined ||
192 property.maxSize !== undefined ||
193 property.accept ||
194 property.enum ||
195 property.const ||
196 property.default !== undefined ||
197 property.knownValues ||
198 property.closed;
199
200 return (
201 <div class="flex flex-col gap-2 py-3">
202 <Show when={!props.hideNameType}>
203 <div class="flex flex-wrap items-center gap-2">
204 <span class="font-mono text-sm font-semibold">{props.name}</span>
205 <Show when={!props.property.refs}>
206 <TypeBadge
207 type={props.property.type}
208 format={props.property.format}
209 refType={props.property.ref}
210 />
211 </Show>
212 <Show when={props.property.refs}>
213 <span class="inline-block rounded bg-blue-100 px-1.5 py-0.5 font-mono text-xs text-blue-800 dark:bg-blue-900/30 dark:text-blue-300">
214 union
215 </span>
216 </Show>
217 <Show when={props.required}>
218 <span class="text-xs font-semibold text-red-500 dark:text-red-400">required</span>
219 </Show>
220 </div>
221 </Show>
222 <Show when={props.property.refs}>
223 <UnionBadges refs={props.property.refs!} />
224 </Show>
225 <Show when={hasConstraints(props.property)}>
226 <ConstraintsList property={props.property} />
227 </Show>
228 <Show when={props.property.items}>
229 <div class="flex flex-col gap-2">
230 <div class="flex items-center gap-2 text-xs text-neutral-500 dark:text-neutral-400">
231 <span class="font-semibold">items:</span>
232 <Show when={!props.property.items!.refs}>
233 <TypeBadge
234 type={props.property.items!.type}
235 format={props.property.items!.format}
236 refType={props.property.items!.ref}
237 />
238 </Show>
239 <Show when={props.property.items!.refs}>
240 <span class="inline-block rounded bg-blue-100 px-1.5 py-0.5 font-mono text-xs text-blue-800 dark:bg-blue-900/30 dark:text-blue-300">
241 union
242 </span>
243 </Show>
244 </div>
245 <Show when={props.property.items!.refs}>
246 <UnionBadges refs={props.property.items!.refs!} />
247 </Show>
248 </div>
249 </Show>
250 <Show when={props.property.items && hasConstraints(props.property.items)}>
251 <ConstraintsList property={props.property.items!} />
252 </Show>
253 <Show when={props.property.description && !props.hideNameType}>
254 <p class="text-sm text-neutral-700 dark:text-neutral-300">{props.property.description}</p>
255 </Show>
256 </div>
257 );
258};
259
260const DefSection = (props: { name: string; def: LexiconDef }) => {
261 const defTypeColor = () => {
262 switch (props.def.type) {
263 case "record":
264 return "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300";
265 case "query":
266 return "bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300";
267 case "procedure":
268 return "bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300";
269 case "subscription":
270 return "bg-pink-100 text-pink-800 dark:bg-pink-900/30 dark:text-pink-300";
271 case "object":
272 return "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300";
273 case "token":
274 return "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300";
275 default:
276 return "bg-neutral-200 text-neutral-800 dark:bg-neutral-700 dark:text-neutral-300";
277 }
278 };
279
280 const hasDefContent = () =>
281 props.def.refs ||
282 props.def.minLength !== undefined ||
283 props.def.maxLength !== undefined ||
284 props.def.maxGraphemes !== undefined ||
285 props.def.minGraphemes !== undefined ||
286 props.def.minimum !== undefined ||
287 props.def.maximum !== undefined ||
288 props.def.maxSize !== undefined ||
289 props.def.accept ||
290 props.def.enum ||
291 props.def.const ||
292 props.def.default !== undefined ||
293 props.def.closed ||
294 props.def.items ||
295 props.def.knownValues;
296
297 const handleHeaderClick = () => {
298 window.history.replaceState(null, "", `#schema:${props.name}`);
299 const element = document.getElementById(`def-${props.name}`);
300 if (element) {
301 element.scrollIntoView({ behavior: "instant", block: "start" });
302 }
303 };
304
305 return (
306 <div class="flex flex-col gap-3" id={`def-${props.name}`}>
307 <div class="group flex items-center gap-2">
308 <button
309 type="button"
310 onClick={handleHeaderClick}
311 class="relative cursor-pointer text-lg font-semibold hover:underline"
312 >
313 <span class="iconify lucide--link absolute top-1/2 -left-6 -translate-y-1/2 text-base opacity-0 transition-opacity group-hover:opacity-100" />
314 {props.name === "main" ? "Main Definition" : props.name}
315 </button>
316 <span class={`rounded px-2 py-0.5 text-xs font-semibold uppercase ${defTypeColor()}`}>
317 {props.def.type}
318 </span>
319 </div>
320
321 <Show when={props.def.description}>
322 <p class="text-sm text-neutral-700 dark:text-neutral-300">{props.def.description}</p>
323 </Show>
324
325 {/* Record key */}
326 <Show when={props.def.key}>
327 <div>
328 <span class="text-sm font-semibold">Record Key: </span>
329 <span class="font-mono text-sm">{props.def.key}</span>
330 </div>
331 </Show>
332
333 {/* Properties (for record/object types) */}
334 <Show
335 when={Object.keys(props.def.properties || props.def.record?.properties || {}).length > 0}
336 >
337 <div class="flex flex-col gap-2">
338 <h4 class="text-sm font-semibold text-neutral-600 uppercase dark:text-neutral-400">
339 Properties
340 </h4>
341 <div class="divide-y divide-neutral-200 rounded-lg border border-neutral-200 bg-neutral-50/50 px-3 dark:divide-neutral-700 dark:border-neutral-700 dark:bg-neutral-800/30">
342 <For each={Object.entries(props.def.properties || props.def.record?.properties || {})}>
343 {([name, property]) => (
344 <PropertyRow
345 name={name}
346 property={property}
347 required={(props.def.required || props.def.record?.required || []).includes(name)}
348 />
349 )}
350 </For>
351 </div>
352 </div>
353 </Show>
354
355 {/* Parameters (for query/procedure) */}
356 <Show
357 when={
358 props.def.parameters?.properties &&
359 Object.keys(props.def.parameters.properties).length > 0
360 }
361 >
362 <div class="flex flex-col gap-2">
363 <h4 class="text-sm font-semibold text-neutral-600 uppercase dark:text-neutral-400">
364 Parameters
365 </h4>
366 <div class="divide-y divide-neutral-200 rounded-lg border border-neutral-200 bg-neutral-50/50 px-3 dark:divide-neutral-700 dark:border-neutral-700 dark:bg-neutral-800/30">
367 <For each={Object.entries(props.def.parameters!.properties!)}>
368 {([name, property]) => (
369 <PropertyRow
370 name={name}
371 property={property}
372 required={(props.def.parameters?.required || []).includes(name)}
373 />
374 )}
375 </For>
376 </div>
377 </div>
378 </Show>
379
380 {/* Input */}
381 <Show when={props.def.input}>
382 <div class="flex flex-col gap-2">
383 <h4 class="text-sm font-semibold text-neutral-600 uppercase dark:text-neutral-400">
384 Input
385 </h4>
386 <div class="flex flex-col gap-2 rounded-lg border border-neutral-200 bg-neutral-50/50 p-3 dark:border-neutral-700 dark:bg-neutral-800/30">
387 <div class="text-sm">
388 <span class="font-semibold">Encoding: </span>
389 <span class="font-mono">{props.def.input!.encoding}</span>
390 </div>
391 <Show when={props.def.input!.schema?.ref}>
392 <div class="flex items-center gap-2">
393 <span class="text-sm font-semibold">Schema:</span>
394 <TypeBadge type="ref" refType={props.def.input!.schema!.ref} />
395 </div>
396 </Show>
397 <Show when={props.def.input!.schema?.refs}>
398 <div class="flex flex-col gap-2">
399 <div class="flex items-center gap-2">
400 <span class="text-sm font-semibold">Schema (union):</span>
401 </div>
402 <UnionBadges refs={props.def.input!.schema!.refs!} />
403 </div>
404 </Show>
405 <Show
406 when={
407 props.def.input!.schema?.properties &&
408 Object.keys(props.def.input!.schema.properties).length > 0
409 }
410 >
411 <div class="divide-y divide-neutral-200 rounded-lg border border-neutral-200 bg-neutral-50/50 px-3 dark:divide-neutral-700 dark:border-neutral-700 dark:bg-neutral-800/30">
412 <For each={Object.entries(props.def.input!.schema!.properties!)}>
413 {([name, property]) => (
414 <PropertyRow
415 name={name}
416 property={property}
417 required={(props.def.input!.schema?.required || []).includes(name)}
418 />
419 )}
420 </For>
421 </div>
422 </Show>
423 </div>
424 </div>
425 </Show>
426
427 {/* Output */}
428 <Show when={props.def.output}>
429 <div class="flex flex-col gap-2">
430 <h4 class="text-sm font-semibold text-neutral-600 uppercase dark:text-neutral-400">
431 Output
432 </h4>
433 <div class="flex flex-col gap-2 rounded-lg border border-neutral-200 bg-neutral-50/50 p-3 dark:border-neutral-700 dark:bg-neutral-800/30">
434 <div class="text-sm">
435 <span class="font-semibold">Encoding: </span>
436 <span class="font-mono">{props.def.output!.encoding}</span>
437 </div>
438 <Show when={props.def.output!.schema?.ref}>
439 <div class="flex items-center gap-2">
440 <span class="text-sm font-semibold">Schema:</span>
441 <TypeBadge type="ref" refType={props.def.output!.schema!.ref} />
442 </div>
443 </Show>
444 <Show when={props.def.output!.schema?.refs}>
445 <div class="flex flex-col gap-2">
446 <div class="flex items-center gap-2">
447 <span class="text-sm font-semibold">Schema (union):</span>
448 </div>
449 <UnionBadges refs={props.def.output!.schema!.refs!} />
450 </div>
451 </Show>
452 <Show
453 when={
454 props.def.output!.schema?.properties &&
455 Object.keys(props.def.output!.schema.properties).length > 0
456 }
457 >
458 <div class="divide-y divide-neutral-200 rounded-lg border border-neutral-200 bg-neutral-50/50 px-3 dark:divide-neutral-700 dark:border-neutral-700 dark:bg-neutral-800/30">
459 <For each={Object.entries(props.def.output!.schema!.properties!)}>
460 {([name, property]) => (
461 <PropertyRow
462 name={name}
463 property={property}
464 required={(props.def.output!.schema?.required || []).includes(name)}
465 />
466 )}
467 </For>
468 </div>
469 </Show>
470 </div>
471 </div>
472 </Show>
473
474 {/* Errors */}
475 <Show when={props.def.errors && props.def.errors.length > 0}>
476 <div class="flex flex-col gap-2">
477 <h4 class="text-sm font-semibold text-neutral-600 uppercase dark:text-neutral-400">
478 Errors
479 </h4>
480 <div class="divide-y divide-neutral-200 rounded-lg border border-neutral-200 bg-neutral-50/50 px-3 dark:divide-neutral-700 dark:border-neutral-700 dark:bg-neutral-800/30">
481 <For each={props.def.errors}>
482 {(error) => (
483 <div class="flex flex-col gap-1 py-2">
484 <div class="font-mono text-sm font-semibold">{error.name}</div>
485 <Show when={error.description}>
486 <p class="text-sm text-neutral-700 dark:text-neutral-300">
487 {error.description}
488 </p>
489 </Show>
490 </div>
491 )}
492 </For>
493 </div>
494 </div>
495 </Show>
496
497 {/* Other Definitions */}
498 <Show
499 when={
500 !(
501 props.def.properties ||
502 props.def.parameters ||
503 props.def.input ||
504 props.def.output ||
505 props.def.errors ||
506 props.def.record
507 ) && hasDefContent()
508 }
509 >
510 <div class="divide-y divide-neutral-200 rounded-lg border border-neutral-200 bg-neutral-50/50 px-3 dark:divide-neutral-700 dark:border-neutral-700 dark:bg-neutral-800/30">
511 <PropertyRow name={props.name} property={props.def} hideNameType />
512 </div>
513 </Show>
514 </div>
515 );
516};
517
518export const LexiconSchemaView = (props: { schema: LexiconSchema }) => {
519 const location = useLocation();
520
521 // Handle scrolling to a definition when hash is like #schema:definitionName
522 createEffect(() => {
523 const hash = location.hash;
524 if (hash.startsWith("#schema:")) {
525 const defName = hash.slice(8);
526 const element = document.getElementById(`def-${defName}`);
527 if (element) element.scrollIntoView({ behavior: "instant", block: "start" });
528 }
529 });
530
531 return (
532 <div class="w-full max-w-4xl px-2">
533 {/* Header */}
534 <div class="flex flex-col gap-2 border-b border-neutral-300 pb-4 dark:border-neutral-700">
535 <h2 class="text-lg font-semibold">{props.schema.id}</h2>
536 <div class="flex gap-4 text-sm text-neutral-600 dark:text-neutral-400">
537 <span>
538 <span class="font-semibold">Lexicon version: </span>
539 <span class="font-mono">{props.schema.lexicon}</span>
540 </span>
541 </div>
542 <Show when={props.schema.description}>
543 <p class="text-sm text-neutral-700 dark:text-neutral-300">{props.schema.description}</p>
544 </Show>
545 </div>
546
547 {/* Definitions */}
548 <div class="flex flex-col gap-6 pt-4">
549 <For each={Object.entries(props.schema.defs)}>
550 {([name, def]) => <DefSection name={name} def={def} />}
551 </For>
552 </div>
553 </div>
554 );
555};