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); }