diff --git a/playground-orig/index.html b/playground-orig/index.html deleted file mode 100644 index 0e7e7af..0000000 --- a/playground-orig/index.html +++ /dev/null @@ -1,258 +0,0 @@ - - - - - RecordPath Playground - - - - - -
-

RecordPath Playground

- draft spec - - - -
- -
-
-
Record JSON
- -
-
- -
-
- -
-
- Available paths - -
-
-
-
-
- Matches - -
-
-
-
-
-
- - - - - diff --git a/playground-orig/playground.js b/playground-orig/playground.js deleted file mode 100644 index 62dd153..0000000 --- a/playground-orig/playground.js +++ /dev/null @@ -1,207 +0,0 @@ -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-orig/recordpath.js b/playground-orig/recordpath.js deleted file mode 100644 index 1ef7378..0000000 --- a/playground-orig/recordpath.js +++ /dev/null @@ -1,200 +0,0 @@ -// RecordPath — parser, matcher, enumerator -// Implements the RecordPath draft spec - -(function () { - const STRUCTURAL = new Set(['.', '[', ']', '{', '}', '!']); - - function escapeFieldName(key) { - let out = ''; - for (const ch of key) { - if (STRUCTURAL.has(ch)) out += '!' + ch; - else out += ch; - } - return out; - } - - // 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]; - i += 2; - } else { - key += str[i]; - i++; - } - } - - const qualifiers = []; - while (i < str.length && (str[i] === '[' || str[i] === '{')) { - const open = str[i]; - const close = open === '[' ? ']' : '}'; - i++; - let content = ''; - while (i < str.length && str[i] !== close) { - content += str[i]; - i++; - } - if (i < str.length) i++; - - if (open === '[') { - qualifiers.push(content === '' - ? { type: 'array' } - : { type: 'arrayUnion', nsid: content }); - } else { - qualifiers.push({ type: 'scalarUnion', nsid: content }); - } - } - - segments.push({ key, qualifiers }); - if (i < str.length && str[i] === '.') i++; - } - - return segments; - } - - // Match a RecordPath against a record, returning all matched values. - function match(record, pathStr) { - if (!pathStr || pathStr.trim() === '') return []; - try { - const segments = parse(pathStr); - return _matchSegments(record, segments, 0); - } catch (e) { - return []; - } - } - - function _matchSegments(data, segments, segIdx) { - if (segIdx >= segments.length) return [data]; - const seg = segments[segIdx]; - if (typeof data !== 'object' || data === null || Array.isArray(data)) return []; - const value = data[seg.key]; - if (value === undefined) return []; - return _applyQualifiers(value, seg.qualifiers, 0, segments, segIdx); - } - - function _applyQualifiers(value, qualifiers, qualIdx, segments, segIdx) { - if (qualIdx >= qualifiers.length) { - if (segIdx + 1 >= segments.length) return [value]; - return _matchSegments(value, segments, segIdx + 1); - } - - const qual = qualifiers[qualIdx]; - - if (qual.type === 'scalarUnion') { - if (typeof value !== 'object' || value === null || Array.isArray(value)) return []; - if (value.$type !== qual.nsid) return []; - return _applyQualifiers(value, qualifiers, qualIdx + 1, segments, segIdx); - } - - if (qual.type === 'array' || qual.type === 'arrayUnion') { - if (!Array.isArray(value)) return []; - const results = []; - for (const elem of value) { - if (qual.type === 'arrayUnion') { - if (typeof elem !== 'object' || elem === null || elem.$type !== qual.nsid) continue; - } - results.push(..._applyQualifiers(elem, qualifiers, qualIdx + 1, segments, segIdx)); - } - return results; - } - - return []; - } - - // Enumerate all RecordPaths reachable from a record. - // Returns [{ path, type: 'scalar'|'vector' }] - function enumerate(record) { - const paths = new Map(); - _enumObject(record, '', true, false, paths); - return Array.from(paths.entries()).map(([path, info]) => ({ path, ...info })); - } - - function _enumObject(obj, prefix, isRoot, isVector, paths) { - for (const key of Object.keys(obj)) { - const child = obj[key]; - const escaped = escapeFieldName(key); - const keyPath = prefix ? prefix + '.' + escaped : escaped; - const vtype = isVector ? 'vector' : 'scalar'; - - if (child === null || child === undefined || typeof child !== 'object') { - paths.set(keyPath, { type: vtype }); - } else if (Array.isArray(child)) { - paths.set(keyPath, { type: vtype }); - _enumArray(child, keyPath, isVector, paths); - } else if (child.$type && !isRoot) { - paths.set(keyPath, { type: vtype }); - const qualified = keyPath + '{' + child.$type + '}'; - paths.set(qualified, { type: vtype }); - _enumObject(child, qualified, false, isVector, paths); - } else { - paths.set(keyPath, { type: vtype }); - _enumObject(child, keyPath, false, isVector, paths); - } - } - } - - function _enumArray(arr, prefix, parentIsVector, paths) { - const hasUnion = arr.some(el => - typeof el === 'object' && el !== null && !Array.isArray(el) && el.$type - ); - - if (hasUnion) { - const byType = {}; - const plain = []; - for (const el of arr) { - if (typeof el === 'object' && el !== null && !Array.isArray(el) && el.$type) { - (byType[el.$type] || (byType[el.$type] = [])).push(el); - } else { - plain.push(el); - } - } - for (const [nsid, elements] of Object.entries(byType)) { - const qp = prefix + '[' + nsid + ']'; - paths.set(qp, { type: 'vector' }); - for (const el of elements) { - _enumObject(el, qp, false, true, paths); - } - } - if (plain.length > 0) { - _enumPlainArray(plain, prefix + '[]', paths); - } - } else { - _enumPlainArray(arr, prefix + '[]', paths); - } - } - - function _enumPlainArray(arr, prefix, paths) { - paths.set(prefix, { type: 'vector' }); - for (const el of arr) { - if (el === null || el === undefined || typeof el !== 'object') { - // scalar elements — path is the array prefix itself - } else if (Array.isArray(el)) { - _enumArray(el, prefix, true, paths); - } else { - _enumObject(el, prefix, false, true, paths); - } - } - } - - // 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 = { escapeFieldName, parse, match, enumerate, isVector }; -})(); diff --git a/playground-orig/style.css b/playground-orig/style.css deleted file mode 100644 index 51987ba..0000000 --- a/playground-orig/style.css +++ /dev/null @@ -1,213 +0,0 @@ -: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); } diff --git a/playground/package-lock.json b/playground/package-lock.json index 61e8964..51d30a4 100644 --- a/playground/package-lock.json +++ b/playground/package-lock.json @@ -7,6 +7,9 @@ "": { "name": "playground", "version": "0.0.1", + "dependencies": { + "recordpath": "file:../ref-impl-js" + }, "devDependencies": { "@eslint/compat": "^2.0.4", "@eslint/js": "^10.0.1", @@ -34,6 +37,14 @@ "vitest-browser-svelte": "^2.1.0" } }, + "../ref-impl-js": { + "name": "recordpath", + "version": "0.0.1", + "license": "MIT OR Apache-2.0", + "devDependencies": { + "typescript": "^5.0.0" + } + }, "node_modules/@blazediff/core": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/@blazediff/core/-/core-1.9.1.tgz", @@ -3054,6 +3065,10 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/recordpath": { + "resolved": "../ref-impl-js", + "link": true + }, "node_modules/rolldown": { "version": "1.0.0-rc.15", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.15.tgz", diff --git a/playground/package.json b/playground/package.json index a6d9f72..19a6923 100644 --- a/playground/package.json +++ b/playground/package.json @@ -15,6 +15,9 @@ "test:unit": "vitest", "test": "npm run test:unit -- --run" }, + "dependencies": { + "recordpath": "file:../ref-impl-js" + }, "devDependencies": { "@eslint/compat": "^2.0.4", "@eslint/js": "^10.0.1", diff --git a/playground/src/routes/+page.svelte b/playground/src/routes/+page.svelte index c52b4eb..27678c9 100644 --- a/playground/src/routes/+page.svelte +++ b/playground/src/routes/+page.svelte @@ -1,5 +1,5 @@