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.

playground initial edits

phil cd27b765 cd9891c2

+436 -221
+4 -218
playground/index.html
··· 1 1 <!DOCTYPE html> 2 2 <html lang="en"> 3 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> 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" /> 222 8 </head> 223 9 <body> 224 10
+207
playground/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();
+12 -3
playground/recordpath.js
··· 4 4 (function () { 5 5 const STRUCTURAL = new Set(['.', '[', ']', '{', '}', '!']); 6 6 7 - function escapeKey(key) { 7 + function escapeFieldName(key) { 8 8 let out = ''; 9 9 for (const ch of key) { 10 10 if (STRUCTURAL.has(ch)) out += '!' + ch; ··· 15 15 16 16 // Parse a RecordPath string into segments. 17 17 // Each segment: { key: string, qualifiers: [{type, nsid?}] } 18 + // 19 + // TODO: error returns?? (or throws) 18 20 function parse(str) { 19 21 const segments = []; 20 22 let i = 0; 21 23 22 24 while (i < str.length) { 23 25 let key = ''; 26 + 27 + // TODO: does this permit misplaced unescaped close chars? `]` and `}` 28 + // we should probably fail/reject those. 24 29 while (i < str.length && str[i] !== '.' && str[i] !== '[' && str[i] !== '{') { 25 30 if (str[i] === '!' && i + 1 < str.length) { 26 31 key += str[i + 1]; ··· 119 124 function _enumObject(obj, prefix, isRoot, isVector, paths) { 120 125 for (const key of Object.keys(obj)) { 121 126 const child = obj[key]; 122 - const escaped = escapeKey(key); 127 + const escaped = escapeFieldName(key); 123 128 const keyPath = prefix ? prefix + '.' + escaped : escaped; 124 129 const vtype = isVector ? 'vector' : 'scalar'; 125 130 ··· 183 188 } 184 189 } 185 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 + 186 195 function isVector(pathStr) { 187 196 return /\[/.test(pathStr); 188 197 } 189 198 190 - window.RecordPath = { escapeKey, parse, match, enumerate, isVector }; 199 + window.RecordPath = { escapeFieldName, parse, match, enumerate, isVector }; 191 200 })();
+213
playground/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); }