forked from
pds.ls/pdsls
atmosphere explorer
1import { Nsid } from "@atcute/lexicons";
2import { AtprotoDid } from "@atcute/lexicons/syntax";
3import { A, useLocation, useNavigate } from "@solidjs/router";
4import { createEffect, For, Show } from "solid-js";
5import { resolveLexiconAuthority } from "../utils/api.js";
6import Tooltip from "./tooltip.jsx";
7
8// Style constants
9const CONTAINER_CLASS =
10 "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";
11
12const CARD_CLASS =
13 "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";
14
15const RESOURCE_COLORS = {
16 repo: "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300",
17 rpc: "bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300",
18 default: "bg-neutral-200 text-neutral-800 dark:bg-neutral-700 dark:text-neutral-300",
19} as const;
20
21const DEF_TYPE_COLORS = {
22 record: "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300",
23 query: "bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300",
24 procedure: "bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300",
25 subscription: "bg-pink-100 text-pink-800 dark:bg-pink-900/30 dark:text-pink-300",
26 object: "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300",
27 token: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300",
28 "permission-set": "bg-cyan-100 text-cyan-800 dark:bg-cyan-900/30 dark:text-cyan-300",
29 default: "bg-neutral-200 text-neutral-800 dark:bg-neutral-700 dark:text-neutral-300",
30} as const;
31
32// Utility functions
33const hasConstraints = (property: LexiconProperty | LexiconDef) =>
34 property.minLength !== undefined ||
35 property.maxLength !== undefined ||
36 property.maxGraphemes !== undefined ||
37 property.minGraphemes !== undefined ||
38 property.minimum !== undefined ||
39 property.maximum !== undefined ||
40 property.maxSize !== undefined ||
41 property.accept ||
42 property.enum ||
43 property.const ||
44 property.default !== undefined ||
45 property.knownValues ||
46 property.closed;
47
48interface LexiconSchema {
49 lexicon: number;
50 id: string;
51 description?: string;
52 defs: {
53 [key: string]: LexiconDef;
54 };
55}
56
57interface LexiconPermission {
58 type: "permission";
59 // NOTE: blob, account, and identity are not supported in lexicon schema context
60 resource: "repo" | "rpc" | "blob" | "account" | "identity";
61 collection?: string[];
62 action?: string[];
63 lxm?: string[];
64 aud?: string;
65 inheritAud?: boolean;
66}
67
68interface LexiconDef {
69 type: string;
70 description?: string;
71 key?: string;
72 record?: LexiconObject;
73 parameters?: LexiconObject;
74 input?: { encoding: string; schema?: LexiconObject };
75 output?: { encoding: string; schema?: LexiconObject };
76 errors?: Array<{ name: string; description?: string }>;
77 properties?: { [key: string]: LexiconProperty };
78 required?: string[];
79 nullable?: string[];
80 maxLength?: number;
81 minLength?: number;
82 maxGraphemes?: number;
83 minGraphemes?: number;
84 items?: LexiconProperty;
85 refs?: string[];
86 closed?: boolean;
87 enum?: string[];
88 const?: string;
89 default?: string | number | boolean;
90 minimum?: number;
91 maximum?: number;
92 accept?: string[];
93 maxSize?: number;
94 knownValues?: string[];
95 format?: string;
96 // Permission-set fields
97 title?: string;
98 "title:lang"?: { [lang: string]: string };
99 detail?: string;
100 "detail:lang"?: { [lang: string]: string };
101 permissions?: LexiconPermission[];
102}
103
104interface LexiconObject {
105 type: string;
106 description?: string;
107 ref?: string;
108 refs?: string[];
109 closed?: boolean;
110 properties?: { [key: string]: LexiconProperty };
111 required?: string[];
112 nullable?: string[];
113}
114
115interface LexiconProperty {
116 type: string;
117 description?: string;
118 ref?: string;
119 refs?: string[];
120 closed?: boolean;
121 format?: string;
122 items?: LexiconProperty;
123 minLength?: number;
124 maxLength?: number;
125 maxGraphemes?: number;
126 minGraphemes?: number;
127 minimum?: number;
128 maximum?: number;
129 enum?: string[];
130 const?: string | boolean | number;
131 default?: string | number | boolean;
132 knownValues?: string[];
133 accept?: string[];
134 maxSize?: number;
135}
136
137const TypeBadge = (props: { type: string; format?: string; refType?: string }) => {
138 const navigate = useNavigate();
139 const displayType =
140 props.refType ? props.refType.replace(/^#/, "")
141 : props.format ? `${props.type}:${props.format}`
142 : props.type;
143
144 const isLocalRef = () => props.refType?.startsWith("#");
145 const isExternalRef = () => props.refType && !props.refType.startsWith("#");
146
147 const handleClick = async () => {
148 if (isLocalRef()) {
149 const defName = props.refType!.slice(1);
150 window.history.replaceState(null, "", `#schema:${defName}`);
151 const element = document.getElementById(`def-${defName}`);
152 if (element) {
153 element.scrollIntoView({ behavior: "instant", block: "start" });
154 }
155 } else if (isExternalRef()) {
156 try {
157 const [nsid, anchor] = props.refType!.split("#");
158 const authority = await resolveLexiconAuthority(nsid as Nsid);
159
160 const hash = anchor ? `#schema:${anchor}` : "#schema";
161 navigate(`/at://${authority}/com.atproto.lexicon.schema/${nsid}${hash}`);
162 } catch (err) {
163 console.error("Failed to resolve lexicon authority:", err);
164 }
165 }
166 };
167
168 return (
169 <Show
170 when={props.refType}
171 fallback={
172 <span class="font-mono text-xs text-neutral-600 dark:text-neutral-400">{displayType}</span>
173 }
174 >
175 <button
176 type="button"
177 onClick={handleClick}
178 class="inline-block cursor-pointer truncate font-mono text-xs text-blue-500 hover:underline dark:text-blue-400"
179 >
180 {displayType}
181 </button>
182 </Show>
183 );
184};
185
186const UnionBadges = (props: { refs: string[] }) => (
187 <div class="flex flex-col items-start gap-1">
188 <For each={props.refs}>{(refType) => <TypeBadge type="union" refType={refType} />}</For>
189 </div>
190);
191
192const ConstraintsList = (props: { property: LexiconProperty }) => {
193 const valueClass = "text-neutral-600 dark:text-neutral-400";
194 return (
195 <div class="flex flex-wrap gap-x-4 gap-y-1 text-xs">
196 <Show when={props.property.minLength !== undefined}>
197 <span>
198 minLength: <span class={valueClass}>{props.property.minLength}</span>
199 </span>
200 </Show>
201 <Show when={props.property.maxLength !== undefined}>
202 <span>
203 maxLength: <span class={valueClass}>{props.property.maxLength}</span>
204 </span>
205 </Show>
206 <Show when={props.property.maxGraphemes !== undefined}>
207 <span>
208 maxGraphemes: <span class={valueClass}>{props.property.maxGraphemes}</span>
209 </span>
210 </Show>
211 <Show when={props.property.minGraphemes !== undefined}>
212 <span>
213 minGraphemes: <span class={valueClass}>{props.property.minGraphemes}</span>
214 </span>
215 </Show>
216 <Show when={props.property.minimum !== undefined}>
217 <span>
218 min: <span class={valueClass}>{props.property.minimum}</span>
219 </span>
220 </Show>
221 <Show when={props.property.maximum !== undefined}>
222 <span>
223 max: <span class={valueClass}>{props.property.maximum}</span>
224 </span>
225 </Show>
226 <Show when={props.property.maxSize !== undefined}>
227 <span>
228 maxSize: <span class={valueClass}>{props.property.maxSize}</span>
229 </span>
230 </Show>
231 <Show when={props.property.accept}>
232 <span>
233 accept: <span class={valueClass}>[{props.property.accept!.join(", ")}]</span>
234 </span>
235 </Show>
236 <Show when={props.property.enum}>
237 <span>
238 enum: <span class={valueClass}>[{props.property.enum!.join(", ")}]</span>
239 </span>
240 </Show>
241 <Show when={props.property.const}>
242 <span>
243 const: <span class={valueClass}>{props.property.const?.toString()}</span>
244 </span>
245 </Show>
246 <Show when={props.property.default !== undefined}>
247 <span>
248 default: <span class={valueClass}>{JSON.stringify(props.property.default)}</span>
249 </span>
250 </Show>
251 <Show when={props.property.knownValues}>
252 <span>
253 knownValues: <span class={valueClass}>[{props.property.knownValues!.join(", ")}]</span>
254 </span>
255 </Show>
256 <Show when={props.property.closed}>
257 <span>
258 closed: <span class={valueClass}>true</span>
259 </span>
260 </Show>
261 </div>
262 );
263};
264
265const PropertyRow = (props: {
266 name: string;
267 property: LexiconProperty;
268 required?: boolean;
269 hideNameType?: boolean;
270}) => {
271 return (
272 <div class="flex flex-col gap-2 py-3">
273 <Show when={!props.hideNameType}>
274 <div class="flex flex-wrap items-baseline gap-2">
275 <span class="font-semibold">{props.name}</span>
276 <Show when={!props.property.refs}>
277 <TypeBadge
278 type={props.property.type}
279 format={props.property.format}
280 refType={props.property.ref}
281 />
282 </Show>
283 <Show when={props.property.refs}>
284 <span class="font-mono text-xs text-neutral-600 dark:text-neutral-400">union</span>
285 </Show>
286 <Show when={props.required}>
287 <span class="text-xs font-semibold text-red-500 dark:text-red-400">required</span>
288 </Show>
289 </div>
290 </Show>
291 <Show when={props.property.refs}>
292 <UnionBadges refs={props.property.refs!} />
293 </Show>
294 <Show when={hasConstraints(props.property)}>
295 <ConstraintsList property={props.property} />
296 </Show>
297 <Show when={props.property.items}>
298 <div class="flex flex-col gap-2">
299 <div class="flex items-baseline gap-2 text-xs">
300 <span class="font-medium">items:</span>
301 <Show when={!props.property.items!.refs}>
302 <TypeBadge
303 type={props.property.items!.type}
304 format={props.property.items!.format}
305 refType={props.property.items!.ref}
306 />
307 </Show>
308 <Show when={props.property.items!.refs}>
309 <span class="font-mono text-xs text-neutral-600 dark:text-neutral-400">union</span>
310 </Show>
311 </div>
312 <Show when={props.property.items!.refs}>
313 <UnionBadges refs={props.property.items!.refs!} />
314 </Show>
315 </div>
316 </Show>
317 <Show when={props.property.items && hasConstraints(props.property.items)}>
318 <ConstraintsList property={props.property.items!} />
319 </Show>
320 <Show when={props.property.description && !props.hideNameType}>
321 <p class="text-sm wrap-break-word text-neutral-700 dark:text-neutral-300">
322 {props.property.description}
323 </p>
324 </Show>
325 </div>
326 );
327};
328
329const NsidLink = (props: { nsid: string }) => {
330 const navigate = useNavigate();
331
332 const handleClick = async () => {
333 try {
334 const authority = await resolveLexiconAuthority(props.nsid as Nsid);
335 navigate(`/at://${authority}/com.atproto.lexicon.schema/${props.nsid}#schema`);
336 } catch (err) {
337 console.error("Failed to resolve lexicon authority:", err);
338 }
339 };
340
341 return (
342 <button
343 type="button"
344 onClick={handleClick}
345 class="cursor-pointer font-mono text-xs text-blue-500 hover:underline dark:text-blue-400"
346 >
347 {props.nsid}
348 </button>
349 );
350};
351
352const resourceColor = (resource: string) =>
353 RESOURCE_COLORS[resource as keyof typeof RESOURCE_COLORS] || RESOURCE_COLORS.default;
354
355const SchemaSection = (props: { title: string; encoding: string; schema?: LexiconObject }) => {
356 return (
357 <div class="flex flex-col gap-2">
358 <h4 class="text-sm font-semibold text-neutral-600 uppercase dark:text-neutral-400">
359 {props.title}
360 </h4>
361 <div class={CARD_CLASS}>
362 <div class="text-sm">
363 <span class="font-semibold">Encoding: </span>
364 <span class="font-mono">{props.encoding}</span>
365 </div>
366 <Show when={props.schema?.ref}>
367 <div class="flex items-center gap-2">
368 <span class="text-sm font-semibold">Schema:</span>
369 <TypeBadge type="ref" refType={props.schema!.ref} />
370 </div>
371 </Show>
372 <Show when={props.schema?.refs}>
373 <div class="flex flex-col gap-2">
374 <div class="flex items-center gap-2">
375 <span class="text-sm font-semibold">Schema (union):</span>
376 </div>
377 <UnionBadges refs={props.schema!.refs!} />
378 </div>
379 </Show>
380 <Show when={props.schema?.properties && Object.keys(props.schema.properties).length > 0}>
381 <div class={CONTAINER_CLASS}>
382 <For each={Object.entries(props.schema!.properties!)}>
383 {([name, property]) => (
384 <PropertyRow
385 name={name}
386 property={property}
387 required={(props.schema?.required || []).includes(name)}
388 />
389 )}
390 </For>
391 </div>
392 </Show>
393 </div>
394 </div>
395 );
396};
397
398const PermissionRow = (props: { permission: LexiconPermission; index: number }) => {
399 return (
400 <div class="flex flex-col gap-2 py-3">
401 <div class="flex flex-wrap items-center gap-2">
402 <span class="font-semibold">#{props.index + 1}</span>
403 <span
404 class={`rounded px-1.5 py-0.5 font-mono text-xs font-semibold ${resourceColor(props.permission.resource)}`}
405 >
406 {props.permission.resource}
407 </span>
408 </div>
409
410 {/* Collections (for repo resource) */}
411 <Show when={props.permission.collection && props.permission.collection.length > 0}>
412 <div class="flex flex-col gap-1">
413 <span class="text-xs font-semibold text-neutral-500 dark:text-neutral-400">
414 Collections:
415 </span>
416 <div class="flex flex-col items-start gap-1">
417 <For each={props.permission.collection}>{(col) => <NsidLink nsid={col} />}</For>
418 </div>
419 </div>
420 </Show>
421
422 {/* Actions */}
423 <Show when={props.permission.action && props.permission.action.length > 0}>
424 <div class="flex flex-col gap-1">
425 <span class="text-xs font-semibold text-neutral-500 dark:text-neutral-400">Actions:</span>
426 <div class="flex flex-wrap gap-1">
427 <For each={props.permission.action}>
428 {(action) => (
429 <span class="dark:bg-dark-200 rounded bg-neutral-200/50 px-1.5 py-0.5 font-mono text-xs">
430 {action}
431 </span>
432 )}
433 </For>
434 </div>
435 </div>
436 </Show>
437
438 {/* LXM (for rpc resource) */}
439 <Show when={props.permission.lxm && props.permission.lxm.length > 0}>
440 <div class="flex flex-col gap-1">
441 <span class="text-xs font-semibold text-neutral-500 dark:text-neutral-400">
442 Lexicon Methods:
443 </span>
444 <div class="flex flex-col items-start gap-1">
445 <For each={props.permission.lxm}>{(method) => <NsidLink nsid={method} />}</For>
446 </div>
447 </div>
448 </Show>
449
450 {/* Audience */}
451 <Show when={props.permission.aud}>
452 <div class="flex items-center gap-2 text-xs">
453 <span class="font-semibold text-neutral-500 dark:text-neutral-400">Audience:</span>
454 <span class="font-mono">{props.permission.aud}</span>
455 </div>
456 </Show>
457
458 {/* Inherit Audience */}
459 <Show when={props.permission.inheritAud}>
460 <div class="flex items-center gap-1 text-xs">
461 <span class="font-semibold text-neutral-500 dark:text-neutral-400">
462 Inherit Audience:
463 </span>
464 <span>true</span>
465 </div>
466 </Show>
467 </div>
468 );
469};
470
471const DefSection = (props: { name: string; def: LexiconDef }) => {
472 const defTypeColor = () =>
473 DEF_TYPE_COLORS[props.def.type as keyof typeof DEF_TYPE_COLORS] || DEF_TYPE_COLORS.default;
474
475 const hasDefContent = () => props.def.refs || props.def.items || hasConstraints(props.def);
476
477 return (
478 <div class="flex flex-col gap-3" id={`def-${props.name}`}>
479 <div class="group flex items-center gap-2">
480 <a href={`#schema:${props.name}`} class="relative text-lg font-semibold hover:underline">
481 <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" />
482 {props.name === "main" ? "Main Definition" : props.name}
483 </a>
484 <span class={`rounded px-2 py-0.5 text-xs font-semibold uppercase ${defTypeColor()}`}>
485 {props.def.type.replace("-", " ")}
486 </span>
487 </div>
488
489 <Show when={props.def.description}>
490 <p class="text-sm text-neutral-700 dark:text-neutral-300">{props.def.description}</p>
491 </Show>
492
493 {/* Record key */}
494 <Show when={props.def.key}>
495 <div>
496 <span class="text-sm font-semibold">Record Key: </span>
497 <span class="font-mono text-sm">{props.def.key}</span>
498 </div>
499 </Show>
500
501 {/* Permission-set: Title and Detail */}
502 <Show when={props.def.type === "permission-set" && (props.def.title || props.def.detail)}>
503 <div class={CARD_CLASS}>
504 <Show when={props.def.title}>
505 <div class="flex flex-col gap-1">
506 <span class="text-xs font-semibold text-neutral-500 uppercase dark:text-neutral-400">
507 Title
508 </span>
509 <span class="text-sm font-medium">{props.def.title}</span>
510 </div>
511 </Show>
512 <Show when={props.def["title:lang"]}>
513 <div class="flex flex-col gap-1">
514 <span class="text-xs font-semibold text-neutral-500 uppercase dark:text-neutral-400">
515 Localized Titles
516 </span>
517 <div class="flex flex-col gap-1">
518 <For each={Object.entries(props.def["title:lang"]!)}>
519 {([lang, text]) => (
520 <div class="flex items-center gap-2 text-sm">
521 <span class="dark:bg-dark-200 rounded bg-neutral-200/50 px-1.5 py-0.5 font-mono text-xs">
522 {lang}
523 </span>
524 <span>{text}</span>
525 </div>
526 )}
527 </For>
528 </div>
529 </div>
530 </Show>
531 <Show when={props.def.detail}>
532 <div class="flex flex-col gap-1">
533 <span class="text-xs font-semibold text-neutral-500 uppercase dark:text-neutral-400">
534 Detail
535 </span>
536 <p class="text-sm text-neutral-700 dark:text-neutral-300">{props.def.detail}</p>
537 </div>
538 </Show>
539 <Show when={props.def["detail:lang"]}>
540 <div class="flex flex-col gap-1">
541 <span class="text-xs font-semibold text-neutral-500 uppercase dark:text-neutral-400">
542 Localized Details
543 </span>
544 <div class="flex flex-col gap-1">
545 <For each={Object.entries(props.def["detail:lang"]!)}>
546 {([lang, text]) => (
547 <div class="flex flex-col gap-1 text-sm">
548 <span class="dark:bg-dark-200 w-fit rounded bg-neutral-200/50 px-1.5 py-0.5 font-mono text-xs">
549 {lang}
550 </span>
551 <p class="text-neutral-700 dark:text-neutral-300">{text}</p>
552 </div>
553 )}
554 </For>
555 </div>
556 </div>
557 </Show>
558 </div>
559 </Show>
560
561 {/* Permission-set: Permissions list */}
562 <Show
563 when={
564 props.def.permissions &&
565 props.def.permissions.filter((p) => p.resource === "repo" || p.resource === "rpc")
566 .length > 0
567 }
568 >
569 <div class="flex flex-col gap-2">
570 <h4 class="text-sm font-semibold text-neutral-600 uppercase dark:text-neutral-400">
571 Permissions
572 </h4>
573 <div class={CONTAINER_CLASS}>
574 <For
575 each={props.def.permissions!.filter(
576 (p) => p.resource === "repo" || p.resource === "rpc",
577 )}
578 >
579 {(permission, index) => <PermissionRow permission={permission} index={index()} />}
580 </For>
581 </div>
582 </div>
583 </Show>
584
585 {/* Properties (for record/object types) */}
586 <Show
587 when={Object.keys(props.def.properties || props.def.record?.properties || {}).length > 0}
588 >
589 <div class="flex flex-col gap-2">
590 <h4 class="text-sm font-semibold text-neutral-600 uppercase dark:text-neutral-400">
591 Properties
592 </h4>
593 <div class={CONTAINER_CLASS}>
594 <For each={Object.entries(props.def.properties || props.def.record?.properties || {})}>
595 {([name, property]) => (
596 <PropertyRow
597 name={name}
598 property={property}
599 required={(props.def.required || props.def.record?.required || []).includes(name)}
600 />
601 )}
602 </For>
603 </div>
604 </div>
605 </Show>
606
607 {/* Parameters (for query/procedure) */}
608 <Show
609 when={
610 props.def.parameters?.properties &&
611 Object.keys(props.def.parameters.properties).length > 0
612 }
613 >
614 <div class="flex flex-col gap-2">
615 <h4 class="text-sm font-semibold text-neutral-600 uppercase dark:text-neutral-400">
616 Parameters
617 </h4>
618 <div class={CONTAINER_CLASS}>
619 <For each={Object.entries(props.def.parameters!.properties!)}>
620 {([name, property]) => (
621 <PropertyRow
622 name={name}
623 property={property}
624 required={(props.def.parameters?.required || []).includes(name)}
625 />
626 )}
627 </For>
628 </div>
629 </div>
630 </Show>
631
632 {/* Input */}
633 <Show when={props.def.input}>
634 <SchemaSection
635 title="Input"
636 encoding={props.def.input!.encoding}
637 schema={props.def.input!.schema}
638 />
639 </Show>
640
641 {/* Output */}
642 <Show when={props.def.output}>
643 <SchemaSection
644 title="Output"
645 encoding={props.def.output!.encoding}
646 schema={props.def.output!.schema}
647 />
648 </Show>
649
650 {/* Errors */}
651 <Show when={props.def.errors && props.def.errors.length > 0}>
652 <div class="flex flex-col gap-2">
653 <h4 class="text-sm font-semibold text-neutral-600 uppercase dark:text-neutral-400">
654 Errors
655 </h4>
656 <div class={CONTAINER_CLASS}>
657 <For each={props.def.errors}>
658 {(error) => (
659 <div class="flex flex-col gap-1 py-2">
660 <div class="font-semibold">{error.name}</div>
661 <Show when={error.description}>
662 <p class="text-sm text-neutral-700 dark:text-neutral-300">
663 {error.description}
664 </p>
665 </Show>
666 </div>
667 )}
668 </For>
669 </div>
670 </div>
671 </Show>
672
673 {/* Other Definitions */}
674 <Show
675 when={
676 !(
677 props.def.properties ||
678 props.def.parameters ||
679 props.def.input ||
680 props.def.output ||
681 props.def.errors ||
682 props.def.record
683 ) && hasDefContent()
684 }
685 >
686 <div class={CONTAINER_CLASS}>
687 <PropertyRow name={props.name} property={props.def} hideNameType />
688 </div>
689 </Show>
690 </div>
691 );
692};
693
694export const LexiconSchemaView = (props: { schema: LexiconSchema; authority?: AtprotoDid }) => {
695 const location = useLocation();
696
697 // Handle scrolling to a definition when hash is like #schema:definitionName
698 createEffect(() => {
699 const hash = location.hash;
700 if (hash.startsWith("#schema:")) {
701 const defName = hash.slice(8);
702 requestAnimationFrame(() => {
703 const element = document.getElementById(`def-${defName}`);
704 if (element) element.scrollIntoView({ behavior: "instant", block: "start" });
705 });
706 }
707 });
708
709 return (
710 <div class="w-full max-w-4xl px-2">
711 {/* Header */}
712 <div class="flex flex-col gap-2 border-b border-neutral-300 pb-3 dark:border-neutral-700">
713 <div class="flex items-center gap-0.5">
714 <h2 class="text-lg font-semibold">{props.schema.id}</h2>
715 <Show when={props.authority}>
716 <Tooltip text="View record">
717 <A
718 href={`/at://${props.authority}/com.atproto.lexicon.schema/${props.schema.id}`}
719 class="flex items-center p-1.5 text-neutral-600 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-neutral-200"
720 target="_blank"
721 >
722 <span class="iconify lucide--external-link text-sm"></span>
723 </A>
724 </Tooltip>
725 </Show>
726 </div>
727 <div class="flex gap-4 text-sm text-neutral-600 dark:text-neutral-400">
728 <span>
729 <span class="font-medium">Lexicon version: </span>
730 <span>{props.schema.lexicon}</span>
731 </span>
732 </div>
733 <Show when={props.schema.description}>
734 <p class="text-sm text-neutral-700 dark:text-neutral-300">{props.schema.description}</p>
735 </Show>
736 </div>
737
738 {/* Definitions */}
739 <div class="flex flex-col gap-6 pt-3">
740 <For each={Object.entries(props.schema.defs)}>
741 {([name, def]) => <DefSection name={name} def={def} />}
742 </For>
743 </div>
744 </div>
745 );
746};