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
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
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 @@