a textual notation to locate fields within atproto records (draft spec)
microcosm.tngl.io/RecordPath/
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>