atproto explorer

lexicon schema doc

handle.invalid 5c63396a 461d0ad4

verified
+531 -19
+490
src/components/lexicon-schema.tsx
··· 1 + import { Nsid } from "@atcute/lexicons"; 2 + import { useLocation, useNavigate } from "@solidjs/router"; 3 + import { createEffect, For, Show } from "solid-js"; 4 + import { resolveLexiconAuthority } from "../utils/api.js"; 5 + 6 + interface LexiconSchema { 7 + lexicon: number; 8 + id: string; 9 + description?: string; 10 + defs: { 11 + [key: string]: LexiconDef; 12 + }; 13 + } 14 + 15 + interface 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 + items?: LexiconProperty; 30 + refs?: string[]; 31 + closed?: boolean; 32 + enum?: string[]; 33 + const?: string; 34 + default?: any; 35 + minimum?: number; 36 + maximum?: number; 37 + } 38 + 39 + interface LexiconObject { 40 + type: string; 41 + description?: string; 42 + ref?: string; 43 + refs?: string[]; 44 + closed?: boolean; 45 + properties?: { [key: string]: LexiconProperty }; 46 + required?: string[]; 47 + nullable?: string[]; 48 + } 49 + 50 + interface LexiconProperty { 51 + type: string; 52 + description?: string; 53 + ref?: string; 54 + refs?: string[]; 55 + closed?: boolean; 56 + format?: string; 57 + items?: LexiconProperty; 58 + minLength?: number; 59 + maxLength?: number; 60 + maxGraphemes?: number; 61 + minimum?: number; 62 + maximum?: number; 63 + enum?: string[]; 64 + const?: string | boolean | number; 65 + default?: any; 66 + knownValues?: string[]; 67 + accept?: string[]; 68 + maxSize?: number; 69 + } 70 + 71 + const TypeBadge = (props: { type: string; format?: string; refType?: string }) => { 72 + const navigate = useNavigate(); 73 + const displayType = 74 + props.refType ? props.refType.replace(/^#/, "") 75 + : props.format ? `${props.type}:${props.format}` 76 + : props.type; 77 + 78 + const isLocalRef = () => props.refType?.startsWith("#"); 79 + const isExternalRef = () => props.refType && !props.refType.startsWith("#"); 80 + 81 + const handleClick = async (e: MouseEvent) => { 82 + e.preventDefault(); 83 + if (isLocalRef()) { 84 + const defName = props.refType!.slice(1); 85 + window.history.replaceState(null, "", `#schema:${defName}`); 86 + const element = document.getElementById(`def-${defName}`); 87 + if (element) { 88 + element.scrollIntoView({ behavior: "instant", block: "start" }); 89 + } 90 + } else if (isExternalRef()) { 91 + try { 92 + const [nsid, anchor] = props.refType!.split("#"); 93 + const authority = await resolveLexiconAuthority(nsid as Nsid); 94 + 95 + const hash = anchor ? `#schema:${anchor}` : "#schema"; 96 + navigate(`/at://${authority}/com.atproto.lexicon.schema/${nsid}${hash}`); 97 + } catch (err) { 98 + console.error("Failed to resolve lexicon authority:", err); 99 + } 100 + } 101 + }; 102 + 103 + return ( 104 + <> 105 + <Show when={props.refType}> 106 + <a 107 + href={props.refType} 108 + onClick={handleClick} 109 + class="inline-block 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" 110 + > 111 + {displayType} 112 + </a> 113 + </Show> 114 + <Show when={!props.refType}> 115 + <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"> 116 + {displayType} 117 + </span> 118 + </Show> 119 + </> 120 + ); 121 + }; 122 + 123 + const UnionBadges = (props: { refs: string[] }) => ( 124 + <div class="flex flex-wrap gap-2"> 125 + <For each={props.refs}>{(refType) => <TypeBadge type="union" refType={refType} />}</For> 126 + </div> 127 + ); 128 + 129 + const ConstraintsList = (props: { property: LexiconProperty }) => ( 130 + <div class="flex flex-wrap gap-x-4 gap-y-1 text-xs text-neutral-500 dark:text-neutral-400"> 131 + <Show when={props.property.minLength !== undefined}> 132 + <span>minLength: {props.property.minLength}</span> 133 + </Show> 134 + <Show when={props.property.maxLength !== undefined}> 135 + <span>maxLength: {props.property.maxLength}</span> 136 + </Show> 137 + <Show when={props.property.maxGraphemes !== undefined}> 138 + <span>maxGraphemes: {props.property.maxGraphemes}</span> 139 + </Show> 140 + <Show when={props.property.minimum !== undefined}> 141 + <span>min: {props.property.minimum}</span> 142 + </Show> 143 + <Show when={props.property.maximum !== undefined}> 144 + <span>max: {props.property.maximum}</span> 145 + </Show> 146 + <Show when={props.property.maxSize !== undefined}> 147 + <span>maxSize: {props.property.maxSize}</span> 148 + </Show> 149 + <Show when={props.property.accept}> 150 + <span>accept: [{props.property.accept!.join(", ")}]</span> 151 + </Show> 152 + <Show when={props.property.enum}> 153 + <span>enum: [{props.property.enum!.join(", ")}]</span> 154 + </Show> 155 + <Show when={props.property.const}> 156 + <span>const: {props.property.const?.toString()}</span> 157 + </Show> 158 + <Show when={props.property.default !== undefined}> 159 + <span>default: {JSON.stringify(props.property.default)}</span> 160 + </Show> 161 + <Show when={props.property.knownValues}> 162 + <span>knownValues: [{props.property.knownValues!.join(", ")}]</span> 163 + </Show> 164 + <Show when={props.property.closed}> 165 + <span>closed: true</span> 166 + </Show> 167 + </div> 168 + ); 169 + 170 + const PropertyRow = (props: { name: string; property: LexiconProperty; required?: boolean }) => { 171 + const hasConstraints = (property: LexiconProperty) => 172 + property.minLength !== undefined || 173 + property.maxLength !== undefined || 174 + property.maxGraphemes !== undefined || 175 + property.minimum !== undefined || 176 + property.maximum !== undefined || 177 + property.maxSize !== undefined || 178 + property.accept || 179 + property.enum || 180 + property.const || 181 + property.default !== undefined || 182 + property.knownValues || 183 + property.closed; 184 + 185 + return ( 186 + <div class="flex flex-col gap-2 py-3"> 187 + <div class="flex flex-wrap items-center gap-2"> 188 + <span class="font-mono text-sm font-semibold">{props.name}</span> 189 + <Show when={!props.property.refs}> 190 + <TypeBadge 191 + type={props.property.type} 192 + format={props.property.format} 193 + refType={props.property.ref} 194 + /> 195 + </Show> 196 + <Show when={props.property.refs}> 197 + <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"> 198 + union 199 + </span> 200 + </Show> 201 + <Show when={props.required}> 202 + <span class="text-xs font-semibold text-red-500 dark:text-red-400">required</span> 203 + </Show> 204 + </div> 205 + <Show when={props.property.refs}> 206 + <UnionBadges refs={props.property.refs!} /> 207 + </Show> 208 + <Show when={hasConstraints(props.property)}> 209 + <ConstraintsList property={props.property} /> 210 + </Show> 211 + <Show when={props.property.items}> 212 + <div class="flex flex-col gap-2"> 213 + <div class="flex items-center gap-2 text-xs text-neutral-500 dark:text-neutral-400"> 214 + <span class="font-semibold">items:</span> 215 + <Show when={!props.property.items!.refs}> 216 + <TypeBadge 217 + type={props.property.items!.type} 218 + format={props.property.items!.format} 219 + refType={props.property.items!.ref} 220 + /> 221 + </Show> 222 + <Show when={props.property.items!.refs}> 223 + <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"> 224 + union 225 + </span> 226 + </Show> 227 + </div> 228 + <Show when={props.property.items!.refs}> 229 + <UnionBadges refs={props.property.items!.refs!} /> 230 + </Show> 231 + </div> 232 + </Show> 233 + <Show when={props.property.items && hasConstraints(props.property.items)}> 234 + <ConstraintsList property={props.property.items!} /> 235 + </Show> 236 + <Show when={props.property.description}> 237 + <p class="text-sm text-neutral-700 dark:text-neutral-300">{props.property.description}</p> 238 + </Show> 239 + </div> 240 + ); 241 + }; 242 + 243 + const DefSection = (props: { name: string; def: LexiconDef }) => { 244 + const defTypeColor = () => { 245 + switch (props.def.type) { 246 + case "record": 247 + return "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300"; 248 + case "query": 249 + return "bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300"; 250 + case "procedure": 251 + return "bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300"; 252 + case "subscription": 253 + return "bg-pink-100 text-pink-800 dark:bg-pink-900/30 dark:text-pink-300"; 254 + case "object": 255 + return "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300"; 256 + case "token": 257 + return "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300"; 258 + default: 259 + return "bg-neutral-100 text-neutral-800 dark:bg-neutral-700 dark:text-neutral-300"; 260 + } 261 + }; 262 + 263 + const handleHeaderClick = (e: MouseEvent) => { 264 + e.preventDefault(); 265 + window.history.replaceState(null, "", `#schema:${props.name}`); 266 + const element = document.getElementById(`def-${props.name}`); 267 + if (element) { 268 + element.scrollIntoView({ behavior: "instant", block: "start" }); 269 + } 270 + }; 271 + 272 + return ( 273 + <div class="flex flex-col gap-3" id={`def-${props.name}`}> 274 + <div class="flex items-center gap-2"> 275 + <a 276 + href={`#schema:${props.name}`} 277 + onClick={handleHeaderClick} 278 + class="text-lg font-semibold hover:underline" 279 + > 280 + {props.name === "main" ? "Main Definition" : props.name} 281 + </a> 282 + <span class={`rounded px-2 py-0.5 text-xs font-semibold uppercase ${defTypeColor()}`}> 283 + {props.def.type} 284 + </span> 285 + </div> 286 + 287 + <Show when={props.def.description}> 288 + <p class="text-sm text-neutral-700 dark:text-neutral-300">{props.def.description}</p> 289 + </Show> 290 + 291 + {/* Record key */} 292 + <Show when={props.def.key}> 293 + <div> 294 + <span class="text-sm font-semibold">Record Key: </span> 295 + <span class="font-mono text-sm">{props.def.key}</span> 296 + </div> 297 + </Show> 298 + 299 + {/* Properties (for record/object types) */} 300 + <Show when={props.def.properties || props.def.record?.properties}> 301 + <div class="flex flex-col gap-2"> 302 + <h4 class="text-sm font-semibold text-neutral-600 uppercase dark:text-neutral-400"> 303 + Properties 304 + </h4> 305 + <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"> 306 + <For each={Object.entries(props.def.properties || props.def.record?.properties || {})}> 307 + {([name, property]) => ( 308 + <PropertyRow 309 + name={name} 310 + property={property} 311 + required={(props.def.required || props.def.record?.required || []).includes(name)} 312 + /> 313 + )} 314 + </For> 315 + </div> 316 + </div> 317 + </Show> 318 + 319 + {/* Parameters (for query/procedure) */} 320 + <Show when={props.def.parameters?.properties}> 321 + <div class="flex flex-col gap-2"> 322 + <h4 class="text-sm font-semibold text-neutral-600 uppercase dark:text-neutral-400"> 323 + Parameters 324 + </h4> 325 + <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"> 326 + <For each={Object.entries(props.def.parameters!.properties!)}> 327 + {([name, property]) => ( 328 + <PropertyRow 329 + name={name} 330 + property={property} 331 + required={(props.def.parameters?.required || []).includes(name)} 332 + /> 333 + )} 334 + </For> 335 + </div> 336 + </div> 337 + </Show> 338 + 339 + {/* Input */} 340 + <Show when={props.def.input}> 341 + <div class="flex flex-col gap-2"> 342 + <h4 class="text-sm font-semibold text-neutral-600 uppercase dark:text-neutral-400"> 343 + Input 344 + </h4> 345 + <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"> 346 + <div class="text-sm"> 347 + <span class="font-semibold">Encoding: </span> 348 + <span class="font-mono">{props.def.input!.encoding}</span> 349 + </div> 350 + <Show when={props.def.input!.schema?.ref}> 351 + <div class="flex items-center gap-2"> 352 + <span class="text-sm font-semibold">Schema:</span> 353 + <TypeBadge type="ref" refType={props.def.input!.schema!.ref} /> 354 + </div> 355 + </Show> 356 + <Show when={props.def.input!.schema?.refs}> 357 + <div class="flex flex-col gap-2"> 358 + <div class="flex items-center gap-2"> 359 + <span class="text-sm font-semibold">Schema (union):</span> 360 + </div> 361 + <UnionBadges refs={props.def.input!.schema!.refs!} /> 362 + </div> 363 + </Show> 364 + <Show when={props.def.input!.schema?.properties}> 365 + <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"> 366 + <For each={Object.entries(props.def.input!.schema!.properties!)}> 367 + {([name, property]) => ( 368 + <PropertyRow 369 + name={name} 370 + property={property} 371 + required={(props.def.input!.schema?.required || []).includes(name)} 372 + /> 373 + )} 374 + </For> 375 + </div> 376 + </Show> 377 + </div> 378 + </div> 379 + </Show> 380 + 381 + {/* Output */} 382 + <Show when={props.def.output}> 383 + <div class="flex flex-col gap-2"> 384 + <h4 class="text-sm font-semibold text-neutral-600 uppercase dark:text-neutral-400"> 385 + Output 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.output!.encoding}</span> 391 + </div> 392 + <Show when={props.def.output!.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.output!.schema!.ref} /> 396 + </div> 397 + </Show> 398 + <Show when={props.def.output!.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.output!.schema!.refs!} /> 404 + </div> 405 + </Show> 406 + <Show when={props.def.output!.schema?.properties}> 407 + <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"> 408 + <For each={Object.entries(props.def.output!.schema!.properties!)}> 409 + {([name, property]) => ( 410 + <PropertyRow 411 + name={name} 412 + property={property} 413 + required={(props.def.output!.schema?.required || []).includes(name)} 414 + /> 415 + )} 416 + </For> 417 + </div> 418 + </Show> 419 + </div> 420 + </div> 421 + </Show> 422 + 423 + {/* Errors */} 424 + <Show when={props.def.errors && props.def.errors.length > 0}> 425 + <div class="flex flex-col gap-2"> 426 + <h4 class="text-sm font-semibold text-neutral-600 uppercase dark:text-neutral-400"> 427 + Errors 428 + </h4> 429 + <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"> 430 + <For each={props.def.errors}> 431 + {(error) => ( 432 + <div class="flex flex-col gap-1 py-2"> 433 + <div class="font-mono text-sm font-semibold">{error.name}</div> 434 + <Show when={error.description}> 435 + <p class="text-sm text-neutral-700 dark:text-neutral-300"> 436 + {error.description} 437 + </p> 438 + </Show> 439 + </div> 440 + )} 441 + </For> 442 + </div> 443 + </div> 444 + </Show> 445 + </div> 446 + ); 447 + }; 448 + 449 + export const LexiconSchemaView = (props: { schema: LexiconSchema }) => { 450 + const location = useLocation(); 451 + 452 + // Handle scrolling to a definition when hash is like #schema:definitionName 453 + createEffect(() => { 454 + const hash = location.hash; 455 + if (hash.startsWith("#schema:")) { 456 + const defName = hash.slice(8); 457 + setTimeout(() => { 458 + const element = document.getElementById(`def-${defName}`); 459 + if (element) { 460 + element.scrollIntoView({ behavior: "instant", block: "start" }); 461 + } 462 + }, 100); 463 + } 464 + }); 465 + 466 + return ( 467 + <div class="w-full max-w-4xl px-2"> 468 + {/* Header */} 469 + <div class="flex flex-col gap-2 border-b border-neutral-300 pb-4 dark:border-neutral-700"> 470 + <h2 class="text-lg font-semibold">{props.schema.id}</h2> 471 + <div class="flex gap-4 text-sm text-neutral-600 dark:text-neutral-400"> 472 + <span> 473 + <span class="font-semibold">Lexicon version: </span> 474 + <span class="font-mono">{props.schema.lexicon}</span> 475 + </span> 476 + </div> 477 + <Show when={props.schema.description}> 478 + <p class="text-sm text-neutral-700 dark:text-neutral-300">{props.schema.description}</p> 479 + </Show> 480 + </div> 481 + 482 + {/* Definitions */} 483 + <div class="flex flex-col gap-6 pt-4"> 484 + <For each={Object.entries(props.schema.defs)}> 485 + {([name, def]) => <DefSection name={name} def={def} />} 486 + </For> 487 + </div> 488 + </div> 489 + ); 490 + };
+41 -19
src/views/record.tsx
··· 8 8 import { RecordEditor, setPlaceholder } from "../components/create.jsx"; 9 9 import { CopyMenu, DropdownMenu, MenuProvider, NavMenu } from "../components/dropdown.jsx"; 10 10 import { JSONValue } from "../components/json.jsx"; 11 + import { LexiconSchemaView } from "../components/lexicon-schema.jsx"; 11 12 import { agent } from "../components/login.jsx"; 12 13 import { Modal } from "../components/modal.jsx"; 13 14 import { pds } from "../components/navbar.jsx"; ··· 129 130 }; 130 131 131 132 const RecordTab = (props: { 132 - tab: "record" | "backlinks" | "info"; 133 + tab: "record" | "backlinks" | "info" | "schema"; 133 134 label: string; 134 135 error?: boolean; 135 - }) => ( 136 - <div class="flex items-center gap-0.5"> 137 - <A 138 - classList={{ 139 - "flex items-center gap-1 border-b-2": true, 140 - "border-transparent hover:border-neutral-400 dark:hover:border-neutral-600": 141 - (!!location.hash && location.hash !== `#${props.tab}`) || 142 - (!location.hash && props.tab !== "record"), 143 - }} 144 - href={`/at://${did}/${params.collection}/${params.rkey}#${props.tab}`} 145 - > 146 - {props.label} 147 - </A> 148 - <Show when={props.error && (validRecord() === false || validSchema() === false)}> 149 - <span class="iconify lucide--x text-red-500 dark:text-red-400"></span> 150 - </Show> 151 - </div> 152 - ); 136 + }) => { 137 + const isActive = () => { 138 + if (!location.hash && props.tab === "record") return true; 139 + if (location.hash === `#${props.tab}`) return true; 140 + if (props.tab === "schema" && location.hash.startsWith("#schema:")) return true; 141 + return false; 142 + }; 143 + 144 + return ( 145 + <div class="flex items-center gap-0.5"> 146 + <A 147 + classList={{ 148 + "flex items-center gap-1 border-b-2": true, 149 + "border-transparent hover:border-neutral-400 dark:hover:border-neutral-600": 150 + !isActive(), 151 + }} 152 + href={`/at://${did}/${params.collection}/${params.rkey}#${props.tab}`} 153 + > 154 + {props.label} 155 + </A> 156 + <Show when={props.error && (validRecord() === false || validSchema() === false)}> 157 + <span class="iconify lucide--x text-red-500 dark:text-red-400"></span> 158 + </Show> 159 + </div> 160 + ); 161 + }; 153 162 154 163 return ( 155 164 <Show when={record()} keyed> ··· 157 166 <div class="dark:shadow-dark-700 dark:bg-dark-300 mb-3 flex w-full justify-between rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 px-2 py-1.5 text-sm shadow-xs dark:border-neutral-700"> 158 167 <div class="flex gap-3"> 159 168 <RecordTab tab="record" label="Record" /> 169 + <Show when={params.collection === "com.atproto.lexicon.schema"}> 170 + <RecordTab tab="schema" label="Schema" /> 171 + </Show> 160 172 <RecordTab tab="backlinks" label="Backlinks" /> 161 173 <RecordTab tab="info" label="Info" error /> 162 174 </div> ··· 224 236 <div class="w-max max-w-screen min-w-full px-4 font-mono text-xs wrap-anywhere whitespace-pre-wrap sm:px-2 sm:text-sm md:max-w-[48rem]"> 225 237 <JSONValue data={record()?.value as any} repo={record()!.uri.split("/")[2]} /> 226 238 </div> 239 + </Show> 240 + <Show 241 + when={ 242 + (location.hash === "#schema" || location.hash.startsWith("#schema:")) && 243 + params.collection === "com.atproto.lexicon.schema" 244 + } 245 + > 246 + <ErrorBoundary fallback={(err) => <div>Error: {err.message}</div>}> 247 + <LexiconSchemaView schema={record()?.value as any} /> 248 + </ErrorBoundary> 227 249 </Show> 228 250 <Show when={location.hash === "#backlinks"}> 229 251 <ErrorBoundary fallback={(err) => <div class="break-words">Error: {err.message}</div>}>