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.

browser playground sketch

phil cd9891c2 364c95af

+663
+472
playground/index.html
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8"> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 + <title>RecordPath Playground</title> 7 + <style> 8 + :root { 9 + --bg: #f5f5f4; 10 + --panel: #fff; 11 + --border: #d6d3d1; 12 + --text: #1c1917; 13 + --muted: #78716c; 14 + --accent: #2563eb; 15 + --accent-light: #dbeafe; 16 + --match-bg: #fef9c3; 17 + --match-border: #facc15; 18 + --error: #dc2626; 19 + --mono: ui-monospace, 'SF Mono', 'Cascadia Code', 'Fira Code', monospace; 20 + --sans: system-ui, -apple-system, sans-serif; 21 + } 22 + 23 + * { box-sizing: border-box; margin: 0; padding: 0; } 24 + 25 + body { 26 + font-family: var(--sans); 27 + background: var(--bg); 28 + color: var(--text); 29 + height: 100vh; 30 + display: flex; 31 + flex-direction: column; 32 + overflow: hidden; 33 + } 34 + 35 + header { 36 + display: flex; 37 + align-items: center; 38 + gap: 1rem; 39 + padding: 0.625rem 1rem; 40 + border-bottom: 1px solid var(--border); 41 + background: var(--panel); 42 + } 43 + 44 + header h1 { 45 + font-size: 0.9375rem; 46 + font-weight: 600; 47 + } 48 + 49 + header .tag { 50 + font-size: 0.6875rem; 51 + padding: 0.125rem 0.5rem; 52 + border-radius: 9999px; 53 + background: var(--accent-light); 54 + color: var(--accent); 55 + font-weight: 500; 56 + } 57 + 58 + header .spacer { flex: 1; } 59 + 60 + header button { 61 + font-size: 0.8125rem; 62 + padding: 0.375rem 0.75rem; 63 + border: 1px solid var(--border); 64 + border-radius: 5px; 65 + background: var(--panel); 66 + cursor: pointer; 67 + color: var(--text); 68 + } 69 + header button:hover { background: var(--bg); } 70 + 71 + main { 72 + flex: 1; 73 + display: grid; 74 + grid-template-columns: 1fr 1fr; 75 + gap: 0; 76 + overflow: hidden; 77 + } 78 + 79 + .panel { 80 + display: flex; 81 + flex-direction: column; 82 + overflow: hidden; 83 + border-right: 1px solid var(--border); 84 + } 85 + .panel:last-child { border-right: none; } 86 + 87 + .panel-header { 88 + padding: 0.5rem 0.75rem; 89 + border-bottom: 1px solid var(--border); 90 + font-size: 0.75rem; 91 + font-weight: 600; 92 + color: var(--muted); 93 + text-transform: uppercase; 94 + letter-spacing: 0.04em; 95 + background: var(--panel); 96 + display: flex; 97 + align-items: center; 98 + gap: 0.5rem; 99 + } 100 + 101 + .panel-header .count { 102 + font-weight: 400; 103 + color: var(--muted); 104 + } 105 + 106 + #json-input { 107 + flex: 1; 108 + border: none; 109 + padding: 0.75rem; 110 + font-family: var(--mono); 111 + font-size: 0.8125rem; 112 + line-height: 1.6; 113 + resize: none; 114 + outline: none; 115 + tab-size: 2; 116 + background: var(--panel); 117 + } 118 + 119 + .json-error { 120 + padding: 0.375rem 0.75rem; 121 + font-size: 0.75rem; 122 + color: var(--error); 123 + background: #fef2f2; 124 + border-top: 1px solid #fecaca; 125 + display: none; 126 + } 127 + .json-error.visible { display: block; } 128 + 129 + .path-input-wrap { 130 + padding: 0.5rem 0.75rem; 131 + background: var(--panel); 132 + border-bottom: 1px solid var(--border); 133 + } 134 + 135 + #path-input { 136 + width: 100%; 137 + font-family: var(--mono); 138 + font-size: 0.875rem; 139 + padding: 0.5rem 0.625rem; 140 + border: 1.5px solid var(--border); 141 + border-radius: 5px; 142 + outline: none; 143 + background: var(--panel); 144 + } 145 + #path-input:focus { 146 + border-color: var(--accent); 147 + box-shadow: 0 0 0 2px var(--accent-light); 148 + } 149 + #path-input.no-match { 150 + border-color: var(--error); 151 + box-shadow: 0 0 0 2px #fee2e2; 152 + } 153 + 154 + .right-body { 155 + flex: 1; 156 + display: flex; 157 + flex-direction: column; 158 + overflow: hidden; 159 + } 160 + 161 + .paths-list { 162 + flex: 1; 163 + overflow-y: auto; 164 + background: var(--panel); 165 + } 166 + 167 + .path-item { 168 + display: flex; 169 + align-items: center; 170 + gap: 0.5rem; 171 + padding: 0.3125rem 0.75rem; 172 + font-family: var(--mono); 173 + font-size: 0.8125rem; 174 + cursor: pointer; 175 + border-left: 3px solid transparent; 176 + } 177 + .path-item:hover { background: #fafaf9; } 178 + .path-item.active { 179 + background: var(--accent-light); 180 + border-left-color: var(--accent); 181 + } 182 + 183 + .path-item .path-text { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } 184 + .path-item .badge { 185 + font-size: 0.625rem; 186 + padding: 0.0625rem 0.375rem; 187 + border-radius: 3px; 188 + font-weight: 500; 189 + text-transform: uppercase; 190 + letter-spacing: 0.03em; 191 + flex-shrink: 0; 192 + } 193 + .badge-scalar { background: #e0f2fe; color: #0369a1; } 194 + .badge-vector { background: #f0fdf4; color: #15803d; } 195 + 196 + .matches-wrap { 197 + border-top: 1px solid var(--border); 198 + max-height: 45%; 199 + display: flex; 200 + flex-direction: column; 201 + overflow: hidden; 202 + } 203 + .matches-wrap.empty { display: none; } 204 + 205 + .matches-list { 206 + overflow-y: auto; 207 + background: var(--panel); 208 + } 209 + 210 + .match-item { 211 + padding: 0.375rem 0.75rem; 212 + font-family: var(--mono); 213 + font-size: 0.8125rem; 214 + line-height: 1.5; 215 + border-bottom: 1px solid #fef3c7; 216 + background: var(--match-bg); 217 + white-space: pre-wrap; 218 + word-break: break-all; 219 + } 220 + .match-item:last-child { border-bottom: 1px solid var(--border); } 221 + </style> 222 + </head> 223 + <body> 224 + 225 + <header> 226 + <h1>RecordPath Playground</h1> 227 + <span class="tag">draft spec</span> 228 + <span class="spacer"></span> 229 + <button id="btn-sample">Load sample post</button> 230 + <button id="btn-format">Format JSON</button> 231 + </header> 232 + 233 + <main> 234 + <div class="panel"> 235 + <div class="panel-header">Record JSON</div> 236 + <textarea id="json-input" spellcheck="false" placeholder="Paste a JSON record..."></textarea> 237 + <div class="json-error" id="json-error"></div> 238 + </div> 239 + 240 + <div class="panel"> 241 + <div class="path-input-wrap"> 242 + <input type="text" id="path-input" placeholder="Type or click a RecordPath..." spellcheck="false" autocomplete="off"> 243 + </div> 244 + <div class="panel-header"> 245 + Available paths 246 + <span class="count" id="path-count"></span> 247 + </div> 248 + <div class="right-body"> 249 + <div class="paths-list" id="paths-list"></div> 250 + <div class="matches-wrap empty" id="matches-wrap"> 251 + <div class="panel-header"> 252 + Matches 253 + <span class="count" id="match-count"></span> 254 + </div> 255 + <div class="matches-list" id="matches-list"></div> 256 + </div> 257 + </div> 258 + </div> 259 + </main> 260 + 261 + <script src="recordpath.js"></script> 262 + <script> 263 + const SAMPLE = { 264 + "$type": "app.bsky.feed.post", 265 + "text": "Check out this project by @alice.bsky.social! Really impressive work on atproto tooling.", 266 + "langs": ["en"], 267 + "createdAt": "2026-04-15T12:38:49.982Z", 268 + "reply": { 269 + "root": { 270 + "cid": "bafyreieac34fnjyhuuzvgdnsyeeueyn45se5kuk6yppesn25gydjf5m5hy", 271 + "uri": "at://did:plc:rnpkyqnmsw4ipey6eotbdnnf/app.bsky.feed.post/3mjjvykdfo22r" 272 + }, 273 + "parent": { 274 + "cid": "bafyreibm3lim4hfn4fggpbtxkuoyyjokbonyetkyqfmr6qegqrgatxkhue", 275 + "uri": "at://did:plc:rnpkyqnmsw4ipey6eotbdnnf/app.bsky.feed.post/3mjkj5zd7lc2b" 276 + } 277 + }, 278 + "facets": [ 279 + { 280 + "index": { "byteStart": 32, "byteEnd": 52 }, 281 + "features": [ 282 + { 283 + "$type": "app.bsky.richtext.facet#mention", 284 + "did": "did:plc:ewvi7nxzyoun6zhxrhs64oiz" 285 + } 286 + ] 287 + }, 288 + { 289 + "index": { "byteStart": 0, "byteEnd": 31 }, 290 + "features": [ 291 + { 292 + "$type": "app.bsky.richtext.facet#link", 293 + "uri": "https://example.com/atproto-tooling" 294 + } 295 + ] 296 + } 297 + ], 298 + "embed": { 299 + "$type": "app.bsky.embed.external", 300 + "external": { 301 + "uri": "https://example.com/atproto-tooling", 302 + "title": "ATProto Tooling Project", 303 + "description": "A collection of tools for building on the atmosphere." 304 + } 305 + } 306 + }; 307 + 308 + const $json = document.getElementById('json-input'); 309 + const $pathInput = document.getElementById('path-input'); 310 + const $pathsList = document.getElementById('paths-list'); 311 + const $pathCount = document.getElementById('path-count'); 312 + const $matchesWrap = document.getElementById('matches-wrap'); 313 + const $matchesList = document.getElementById('matches-list'); 314 + const $matchCount = document.getElementById('match-count'); 315 + const $jsonError = document.getElementById('json-error'); 316 + 317 + let currentRecord = null; 318 + let currentPaths = []; 319 + 320 + function tryParseJSON(text) { 321 + try { 322 + const record = JSON.parse(text); 323 + $jsonError.classList.remove('visible'); 324 + return record; 325 + } catch (e) { 326 + $jsonError.textContent = e.message; 327 + $jsonError.classList.add('visible'); 328 + return null; 329 + } 330 + } 331 + 332 + function updatePaths() { 333 + const text = $json.value.trim(); 334 + if (!text) { 335 + currentRecord = null; 336 + currentPaths = []; 337 + renderPaths(); 338 + updateMatches(); 339 + return; 340 + } 341 + 342 + const record = tryParseJSON(text); 343 + if (!record || typeof record !== 'object' || Array.isArray(record)) { 344 + currentRecord = null; 345 + currentPaths = []; 346 + renderPaths(); 347 + updateMatches(); 348 + return; 349 + } 350 + 351 + currentRecord = record; 352 + currentPaths = RecordPath.enumerate(record); 353 + renderPaths(); 354 + updateMatches(); 355 + } 356 + 357 + function renderPaths() { 358 + $pathsList.innerHTML = ''; 359 + $pathCount.textContent = currentPaths.length ? `(${currentPaths.length})` : ''; 360 + 361 + for (const { path, type } of currentPaths) { 362 + const el = document.createElement('div'); 363 + el.className = 'path-item'; 364 + if (path === $pathInput.value) el.classList.add('active'); 365 + 366 + const pathSpan = document.createElement('span'); 367 + pathSpan.className = 'path-text'; 368 + pathSpan.textContent = path; 369 + pathSpan.title = path; 370 + 371 + const badge = document.createElement('span'); 372 + badge.className = 'badge ' + (type === 'vector' ? 'badge-vector' : 'badge-scalar'); 373 + badge.textContent = type; 374 + 375 + el.appendChild(pathSpan); 376 + el.appendChild(badge); 377 + el.addEventListener('click', () => { 378 + $pathInput.value = path; 379 + updateMatches(); 380 + highlightActive(); 381 + }); 382 + $pathsList.appendChild(el); 383 + } 384 + } 385 + 386 + function highlightActive() { 387 + const items = $pathsList.querySelectorAll('.path-item'); 388 + const val = $pathInput.value; 389 + items.forEach(item => { 390 + const text = item.querySelector('.path-text').textContent; 391 + item.classList.toggle('active', text === val); 392 + }); 393 + } 394 + 395 + function updateMatches() { 396 + const pathStr = $pathInput.value.trim(); 397 + 398 + if (!pathStr || !currentRecord) { 399 + $matchesWrap.classList.add('empty'); 400 + $matchesList.innerHTML = ''; 401 + $matchCount.textContent = ''; 402 + $pathInput.classList.remove('no-match'); 403 + highlightActive(); 404 + return; 405 + } 406 + 407 + const values = RecordPath.match(currentRecord, pathStr); 408 + 409 + $matchesWrap.classList.toggle('empty', values.length === 0 && !pathStr); 410 + $pathInput.classList.toggle('no-match', values.length === 0 && pathStr.length > 0); 411 + 412 + if (values.length === 0) { 413 + $matchesWrap.classList.remove('empty'); 414 + $matchesList.innerHTML = '<div class="match-item" style="color:var(--muted);background:var(--panel)">No matches</div>'; 415 + $matchCount.textContent = '(0)'; 416 + } else { 417 + $matchesWrap.classList.remove('empty'); 418 + $matchesList.innerHTML = ''; 419 + $matchCount.textContent = `(${values.length})`; 420 + for (const val of values) { 421 + const el = document.createElement('div'); 422 + el.className = 'match-item'; 423 + el.textContent = formatValue(val); 424 + $matchesList.appendChild(el); 425 + } 426 + } 427 + 428 + highlightActive(); 429 + } 430 + 431 + function formatValue(val) { 432 + if (val === null) return 'null'; 433 + if (val === undefined) return 'undefined'; 434 + if (typeof val === 'string') return JSON.stringify(val); 435 + if (typeof val !== 'object') return String(val); 436 + const str = JSON.stringify(val, null, 2); 437 + if (str.length > 500) return str.slice(0, 500) + '\n...'; 438 + return str; 439 + } 440 + 441 + // Wire up events 442 + let debounceTimer; 443 + $json.addEventListener('input', () => { 444 + clearTimeout(debounceTimer); 445 + debounceTimer = setTimeout(updatePaths, 200); 446 + }); 447 + 448 + $pathInput.addEventListener('input', () => { 449 + updateMatches(); 450 + }); 451 + 452 + document.getElementById('btn-sample').addEventListener('click', () => { 453 + $json.value = JSON.stringify(SAMPLE, null, 2); 454 + updatePaths(); 455 + }); 456 + 457 + document.getElementById('btn-format').addEventListener('click', () => { 458 + const text = $json.value.trim(); 459 + if (!text) return; 460 + try { 461 + const obj = JSON.parse(text); 462 + $json.value = JSON.stringify(obj, null, 2); 463 + updatePaths(); 464 + } catch (e) { /* already shown */ } 465 + }); 466 + 467 + // Start with sample loaded 468 + $json.value = JSON.stringify(SAMPLE, null, 2); 469 + updatePaths(); 470 + </script> 471 + </body> 472 + </html>
+191
playground/recordpath.js
··· 1 + // RecordPath — parser, matcher, enumerator 2 + // Implements the RecordPath draft spec 3 + 4 + (function () { 5 + const STRUCTURAL = new Set(['.', '[', ']', '{', '}', '!']); 6 + 7 + function escapeKey(key) { 8 + let out = ''; 9 + for (const ch of key) { 10 + if (STRUCTURAL.has(ch)) out += '!' + ch; 11 + else out += ch; 12 + } 13 + return out; 14 + } 15 + 16 + // Parse a RecordPath string into segments. 17 + // Each segment: { key: string, qualifiers: [{type, nsid?}] } 18 + function parse(str) { 19 + const segments = []; 20 + let i = 0; 21 + 22 + while (i < str.length) { 23 + let key = ''; 24 + while (i < str.length && str[i] !== '.' && str[i] !== '[' && str[i] !== '{') { 25 + if (str[i] === '!' && i + 1 < str.length) { 26 + key += str[i + 1]; 27 + i += 2; 28 + } else { 29 + key += str[i]; 30 + i++; 31 + } 32 + } 33 + 34 + const qualifiers = []; 35 + while (i < str.length && (str[i] === '[' || str[i] === '{')) { 36 + const open = str[i]; 37 + const close = open === '[' ? ']' : '}'; 38 + i++; 39 + let content = ''; 40 + while (i < str.length && str[i] !== close) { 41 + content += str[i]; 42 + i++; 43 + } 44 + if (i < str.length) i++; 45 + 46 + if (open === '[') { 47 + qualifiers.push(content === '' 48 + ? { type: 'array' } 49 + : { type: 'arrayUnion', nsid: content }); 50 + } else { 51 + qualifiers.push({ type: 'scalarUnion', nsid: content }); 52 + } 53 + } 54 + 55 + segments.push({ key, qualifiers }); 56 + if (i < str.length && str[i] === '.') i++; 57 + } 58 + 59 + return segments; 60 + } 61 + 62 + // Match a RecordPath against a record, returning all matched values. 63 + function match(record, pathStr) { 64 + if (!pathStr || pathStr.trim() === '') return []; 65 + try { 66 + const segments = parse(pathStr); 67 + return _matchSegments(record, segments, 0); 68 + } catch (e) { 69 + return []; 70 + } 71 + } 72 + 73 + function _matchSegments(data, segments, segIdx) { 74 + if (segIdx >= segments.length) return [data]; 75 + const seg = segments[segIdx]; 76 + if (typeof data !== 'object' || data === null || Array.isArray(data)) return []; 77 + const value = data[seg.key]; 78 + if (value === undefined) return []; 79 + return _applyQualifiers(value, seg.qualifiers, 0, segments, segIdx); 80 + } 81 + 82 + function _applyQualifiers(value, qualifiers, qualIdx, segments, segIdx) { 83 + if (qualIdx >= qualifiers.length) { 84 + if (segIdx + 1 >= segments.length) return [value]; 85 + return _matchSegments(value, segments, segIdx + 1); 86 + } 87 + 88 + const qual = qualifiers[qualIdx]; 89 + 90 + if (qual.type === 'scalarUnion') { 91 + if (typeof value !== 'object' || value === null || Array.isArray(value)) return []; 92 + if (value.$type !== qual.nsid) return []; 93 + return _applyQualifiers(value, qualifiers, qualIdx + 1, segments, segIdx); 94 + } 95 + 96 + if (qual.type === 'array' || qual.type === 'arrayUnion') { 97 + if (!Array.isArray(value)) return []; 98 + const results = []; 99 + for (const elem of value) { 100 + if (qual.type === 'arrayUnion') { 101 + if (typeof elem !== 'object' || elem === null || elem.$type !== qual.nsid) continue; 102 + } 103 + results.push(..._applyQualifiers(elem, qualifiers, qualIdx + 1, segments, segIdx)); 104 + } 105 + return results; 106 + } 107 + 108 + return []; 109 + } 110 + 111 + // Enumerate all RecordPaths reachable from a record. 112 + // Returns [{ path, type: 'scalar'|'vector' }] 113 + function enumerate(record) { 114 + const paths = new Map(); 115 + _enumObject(record, '', true, false, paths); 116 + return Array.from(paths.entries()).map(([path, info]) => ({ path, ...info })); 117 + } 118 + 119 + function _enumObject(obj, prefix, isRoot, isVector, paths) { 120 + for (const key of Object.keys(obj)) { 121 + const child = obj[key]; 122 + const escaped = escapeKey(key); 123 + const keyPath = prefix ? prefix + '.' + escaped : escaped; 124 + const vtype = isVector ? 'vector' : 'scalar'; 125 + 126 + if (child === null || child === undefined || typeof child !== 'object') { 127 + paths.set(keyPath, { type: vtype }); 128 + } else if (Array.isArray(child)) { 129 + paths.set(keyPath, { type: vtype }); 130 + _enumArray(child, keyPath, isVector, paths); 131 + } else if (child.$type && !isRoot) { 132 + paths.set(keyPath, { type: vtype }); 133 + const qualified = keyPath + '{' + child.$type + '}'; 134 + paths.set(qualified, { type: vtype }); 135 + _enumObject(child, qualified, false, isVector, paths); 136 + } else { 137 + paths.set(keyPath, { type: vtype }); 138 + _enumObject(child, keyPath, false, isVector, paths); 139 + } 140 + } 141 + } 142 + 143 + function _enumArray(arr, prefix, parentIsVector, paths) { 144 + const hasUnion = arr.some(el => 145 + typeof el === 'object' && el !== null && !Array.isArray(el) && el.$type 146 + ); 147 + 148 + if (hasUnion) { 149 + const byType = {}; 150 + const plain = []; 151 + for (const el of arr) { 152 + if (typeof el === 'object' && el !== null && !Array.isArray(el) && el.$type) { 153 + (byType[el.$type] || (byType[el.$type] = [])).push(el); 154 + } else { 155 + plain.push(el); 156 + } 157 + } 158 + for (const [nsid, elements] of Object.entries(byType)) { 159 + const qp = prefix + '[' + nsid + ']'; 160 + paths.set(qp, { type: 'vector' }); 161 + for (const el of elements) { 162 + _enumObject(el, qp, false, true, paths); 163 + } 164 + } 165 + if (plain.length > 0) { 166 + _enumPlainArray(plain, prefix + '[]', paths); 167 + } 168 + } else { 169 + _enumPlainArray(arr, prefix + '[]', paths); 170 + } 171 + } 172 + 173 + function _enumPlainArray(arr, prefix, paths) { 174 + paths.set(prefix, { type: 'vector' }); 175 + for (const el of arr) { 176 + if (el === null || el === undefined || typeof el !== 'object') { 177 + // scalar elements — path is the array prefix itself 178 + } else if (Array.isArray(el)) { 179 + _enumArray(el, prefix, true, paths); 180 + } else { 181 + _enumObject(el, prefix, false, true, paths); 182 + } 183 + } 184 + } 185 + 186 + function isVector(pathStr) { 187 + return /\[/.test(pathStr); 188 + } 189 + 190 + window.RecordPath = { escapeKey, parse, match, enumerate, isVector }; 191 + })();