forked from
standard.site/standard.site
Standard.site landing page built in Next.js
1'use client'
2
3import { useState } from 'react'
4import { motion, AnimatePresence } from 'motion/react'
5import type { LexiconDef, LexiconProperty, LexiconSchema, ParsedField } from '@/app/lib/lexicon'
6import { getDescriptionOverride, getPropertyOrder } from '@/app/data/lexicon-overrides'
7import { EyeIcon, EyeOffIcon, FileIcon } from 'lucide-react'
8
9// Client-side lexicon cache (passed from server)
10let lexiconCache: Record<string, LexiconSchema> = {}
11
12export function setLexiconCache(cache: Record<string, LexiconSchema>) {
13 lexiconCache = cache
14}
15
16// Client-side ref resolution
17function resolveRef(ref: string, currentSchema: LexiconSchema): LexiconDef | null {
18 if (ref.startsWith('#')) {
19 const defName = ref.slice(1)
20 return currentSchema.defs[defName] ?? null
21 }
22
23 if (ref.includes('#')) {
24 const [nsid, defName] = ref.split('#')
25 const schema = lexiconCache[nsid]
26 return schema?.defs[defName] ?? null
27 }
28
29 const schema = lexiconCache[ref]
30 return schema?.defs.main ?? null
31}
32
33// Extended def type to handle array items
34interface ExtendedLexiconDef extends LexiconDef {
35 items?: {
36 type: string;
37 refs?: string[];
38 maxLength?: number;
39 maxGraphemes?: number;
40 };
41 minimum?: number;
42 maximum?: number;
43}
44
45// Helper to get the object schema from a def (handles record types)
46function getObjectSchema(def: LexiconDef): { properties?: Record<string, LexiconProperty>; required?: string[] } | null {
47 // For record types, the object schema is nested inside 'record'
48 if (def.type === 'record' && def.record) {
49 return def.record
50 }
51 // For object types, properties are directly on the def
52 if (def.type === 'object' && def.properties) {
53 return def
54 }
55 return null
56}
57
58// Parse a LexiconDef into fields (for object types with properties)
59function parseDefFields(
60 def: LexiconDef,
61 lexiconId?: string,
62 defName?: string | null
63): ParsedField[] {
64 const schema = getObjectSchema(def)
65 if (!schema?.properties) {
66 return []
67 }
68
69 const requiredFields = new Set(schema.required || [])
70 const propertyOrder = lexiconId ? getPropertyOrder(lexiconId, defName ?? null) : undefined
71
72 // Sort entries by custom order if defined
73 let entries = Object.entries(schema.properties)
74 if (propertyOrder) {
75 entries = entries.sort((a, b) => {
76 const indexA = propertyOrder.indexOf(a[0])
77 const indexB = propertyOrder.indexOf(b[0])
78 // Properties not in order list go to the end
79 const orderA = indexA === -1 ? Infinity : indexA
80 const orderB = indexB === -1 ? Infinity : indexB
81 return orderA - orderB
82 })
83 }
84
85 return entries.map(([name, prop]) => {
86 const constraints: string[] = []
87
88 if (prop.maxLength) constraints.push(`maxLength: ${prop.maxLength}`)
89 if (prop.maxGraphemes) constraints.push(`maxGraphemes: ${prop.maxGraphemes}`)
90 if (prop.maxSize) {
91 const bytes = prop.maxSize
92 const formatted = bytes >= 1_000_000 ? `${bytes / 1_000_000}MB` : bytes >= 1_000 ? `${bytes / 1_000}KB` : `${bytes}B`
93 constraints.push(`maxSize: ${formatted}`)
94 }
95 if (prop.accept) constraints.push(`accept: [${prop.accept.join(', ')}]`)
96
97 if (prop.items) {
98 if (prop.items.maxLength) constraints.push(`items.maxLength: ${prop.items.maxLength}`)
99 if (prop.items.maxGraphemes) constraints.push(`items.maxGraphemes: ${prop.items.maxGraphemes}`)
100 }
101
102 let type = prop.type
103 if (prop.format) type = `${prop.type}:${prop.format}`
104 if (prop.ref) type = prop.ref
105 if (prop.type === 'array' && prop.items) {
106 type = `array<${prop.items.type}>`
107 }
108
109 const refs = (prop as { refs?: string[] }).refs
110
111 // Check for description override
112 const description = lexiconId
113 ? getDescriptionOverride(lexiconId, defName ?? null, name) ?? prop.description
114 : prop.description
115
116 return {
117 name,
118 type,
119 required: requiredFields.has(name),
120 description,
121 constraints,
122 ref: prop.ref,
123 refs,
124 }
125 })
126}
127
128// Check if def has properties (is an object type with fields)
129function defHasProperties(def: LexiconDef): boolean {
130 const schema = getObjectSchema(def)
131 return !!schema?.properties && Object.keys(schema.properties).length > 0
132}
133
134// Get summary info for non-object defs
135function getDefSummary(def: ExtendedLexiconDef): {
136 type: string;
137 description?: string;
138 constraints: string[];
139 refs?: string[]
140} {
141 const constraints: string[] = []
142 let type = def.type
143
144 if (def.type === 'array' && def.items) {
145 type = `array<${def.items.type}>`
146 if (def.items.maxLength) constraints.push(`items.maxLength: ${def.items.maxLength}`)
147 if (def.items.maxGraphemes) constraints.push(`items.maxGraphemes: ${def.items.maxGraphemes}`)
148 }
149
150 if ((def as ExtendedLexiconDef).minimum !== undefined) {
151 constraints.push(`minimum: ${(def as ExtendedLexiconDef).minimum}`)
152 }
153 if ((def as ExtendedLexiconDef).maximum !== undefined) {
154 constraints.push(`maximum: ${(def as ExtendedLexiconDef).maximum}`)
155 }
156
157 return {
158 type,
159 description: def.description,
160 constraints,
161 refs: def.items?.refs,
162 }
163}
164
165// Helper to parse description with inline code (backticks)
166function parseInlineCode(text: string): React.ReactNode {
167 const parts: React.ReactNode[] = []
168 let lastIndex = 0
169 const regex = /`([^`]+)`/g
170 let match
171
172 while ((match = regex.exec(text)) !== null) {
173 // Add text before the match
174 if (match.index > lastIndex) {
175 parts.push(text.slice(lastIndex, match.index))
176 }
177 // Add the code part with styling
178 parts.push(
179 <code
180 key={match.index}
181 className="rounded bg-base-200 px-1 mx-0.5 font-mono text-sm text-base-content italic"
182 >
183 {match[1]}
184 </code>
185 )
186 lastIndex = match.index + match[0].length
187 }
188
189 // Add remaining text
190 if (lastIndex < text.length) {
191 parts.push(text.slice(lastIndex))
192 }
193
194 return parts.length > 0 ? parts : text
195}
196
197function TypeBadge({ name, type, required }: { name: string; type: string; required?: boolean }) {
198 return (
199 <div className="flex flex-wrap items-center gap-2">
200 <span className="font-mono font-semibold text-base leading-snug tracking-tight text-base-content">
201 {name}
202 </span>
203 <span className="rounded border border-sky-200 bg-sky-100 text-sky-800 dark:border-sky-900 dark:bg-sky-950 dark:text-sky-100 px-1 py-0.5 font-mono font-medium text-sm leading-none tracking-tight">
204 {type}
205 </span>
206 {required && (
207 <span className="rounded border border-red-200 bg-red-100 text-red-800 dark:border-red-900 dark:bg-red-950 dark:text-red-100 px-1 py-0.5 font-mono font-medium text-sm leading-none tracking-tight">
208 required
209 </span>
210 )}
211 </div>
212 )
213}
214
215function RefBadge({ ref }: { ref: string }) {
216 return (
217 <span className="rounded border border-emerald-200 bg-emerald-100 text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950 dark:text-emerald-100 px-1 py-0.5 font-mono font-medium text-sm leading-none tracking-tight">
218 {ref}
219 </span>
220 )
221}
222
223interface ExpandableFieldProps {
224 field: ParsedField;
225 schema: LexiconSchema;
226}
227
228// Component to display a non-object def (array, primitive, etc.)
229function DefSummaryDisplay({ def }: { def: ExtendedLexiconDef }) {
230 const summary = getDefSummary(def)
231
232 return (
233 <div className="flex flex-col gap-1 p-4">
234 <div className="flex flex-wrap items-center gap-2">
235 <span className="rounded border border-sky-200 bg-sky-100 text-sky-800 dark:border-sky-900 dark:bg-sky-950 dark:text-sky-100 px-1 py-0.5 font-mono font-medium text-sm leading-none tracking-tight">
236 {summary.type}
237 </span>
238 </div>
239
240 {/* Union refs as badges */}
241 {summary.refs && summary.refs.length > 0 && (
242 <div className="flex flex-wrap gap-2">
243 {summary.refs.map((ref) => (
244 <RefBadge key={ref} ref={ref} />
245 ))}
246 </div>
247 )}
248
249 {summary.constraints.length > 0 && (
250 <div className="flex flex-wrap gap-4 font-mono text-sm leading-none tracking-tight text-muted">
251 {summary.constraints.map((constraint) => (
252 <span key={constraint}>{constraint}</span>
253 ))}
254 </div>
255 )}
256
257 {summary.description && (
258 <p className="text-sm italic leading-snug tracking-tight text-muted">
259 {parseInlineCode(summary.description)}
260 </p>
261 )}
262 </div>
263 )
264}
265
266// Refs that should not be expandable
267const NON_EXPANDABLE_REFS = [
268 'com.atproto.repo.strongRef',
269]
270
271// Parse ref string to get lexiconId and defName for overrides
272function parseRefContext(ref: string, currentSchema: LexiconSchema): { lexiconId: string; defName: string | null } {
273 if (ref.startsWith('#')) {
274 return { lexiconId: currentSchema.id, defName: ref.slice(1) }
275 }
276 if (ref.includes('#')) {
277 const [nsid, defName] = ref.split('#')
278 return { lexiconId: nsid, defName }
279 }
280 return { lexiconId: ref, defName: 'main' }
281}
282
283export function ExpandableField({ field, schema }: ExpandableFieldProps) {
284 const [expanded, setExpanded] = useState(false)
285 const isExpandable = !!field.ref && !NON_EXPANDABLE_REFS.includes(field.ref)
286
287 const toggleExpand = () => {
288 setExpanded(!expanded)
289 }
290
291 const resolvedDef = field.ref ? resolveRef(field.ref, schema) : null
292 const refContext = field.ref ? parseRefContext(field.ref, schema) : null
293 const hasNestedFields = resolvedDef ? defHasProperties(resolvedDef) : false
294 const nestedFields = resolvedDef && hasNestedFields
295 ? parseDefFields(resolvedDef, refContext?.lexiconId, refContext?.defName)
296 : []
297
298 // Get display name for the expand button
299 const refDisplayName = field.ref?.startsWith('#') ? field.ref.slice(1) : field.ref
300
301 return (
302 <div className="flex flex-col gap-1 border-b border-border p-4 last:border-b-0">
303 <TypeBadge name={field.name} type={field.type} required={field.required} />
304
305 {/* Union refs as badges */}
306 {field.refs && field.refs.length > 0 && (
307 <div className="flex flex-wrap gap-2">
308 {field.refs.map((ref) => (
309 <RefBadge key={ref} ref={ref} />
310 ))}
311 </div>
312 )}
313
314 {field.constraints.length > 0 && (
315 <div className="flex flex-wrap gap-4 font-mono text-sm leading-none tracking-tight text-muted">
316 {field.constraints.map((constraint) => (
317 <span key={constraint}>{constraint}</span>
318 ))}
319 </div>
320 )}
321
322 {field.description && (
323 <p className="text-sm italic leading-snug tracking-tight text-muted">
324 {parseInlineCode(field.description)}
325 </p>
326 )}
327
328 {isExpandable && (
329 <div className="pt-2">
330 <div className="flex flex-col rounded-xl border border-border bg-base-200 overflow-hidden">
331 {/* Header button */}
332 <button
333 onClick={toggleExpand}
334 className={`flex items-center gap-2 px-4 py-2.5 hover:bg-base-300 transition-colors hover:cursor-pointer ${expanded ? 'border-b border-border' : ''}`}
335 >
336 <FileIcon className="size-4 text-base-content" />
337 <span className="font-medium text-base leading-snug tracking-tight text-base-content">
338 {refDisplayName}
339 </span>
340 <motion.div
341 className="ml-auto"
342 initial={false}
343 animate={{ opacity: 1 }}
344 >
345 {expanded ? (
346 <EyeOffIcon className="size-4 text-muted" />
347 ) : (
348 <EyeIcon className="size-4 text-muted" />
349 )}
350 </motion.div>
351 </button>
352
353 {/* Expandable content */}
354 <AnimatePresence initial={false}>
355 {expanded && (
356 <motion.div
357 initial={{ height: 0, opacity: 0 }}
358 animate={{ height: 'auto', opacity: 1 }}
359 exit={{ height: 0, opacity: 0 }}
360 transition={{ duration: 0.3, ease: [0.25, 0.1, 0.25, 1] }}
361 className="overflow-hidden"
362 >
363 <div className="p-1">
364 <div className="flex flex-col rounded-xl border border-border bg-card overflow-hidden">
365 {hasNestedFields ? (
366 nestedFields.map((nestedField) => (
367 <ExpandableField
368 key={nestedField.name}
369 field={nestedField}
370 schema={schema}
371 />
372 ))
373 ) : resolvedDef ? (
374 <DefSummaryDisplay def={resolvedDef as ExtendedLexiconDef} />
375 ) : null}
376 </div>
377 </div>
378 </motion.div>
379 )}
380 </AnimatePresence>
381 </div>
382 </div>
383 )}
384 </div>
385 )
386}
387
388interface LexiconContentProps {
389 schema: LexiconSchema;
390 fields: ParsedField[];
391 allSchemas: Record<string, LexiconSchema>;
392}
393
394export function LexiconContent({ schema, fields, allSchemas }: LexiconContentProps) {
395 // Initialize the lexicon cache on first render
396 if (Object.keys(lexiconCache).length === 0) {
397 setLexiconCache(allSchemas)
398 }
399
400 return (
401 <div className="p-1">
402 <div className="flex flex-col rounded-xl border border-border bg-card overflow-hidden">
403 {fields.map((field) => (
404 <ExpandableField key={field.name} field={field} schema={schema} />
405 ))}
406 </div>
407 </div>
408 )
409}