atproto explorer pdsls.dev
atproto tool
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};