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