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

Configure Feed

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

Merge branch 'main' into playground

phil 391ddea7 cbbda27b

+289 -50
+38 -6
playground/src/lib/JsonDisplay.svelte
··· 1 1 <script lang="ts"> 2 2 import { parse, escapeFieldName, type Segment } from 'recordpath'; 3 + import { isAtUri } from '$lib/slingshot'; 3 4 4 5 interface Props { 5 6 record: Record<string, unknown>; 6 7 activePath: string; 7 8 onSelectPath: (path: string) => void; 9 + onLoadUri?: (uri: string) => void; 8 10 } 9 11 10 - let { record, activePath, onSelectPath }: Props = $props(); 12 + let { record, activePath, onSelectPath, onLoadUri }: Props = $props(); 11 13 12 14 let activeSegs: Segment[] = $derived.by(() => { 13 15 if (!activePath.trim()) return []; ··· 88 90 } 89 91 </script> 90 92 91 - <div class="flex-1 overflow-auto bg-white py-2 font-mono text-[0.8125rem] leading-relaxed"> 93 + <div class="flex-1 overflow-auto bg-white py-2 font-mono text-[0.8125rem] leading-relaxed whitespace-nowrap"> 92 94 {@render objectBlock(record, '', activeSegs, 0)} 93 95 </div> 94 96 ··· 120 122 <button class={keyClass(key, child, onPath)} onclick={() => onSelectPath(path)} 121 123 >{JSON.stringify(key)}</button 122 124 ><span class="text-stone-300">: </span> 123 - <button class={leafClass(matched)} onclick={() => onSelectPath(path)} 124 - >{formatLeaf(child)}</button 125 - ><span class="text-stone-300">{comma}</span> 125 + {#if typeof child === 'string' && isAtUri(child)} 126 + <button 127 + class="cursor-pointer {matched 128 + ? 'text-amber-800 bg-amber-100 rounded-sm' 129 + : 'text-blue-500 hover:text-blue-700'}" 130 + onclick={() => onSelectPath(path)} 131 + >{formatLeaf(child)}</button 132 + >{#if onLoadUri}<button 133 + class="ml-1.5 cursor-pointer rounded bg-blue-50 px-1.5 py-px text-[0.625rem] font-medium text-blue-500 hover:bg-blue-100 hover:text-blue-700" 134 + onclick={() => onLoadUri(child)} 135 + >load</button 136 + >{/if} 137 + {:else} 138 + <button class={leafClass(matched)} onclick={() => onSelectPath(path)} 139 + >{formatLeaf(child)}</button 140 + > 141 + {/if}<span class="text-stone-300">{comma}</span> 126 142 </div> 127 143 {:else if Array.isArray(child)} 128 144 <!-- key: [ ...elements ] --> ··· 166 182 167 183 {#if isLeaf(elem)} 168 184 <div style="padding-left: {depth * 2}ch" class="px-3"> 169 - <span class={leafClass(matched)}>{formatLeaf(elem)}</span><span class="text-stone-300" 185 + {#if typeof elem === 'string' && isAtUri(elem)} 186 + <button 187 + class="cursor-pointer {matched 188 + ? 'text-amber-800 bg-amber-100 rounded-sm' 189 + : 'text-blue-500 hover:text-blue-700'}" 190 + onclick={() => onSelectPath(ePrefix)} 191 + >{formatLeaf(elem)}</button 192 + >{#if onLoadUri}<button 193 + class="ml-1.5 cursor-pointer rounded bg-blue-50 px-1.5 py-px text-[0.625rem] font-medium text-blue-500 hover:bg-blue-100 hover:text-blue-700" 194 + onclick={() => onLoadUri(elem)} 195 + >load</button 196 + >{/if} 197 + {:else} 198 + <button class={leafClass(matched)} onclick={() => onSelectPath(ePrefix)} 199 + >{formatLeaf(elem)}</button 200 + > 201 + {/if}<span class="text-stone-300" 170 202 >{comma}</span 171 203 > 172 204 </div>
+22
playground/src/lib/slingshot.ts
··· 1 + const SLINGSHOT_BASE = 'https://slingshot.microcosm.blue'; 2 + 3 + export interface SlingshotRecord { 4 + uri: string; 5 + cid: string; 6 + value: Record<string, unknown>; 7 + } 8 + 9 + export async function fetchRecord(atUri: string): Promise<SlingshotRecord> { 10 + const url = `${SLINGSHOT_BASE}/xrpc/blue.microcosm.repo.getRecordByUri?at_uri=${encodeURIComponent(atUri)}`; 11 + const res = await fetch(url); 12 + if (!res.ok) { 13 + throw new Error(`${res.status} ${res.statusText}`); 14 + } 15 + return res.json(); 16 + } 17 + 18 + const AT_URI_RE = /^at:\/\/[^/]+\/[^/]+\/[^/]+$/; 19 + 20 + export function isAtUri(s: string): boolean { 21 + return AT_URI_RE.test(s); 22 + }
+229 -44
playground/src/routes/+page.svelte
··· 1 1 <script lang="ts"> 2 + import { onMount, tick } from 'svelte'; 2 3 import { enumerate, match, parse, RecordPathParseError, type PathInfo } from 'recordpath'; 3 4 import JsonDisplay from '$lib/JsonDisplay.svelte'; 5 + import { fetchRecord, isAtUri } from '$lib/slingshot'; 4 6 5 7 const SAMPLE = { 6 8 $type: 'app.bsky.feed.post', ··· 47 49 } 48 50 }; 49 51 50 - let jsonText = $state(JSON.stringify(SAMPLE, null, 2)); 52 + let jsonText = $state(''); 51 53 let pathInput = $state(''); 52 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 + }); 53 152 54 153 let parseResult = $derived.by(() => { 55 154 const text = jsonText.trim(); ··· 98 197 99 198 function loadSample() { 100 199 jsonText = JSON.stringify(SAMPLE, null, 2); 200 + pathInput = ''; 201 + editing = false; 202 + loadError = ''; 203 + uriInput = ''; 204 + pushHash(jsonText); 101 205 } 102 206 103 207 function formatJson() { ··· 105 209 jsonText = JSON.stringify(record, null, 2); 106 210 } 107 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 + 108 231 function formatValue(val: unknown): string { 109 232 if (val === null) return 'null'; 110 233 if (val === undefined) return 'undefined'; ··· 118 241 119 242 <div class="flex h-screen flex-col overflow-hidden bg-stone-50 text-stone-900"> 120 243 <!-- Header --> 121 - <header class="flex items-center gap-3 border-b border-stone-200 bg-white px-4 py-2"> 244 + <header class="flex items-center gap-2 border-b border-stone-200 bg-white px-4 py-2"> 122 245 <h1 class="text-sm font-semibold">RecordPath Playground</h1> 123 246 <span class="rounded-full bg-blue-50 px-2 py-0.5 text-[0.6875rem] font-medium text-blue-600" 124 247 >draft spec</span 125 248 > 126 - <span class="flex-1"></span> 127 - <button 128 - onclick={loadSample} 129 - class="cursor-pointer rounded border border-stone-200 bg-white px-3 py-1.5 text-xs hover:bg-stone-50" 130 - >Load sample</button 131 - > 132 - <button 133 - onclick={formatJson} 134 - class="cursor-pointer rounded border border-stone-200 bg-white px-3 py-1.5 text-xs hover:bg-stone-50" 135 - >Format</button 136 - > 137 249 </header> 138 250 139 251 <!-- Main panels --> 140 252 <main class="grid flex-1 grid-cols-2 overflow-hidden"> 141 253 <!-- Left: JSON editor / display --> 142 254 <div class="flex flex-col flex-2 overflow-hidden border-r border-stone-200"> 143 - <div 144 - 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" 145 - > 146 - Record JSON 147 - <span class="flex-1"></span> 148 - <button 149 - class="cursor-pointer rounded px-1.5 py-0.5 text-[0.625rem] normal-case tracking-normal 150 - {editing 151 - ? 'bg-sky-50 text-sky-600' 152 - : 'text-stone-400 hover:text-stone-600'}" 153 - onclick={() => (editing = true)} 154 - > 155 - Edit 156 - </button> 157 - <button 158 - class="cursor-pointer rounded px-1.5 py-0.5 text-[0.625rem] normal-case tracking-normal 159 - {!editing 160 - ? 'bg-sky-50 text-sky-600' 161 - : 'text-stone-400 hover:text-stone-600'}" 162 - onclick={() => (editing = false)} 255 + {#if record || editing || jsonError} 256 + <!-- Panel header --> 257 + <div 258 + class="flex flex-wrap items-center gap-x-2 gap-y-1 border-b border-stone-200 bg-white px-3 py-1.5" 163 259 > 164 - Display 165 - </button> 166 - </div> 167 - {#if editing} 260 + <span 261 + class="text-[0.6875rem] font-semibold tracking-wide text-stone-400 uppercase" 262 + >Record</span 263 + > 264 + <!-- AT-URI form --> 265 + <form 266 + class="flex items-center gap-1" 267 + onsubmit={(e) => { 268 + e.preventDefault(); 269 + loadUri(); 270 + }} 271 + > 272 + <input 273 + type="text" 274 + bind:value={uriInput} 275 + placeholder="at://..." 276 + spellcheck="false" 277 + class="w-52 rounded border border-stone-200 bg-white px-1.5 py-0.5 font-mono text-[0.625rem] outline-none 278 + focus:border-blue-400 279 + {loadError ? 'border-red-300' : ''}" 280 + /> 281 + <button 282 + type="submit" 283 + disabled={loading || !uriInput.trim()} 284 + class="cursor-pointer rounded border border-stone-200 px-1.5 py-0.5 text-[0.625rem] hover:bg-stone-50 285 + disabled:cursor-default disabled:opacity-40" 286 + > 287 + {loading ? '...' : 'Load'} 288 + </button> 289 + </form> 290 + {#if loadError} 291 + <span class="text-[0.625rem] text-red-500">{loadError}</span> 292 + {/if} 293 + <span class="flex-1"></span> 294 + <button 295 + onclick={loadSample} 296 + class="cursor-pointer rounded px-1.5 py-0.5 text-[0.625rem] text-stone-400 hover:text-stone-600" 297 + >Sample</button 298 + > 299 + <button 300 + onclick={formatJson} 301 + class="cursor-pointer rounded px-1.5 py-0.5 text-[0.625rem] text-stone-400 hover:text-stone-600" 302 + >Format</button 303 + > 304 + <span class="text-stone-200">|</span> 305 + <button 306 + class="cursor-pointer rounded px-1.5 py-0.5 text-[0.625rem] 307 + {editing 308 + ? 'bg-sky-50 text-sky-600' 309 + : 'text-stone-400 hover:text-stone-600'}" 310 + onclick={() => (editing = true)} 311 + > 312 + Edit 313 + </button> 314 + <button 315 + class="cursor-pointer rounded px-1.5 py-0.5 text-[0.625rem] 316 + {!editing 317 + ? 'bg-sky-50 text-sky-600' 318 + : 'text-stone-400 hover:text-stone-600'}" 319 + onclick={() => { 320 + editing = false; 321 + if (record && !uriInput) pushHash(jsonText); 322 + }} 323 + > 324 + Display 325 + </button> 326 + </div> 327 + {/if} 328 + 329 + {#if editing || jsonError} 168 330 <textarea 331 + bind:this={jsonTextarea} 169 332 bind:value={jsonText} 170 333 spellcheck="false" 171 334 placeholder="Paste a JSON record..." 335 + onpaste={handleTextareaPaste} 172 336 class="flex-1 resize-none bg-white p-3 font-mono text-[0.8125rem] leading-relaxed outline-none" 173 337 style="tab-size: 2" 174 338 ></textarea> 339 + {#if jsonError} 340 + <div class="border-t border-red-200 bg-red-50 px-3 py-1.5 text-xs text-red-600"> 341 + {jsonError} 342 + </div> 343 + {/if} 175 344 {:else if record} 176 345 <JsonDisplay 177 346 {record} 178 347 activePath={pathInput} 179 348 onSelectPath={(p) => (pathInput = p)} 349 + onLoadUri={(uri) => loadUri(uri)} 180 350 /> 181 351 {:else} 182 - <div class="flex flex-1 items-center justify-center bg-white text-xs text-stone-400"> 183 - Paste a JSON record to get started 184 - </div> 185 - {/if} 186 - {#if jsonError} 187 - <div class="border-t border-red-200 bg-red-50 px-3 py-1.5 text-xs text-red-600"> 188 - {jsonError} 352 + <!-- Welcome state --> 353 + <div class="flex flex-1 flex-col items-center justify-center gap-4 bg-white px-8"> 354 + <label class="text-sm text-stone-500">Paste an atproto record (JSON)</label> 355 + <textarea 356 + bind:value={jsonText} 357 + spellcheck="false" 358 + placeholder={'{"$type": "app.bsky.feed.post", ...}'} 359 + onpaste={handleTextareaPaste} 360 + onfocus={async () => { 361 + editing = true; 362 + await tick(); 363 + jsonTextarea?.focus(); 364 + }} 365 + 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" 366 + style="tab-size: 2" 367 + ></textarea> 368 + <button 369 + onclick={loadSample} 370 + class="cursor-pointer rounded border border-stone-200 bg-white px-4 py-1.5 text-xs text-stone-600 hover:bg-stone-50" 371 + > 372 + Load sample record 373 + </button> 189 374 </div> 190 375 {/if} 191 376 </div>