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.

display mode for preview values

phil 136ef8cc 367dacb9

+235 -9
+193
playground/src/lib/JsonDisplay.svelte
··· 1 + <script lang="ts"> 2 + import { parse, escapeFieldName, type Segment } from 'recordpath'; 3 + 4 + interface Props { 5 + record: Record<string, unknown>; 6 + activePath: string; 7 + onSelectPath: (path: string) => void; 8 + } 9 + 10 + let { record, activePath, onSelectPath }: Props = $props(); 11 + 12 + let activeSegs: Segment[] = $derived.by(() => { 13 + if (!activePath.trim()) return []; 14 + try { 15 + return parse(activePath.trim()); 16 + } catch { 17 + return []; 18 + } 19 + }); 20 + 21 + function kp(prefix: string, key: string): string { 22 + const esc = escapeFieldName(key); 23 + return prefix ? `${prefix}.${esc}` : esc; 24 + } 25 + 26 + function isObj(v: unknown): v is Record<string, unknown> { 27 + return typeof v === 'object' && v !== null && !Array.isArray(v); 28 + } 29 + 30 + function isLeaf(v: unknown): boolean { 31 + return v === null || v === undefined || typeof v !== 'object'; 32 + } 33 + 34 + function formatLeaf(v: unknown): string { 35 + if (v === null) return 'null'; 36 + if (typeof v === 'string') return JSON.stringify(v); 37 + return String(v); 38 + } 39 + 40 + // Resolve which segments the child should receive after matching a key. 41 + function childSegs( 42 + onPath: boolean, 43 + seg: Segment | null, 44 + rest: Segment[], 45 + child: unknown 46 + ): Segment[] { 47 + if (!onPath || !seg) return []; 48 + // If segment has a scalar union qualifier, verify $type matches 49 + const unionNsid = seg.qualifiers.find((q) => q.type === 'scalarUnion')?.nsid; 50 + if (unionNsid && isObj(child) && child.$type !== unionNsid) return []; 51 + return rest; 52 + } 53 + 54 + // For array elements: is this element on the active path? 55 + function elemOnPath(seg: Segment | null, elem: unknown): boolean { 56 + if (!seg) return false; 57 + for (const q of seg.qualifiers) { 58 + if (q.type === 'array') return true; 59 + if (q.type === 'arrayUnion') { 60 + return isObj(elem) && elem.$type === q.nsid; 61 + } 62 + } 63 + return false; 64 + } 65 + 66 + // Key classes based on child type and path state 67 + function keyClass(key: string, child: unknown, onPath: boolean): string { 68 + const base = 'cursor-pointer hover:underline decoration-1 underline-offset-2'; 69 + if (key === '$type') 70 + return `${base} ${onPath ? 'text-amber-600 font-semibold' : 'text-amber-500/70'}`; 71 + if (Array.isArray(child)) 72 + return `${base} ${onPath ? 'text-emerald-700 font-semibold' : 'text-emerald-600/70'}`; 73 + if (isObj(child)) 74 + return `${base} ${onPath ? 'text-violet-700 font-semibold' : 'text-violet-600/60'}`; 75 + return `${base} ${onPath ? 'text-sky-700 font-semibold' : 'text-sky-600/70'}`; 76 + } 77 + 78 + function leafClass(matched: boolean): string { 79 + return matched 80 + ? 'text-amber-800 bg-amber-100 rounded-sm cursor-pointer' 81 + : 'text-stone-400 cursor-pointer hover:text-stone-600'; 82 + } 83 + 84 + // Click target: the RecordPath to select when a key is clicked 85 + function clickTarget(path: string, child: unknown): string { 86 + if (isObj(child) && child.$type) return `${path}{${child.$type}}`; 87 + return path; 88 + } 89 + </script> 90 + 91 + <div class="flex-1 overflow-auto bg-white py-2 font-mono text-[0.8125rem] leading-relaxed"> 92 + {@render objectBlock(record, '', activeSegs, 0)} 93 + </div> 94 + 95 + <!-- Object: { ... } --> 96 + {#snippet objectBlock(obj: Record<string, unknown>, prefix: string, segs: Segment[], depth: number)} 97 + <div style="padding-left: {depth * 2}ch" class="px-3"> 98 + <span class="text-stone-300">{'{'}</span> 99 + </div> 100 + {@render objectEntries(obj, prefix, segs, depth + 1)} 101 + <div style="padding-left: {depth * 2}ch" class="px-3"> 102 + <span class="text-stone-300">{'}'}</span> 103 + </div> 104 + {/snippet} 105 + 106 + <!-- Object entries --> 107 + {#snippet objectEntries(obj: Record<string, unknown>, prefix: string, segs: Segment[], depth: number)} 108 + {@const entries = Object.entries(obj)} 109 + {#each entries as [key, child], idx} 110 + {@const path = kp(prefix, key)} 111 + {@const onPath = segs.length > 0 && segs[0].key === key} 112 + {@const seg = onPath ? segs[0] : null} 113 + {@const rest = onPath ? segs.slice(1) : []} 114 + {@const comma = idx < entries.length - 1 ? ',' : ''} 115 + {@const matched = onPath && rest.length === 0 && seg !== null && seg.qualifiers.length === 0} 116 + 117 + {#if isLeaf(child)} 118 + <!-- key: leafValue --> 119 + <div style="padding-left: {depth * 2}ch" class="px-3"> 120 + <button class={keyClass(key, child, onPath)} onclick={() => onSelectPath(path)} 121 + >{JSON.stringify(key)}</button 122 + ><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> 126 + </div> 127 + {:else if Array.isArray(child)} 128 + <!-- key: [ ...elements ] --> 129 + <div style="padding-left: {depth * 2}ch" class="px-3"> 130 + <button class={keyClass(key, child, onPath)} onclick={() => onSelectPath(path)} 131 + >{JSON.stringify(key)}</button 132 + ><span class="text-stone-300">: [</span> 133 + </div> 134 + {@render arrayEntries(child, path, seg, rest, depth + 1)} 135 + <div style="padding-left: {depth * 2}ch" class="px-3"> 136 + <span class="text-stone-300">]{comma}</span> 137 + </div> 138 + {:else} 139 + <!-- key: { ...object } (maybe union) --> 140 + {@const isUnion = isObj(child) && !!child.$type} 141 + {@const qualPrefix = isUnion ? `${path}{${(child as Record<string, unknown>).$type}}` : path} 142 + {@const cSegs = childSegs(onPath, seg, rest, child)} 143 + <div style="padding-left: {depth * 2}ch" class="px-3"> 144 + <button 145 + class={keyClass(key, child, onPath)} 146 + onclick={() => onSelectPath(clickTarget(path, child))} 147 + >{JSON.stringify(key)}</button 148 + ><span class="text-stone-300">: {'{'}</span> 149 + </div> 150 + {@render objectEntries(child as Record<string, unknown>, qualPrefix, cSegs, depth + 1)} 151 + <div style="padding-left: {depth * 2}ch" class="px-3"> 152 + <span class="text-stone-300">{'}'}{comma}</span> 153 + </div> 154 + {/if} 155 + {/each} 156 + {/snippet} 157 + 158 + <!-- Array entries --> 159 + {#snippet arrayEntries(arr: unknown[], prefix: string, seg: Segment | null, restSegs: Segment[], depth: number)} 160 + {#each arr as elem, idx} 161 + {@const comma = idx < arr.length - 1 ? ',' : ''} 162 + {@const onPath = elemOnPath(seg, elem)} 163 + {@const eSegs = onPath ? restSegs : []} 164 + {@const ePrefix = isObj(elem) && elem.$type ? `${prefix}[${elem.$type}]` : `${prefix}[]`} 165 + {@const matched = onPath && eSegs.length === 0} 166 + 167 + {#if isLeaf(elem)} 168 + <div style="padding-left: {depth * 2}ch" class="px-3"> 169 + <span class={leafClass(matched)}>{formatLeaf(elem)}</span><span class="text-stone-300" 170 + >{comma}</span 171 + > 172 + </div> 173 + {:else if Array.isArray(elem)} 174 + <div style="padding-left: {depth * 2}ch" class="px-3"> 175 + <span class="text-stone-300">[</span> 176 + </div> 177 + {@render arrayEntries(elem, ePrefix, null, [], depth + 1)} 178 + <div style="padding-left: {depth * 2}ch" class="px-3"> 179 + <span class="text-stone-300">]{comma}</span> 180 + </div> 181 + {:else} 182 + {@const isUnion = isObj(elem) && !!elem.$type} 183 + {@const qualPrefix = isUnion ? `${prefix}[${(elem as Record<string, unknown>).$type}]` : `${prefix}[]`} 184 + <div style="padding-left: {depth * 2}ch" class="px-3"> 185 + <span class="text-stone-300">{'{'}</span> 186 + </div> 187 + {@render objectEntries(elem as Record<string, unknown>, qualPrefix, eSegs, depth + 1)} 188 + <div style="padding-left: {depth * 2}ch" class="px-3"> 189 + <span class="text-stone-300">{'}'}{comma}</span> 190 + </div> 191 + {/if} 192 + {/each} 193 + {/snippet}
+42 -9
playground/src/routes/+page.svelte
··· 1 1 <script lang="ts"> 2 2 import { enumerate, match, parse, RecordPathParseError, type PathInfo } from 'recordpath'; 3 + import JsonDisplay from '$lib/JsonDisplay.svelte'; 3 4 4 5 const SAMPLE = { 5 6 $type: 'app.bsky.feed.post', ··· 48 49 49 50 let jsonText = $state(JSON.stringify(SAMPLE, null, 2)); 50 51 let pathInput = $state(''); 52 + let editing = $state(false); 51 53 52 54 let parseResult = $derived.by(() => { 53 55 const text = jsonText.trim(); ··· 136 138 137 139 <!-- Main panels --> 138 140 <main class="grid flex-1 grid-cols-2 overflow-hidden"> 139 - <!-- Left: JSON editor --> 141 + <!-- Left: JSON editor / display --> 140 142 <div class="flex flex-col flex-2 overflow-hidden border-r border-stone-200"> 141 143 <div 142 - class="border-b border-stone-200 bg-white px-3 py-1.5 text-[0.6875rem] font-semibold tracking-wide text-stone-400 uppercase" 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" 143 145 > 144 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)} 163 + > 164 + Display 165 + </button> 145 166 </div> 146 - <textarea 147 - bind:value={jsonText} 148 - spellcheck="false" 149 - placeholder="Paste a JSON record..." 150 - class="flex-1 resize-none bg-white p-3 font-mono text-[0.8125rem] leading-relaxed outline-none" 151 - style="tab-size: 2" 152 - ></textarea> 167 + {#if editing} 168 + <textarea 169 + bind:value={jsonText} 170 + spellcheck="false" 171 + placeholder="Paste a JSON record..." 172 + class="flex-1 resize-none bg-white p-3 font-mono text-[0.8125rem] leading-relaxed outline-none" 173 + style="tab-size: 2" 174 + ></textarea> 175 + {:else if record} 176 + <JsonDisplay 177 + {record} 178 + activePath={pathInput} 179 + onSelectPath={(p) => (pathInput = p)} 180 + /> 181 + {: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} 153 186 {#if jsonError} 154 187 <div class="border-t border-red-200 bg-red-50 px-3 py-1.5 text-xs text-red-600"> 155 188 {jsonError}