From e4da54f4e4d2d4d01f8a03ac624f67f9bea85cf1 Mon Sep 17 00:00:00 2001 From: Melvin Carvalho Date: Fri, 5 Jun 2026 09:21:59 +0200 Subject: [PATCH 1/2] feat(browser): add --browser panes, a pane-aware data browser augmented by local panes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A third selectable data browser alongside json and folder, chosen with the existing --browser switch as 'panes'. It reuses the folder/JSON rendering and adds type-driven panes for single resources: read the #dataisland resource, match the primary subject's @type, and render the first pod-local pane that handles it (collapsible Source underneath); containers and unknown types fall back exactly like the folder browser. Crucially the browser ships NO panes — they live on the pod under /public/panes/ and are discovered/imported at render time. Adding a pane is 'drop a module on your pod', never 'fork the shipped browser'. - data-browser-panes.js: the shell (folder/JSON + /public/panes/ discovery) - data-browser-panes.css: empty sibling stub (JSS auto-fetches it) - index.js / lib/start.js: widen --browser to accept 'panes' - examples/panes/{bookmark,tracker}.js: reference panes (bookmark:Bookmark, wf:Tracker) data-browser-folder.js and data-browser.js are unchanged — fully opt-in. Refs #69 --- data-browser-panes.css | 5 + data-browser-panes.js | 285 +++++++++++++++++++++++++++++++++++++ examples/panes/bookmark.js | 32 +++++ examples/panes/tracker.js | 27 ++++ index.js | 6 +- lib/start.js | 4 +- 6 files changed, 355 insertions(+), 4 deletions(-) create mode 100644 data-browser-panes.css create mode 100644 data-browser-panes.js create mode 100644 examples/panes/bookmark.js create mode 100644 examples/panes/tracker.js diff --git a/data-browser-panes.css b/data-browser-panes.css new file mode 100644 index 0000000..9a731c4 --- /dev/null +++ b/data-browser-panes.css @@ -0,0 +1,5 @@ +/* Intentionally empty. Styles live inside data-browser-panes.js so the + browser is a single file. JSS auto-fetches this sibling URL + (javascript-solid-server/src/mashlib/index.js:307); shipping an empty + 200 keeps the console warning-free until JSS makes the .css fetch + optional. */ diff --git a/data-browser-panes.js b/data-browser-panes.js new file mode 100644 index 0000000..13f834f --- /dev/null +++ b/data-browser-panes.js @@ -0,0 +1,285 @@ +// jspod pane-aware data browser. Sibling of data-browser.js (json) and +// data-browser-folder.js (folder); selected with `--browser panes`. JSS +// embeds the resource as JSON-LD in #dataisland; this script: +// 1. If the resource is an LDP container, render breadcrumb + table +// (NAME / TYPE / SIZE / MODIFIED) — identical to the folder browser. +// 2. If it's a single resource, load the pod's own panes from +// /public/panes/ and render the first whose canHandle(@type) matches, +// with a collapsible Source. Panes are NOT shipped here — they live on +// the pod and are augmented locally (drop a module in /public/panes/). +// 3. No matching pane → pretty-printed JSON-LD (same as data-browser.js). +// +// Single file, no build, no framework. Local pane contract (ES module): +// export default { canHandle(node, h) -> boolean, render(node, h) -> htmlString } +// where h = { escape, prop, propAll, idOf, types, host, fmtDate, localName }. + +document.head.insertAdjacentHTML('beforeend', ``); + +(function () { + main(); + + async function main() { + const here = window.location.pathname; + const up = (here === '/' || !here) + ? null + : (s => { const i = s.lastIndexOf('/'); return i === 0 ? '/' : s.slice(0, i) + '/'; })(here.replace(/\/$/, '')); + const navHTML = ``; + + let doc = null; + try { doc = JSON.parse(document.getElementById('dataisland').textContent); } catch (e) {} + + const items = doc ? parseContainer(doc, window.location.href) : null; + const isContainer = Array.isArray(items); + + const target = document.getElementById('mashlib'); + if (isContainer) { + target.innerHTML = `
${navHTML}
${renderFolder(items, here)}
`; + return; + } + + // Single resource: try a pod-local pane, else fall back to the JSON dump. + const node = primaryNode(doc); + const paneHTML = node ? await renderLocalPane(node) : null; + if (paneHTML) { + target.innerHTML = `
${navHTML}
${paneHTML}
` + + `
Source${renderJson(doc)}
`; + } else { + target.innerHTML = `
${navHTML}${renderJson(doc)}
`; + } + } + + // ---- local panes: discovered from /public/panes/, augmentable per-pod ---- + + async function renderLocalPane(node) { + const panes = await loadLocalPanes(); + if (!panes.length) return null; + const h = { escape, prop, propAll, idOf, types: typesOf, host: hostOf, fmtDate: fmtDay, localName: localType }; + for (const p of panes) { + try { if (p.canHandle(node, h)) return p.render(node, h); } catch (e) { /* skip */ } + } + return null; + } + + async function loadLocalPanes() { + try { + const base = new URL('/public/panes/', window.location.origin).href; + const r = await fetch(base, { headers: { Accept: 'application/ld+json' } }); + if (!r.ok) return []; + const items = parseContainer(await r.json(), base) || []; + const urls = items + .filter(it => it.type !== 'container' && it.url.endsWith('.js') && !nameOf(it.url).startsWith('.')) + .map(it => it.url); + const mods = await Promise.all(urls.map(u => import(u).then(m => m.default).catch(() => null))); + return mods.filter(p => p && typeof p.canHandle === 'function' && typeof p.render === 'function'); + } catch (e) { + return []; + } + } + + // The primary subject of a single-resource doc: #this, else the @graph node + // carrying an @type, else the doc itself. + function primaryNode(d) { + if (!d) return null; + const graph = Array.isArray(d['@graph']) ? d['@graph'] : null; + if (graph) { + return graph.find(n => /(^|#)this$/.test(n['@id'] || '')) || + graph.find(n => n['@type']) || graph[0] || null; + } + return d; + } + function localType(t) { return String(t).split(/[#/:]/).pop(); } + function typesOf(node) { + const t = node && node['@type']; + return (Array.isArray(t) ? t : (t == null ? [] : [t])).map(localType); + } + // Read a property tolerant of prefixed / aliased / expanded keys (pass short key). + function prop(node, key) { + if (!node) return undefined; + for (const k of Object.keys(node)) { + if (k.startsWith('@')) continue; + if (k === key || k.endsWith(':' + key) || k.endsWith('/' + key) || k.endsWith('#' + key)) return node[k]; + } + return undefined; + } + function propAll(node, key) { const v = prop(node, key); return v == null ? [] : (Array.isArray(v) ? v : [v]); } + function idOf(v) { return v == null ? '' : (typeof v === 'string' ? v : (v['@id'] || v.id || '')); } + function hostOf(u) { try { return new URL(u).host; } catch (e) { return u; } } + function fmtDay(iso) { + if (!iso) return ''; + const d = new Date(iso); + return isNaN(d) ? '' : d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }); + } + + function renderFolder(items, path) { + const folders = items.filter(i => i.type === 'container').length; + const files = items.length - folders; + items.sort((a, b) => { + if (a.type !== b.type) return a.type === 'container' ? -1 : 1; + return nameOf(a.url).localeCompare(nameOf(b.url)); + }); + + const segments = path.replace(/\/$/, '').split('/').filter(Boolean); + const here = segments[segments.length - 1] || window.location.host; + let acc = '/'; + const crumbs = [`${escape(window.location.host)}`].concat( + segments.map((s, i) => { + acc += s + '/'; + return i === segments.length - 1 + ? `/${escape(decodeURIComponent(s))}` + : `/${escape(decodeURIComponent(s))}`; + }) + ).join(''); + + const rows = items.length + ? ` + + ${items.map(rowHTML).join('')} +
NAMETYPESIZEMODIFIED
` + : `
Empty container
`; + + return ` +
${crumbs}
+
${escape(decodeURIComponent(here))}
+
${escape(window.location.href)}
+
${folders} folder${folders === 1 ? '' : 's'}, ${files} file${files === 1 ? '' : 's'}
+ ${rows} + `; + } + + function rowHTML(it) { + const name = nameOf(it.url); + const tl = typeLabel(it); + const icon = iconFor(it); + return ` + ${icon}${escape(name)}${it.type === 'container' ? '/' : ''} + ${escape(tl)} + ${it.size != null ? fmtBytes(it.size) : '—'} + ${it.modified ? fmtDate(it.modified) : '—'} + `; + } + + function renderJson(d) { + if (!d) return `
No data.
`; + const pretty = JSON.stringify(d, null, 2) + .replace(/[<>&]/g, c => ({ '<': '<', '>': '>', '&': '&' }[c])) + .replace(/https?:\/\/[^"\s]+/g, m => + `${m}`); + return `
${pretty}
`; + } + + // ---- container parsing (JSON-LD with ldp:contains) ---- + + function parseContainer(doc, baseUrl) { + const nodes = Array.isArray(doc?.['@graph']) ? doc['@graph'] : [doc]; + const container = nodes.find(n => + n['@id'] === baseUrl || + (typeof n['@id'] === 'string' && resolveUrl(n['@id'], baseUrl) === baseUrl) + ) || nodes[0]; + if (!container) return null; + const raw = container['contains'] + ?? container['ldp:contains'] + ?? container['http://www.w3.org/ns/ldp#contains']; + if (raw == null) return null; + const arr = Array.isArray(raw) ? raw : [raw]; + return arr.map(c => { + if (typeof c === 'string') return { url: resolveUrl(c, baseUrl), type: c.endsWith('/') ? 'container' : 'resource' }; + const url = resolveUrl(c['@id'], baseUrl); + const types = [].concat(c['@type'] || []); + const isC = types.some(t => /(?:#|\/)(?:BasicContainer|Container)$/.test(t)) || url.endsWith('/'); + return { + url, + type: isC ? 'container' : 'resource', + size: c['stat:size'] ?? c['http://www.w3.org/ns/posix/stat#size'], + modified: c['dcterms:modified'] ?? c['http://purl.org/dc/terms/modified'] ?? c['dc:modified'], + }; + }).filter(x => x.url && x.url !== baseUrl); + } + + // ---- formatting helpers ---- + + function nameOf(url) { + return decodeURIComponent(url.replace(/\/$/, '').split('/').pop() || url); + } + function typeLabel(it) { + if (it.type === 'container') return 'Folder'; + const ext = nameOf(it.url).split('.').pop()?.toLowerCase() || ''; + const map = { + html: 'HTML', htm: 'HTML', + md: 'Markdown', markdown: 'Markdown', + json: 'JSON', jsonld: 'JSON-LD', + ttl: 'Turtle', n3: 'N3', rdf: 'RDF', + css: 'CSS', js: 'JavaScript', ts: 'TypeScript', + png: 'Image', jpg: 'Image', jpeg: 'Image', gif: 'Image', webp: 'Image', svg: 'SVG', avif: 'Image', + mp3: 'Audio', ogg: 'Audio', wav: 'Audio', flac: 'Audio', + mp4: 'Video', webm: 'Video', mov: 'Video', + pdf: 'PDF', + txt: 'Text', text: 'Text', + acl: 'ACL', + }; + return map[ext] || (ext ? ext.toUpperCase() : '—'); + } + function iconFor(it) { + if (it.type === 'container') return '📁'; + const ext = nameOf(it.url).split('.').pop()?.toLowerCase() || ''; + if (/^(html|htm)$/.test(ext)) return '🌐'; + if (/^(json|jsonld|ttl|n3|rdf)$/.test(ext)) return '🔗'; + if (/^(md|markdown|txt|text)$/.test(ext)) return '📝'; + if (/^(png|jpg|jpeg|gif|webp|svg|avif)$/.test(ext)) return '🖼'; + if (/^(mp3|ogg|wav|flac)$/.test(ext)) return '🎵'; + if (/^(mp4|webm|mov)$/.test(ext)) return '🎬'; + if (ext === 'pdf') return '📕'; + if (ext === 'acl') return '🔒'; + return '📄'; + } + function fmtBytes(n) { + if (n < 1024) return n + ' B'; + if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB'; + if (n < 1024 * 1024 * 1024) return (n / 1024 / 1024).toFixed(1) + ' MB'; + return (n / 1024 / 1024 / 1024).toFixed(2) + ' GB'; + } + function fmtDate(iso) { + try { + return new Date(iso).toLocaleString(undefined, { + month: 'short', day: 'numeric', year: 'numeric', + hour: 'numeric', minute: '2-digit' + }); + } catch { return String(iso); } + } + function resolveUrl(href, base) { try { return new URL(href, base).href; } catch { return href; } } + function escape(s) { + return String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); + } +})(); diff --git a/examples/panes/bookmark.js b/examples/panes/bookmark.js new file mode 100644 index 0000000..f2a753c --- /dev/null +++ b/examples/panes/bookmark.js @@ -0,0 +1,32 @@ +// Pod-local pane for bookmark:Bookmark (dc:title / bookmark:recalls / dcterms:created). +// Loaded by the `--browser panes` data browser from /public/panes/. +// Contract: export default { canHandle(node, h) -> bool, render(node, h) -> htmlString } +// h = { escape, prop, propAll, idOf, types, host, fmtDate, localName }. + +export default { + canHandle(node, h) { + return h.types(node).includes('Bookmark'); + }, + + render(node, h) { + const url = h.idOf(h.prop(node, 'recalls')); + const title = h.prop(node, 'title') || url || 'Untitled bookmark'; + const site = h.host(url); + const date = h.fmtDate(h.prop(node, 'created')); + const fav = url ? h.escape(new URL('/favicon.ico', url).href) : ''; + return `
+
Bookmark
+ + +
+
${h.escape(title)}
+
${h.escape(site)}
+
+
+
+ Open ↗ + ${date ? `saved ${h.escape(date)}` : ''} +
+
`; + } +}; diff --git a/examples/panes/tracker.js b/examples/panes/tracker.js new file mode 100644 index 0000000..ff1463a --- /dev/null +++ b/examples/panes/tracker.js @@ -0,0 +1,27 @@ +// Pod-local pane for wf:Tracker / ical:Vtodo (a task list). +// Loaded by the `--browser panes` data browser from /public/panes/. +// Contract: export default { canHandle(node, h) -> bool, render(node, h) -> htmlString } +// h = { escape, prop, propAll, idOf, types, host, fmtDate, localName }. + +export default { + canHandle(node, h) { + return h.types(node).some(t => t === 'Tracker' || t === 'Vtodo'); + }, + + render(node, h) { + const issues = h.propAll(node, 'issue'); + const done = i => { + const s = (i && (h.prop(i, 'status') || i.status)) || 'NEEDS-ACTION'; + return s === 'COMPLETED' || s === 'CANCELLED'; + }; + const active = issues.filter(i => !done(i)); + const title = h.prop(node, 'title') || 'Tasks'; + const row = i => `
  • ${done(i) ? '☑' : '☐'} ${h.escape(h.prop(i, 'summary') || h.idOf(i))}
  • `; + return `
    +
    Tasks
    +
    ${h.escape(title)}
    +
    ${active.length === 0 ? 'All clear.' : active.length + ' task' + (active.length === 1 ? '' : 's') + ' remaining.'}
    + +
    `; + } +}; diff --git a/index.js b/index.js index c9365f3..56d9cdf 100755 --- a/index.js +++ b/index.js @@ -381,9 +381,9 @@ for (let i = 0; i < args.length; i++) { options.nostrMaxEvents = parsed; } else if (arg === '--browser') { const raw = requireValue(arg, args[++i]); - if (raw !== 'json' && raw !== 'folder') { + if (raw !== 'json' && raw !== 'folder' && raw !== 'panes') { console.error(chalk.red(`✗ Invalid --browser value: ${raw}`)); - console.error(chalk.dim('Must be one of: json, folder')); + console.error(chalk.dim('Must be one of: json, folder, panes')); process.exit(1); } options.browser = raw; @@ -408,7 +408,7 @@ for (let i = 0; i < args.length; i++) { console.log(chalk.green(' --no-auth') + chalk.dim(' Disable authentication')); console.log(chalk.green(' --no-open') + chalk.dim(' Do not open the browser automatically')); console.log(chalk.green(' --no-git') + chalk.dim(' Disable JSS\'s git HTTP backend (it is on by default)')); - console.log(chalk.green(' --browser ') + chalk.yellow('') + chalk.dim(' Data browser style (default: folder)')); + console.log(chalk.green(' --browser ') + chalk.yellow('') + chalk.dim(' Data browser style (default: folder; panes = type-driven panes from /public/panes/)')); console.log(chalk.green(' --provision-keys') + chalk.dim(' Generate a Nostr-compatible owner keypair on first start')); console.log(chalk.green(' --mcp') + chalk.dim(' Expose /mcp (Model Context Protocol) tool surface for agents')); console.log(chalk.green(' --nostr') + chalk.dim(' Run a Nostr relay (NIP-01) at /relay')); diff --git a/lib/start.js b/lib/start.js index cbe0a3b..ae7cc7d 100644 --- a/lib/start.js +++ b/lib/start.js @@ -230,7 +230,9 @@ export async function start(userOptions = {}) { // The data browser URL is a version-pinned jsdelivr asset so the // running pod always loads the matching browser code for this jspod. - const browserFile = options.browser === 'folder' ? 'data-browser-folder.js' : 'data-browser.js'; + const browserFile = options.browser === 'folder' ? 'data-browser-folder.js' + : options.browser === 'panes' ? 'data-browser-panes.js' + : 'data-browser.js'; const dataBrowserUrl = `https://cdn.jsdelivr.net/npm/jspod@${pkg.version}/${browserFile}`; const RUNG_1_PASSWORD = process.env.JSS_SINGLE_USER_PASSWORD || 'me'; From c90cad750918e0b3e45d765a91be0cb5c366d978 Mon Sep 17 00:00:00 2001 From: Melvin Carvalho Date: Fri, 5 Jun 2026 09:38:43 +0200 Subject: [PATCH 2/2] fix(browser): address Copilot review on #70 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - start(): accept 'panes' in the library-API browser validation (not just the CLI path) so start({ browser: 'panes' }) no longer throws. - package.json: add data-browser-panes.{js,css} + examples/ to the files allowlist so the jsDelivr/npm asset exists after publish (was a guaranteed 404 for --browser panes). - panes: normalize multi-valued JSON-LD props — idOf() unwraps arrays and a new h.first helper takes the first value, so array-valued title/recalls/ created/summary render correctly instead of '' or [object Object]. --- data-browser-panes.js | 9 ++++++--- examples/panes/bookmark.js | 4 ++-- examples/panes/tracker.js | 4 ++-- lib/start.js | 4 ++-- package.json | 3 +++ 5 files changed, 15 insertions(+), 9 deletions(-) diff --git a/data-browser-panes.js b/data-browser-panes.js index 13f834f..e0a654e 100644 --- a/data-browser-panes.js +++ b/data-browser-panes.js @@ -11,7 +11,8 @@ // // Single file, no build, no framework. Local pane contract (ES module): // export default { canHandle(node, h) -> boolean, render(node, h) -> htmlString } -// where h = { escape, prop, propAll, idOf, types, host, fmtDate, localName }. +// where h = { escape, prop, propAll, first, idOf, types, host, fmtDate, localName }. +// (prop may return an array for multi-valued JSON-LD; use h.first for single values.) document.head.insertAdjacentHTML('beforeend', `