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 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};