diff --git a/playground/index.html b/playground/index.html index b2bc9fd..0e7e7af 100644 --- a/playground/index.html +++ b/playground/index.html @@ -1,224 +1,10 @@ - - -RecordPath Playground - + + RecordPath Playground + + diff --git a/playground/playground.js b/playground/playground.js new file mode 100644 index 0000000..62dd153 --- /dev/null +++ b/playground/playground.js @@ -0,0 +1,207 @@ +const SAMPLE = { + "$type": "app.bsky.feed.post", + "text": "Check out this project by @alice.bsky.social! Really impressive work on atproto tooling.", + "langs": ["en"], + "createdAt": "2026-04-15T12:38:49.982Z", + "reply": { + "root": { + "cid": "bafyreieac34fnjyhuuzvgdnsyeeueyn45se5kuk6yppesn25gydjf5m5hy", + "uri": "at://did:plc:rnpkyqnmsw4ipey6eotbdnnf/app.bsky.feed.post/3mjjvykdfo22r" + }, + "parent": { + "cid": "bafyreibm3lim4hfn4fggpbtxkuoyyjokbonyetkyqfmr6qegqrgatxkhue", + "uri": "at://did:plc:rnpkyqnmsw4ipey6eotbdnnf/app.bsky.feed.post/3mjkj5zd7lc2b" + } + }, + "facets": [ + { + "index": { "byteStart": 32, "byteEnd": 52 }, + "features": [ + { + "$type": "app.bsky.richtext.facet#mention", + "did": "did:plc:ewvi7nxzyoun6zhxrhs64oiz" + } + ] + }, + { + "index": { "byteStart": 0, "byteEnd": 31 }, + "features": [ + { + "$type": "app.bsky.richtext.facet#link", + "uri": "https://example.com/atproto-tooling" + } + ] + } + ], + "embed": { + "$type": "app.bsky.embed.external", + "external": { + "uri": "https://example.com/atproto-tooling", + "title": "ATProto Tooling Project", + "description": "A collection of tools for building on the atmosphere." + } + } +}; + +const $json = document.getElementById('json-input'); +const $pathInput = document.getElementById('path-input'); +const $pathsList = document.getElementById('paths-list'); +const $pathCount = document.getElementById('path-count'); +const $matchesWrap = document.getElementById('matches-wrap'); +const $matchesList = document.getElementById('matches-list'); +const $matchCount = document.getElementById('match-count'); +const $jsonError = document.getElementById('json-error'); + +let currentRecord = null; +let currentPaths = []; + +function tryParseJSON(text) { + try { + const record = JSON.parse(text); + $jsonError.classList.remove('visible'); + return record; + } catch (e) { + $jsonError.textContent = e.message; + $jsonError.classList.add('visible'); + return null; + } +} + +function updatePaths() { + const text = $json.value.trim(); + if (!text) { + currentRecord = null; + currentPaths = []; + renderPaths(); + updateMatches(); + return; + } + + const record = tryParseJSON(text); + if (!record || typeof record !== 'object' || Array.isArray(record)) { + currentRecord = null; + currentPaths = []; + renderPaths(); + updateMatches(); + return; + } + + currentRecord = record; + currentPaths = RecordPath.enumerate(record); + renderPaths(); + updateMatches(); +} + +function renderPaths() { + $pathsList.innerHTML = ''; + $pathCount.textContent = currentPaths.length ? `(${currentPaths.length})` : ''; + + for (const { path, type } of currentPaths) { + const el = document.createElement('div'); + el.className = 'path-item'; + if (path === $pathInput.value) el.classList.add('active'); + + const pathSpan = document.createElement('span'); + pathSpan.className = 'path-text'; + pathSpan.textContent = path; + pathSpan.title = path; + + const badge = document.createElement('span'); + badge.className = 'badge ' + (type === 'vector' ? 'badge-vector' : 'badge-scalar'); + badge.textContent = type; + + el.appendChild(pathSpan); + el.appendChild(badge); + el.addEventListener('click', () => { + $pathInput.value = path; + updateMatches(); + highlightActive(); + }); + $pathsList.appendChild(el); + } +} + +function highlightActive() { + const items = $pathsList.querySelectorAll('.path-item'); + const val = $pathInput.value; + items.forEach(item => { + const text = item.querySelector('.path-text').textContent; + item.classList.toggle('active', text === val); + }); +} + +function updateMatches() { + const pathStr = $pathInput.value.trim(); + + if (!pathStr || !currentRecord) { + $matchesWrap.classList.add('empty'); + $matchesList.innerHTML = ''; + $matchCount.textContent = ''; + $pathInput.classList.remove('no-match'); + highlightActive(); + return; + } + + const values = RecordPath.match(currentRecord, pathStr); + + $matchesWrap.classList.toggle('empty', values.length === 0 && !pathStr); + $pathInput.classList.toggle('no-match', values.length === 0 && pathStr.length > 0); + + if (values.length === 0) { + $matchesWrap.classList.remove('empty'); + $matchesList.innerHTML = '
No matches
'; + $matchCount.textContent = '(0)'; + } else { + $matchesWrap.classList.remove('empty'); + $matchesList.innerHTML = ''; + $matchCount.textContent = `(${values.length})`; + for (const val of values) { + const el = document.createElement('div'); + el.className = 'match-item'; + el.textContent = formatValue(val); + $matchesList.appendChild(el); + } + } + + highlightActive(); +} + +function formatValue(val) { + if (val === null) return 'null'; + if (val === undefined) return 'undefined'; + if (typeof val === 'string') return JSON.stringify(val); + if (typeof val !== 'object') return String(val); + const str = JSON.stringify(val, null, 2); + if (str.length > 500) return str.slice(0, 500) + '\n...'; + return str; +} + +// Wire up events +let debounceTimer; +$json.addEventListener('input', () => { + clearTimeout(debounceTimer); + debounceTimer = setTimeout(updatePaths, 200); +}); + +$pathInput.addEventListener('input', () => { + updateMatches(); +}); + +document.getElementById('btn-sample').addEventListener('click', () => { + $json.value = JSON.stringify(SAMPLE, null, 2); + updatePaths(); +}); + +document.getElementById('btn-format').addEventListener('click', () => { + const text = $json.value.trim(); + if (!text) return; + try { + const obj = JSON.parse(text); + $json.value = JSON.stringify(obj, null, 2); + updatePaths(); + } catch (e) { /* already shown */ } +}); + +// Start with sample loaded +$json.value = JSON.stringify(SAMPLE, null, 2); +updatePaths(); diff --git a/playground/recordpath.js b/playground/recordpath.js index 56f964c..1ef7378 100644 --- a/playground/recordpath.js +++ b/playground/recordpath.js @@ -4,7 +4,7 @@ (function () { const STRUCTURAL = new Set(['.', '[', ']', '{', '}', '!']); - function escapeKey(key) { + function escapeFieldName(key) { let out = ''; for (const ch of key) { if (STRUCTURAL.has(ch)) out += '!' + ch; @@ -15,12 +15,17 @@ // Parse a RecordPath string into segments. // Each segment: { key: string, qualifiers: [{type, nsid?}] } + // + // TODO: error returns?? (or throws) function parse(str) { const segments = []; let i = 0; while (i < str.length) { let key = ''; + + // TODO: does this permit misplaced unescaped close chars? `]` and `}` + // we should probably fail/reject those. while (i < str.length && str[i] !== '.' && str[i] !== '[' && str[i] !== '{') { if (str[i] === '!' && i + 1 < str.length) { key += str[i + 1]; @@ -119,7 +124,7 @@ function _enumObject(obj, prefix, isRoot, isVector, paths) { for (const key of Object.keys(obj)) { const child = obj[key]; - const escaped = escapeKey(key); + const escaped = escapeFieldName(key); const keyPath = prefix ? prefix + '.' + escaped : escaped; const vtype = isVector ? 'vector' : 'scalar'; @@ -183,9 +188,13 @@ } } + // TODO: enumerateMatching? pass a test fn that accepts (path, value) pairs and returns bool + // could just filter the output of enumerate, but this avoids collecting lots of stuff we don't need in the mean time + // (or enumerate could be a generator?? that might be even nicer) + function isVector(pathStr) { return /\[/.test(pathStr); } - window.RecordPath = { escapeKey, parse, match, enumerate, isVector }; + window.RecordPath = { escapeFieldName, parse, match, enumerate, isVector }; })(); diff --git a/playground/style.css b/playground/style.css new file mode 100644 index 0000000..51987ba --- /dev/null +++ b/playground/style.css @@ -0,0 +1,213 @@ +:root { + --bg: #f5f5f4; + --panel: #fff; + --border: #d6d3d1; + --text: #1c1917; + --muted: #78716c; + --accent: #2563eb; + --accent-light: #dbeafe; + --match-bg: #fef9c3; + --match-border: #facc15; + --error: #dc2626; + --mono: ui-monospace, 'SF Mono', 'Cascadia Code', 'Fira Code', monospace; + --sans: system-ui, -apple-system, sans-serif; +} + +* { box-sizing: border-box; margin: 0; padding: 0; } + +body { + font-family: var(--sans); + background: var(--bg); + color: var(--text); + height: 100vh; + display: flex; + flex-direction: column; + overflow: hidden; +} + +header { + display: flex; + align-items: center; + gap: 1rem; + padding: 0.625rem 1rem; + border-bottom: 1px solid var(--border); + background: var(--panel); +} + +header h1 { + font-size: 0.9375rem; + font-weight: 600; +} + +header .tag { + font-size: 0.6875rem; + padding: 0.125rem 0.5rem; + border-radius: 9999px; + background: var(--accent-light); + color: var(--accent); + font-weight: 500; +} + +header .spacer { flex: 1; } + +header button { + font-size: 0.8125rem; + padding: 0.375rem 0.75rem; + border: 1px solid var(--border); + border-radius: 5px; + background: var(--panel); + cursor: pointer; + color: var(--text); +} +header button:hover { background: var(--bg); } + +main { + flex: 1; + display: grid; + grid-template-columns: 3fr 2fr; + gap: 0; + overflow: hidden; +} + +.panel { + display: flex; + flex-direction: column; + overflow: hidden; + border-right: 1px solid var(--border); +} +.panel:last-child { border-right: none; } + +.panel-header { + padding: 0.5rem 0.75rem; + border-bottom: 1px solid var(--border); + font-size: 0.75rem; + font-weight: 600; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.04em; + background: var(--panel); + display: flex; + align-items: center; + gap: 0.5rem; +} + +.panel-header .count { + font-weight: 400; + color: var(--muted); +} + +#json-input { + flex: 1; + border: none; + padding: 0.75rem; + font-family: var(--mono); + font-size: 0.8125rem; + line-height: 1.6; + resize: none; + outline: none; + tab-size: 2; + background: var(--panel); +} + +.json-error { + padding: 0.375rem 0.75rem; + font-size: 0.75rem; + color: var(--error); + background: #fef2f2; + border-top: 1px solid #fecaca; + display: none; +} +.json-error.visible { display: block; } + +.path-input-wrap { + padding: 0.5rem 0.75rem; + background: var(--panel); + border-bottom: 1px solid var(--border); +} + +#path-input { + width: 100%; + font-family: var(--mono); + font-size: 0.875rem; + padding: 0.5rem 0.625rem; + border: 1.5px solid var(--border); + border-radius: 5px; + outline: none; + background: var(--panel); +} +#path-input:focus { + border-color: var(--accent); + box-shadow: 0 0 0 2px var(--accent-light); +} +#path-input.no-match { + border-color: var(--error); + box-shadow: 0 0 0 2px #fee2e2; +} + +.right-body { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.paths-list { + flex: 1; + overflow-y: auto; + background: var(--panel); +} + +.path-item { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.3125rem 0.75rem; + font-family: var(--mono); + font-size: 0.8125rem; + cursor: pointer; + border-left: 3px solid transparent; +} +.path-item:hover { background: #fafaf9; } +.path-item.active { + background: var(--accent-light); + border-left-color: var(--accent); +} + +.path-item .path-text { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.path-item .badge { + font-size: 0.625rem; + padding: 0.0625rem 0.375rem; + border-radius: 3px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.03em; + flex-shrink: 0; +} +.badge-scalar { background: #e0f2fe; color: #0369a1; } +.badge-vector { background: #f0fdf4; color: #15803d; } + +.matches-wrap { + border-top: 1px solid var(--border); + max-height: 45%; + display: flex; + flex-direction: column; + overflow: hidden; +} +.matches-wrap.empty { display: none; } + +.matches-list { + overflow-y: auto; + background: var(--panel); +} + +.match-item { + padding: 0.375rem 0.75rem; + font-family: var(--mono); + font-size: 0.8125rem; + line-height: 1.5; + border-bottom: 1px solid #fef3c7; + background: var(--match-bg); + white-space: pre-wrap; + word-break: break-all; +} +.match-item:last-child { border-bottom: 1px solid var(--border); }