a textual notation to locate fields within atproto records (draft spec) microcosm.tngl.io/RecordPath/
8
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 475 lines 15 kB view raw
1<script lang="ts"> 2 import { onMount, tick } from 'svelte'; 3 import { enumerate, match, parse, RecordPathParseError, type PathInfo } from 'recordpath'; 4 import JsonDisplay from '$lib/JsonDisplay.svelte'; 5 import { fetchRecord, isAtUri } from '$lib/slingshot'; 6 7 const SAMPLE = { 8 $type: 'app.bsky.feed.post', 9 text: 'Check out this project by @alice.bsky.social! Really impressive work on atproto tooling.', 10 langs: ['en'], 11 createdAt: '2026-04-15T12:38:49.982Z', 12 reply: { 13 root: { 14 cid: 'bafyreieac34fnjyhuuzvgdnsyeeueyn45se5kuk6yppesn25gydjf5m5hy', 15 uri: 'at://did:plc:rnpkyqnmsw4ipey6eotbdnnf/app.bsky.feed.post/3mjjvykdfo22r' 16 }, 17 parent: { 18 cid: 'bafyreibm3lim4hfn4fggpbtxkuoyyjokbonyetkyqfmr6qegqrgatxkhue', 19 uri: 'at://did:plc:rnpkyqnmsw4ipey6eotbdnnf/app.bsky.feed.post/3mjkj5zd7lc2b' 20 } 21 }, 22 facets: [ 23 { 24 index: { byteStart: 32, byteEnd: 52 }, 25 features: [ 26 { 27 $type: 'app.bsky.richtext.facet#mention', 28 did: 'did:plc:ewvi7nxzyoun6zhxrhs64oiz' 29 } 30 ] 31 }, 32 { 33 index: { byteStart: 0, byteEnd: 31 }, 34 features: [ 35 { 36 $type: 'app.bsky.richtext.facet#link', 37 uri: 'https://example.com/atproto-tooling' 38 } 39 ] 40 } 41 ], 42 embed: { 43 $type: 'app.bsky.embed.external', 44 external: { 45 uri: 'https://example.com/atproto-tooling', 46 title: 'ATProto Tooling Project', 47 description: 'A collection of tools for building on the atmosphere.' 48 } 49 } 50 }; 51 52 let jsonText = $state(''); 53 let pathInput = $state(''); 54 let editing = $state(false); 55 let uriInput = $state(''); 56 let loading = $state(false); 57 let loadError = $state(''); 58 let jsonTextarea: HTMLTextAreaElement | undefined = $state(); 59 60 // -- Hash routing -- 61 // Hash is either an AT-URI ("at://...") or URL-encoded JSON ("%7B..."). 62 // Encoding is always applied; decoding distinguishes the two by first char. 63 64 function pushHash(content: string) { 65 const hash = content ? `#${encodeURIComponent(content)}` : ''; 66 if (decodeURIComponent(location.hash) !== decodeURIComponent(hash)) { 67 history.pushState(null, '', hash || location.pathname + location.search); 68 } 69 } 70 71 function readHash(): string { 72 return decodeURIComponent(location.hash.slice(1)); 73 } 74 75 // Load state from decoded hash content — no pushState (called on back/forward) 76 function loadFromHash(content: string) { 77 if (!content) { 78 jsonText = ''; 79 pathInput = ''; 80 uriInput = ''; 81 editing = false; 82 return; 83 } 84 if (content.startsWith('at://')) { 85 loadUri(content, false); 86 } else if (content.startsWith('{')) { 87 try { 88 const parsed = JSON.parse(content); 89 if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) { 90 jsonText = JSON.stringify(parsed, null, 2); 91 editing = false; 92 pathInput = ''; 93 uriInput = ''; 94 } 95 } catch { 96 // bad JSON in hash, ignore 97 } 98 } 99 } 100 101 // -- Paste handling -- 102 103 function tryLoadJson(text: string): boolean { 104 try { 105 const parsed = JSON.parse(text); 106 if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) { 107 jsonText = JSON.stringify(parsed, null, 2); 108 editing = false; 109 pathInput = ''; 110 uriInput = ''; 111 pushHash(jsonText); 112 return true; 113 } 114 } catch { 115 // not valid JSON 116 } 117 return false; 118 } 119 120 function handleGlobalPaste(e: ClipboardEvent) { 121 const active = document.activeElement; 122 if (active instanceof HTMLInputElement || active instanceof HTMLTextAreaElement) return; 123 const text = e.clipboardData?.getData('text') ?? ''; 124 if (tryLoadJson(text)) { 125 e.preventDefault(); 126 } 127 } 128 129 function handleTextareaPaste(e: ClipboardEvent) { 130 const ta = e.target as HTMLTextAreaElement; 131 const replacesAll = ta.selectionStart === 0 && ta.selectionEnd === ta.value.length; 132 const isEmpty = ta.value.trim() === ''; 133 if (!replacesAll && !isEmpty) return; 134 const text = e.clipboardData?.getData('text') ?? ''; 135 if (tryLoadJson(text)) { 136 e.preventDefault(); 137 } 138 } 139 140 onMount(() => { 141 const initial = readHash(); 142 if (initial) loadFromHash(initial); 143 144 const onHashChange = () => loadFromHash(readHash()); 145 window.addEventListener('hashchange', onHashChange); 146 document.addEventListener('paste', handleGlobalPaste); 147 return () => { 148 window.removeEventListener('hashchange', onHashChange); 149 document.removeEventListener('paste', handleGlobalPaste); 150 }; 151 }); 152 153 let parseResult = $derived.by(() => { 154 const text = jsonText.trim(); 155 if (!text) return { record: null, error: '' }; 156 try { 157 const parsed = JSON.parse(text); 158 if (typeof parsed !== 'object' || Array.isArray(parsed) || parsed === null) { 159 return { record: null, error: 'Expected a JSON object' }; 160 } 161 return { record: parsed as Record<string, unknown>, error: '' }; 162 } catch (e) { 163 return { record: null, error: (e as Error).message }; 164 } 165 }); 166 167 let record = $derived(parseResult.record); 168 let jsonError = $derived(parseResult.error); 169 let paths: PathInfo[] = $derived( 170 record ? Array.from(enumerate(record), ([p]) => p) : [] 171 ); 172 173 let pathResult = $derived.by(() => { 174 const trimmed = pathInput.trim(); 175 if (!trimmed || !record) { 176 return { matches: [] as unknown[], parseError: null as RecordPathParseError | null }; 177 } 178 try { 179 parse(trimmed); 180 return { matches: match(record, trimmed), parseError: null }; 181 } catch (e) { 182 if (e instanceof RecordPathParseError) { 183 return { matches: [], parseError: e }; 184 } 185 return { matches: [], parseError: null }; 186 } 187 }); 188 189 let matches = $derived(pathResult.matches); 190 let parseError = $derived(pathResult.parseError); 191 let hasInput = $derived(pathInput.trim().length > 0); 192 let noMatch = $derived(hasInput && record !== null && !parseError && matches.length === 0); 193 194 function selectPath(path: string) { 195 pathInput = path; 196 } 197 198 function loadSample() { 199 jsonText = JSON.stringify(SAMPLE, null, 2); 200 pathInput = ''; 201 editing = false; 202 loadError = ''; 203 uriInput = ''; 204 pushHash(jsonText); 205 } 206 207 function formatJson() { 208 if (!record) return; 209 jsonText = JSON.stringify(record, null, 2); 210 } 211 212 async function loadUri(uri?: string, push = true) { 213 const target = uri ?? uriInput.trim(); 214 if (!target) return; 215 loading = true; 216 loadError = ''; 217 try { 218 const result = await fetchRecord(target); 219 jsonText = JSON.stringify(result.value, null, 2); 220 uriInput = target; 221 pathInput = ''; 222 editing = false; 223 if (push) pushHash(target); 224 } catch (e) { 225 loadError = (e as Error).message; 226 } finally { 227 loading = false; 228 } 229 } 230 231 function formatValue(val: unknown): string { 232 if (val === null) return 'null'; 233 if (val === undefined) return 'undefined'; 234 if (typeof val === 'string') return JSON.stringify(val); 235 if (typeof val !== 'object') return String(val); 236 const str = JSON.stringify(val, null, 2); 237 if (str.length > 500) return str.slice(0, 500) + '\n…'; 238 return str; 239 } 240</script> 241 242<div class="flex h-screen flex-col overflow-hidden bg-stone-50 text-stone-900"> 243 <!-- Header --> 244 <header class="flex items-center gap-2 border-b border-stone-200 bg-white px-4 py-2"> 245 <h1 class="text-sm font-semibold">RecordPath Playground</h1> 246 <a 247 href="https://tangled.org/microcosm.blue/RecordPath/blob/main/spec.md" 248 class="rounded-full bg-blue-50 px-2 py-0.5 text-[0.6875rem] font-medium text-blue-600 hover:bg-blue-100" 249 >draft spec</a 250 > 251 </header> 252 253 <!-- Main panels --> 254 <main class="grid flex-1 grid-cols-2 overflow-hidden"> 255 <!-- Left: JSON editor / display --> 256 <div class="flex flex-col flex-2 overflow-hidden border-r border-stone-200"> 257 {#if record || editing || jsonError} 258 <!-- Panel header --> 259 <div 260 class="flex flex-wrap items-center gap-x-2 gap-y-1 border-b border-stone-200 bg-white px-3 py-1.5" 261 > 262 <span 263 class="text-[0.6875rem] font-semibold tracking-wide text-stone-400 uppercase" 264 >Record</span 265 > 266 <!-- AT-URI form --> 267 <form 268 class="flex items-center gap-1" 269 onsubmit={(e) => { 270 e.preventDefault(); 271 loadUri(); 272 }} 273 > 274 <input 275 type="text" 276 bind:value={uriInput} 277 placeholder="at://..." 278 spellcheck="false" 279 class="w-52 rounded border border-stone-200 bg-white px-1.5 py-0.5 font-mono text-[0.625rem] outline-none 280 focus:border-blue-400 281 {loadError ? 'border-red-300' : ''}" 282 /> 283 <button 284 type="submit" 285 disabled={loading || !uriInput.trim()} 286 class="cursor-pointer rounded border border-stone-200 px-1.5 py-0.5 text-[0.625rem] hover:bg-stone-50 287 disabled:cursor-default disabled:opacity-40" 288 > 289 {loading ? '...' : 'Load'} 290 </button> 291 </form> 292 {#if loadError} 293 <span class="text-[0.625rem] text-red-500">{loadError}</span> 294 {/if} 295 <span class="flex-1"></span> 296 <button 297 onclick={loadSample} 298 class="cursor-pointer rounded px-1.5 py-0.5 text-[0.625rem] text-stone-400 hover:text-stone-600" 299 >Sample</button 300 > 301 <button 302 onclick={formatJson} 303 class="cursor-pointer rounded px-1.5 py-0.5 text-[0.625rem] text-stone-400 hover:text-stone-600" 304 >Format</button 305 > 306 <span class="text-stone-200">|</span> 307 <button 308 class="cursor-pointer rounded px-1.5 py-0.5 text-[0.625rem] 309 {editing 310 ? 'bg-sky-50 text-sky-600' 311 : 'text-stone-400 hover:text-stone-600'}" 312 onclick={() => (editing = true)} 313 > 314 Edit 315 </button> 316 <button 317 class="cursor-pointer rounded px-1.5 py-0.5 text-[0.625rem] 318 {!editing 319 ? 'bg-sky-50 text-sky-600' 320 : 'text-stone-400 hover:text-stone-600'}" 321 onclick={() => { 322 editing = false; 323 if (record && !uriInput) pushHash(jsonText); 324 }} 325 > 326 Display 327 </button> 328 </div> 329 {/if} 330 331 {#if editing || jsonError} 332 <textarea 333 bind:this={jsonTextarea} 334 bind:value={jsonText} 335 spellcheck="false" 336 placeholder="Paste a JSON record..." 337 onpaste={handleTextareaPaste} 338 class="flex-1 resize-none bg-white p-3 font-mono text-[0.8125rem] leading-relaxed outline-none" 339 style="tab-size: 2" 340 ></textarea> 341 {#if jsonError} 342 <div class="border-t border-red-200 bg-red-50 px-3 py-1.5 text-xs text-red-600"> 343 {jsonError} 344 </div> 345 {/if} 346 {:else if record} 347 <JsonDisplay 348 {record} 349 activePath={pathInput} 350 onSelectPath={(p) => (pathInput = p)} 351 onLoadUri={(uri) => loadUri(uri)} 352 /> 353 {:else} 354 <!-- Welcome state --> 355 <div class="flex flex-1 flex-col items-center justify-center gap-4 bg-white px-8"> 356 <label class="text-sm text-stone-500">Paste an atproto record (JSON)</label> 357 <textarea 358 bind:value={jsonText} 359 spellcheck="false" 360 placeholder={'{"$type": "app.bsky.feed.post", ...}'} 361 onpaste={handleTextareaPaste} 362 onfocus={async () => { 363 editing = true; 364 await tick(); 365 jsonTextarea?.focus(); 366 }} 367 class="h-24 w-full max-w-lg resize-none rounded border border-stone-200 p-3 font-mono text-xs leading-relaxed outline-none focus:border-blue-400" 368 style="tab-size: 2" 369 ></textarea> 370 <button 371 onclick={loadSample} 372 class="cursor-pointer rounded border border-stone-200 bg-white px-4 py-1.5 text-xs text-stone-600 hover:bg-stone-50" 373 > 374 Load sample record 375 </button> 376 </div> 377 {/if} 378 </div> 379 380 <!-- Right: paths + matches --> 381 <div class="flex flex-col flex-1 overflow-hidden"> 382 <!-- Path input --> 383 <div class="border-b border-stone-200 bg-white p-2"> 384 <input 385 type="text" 386 bind:value={pathInput} 387 placeholder="Type or click a RecordPath…" 388 spellcheck="false" 389 autocomplete="off" 390 class="w-full rounded border-[1.5px] bg-white px-2.5 py-2 font-mono text-sm outline-none 391 {parseError 392 ? 'border-red-400 shadow-[0_0_0_2px_theme(colors.red.100)]' 393 : noMatch 394 ? 'border-amber-400 shadow-[0_0_0_2px_theme(colors.amber.100)]' 395 : 'border-stone-200 focus:border-blue-500 focus:shadow-[0_0_0_2px_theme(colors.blue.100)]'}" 396 /> 397 {#if parseError} 398 <pre 399 class="mt-1.5 overflow-x-auto rounded bg-red-50 px-2.5 py-2 font-mono text-[0.8125rem]" 400 ><span class="text-red-300">{pathInput.trim()}</span> 401<span class="text-red-500">{' '.repeat(parseError.position)}^ {parseError.message.split(' (position')[0]}</span>{#if parseError.suggestion} 402<span class="text-emerald-500">hint: {parseError.suggestionHint}:</span> <button class="cursor-pointer text-emerald-600 underline decoration-emerald-300 hover:decoration-emerald-500" onclick={() => { pathInput = parseError.suggestion ?? ''; }}>{parseError.suggestion}</button>{/if}</pre> 403 {/if} 404 </div> 405 406 <!-- Path list header --> 407 <div 408 class="flex items-center gap-2 border-b border-stone-200 bg-white px-3 py-1.5 text-[0.6875rem] font-semibold tracking-wide text-stone-400 uppercase" 409 > 410 Available paths 411 {#if paths.length > 0} 412 <span class="font-normal">({paths.length})</span> 413 {/if} 414 </div> 415 416 <!-- Scrollable body --> 417 <div class="flex min-h-0 flex-1 flex-col overflow-hidden"> 418 <!-- Paths list --> 419 <div class="flex-1 overflow-y-auto bg-white"> 420 {#each paths as { path, type }} 421 <button 422 onclick={() => selectPath(path)} 423 class="flex w-full cursor-pointer items-center gap-2 border-l-[3px] px-3 py-[0.3125rem] text-left font-mono text-[0.8125rem] 424 {pathInput === path 425 ? 'border-l-blue-500 bg-blue-50' 426 : 'border-l-transparent hover:bg-stone-50'}" 427 > 428 <span class="min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap" 429 >{path}</span 430 > 431 <span 432 class="shrink-0 rounded px-1.5 py-px text-[0.625rem] font-medium uppercase tracking-wide 433 {type === 'vector' 434 ? 'bg-emerald-50 text-emerald-700' 435 : 'bg-sky-50 text-sky-700'}" 436 > 437 {type} 438 </span> 439 </button> 440 {/each} 441 {#if paths.length === 0 && !jsonError} 442 <div class="px-3 py-6 text-center text-xs text-stone-400"> 443 Paste a JSON record to see available paths 444 </div> 445 {/if} 446 </div> 447 448 <!-- Matches --> 449 {#if hasInput} 450 <div class="flex max-h-[45%] flex-col overflow-hidden border-t border-stone-200"> 451 <div 452 class="flex items-center gap-2 border-b border-stone-200 bg-white px-3 py-1.5 text-[0.6875rem] font-semibold tracking-wide text-stone-400 uppercase" 453 > 454 Matches 455 <span class="font-normal">({matches.length})</span> 456 </div> 457 <div class="overflow-y-auto bg-white"> 458 {#if matches.length === 0} 459 <div class="px-3 py-3 text-xs text-stone-400">No matches</div> 460 {:else} 461 {#each matches as val} 462 <div 463 class="whitespace-pre-wrap break-all border-b border-amber-100 bg-amber-50 px-3 py-1.5 font-mono text-[0.8125rem]" 464 > 465 {formatValue(val)} 466 </div> 467 {/each} 468 {/if} 469 </div> 470 </div> 471 {/if} 472 </div> 473 </div> 474 </main> 475</div>