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.

ref impl js package (not finished)

phil af035107 b460c9ee

+112 -883
-258
playground-orig/index.html
··· 1 - <!DOCTYPE html> 2 - <html lang="en"> 3 - <head> 4 - <meta charset="utf-8"> 5 - <title>RecordPath Playground</title> 6 - <meta name="viewport" content="width=device-width, initial-scale=1.0"> 7 - <link rel="stylesheet" type="text/css" href="./style.css" /> 8 - </head> 9 - <body> 10 - 11 - <header> 12 - <h1>RecordPath Playground</h1> 13 - <span class="tag">draft spec</span> 14 - <span class="spacer"></span> 15 - <button id="btn-sample">Load sample post</button> 16 - <button id="btn-format">Format JSON</button> 17 - </header> 18 - 19 - <main> 20 - <div class="panel"> 21 - <div class="panel-header">Record JSON</div> 22 - <textarea id="json-input" spellcheck="false" placeholder="Paste a JSON record..."></textarea> 23 - <div class="json-error" id="json-error"></div> 24 - </div> 25 - 26 - <div class="panel"> 27 - <div class="path-input-wrap"> 28 - <input type="text" id="path-input" placeholder="Type or click a RecordPath..." spellcheck="false" autocomplete="off"> 29 - </div> 30 - <div class="panel-header"> 31 - Available paths 32 - <span class="count" id="path-count"></span> 33 - </div> 34 - <div class="right-body"> 35 - <div class="paths-list" id="paths-list"></div> 36 - <div class="matches-wrap empty" id="matches-wrap"> 37 - <div class="panel-header"> 38 - Matches 39 - <span class="count" id="match-count"></span> 40 - </div> 41 - <div class="matches-list" id="matches-list"></div> 42 - </div> 43 - </div> 44 - </div> 45 - </main> 46 - 47 - <script src="recordpath.js"></script> 48 - <script> 49 - const SAMPLE = { 50 - "$type": "app.bsky.feed.post", 51 - "text": "Check out this project by @alice.bsky.social! Really impressive work on atproto tooling.", 52 - "langs": ["en"], 53 - "createdAt": "2026-04-15T12:38:49.982Z", 54 - "reply": { 55 - "root": { 56 - "cid": "bafyreieac34fnjyhuuzvgdnsyeeueyn45se5kuk6yppesn25gydjf5m5hy", 57 - "uri": "at://did:plc:rnpkyqnmsw4ipey6eotbdnnf/app.bsky.feed.post/3mjjvykdfo22r" 58 - }, 59 - "parent": { 60 - "cid": "bafyreibm3lim4hfn4fggpbtxkuoyyjokbonyetkyqfmr6qegqrgatxkhue", 61 - "uri": "at://did:plc:rnpkyqnmsw4ipey6eotbdnnf/app.bsky.feed.post/3mjkj5zd7lc2b" 62 - } 63 - }, 64 - "facets": [ 65 - { 66 - "index": { "byteStart": 32, "byteEnd": 52 }, 67 - "features": [ 68 - { 69 - "$type": "app.bsky.richtext.facet#mention", 70 - "did": "did:plc:ewvi7nxzyoun6zhxrhs64oiz" 71 - } 72 - ] 73 - }, 74 - { 75 - "index": { "byteStart": 0, "byteEnd": 31 }, 76 - "features": [ 77 - { 78 - "$type": "app.bsky.richtext.facet#link", 79 - "uri": "https://example.com/atproto-tooling" 80 - } 81 - ] 82 - } 83 - ], 84 - "embed": { 85 - "$type": "app.bsky.embed.external", 86 - "external": { 87 - "uri": "https://example.com/atproto-tooling", 88 - "title": "ATProto Tooling Project", 89 - "description": "A collection of tools for building on the atmosphere." 90 - } 91 - } 92 - }; 93 - 94 - const $json = document.getElementById('json-input'); 95 - const $pathInput = document.getElementById('path-input'); 96 - const $pathsList = document.getElementById('paths-list'); 97 - const $pathCount = document.getElementById('path-count'); 98 - const $matchesWrap = document.getElementById('matches-wrap'); 99 - const $matchesList = document.getElementById('matches-list'); 100 - const $matchCount = document.getElementById('match-count'); 101 - const $jsonError = document.getElementById('json-error'); 102 - 103 - let currentRecord = null; 104 - let currentPaths = []; 105 - 106 - function tryParseJSON(text) { 107 - try { 108 - const record = JSON.parse(text); 109 - $jsonError.classList.remove('visible'); 110 - return record; 111 - } catch (e) { 112 - $jsonError.textContent = e.message; 113 - $jsonError.classList.add('visible'); 114 - return null; 115 - } 116 - } 117 - 118 - function updatePaths() { 119 - const text = $json.value.trim(); 120 - if (!text) { 121 - currentRecord = null; 122 - currentPaths = []; 123 - renderPaths(); 124 - updateMatches(); 125 - return; 126 - } 127 - 128 - const record = tryParseJSON(text); 129 - if (!record || typeof record !== 'object' || Array.isArray(record)) { 130 - currentRecord = null; 131 - currentPaths = []; 132 - renderPaths(); 133 - updateMatches(); 134 - return; 135 - } 136 - 137 - currentRecord = record; 138 - currentPaths = RecordPath.enumerate(record); 139 - renderPaths(); 140 - updateMatches(); 141 - } 142 - 143 - function renderPaths() { 144 - $pathsList.innerHTML = ''; 145 - $pathCount.textContent = currentPaths.length ? `(${currentPaths.length})` : ''; 146 - 147 - for (const { path, type } of currentPaths) { 148 - const el = document.createElement('div'); 149 - el.className = 'path-item'; 150 - if (path === $pathInput.value) el.classList.add('active'); 151 - 152 - const pathSpan = document.createElement('span'); 153 - pathSpan.className = 'path-text'; 154 - pathSpan.textContent = path; 155 - pathSpan.title = path; 156 - 157 - const badge = document.createElement('span'); 158 - badge.className = 'badge ' + (type === 'vector' ? 'badge-vector' : 'badge-scalar'); 159 - badge.textContent = type; 160 - 161 - el.appendChild(pathSpan); 162 - el.appendChild(badge); 163 - el.addEventListener('click', () => { 164 - $pathInput.value = path; 165 - updateMatches(); 166 - highlightActive(); 167 - }); 168 - $pathsList.appendChild(el); 169 - } 170 - } 171 - 172 - function highlightActive() { 173 - const items = $pathsList.querySelectorAll('.path-item'); 174 - const val = $pathInput.value; 175 - items.forEach(item => { 176 - const text = item.querySelector('.path-text').textContent; 177 - item.classList.toggle('active', text === val); 178 - }); 179 - } 180 - 181 - function updateMatches() { 182 - const pathStr = $pathInput.value.trim(); 183 - 184 - if (!pathStr || !currentRecord) { 185 - $matchesWrap.classList.add('empty'); 186 - $matchesList.innerHTML = ''; 187 - $matchCount.textContent = ''; 188 - $pathInput.classList.remove('no-match'); 189 - highlightActive(); 190 - return; 191 - } 192 - 193 - const values = RecordPath.match(currentRecord, pathStr); 194 - 195 - $matchesWrap.classList.toggle('empty', values.length === 0 && !pathStr); 196 - $pathInput.classList.toggle('no-match', values.length === 0 && pathStr.length > 0); 197 - 198 - if (values.length === 0) { 199 - $matchesWrap.classList.remove('empty'); 200 - $matchesList.innerHTML = '<div class="match-item" style="color:var(--muted);background:var(--panel)">No matches</div>'; 201 - $matchCount.textContent = '(0)'; 202 - } else { 203 - $matchesWrap.classList.remove('empty'); 204 - $matchesList.innerHTML = ''; 205 - $matchCount.textContent = `(${values.length})`; 206 - for (const val of values) { 207 - const el = document.createElement('div'); 208 - el.className = 'match-item'; 209 - el.textContent = formatValue(val); 210 - $matchesList.appendChild(el); 211 - } 212 - } 213 - 214 - highlightActive(); 215 - } 216 - 217 - function formatValue(val) { 218 - if (val === null) return 'null'; 219 - if (val === undefined) return 'undefined'; 220 - if (typeof val === 'string') return JSON.stringify(val); 221 - if (typeof val !== 'object') return String(val); 222 - const str = JSON.stringify(val, null, 2); 223 - if (str.length > 500) return str.slice(0, 500) + '\n...'; 224 - return str; 225 - } 226 - 227 - // Wire up events 228 - let debounceTimer; 229 - $json.addEventListener('input', () => { 230 - clearTimeout(debounceTimer); 231 - debounceTimer = setTimeout(updatePaths, 200); 232 - }); 233 - 234 - $pathInput.addEventListener('input', () => { 235 - updateMatches(); 236 - }); 237 - 238 - document.getElementById('btn-sample').addEventListener('click', () => { 239 - $json.value = JSON.stringify(SAMPLE, null, 2); 240 - updatePaths(); 241 - }); 242 - 243 - document.getElementById('btn-format').addEventListener('click', () => { 244 - const text = $json.value.trim(); 245 - if (!text) return; 246 - try { 247 - const obj = JSON.parse(text); 248 - $json.value = JSON.stringify(obj, null, 2); 249 - updatePaths(); 250 - } catch (e) { /* already shown */ } 251 - }); 252 - 253 - // Start with sample loaded 254 - $json.value = JSON.stringify(SAMPLE, null, 2); 255 - updatePaths(); 256 - </script> 257 - </body> 258 - </html>
-207
playground-orig/playground.js
··· 1 - const SAMPLE = { 2 - "$type": "app.bsky.feed.post", 3 - "text": "Check out this project by @alice.bsky.social! Really impressive work on atproto tooling.", 4 - "langs": ["en"], 5 - "createdAt": "2026-04-15T12:38:49.982Z", 6 - "reply": { 7 - "root": { 8 - "cid": "bafyreieac34fnjyhuuzvgdnsyeeueyn45se5kuk6yppesn25gydjf5m5hy", 9 - "uri": "at://did:plc:rnpkyqnmsw4ipey6eotbdnnf/app.bsky.feed.post/3mjjvykdfo22r" 10 - }, 11 - "parent": { 12 - "cid": "bafyreibm3lim4hfn4fggpbtxkuoyyjokbonyetkyqfmr6qegqrgatxkhue", 13 - "uri": "at://did:plc:rnpkyqnmsw4ipey6eotbdnnf/app.bsky.feed.post/3mjkj5zd7lc2b" 14 - } 15 - }, 16 - "facets": [ 17 - { 18 - "index": { "byteStart": 32, "byteEnd": 52 }, 19 - "features": [ 20 - { 21 - "$type": "app.bsky.richtext.facet#mention", 22 - "did": "did:plc:ewvi7nxzyoun6zhxrhs64oiz" 23 - } 24 - ] 25 - }, 26 - { 27 - "index": { "byteStart": 0, "byteEnd": 31 }, 28 - "features": [ 29 - { 30 - "$type": "app.bsky.richtext.facet#link", 31 - "uri": "https://example.com/atproto-tooling" 32 - } 33 - ] 34 - } 35 - ], 36 - "embed": { 37 - "$type": "app.bsky.embed.external", 38 - "external": { 39 - "uri": "https://example.com/atproto-tooling", 40 - "title": "ATProto Tooling Project", 41 - "description": "A collection of tools for building on the atmosphere." 42 - } 43 - } 44 - }; 45 - 46 - const $json = document.getElementById('json-input'); 47 - const $pathInput = document.getElementById('path-input'); 48 - const $pathsList = document.getElementById('paths-list'); 49 - const $pathCount = document.getElementById('path-count'); 50 - const $matchesWrap = document.getElementById('matches-wrap'); 51 - const $matchesList = document.getElementById('matches-list'); 52 - const $matchCount = document.getElementById('match-count'); 53 - const $jsonError = document.getElementById('json-error'); 54 - 55 - let currentRecord = null; 56 - let currentPaths = []; 57 - 58 - function tryParseJSON(text) { 59 - try { 60 - const record = JSON.parse(text); 61 - $jsonError.classList.remove('visible'); 62 - return record; 63 - } catch (e) { 64 - $jsonError.textContent = e.message; 65 - $jsonError.classList.add('visible'); 66 - return null; 67 - } 68 - } 69 - 70 - function updatePaths() { 71 - const text = $json.value.trim(); 72 - if (!text) { 73 - currentRecord = null; 74 - currentPaths = []; 75 - renderPaths(); 76 - updateMatches(); 77 - return; 78 - } 79 - 80 - const record = tryParseJSON(text); 81 - if (!record || typeof record !== 'object' || Array.isArray(record)) { 82 - currentRecord = null; 83 - currentPaths = []; 84 - renderPaths(); 85 - updateMatches(); 86 - return; 87 - } 88 - 89 - currentRecord = record; 90 - currentPaths = RecordPath.enumerate(record); 91 - renderPaths(); 92 - updateMatches(); 93 - } 94 - 95 - function renderPaths() { 96 - $pathsList.innerHTML = ''; 97 - $pathCount.textContent = currentPaths.length ? `(${currentPaths.length})` : ''; 98 - 99 - for (const { path, type } of currentPaths) { 100 - const el = document.createElement('div'); 101 - el.className = 'path-item'; 102 - if (path === $pathInput.value) el.classList.add('active'); 103 - 104 - const pathSpan = document.createElement('span'); 105 - pathSpan.className = 'path-text'; 106 - pathSpan.textContent = path; 107 - pathSpan.title = path; 108 - 109 - const badge = document.createElement('span'); 110 - badge.className = 'badge ' + (type === 'vector' ? 'badge-vector' : 'badge-scalar'); 111 - badge.textContent = type; 112 - 113 - el.appendChild(pathSpan); 114 - el.appendChild(badge); 115 - el.addEventListener('click', () => { 116 - $pathInput.value = path; 117 - updateMatches(); 118 - highlightActive(); 119 - }); 120 - $pathsList.appendChild(el); 121 - } 122 - } 123 - 124 - function highlightActive() { 125 - const items = $pathsList.querySelectorAll('.path-item'); 126 - const val = $pathInput.value; 127 - items.forEach(item => { 128 - const text = item.querySelector('.path-text').textContent; 129 - item.classList.toggle('active', text === val); 130 - }); 131 - } 132 - 133 - function updateMatches() { 134 - const pathStr = $pathInput.value.trim(); 135 - 136 - if (!pathStr || !currentRecord) { 137 - $matchesWrap.classList.add('empty'); 138 - $matchesList.innerHTML = ''; 139 - $matchCount.textContent = ''; 140 - $pathInput.classList.remove('no-match'); 141 - highlightActive(); 142 - return; 143 - } 144 - 145 - const values = RecordPath.match(currentRecord, pathStr); 146 - 147 - $matchesWrap.classList.toggle('empty', values.length === 0 && !pathStr); 148 - $pathInput.classList.toggle('no-match', values.length === 0 && pathStr.length > 0); 149 - 150 - if (values.length === 0) { 151 - $matchesWrap.classList.remove('empty'); 152 - $matchesList.innerHTML = '<div class="match-item" style="color:var(--muted);background:var(--panel)">No matches</div>'; 153 - $matchCount.textContent = '(0)'; 154 - } else { 155 - $matchesWrap.classList.remove('empty'); 156 - $matchesList.innerHTML = ''; 157 - $matchCount.textContent = `(${values.length})`; 158 - for (const val of values) { 159 - const el = document.createElement('div'); 160 - el.className = 'match-item'; 161 - el.textContent = formatValue(val); 162 - $matchesList.appendChild(el); 163 - } 164 - } 165 - 166 - highlightActive(); 167 - } 168 - 169 - function formatValue(val) { 170 - if (val === null) return 'null'; 171 - if (val === undefined) return 'undefined'; 172 - if (typeof val === 'string') return JSON.stringify(val); 173 - if (typeof val !== 'object') return String(val); 174 - const str = JSON.stringify(val, null, 2); 175 - if (str.length > 500) return str.slice(0, 500) + '\n...'; 176 - return str; 177 - } 178 - 179 - // Wire up events 180 - let debounceTimer; 181 - $json.addEventListener('input', () => { 182 - clearTimeout(debounceTimer); 183 - debounceTimer = setTimeout(updatePaths, 200); 184 - }); 185 - 186 - $pathInput.addEventListener('input', () => { 187 - updateMatches(); 188 - }); 189 - 190 - document.getElementById('btn-sample').addEventListener('click', () => { 191 - $json.value = JSON.stringify(SAMPLE, null, 2); 192 - updatePaths(); 193 - }); 194 - 195 - document.getElementById('btn-format').addEventListener('click', () => { 196 - const text = $json.value.trim(); 197 - if (!text) return; 198 - try { 199 - const obj = JSON.parse(text); 200 - $json.value = JSON.stringify(obj, null, 2); 201 - updatePaths(); 202 - } catch (e) { /* already shown */ } 203 - }); 204 - 205 - // Start with sample loaded 206 - $json.value = JSON.stringify(SAMPLE, null, 2); 207 - updatePaths();
-200
playground-orig/recordpath.js
··· 1 - // RecordPath — parser, matcher, enumerator 2 - // Implements the RecordPath draft spec 3 - 4 - (function () { 5 - const STRUCTURAL = new Set(['.', '[', ']', '{', '}', '!']); 6 - 7 - function escapeFieldName(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 - // 19 - // TODO: error returns?? (or throws) 20 - function parse(str) { 21 - const segments = []; 22 - let i = 0; 23 - 24 - while (i < str.length) { 25 - let key = ''; 26 - 27 - // TODO: does this permit misplaced unescaped close chars? `]` and `}` 28 - // we should probably fail/reject those. 29 - while (i < str.length && str[i] !== '.' && str[i] !== '[' && str[i] !== '{') { 30 - if (str[i] === '!' && i + 1 < str.length) { 31 - key += str[i + 1]; 32 - i += 2; 33 - } else { 34 - key += str[i]; 35 - i++; 36 - } 37 - } 38 - 39 - const qualifiers = []; 40 - while (i < str.length && (str[i] === '[' || str[i] === '{')) { 41 - const open = str[i]; 42 - const close = open === '[' ? ']' : '}'; 43 - i++; 44 - let content = ''; 45 - while (i < str.length && str[i] !== close) { 46 - content += str[i]; 47 - i++; 48 - } 49 - if (i < str.length) i++; 50 - 51 - if (open === '[') { 52 - qualifiers.push(content === '' 53 - ? { type: 'array' } 54 - : { type: 'arrayUnion', nsid: content }); 55 - } else { 56 - qualifiers.push({ type: 'scalarUnion', nsid: content }); 57 - } 58 - } 59 - 60 - segments.push({ key, qualifiers }); 61 - if (i < str.length && str[i] === '.') i++; 62 - } 63 - 64 - return segments; 65 - } 66 - 67 - // Match a RecordPath against a record, returning all matched values. 68 - function match(record, pathStr) { 69 - if (!pathStr || pathStr.trim() === '') return []; 70 - try { 71 - const segments = parse(pathStr); 72 - return _matchSegments(record, segments, 0); 73 - } catch (e) { 74 - return []; 75 - } 76 - } 77 - 78 - function _matchSegments(data, segments, segIdx) { 79 - if (segIdx >= segments.length) return [data]; 80 - const seg = segments[segIdx]; 81 - if (typeof data !== 'object' || data === null || Array.isArray(data)) return []; 82 - const value = data[seg.key]; 83 - if (value === undefined) return []; 84 - return _applyQualifiers(value, seg.qualifiers, 0, segments, segIdx); 85 - } 86 - 87 - function _applyQualifiers(value, qualifiers, qualIdx, segments, segIdx) { 88 - if (qualIdx >= qualifiers.length) { 89 - if (segIdx + 1 >= segments.length) return [value]; 90 - return _matchSegments(value, segments, segIdx + 1); 91 - } 92 - 93 - const qual = qualifiers[qualIdx]; 94 - 95 - if (qual.type === 'scalarUnion') { 96 - if (typeof value !== 'object' || value === null || Array.isArray(value)) return []; 97 - if (value.$type !== qual.nsid) return []; 98 - return _applyQualifiers(value, qualifiers, qualIdx + 1, segments, segIdx); 99 - } 100 - 101 - if (qual.type === 'array' || qual.type === 'arrayUnion') { 102 - if (!Array.isArray(value)) return []; 103 - const results = []; 104 - for (const elem of value) { 105 - if (qual.type === 'arrayUnion') { 106 - if (typeof elem !== 'object' || elem === null || elem.$type !== qual.nsid) continue; 107 - } 108 - results.push(..._applyQualifiers(elem, qualifiers, qualIdx + 1, segments, segIdx)); 109 - } 110 - return results; 111 - } 112 - 113 - return []; 114 - } 115 - 116 - // Enumerate all RecordPaths reachable from a record. 117 - // Returns [{ path, type: 'scalar'|'vector' }] 118 - function enumerate(record) { 119 - const paths = new Map(); 120 - _enumObject(record, '', true, false, paths); 121 - return Array.from(paths.entries()).map(([path, info]) => ({ path, ...info })); 122 - } 123 - 124 - function _enumObject(obj, prefix, isRoot, isVector, paths) { 125 - for (const key of Object.keys(obj)) { 126 - const child = obj[key]; 127 - const escaped = escapeFieldName(key); 128 - const keyPath = prefix ? prefix + '.' + escaped : escaped; 129 - const vtype = isVector ? 'vector' : 'scalar'; 130 - 131 - if (child === null || child === undefined || typeof child !== 'object') { 132 - paths.set(keyPath, { type: vtype }); 133 - } else if (Array.isArray(child)) { 134 - paths.set(keyPath, { type: vtype }); 135 - _enumArray(child, keyPath, isVector, paths); 136 - } else if (child.$type && !isRoot) { 137 - paths.set(keyPath, { type: vtype }); 138 - const qualified = keyPath + '{' + child.$type + '}'; 139 - paths.set(qualified, { type: vtype }); 140 - _enumObject(child, qualified, false, isVector, paths); 141 - } else { 142 - paths.set(keyPath, { type: vtype }); 143 - _enumObject(child, keyPath, false, isVector, paths); 144 - } 145 - } 146 - } 147 - 148 - function _enumArray(arr, prefix, parentIsVector, paths) { 149 - const hasUnion = arr.some(el => 150 - typeof el === 'object' && el !== null && !Array.isArray(el) && el.$type 151 - ); 152 - 153 - if (hasUnion) { 154 - const byType = {}; 155 - const plain = []; 156 - for (const el of arr) { 157 - if (typeof el === 'object' && el !== null && !Array.isArray(el) && el.$type) { 158 - (byType[el.$type] || (byType[el.$type] = [])).push(el); 159 - } else { 160 - plain.push(el); 161 - } 162 - } 163 - for (const [nsid, elements] of Object.entries(byType)) { 164 - const qp = prefix + '[' + nsid + ']'; 165 - paths.set(qp, { type: 'vector' }); 166 - for (const el of elements) { 167 - _enumObject(el, qp, false, true, paths); 168 - } 169 - } 170 - if (plain.length > 0) { 171 - _enumPlainArray(plain, prefix + '[]', paths); 172 - } 173 - } else { 174 - _enumPlainArray(arr, prefix + '[]', paths); 175 - } 176 - } 177 - 178 - function _enumPlainArray(arr, prefix, paths) { 179 - paths.set(prefix, { type: 'vector' }); 180 - for (const el of arr) { 181 - if (el === null || el === undefined || typeof el !== 'object') { 182 - // scalar elements — path is the array prefix itself 183 - } else if (Array.isArray(el)) { 184 - _enumArray(el, prefix, true, paths); 185 - } else { 186 - _enumObject(el, prefix, false, true, paths); 187 - } 188 - } 189 - } 190 - 191 - // TODO: enumerateMatching? pass a test fn that accepts (path, value) pairs and returns bool 192 - // could just filter the output of enumerate, but this avoids collecting lots of stuff we don't need in the mean time 193 - // (or enumerate could be a generator?? that might be even nicer) 194 - 195 - function isVector(pathStr) { 196 - return /\[/.test(pathStr); 197 - } 198 - 199 - window.RecordPath = { escapeFieldName, parse, match, enumerate, isVector }; 200 - })();
-213
playground-orig/style.css
··· 1 - :root { 2 - --bg: #f5f5f4; 3 - --panel: #fff; 4 - --border: #d6d3d1; 5 - --text: #1c1917; 6 - --muted: #78716c; 7 - --accent: #2563eb; 8 - --accent-light: #dbeafe; 9 - --match-bg: #fef9c3; 10 - --match-border: #facc15; 11 - --error: #dc2626; 12 - --mono: ui-monospace, 'SF Mono', 'Cascadia Code', 'Fira Code', monospace; 13 - --sans: system-ui, -apple-system, sans-serif; 14 - } 15 - 16 - * { box-sizing: border-box; margin: 0; padding: 0; } 17 - 18 - body { 19 - font-family: var(--sans); 20 - background: var(--bg); 21 - color: var(--text); 22 - height: 100vh; 23 - display: flex; 24 - flex-direction: column; 25 - overflow: hidden; 26 - } 27 - 28 - header { 29 - display: flex; 30 - align-items: center; 31 - gap: 1rem; 32 - padding: 0.625rem 1rem; 33 - border-bottom: 1px solid var(--border); 34 - background: var(--panel); 35 - } 36 - 37 - header h1 { 38 - font-size: 0.9375rem; 39 - font-weight: 600; 40 - } 41 - 42 - header .tag { 43 - font-size: 0.6875rem; 44 - padding: 0.125rem 0.5rem; 45 - border-radius: 9999px; 46 - background: var(--accent-light); 47 - color: var(--accent); 48 - font-weight: 500; 49 - } 50 - 51 - header .spacer { flex: 1; } 52 - 53 - header button { 54 - font-size: 0.8125rem; 55 - padding: 0.375rem 0.75rem; 56 - border: 1px solid var(--border); 57 - border-radius: 5px; 58 - background: var(--panel); 59 - cursor: pointer; 60 - color: var(--text); 61 - } 62 - header button:hover { background: var(--bg); } 63 - 64 - main { 65 - flex: 1; 66 - display: grid; 67 - grid-template-columns: 3fr 2fr; 68 - gap: 0; 69 - overflow: hidden; 70 - } 71 - 72 - .panel { 73 - display: flex; 74 - flex-direction: column; 75 - overflow: hidden; 76 - border-right: 1px solid var(--border); 77 - } 78 - .panel:last-child { border-right: none; } 79 - 80 - .panel-header { 81 - padding: 0.5rem 0.75rem; 82 - border-bottom: 1px solid var(--border); 83 - font-size: 0.75rem; 84 - font-weight: 600; 85 - color: var(--muted); 86 - text-transform: uppercase; 87 - letter-spacing: 0.04em; 88 - background: var(--panel); 89 - display: flex; 90 - align-items: center; 91 - gap: 0.5rem; 92 - } 93 - 94 - .panel-header .count { 95 - font-weight: 400; 96 - color: var(--muted); 97 - } 98 - 99 - #json-input { 100 - flex: 1; 101 - border: none; 102 - padding: 0.75rem; 103 - font-family: var(--mono); 104 - font-size: 0.8125rem; 105 - line-height: 1.6; 106 - resize: none; 107 - outline: none; 108 - tab-size: 2; 109 - background: var(--panel); 110 - } 111 - 112 - .json-error { 113 - padding: 0.375rem 0.75rem; 114 - font-size: 0.75rem; 115 - color: var(--error); 116 - background: #fef2f2; 117 - border-top: 1px solid #fecaca; 118 - display: none; 119 - } 120 - .json-error.visible { display: block; } 121 - 122 - .path-input-wrap { 123 - padding: 0.5rem 0.75rem; 124 - background: var(--panel); 125 - border-bottom: 1px solid var(--border); 126 - } 127 - 128 - #path-input { 129 - width: 100%; 130 - font-family: var(--mono); 131 - font-size: 0.875rem; 132 - padding: 0.5rem 0.625rem; 133 - border: 1.5px solid var(--border); 134 - border-radius: 5px; 135 - outline: none; 136 - background: var(--panel); 137 - } 138 - #path-input:focus { 139 - border-color: var(--accent); 140 - box-shadow: 0 0 0 2px var(--accent-light); 141 - } 142 - #path-input.no-match { 143 - border-color: var(--error); 144 - box-shadow: 0 0 0 2px #fee2e2; 145 - } 146 - 147 - .right-body { 148 - flex: 1; 149 - display: flex; 150 - flex-direction: column; 151 - overflow: hidden; 152 - } 153 - 154 - .paths-list { 155 - flex: 1; 156 - overflow-y: auto; 157 - background: var(--panel); 158 - } 159 - 160 - .path-item { 161 - display: flex; 162 - align-items: center; 163 - gap: 0.5rem; 164 - padding: 0.3125rem 0.75rem; 165 - font-family: var(--mono); 166 - font-size: 0.8125rem; 167 - cursor: pointer; 168 - border-left: 3px solid transparent; 169 - } 170 - .path-item:hover { background: #fafaf9; } 171 - .path-item.active { 172 - background: var(--accent-light); 173 - border-left-color: var(--accent); 174 - } 175 - 176 - .path-item .path-text { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } 177 - .path-item .badge { 178 - font-size: 0.625rem; 179 - padding: 0.0625rem 0.375rem; 180 - border-radius: 3px; 181 - font-weight: 500; 182 - text-transform: uppercase; 183 - letter-spacing: 0.03em; 184 - flex-shrink: 0; 185 - } 186 - .badge-scalar { background: #e0f2fe; color: #0369a1; } 187 - .badge-vector { background: #f0fdf4; color: #15803d; } 188 - 189 - .matches-wrap { 190 - border-top: 1px solid var(--border); 191 - max-height: 45%; 192 - display: flex; 193 - flex-direction: column; 194 - overflow: hidden; 195 - } 196 - .matches-wrap.empty { display: none; } 197 - 198 - .matches-list { 199 - overflow-y: auto; 200 - background: var(--panel); 201 - } 202 - 203 - .match-item { 204 - padding: 0.375rem 0.75rem; 205 - font-family: var(--mono); 206 - font-size: 0.8125rem; 207 - line-height: 1.5; 208 - border-bottom: 1px solid #fef3c7; 209 - background: var(--match-bg); 210 - white-space: pre-wrap; 211 - word-break: break-all; 212 - } 213 - .match-item:last-child { border-bottom: 1px solid var(--border); }
+15
playground/package-lock.json
··· 7 7 "": { 8 8 "name": "playground", 9 9 "version": "0.0.1", 10 + "dependencies": { 11 + "recordpath": "file:../ref-impl-js" 12 + }, 10 13 "devDependencies": { 11 14 "@eslint/compat": "^2.0.4", 12 15 "@eslint/js": "^10.0.1", ··· 32 35 "vite": "^8.0.7", 33 36 "vitest": "^4.1.3", 34 37 "vitest-browser-svelte": "^2.1.0" 38 + } 39 + }, 40 + "../ref-impl-js": { 41 + "name": "recordpath", 42 + "version": "0.0.1", 43 + "license": "MIT OR Apache-2.0", 44 + "devDependencies": { 45 + "typescript": "^5.0.0" 35 46 } 36 47 }, 37 48 "node_modules/@blazediff/core": { ··· 3053 3064 "type": "individual", 3054 3065 "url": "https://paulmillr.com/funding/" 3055 3066 } 3067 + }, 3068 + "node_modules/recordpath": { 3069 + "resolved": "../ref-impl-js", 3070 + "link": true 3056 3071 }, 3057 3072 "node_modules/rolldown": { 3058 3073 "version": "1.0.0-rc.15",
+3
playground/package.json
··· 15 15 "test:unit": "vitest", 16 16 "test": "npm run test:unit -- --run" 17 17 }, 18 + "dependencies": { 19 + "recordpath": "file:../ref-impl-js" 20 + }, 18 21 "devDependencies": { 19 22 "@eslint/compat": "^2.0.4", 20 23 "@eslint/js": "^10.0.1",
+13 -4
playground/src/lib/recordpath.ts ref-impl-js/src/index.ts
··· 1 1 // RecordPath — parser, matcher, enumerator 2 - // Implements the RecordPath draft spec 2 + // Reference implementation of the RecordPath draft spec 3 3 4 4 const STRUCTURAL = new Set(['.', '[', ']', '{', '}', '!']); 5 5 ··· 175 175 function enumArray( 176 176 arr: unknown[], 177 177 prefix: string, 178 - parentIsVector: boolean, 178 + _parentIsVector: boolean, 179 179 paths: Map<string, PathInfo> 180 180 ) { 181 181 const hasUnion = arr.some( 182 - (el) => typeof el === 'object' && el !== null && !Array.isArray(el) && (el as Record<string, unknown>).$type 182 + (el) => 183 + typeof el === 'object' && 184 + el !== null && 185 + !Array.isArray(el) && 186 + (el as Record<string, unknown>).$type 183 187 ); 184 188 185 189 if (hasUnion) { 186 190 const byType: Record<string, Record<string, unknown>[]> = {}; 187 191 const plain: unknown[] = []; 188 192 for (const el of arr) { 189 - if (typeof el === 'object' && el !== null && !Array.isArray(el) && (el as Record<string, unknown>).$type) { 193 + if ( 194 + typeof el === 'object' && 195 + el !== null && 196 + !Array.isArray(el) && 197 + (el as Record<string, unknown>).$type 198 + ) { 190 199 const nsid = (el as Record<string, unknown>).$type as string; 191 200 (byType[nsid] || (byType[nsid] = [])).push(el as Record<string, unknown>); 192 201 } else {
+1 -1
playground/src/routes/+page.svelte
··· 1 1 <script lang="ts"> 2 - import { enumerate, match, type PathInfo } from '$lib/recordpath'; 2 + import { enumerate, match, type PathInfo } from 'recordpath'; 3 3 4 4 const SAMPLE = { 5 5 $type: 'app.bsky.feed.post',
+2
ref-impl-js/.gitignore
··· 1 + node_modules 2 + /dist
+30
ref-impl-js/package-lock.json
··· 1 + { 2 + "name": "recordpath", 3 + "version": "0.0.1", 4 + "lockfileVersion": 3, 5 + "requires": true, 6 + "packages": { 7 + "": { 8 + "name": "recordpath", 9 + "version": "0.0.1", 10 + "license": "MIT OR Apache-2.0", 11 + "devDependencies": { 12 + "typescript": "^5.0.0" 13 + } 14 + }, 15 + "node_modules/typescript": { 16 + "version": "5.9.3", 17 + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", 18 + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", 19 + "dev": true, 20 + "license": "Apache-2.0", 21 + "bin": { 22 + "tsc": "bin/tsc", 23 + "tsserver": "bin/tsserver" 24 + }, 25 + "engines": { 26 + "node": ">=14.17" 27 + } 28 + } 29 + } 30 + }
+33
ref-impl-js/package.json
··· 1 + { 2 + "name": "recordpath", 3 + "version": "0.0.1", 4 + "description": "a textual notation to locate fields within atproto records", 5 + "type": "module", 6 + "exports": { 7 + ".": { 8 + "types": "./src/index.ts", 9 + "default": "./src/index.ts" 10 + } 11 + }, 12 + "files": [ 13 + "dist", 14 + "src" 15 + ], 16 + "scripts": { 17 + "build": "tsc", 18 + "dev": "tsc --watch", 19 + "test": "echo \"Error: no test specified\" && exit 1" 20 + }, 21 + "repository": { 22 + "type": "git", 23 + "url": "https://tangled.org/microcosm.blue/RecordPath" 24 + }, 25 + "keywords": [ 26 + "atproto" 27 + ], 28 + "author": "fig", 29 + "license": "MIT OR Apache-2.0", 30 + "devDependencies": { 31 + "typescript": "^5.0.0" 32 + } 33 + }
+15
ref-impl-js/tsconfig.json
··· 1 + { 2 + "compilerOptions": { 3 + "target": "ES2022", 4 + "module": "ES2022", 5 + "moduleResolution": "bundler", 6 + "declaration": true, 7 + "declarationMap": true, 8 + "sourceMap": true, 9 + "outDir": "./dist", 10 + "rootDir": "./src", 11 + "strict": true, 12 + "skipLibCheck": true 13 + }, 14 + "include": ["src"] 15 + }