Skip to content

Commit fb55086

Browse files
feat: folder-aware data browser variant (--browser folder) (#31)
Adds a second data-browser style alongside the existing JSON-LD pretty-printer. When the resource is an LDP container, renders a breadcrumb + table (NAME / TYPE / SIZE / MODIFIED) with folder-first sorting and friendly type labels (HTML, JSON-LD, Markdown, …). Falls back to the JSON-LD pretty-print for non-container resources. Opt-in via `--browser folder`; default stays `json` for backward compatibility. - data-browser-folder.js: new variant, single file, no framework - data-browser-folder.css: empty stub (matches data-browser.css pattern to keep JSS's mashlib .css fetch from logging a 404) - index.js: parse --browser flag, switch jsdelivr URL accordingly - package.json: ship the new files in the npm tarball
1 parent 2da6bd1 commit fb55086

4 files changed

Lines changed: 222 additions & 2 deletions

File tree

data-browser-folder.css

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
/* Intentionally empty. Styles live inside data-browser-folder.js so the
2+
browser is a single file. JSS auto-fetches this sibling URL
3+
(mashlib/index.js:307); shipping an empty 200 keeps the console
4+
warning-free until JSS makes the .css fetch optional. */

data-browser-folder.js

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
// jspod folder-aware data browser. JSS embeds the resource as JSON-LD
2+
// in #dataisland; this script:
3+
// 1. If the resource is an LDP container, render breadcrumb + table
4+
// (NAME / TYPE / SIZE / MODIFIED) with folder-first sorting and
5+
// friendly type labels.
6+
// 2. Otherwise, fall back to a pretty-printed JSON-LD dump with
7+
// clickable URIs (same shape as data-browser.js).
8+
//
9+
// Single file, no build, no framework. Container-parsing logic mirrors
10+
// solid-apps/chrome's Files pane (the proven version).
11+
12+
document.head.insertAdjacentHTML('beforeend', `<style>
13+
body{font:14px/1.55 system-ui,-apple-system,sans-serif;margin:0;color:#222;background:#f3eee5}
14+
.db{max-width:880px;margin:2em auto;padding:0 1em}
15+
.db-nav{display:flex;gap:1.2em;margin:0 0 .75em;padding:.6em 1em;background:#fff;border-radius:8px;box-shadow:0 1px 3px rgba(0,0,0,.05);font-size:.9em}
16+
.db-card{background:#fff;border-radius:12px;box-shadow:0 2px 12px rgba(0,0,0,.08);padding:1.5em 2em}
17+
.db-bc{display:flex;align-items:center;flex-wrap:wrap;gap:.25em;font-size:.95em;margin-bottom:.5em}
18+
.db-bc a{color:#7a4ed8;font-weight:500}
19+
.db-bc .sep{color:#888}
20+
.db-bc .here{color:#222;font-weight:600}
21+
.db-title{margin:.2em 0 .15em;font-size:1.6em;font-weight:500}
22+
.db-url{color:#888;font-size:.85em;font-family:"SFMono-Regular",Consolas,monospace;margin-bottom:.6em;word-break:break-all}
23+
.db-count{color:#666;font-size:.9em;margin:.6em 0 1em}
24+
.db-table{width:100%;border-collapse:collapse}
25+
.db-table th{text-align:left;padding:9px 8px;font-weight:600;font-size:11px;color:#666;text-transform:uppercase;letter-spacing:.06em;border-bottom:2px solid #7a4ed8}
26+
.db-table td{padding:11px 8px;border-bottom:1px solid #eee;font-size:14px}
27+
.db-table tr:hover td{background:#faf7f0}
28+
.db-name{display:flex;align-items:center;gap:.6em}
29+
.db-name a{color:#7a4ed8;text-decoration:none}
30+
.db-name a:hover{text-decoration:underline}
31+
.db-icon{font-size:18px;width:24px;text-align:center;flex-shrink:0}
32+
.db-meta{color:#666;white-space:nowrap;font-size:13px}
33+
.db-num{text-align:right}
34+
.db-empty{padding:2em;text-align:center;color:#888}
35+
.db-pre{padding:1.5em;background:#fff;border-radius:12px;box-shadow:0 2px 12px rgba(0,0,0,.08);overflow:auto;white-space:pre-wrap;word-break:break-all;margin:0}
36+
.db-pre a{color:#0a66c2;text-decoration:none}
37+
.db-pre a:hover{text-decoration:underline}
38+
</style>`);
39+
40+
(function () {
41+
const here = window.location.pathname;
42+
const up = (here === '/' || !here)
43+
? null
44+
: (s => { const i = s.lastIndexOf('/'); return i === 0 ? '/' : s.slice(0, i) + '/'; })(here.replace(/\/$/, ''));
45+
const navHTML = `<nav class="db-nav">${
46+
up ? `<a href="${up}" title="One level up">↑ Up</a>` : ''
47+
}<a href="/">Home</a><a href="/account.html">Account</a><a href="/docs.html">Docs</a></nav>`;
48+
49+
let doc = null;
50+
try { doc = JSON.parse(document.getElementById('dataisland').textContent); } catch (e) {}
51+
52+
const items = doc ? parseContainer(doc, window.location.href) : null;
53+
const isContainer = Array.isArray(items);
54+
55+
const target = document.getElementById('mashlib');
56+
if (isContainer) {
57+
target.innerHTML = `<div class="db">${navHTML}<div class="db-card">${renderFolder(items, here)}</div></div>`;
58+
} else {
59+
target.innerHTML = `<div class="db">${navHTML}${renderJson(doc)}</div>`;
60+
}
61+
62+
function renderFolder(items, path) {
63+
const folders = items.filter(i => i.type === 'container').length;
64+
const files = items.length - folders;
65+
items.sort((a, b) => {
66+
if (a.type !== b.type) return a.type === 'container' ? -1 : 1;
67+
return nameOf(a.url).localeCompare(nameOf(b.url));
68+
});
69+
70+
const segments = path.replace(/\/$/, '').split('/').filter(Boolean);
71+
const here = segments[segments.length - 1] || window.location.host;
72+
let acc = '/';
73+
const crumbs = [`<a href="/">${escape(window.location.host)}</a>`].concat(
74+
segments.map((s, i) => {
75+
acc += s + '/';
76+
return i === segments.length - 1
77+
? `<span class="sep">/</span><span class="here">${escape(decodeURIComponent(s))}</span>`
78+
: `<span class="sep">/</span><a href="${escape(acc)}">${escape(decodeURIComponent(s))}</a>`;
79+
})
80+
).join('');
81+
82+
const rows = items.length
83+
? `<table class="db-table">
84+
<thead><tr><th>NAME</th><th>TYPE</th><th class="db-num">SIZE</th><th>MODIFIED</th></tr></thead>
85+
<tbody>${items.map(rowHTML).join('')}</tbody>
86+
</table>`
87+
: `<div class="db-empty">Empty container</div>`;
88+
89+
return `
90+
<div class="db-bc">${crumbs}</div>
91+
<div class="db-title">${escape(decodeURIComponent(here))}</div>
92+
<div class="db-url">${escape(window.location.href)}</div>
93+
<div class="db-count">${folders} folder${folders === 1 ? '' : 's'}, ${files} file${files === 1 ? '' : 's'}</div>
94+
${rows}
95+
`;
96+
}
97+
98+
function rowHTML(it) {
99+
const name = nameOf(it.url);
100+
const tl = typeLabel(it);
101+
const icon = iconFor(it);
102+
return `<tr>
103+
<td class="db-name"><span class="db-icon">${icon}</span><a href="${escape(it.url)}">${escape(name)}${it.type === 'container' ? '/' : ''}</a></td>
104+
<td class="db-meta">${escape(tl)}</td>
105+
<td class="db-meta db-num">${it.size != null ? fmtBytes(it.size) : '—'}</td>
106+
<td class="db-meta">${it.modified ? fmtDate(it.modified) : '—'}</td>
107+
</tr>`;
108+
}
109+
110+
function renderJson(d) {
111+
if (!d) return `<div class="db-pre">No data.</div>`;
112+
const pretty = JSON.stringify(d, null, 2)
113+
.replace(/[<>&]/g, c => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]))
114+
.replace(/https?:\/\/[^"\s]+/g, '<a href="$&">$&</a>');
115+
return `<pre class="db-pre">${pretty}</pre>`;
116+
}
117+
118+
// ---- container parsing (JSON-LD with ldp:contains) ----
119+
120+
function parseContainer(doc, baseUrl) {
121+
const nodes = Array.isArray(doc?.['@graph']) ? doc['@graph'] : [doc];
122+
const container = nodes.find(n =>
123+
n['@id'] === baseUrl ||
124+
(typeof n['@id'] === 'string' && resolveUrl(n['@id'], baseUrl) === baseUrl)
125+
) || nodes[0];
126+
if (!container) return null;
127+
const raw = container['contains']
128+
?? container['ldp:contains']
129+
?? container['http://www.w3.org/ns/ldp#contains'];
130+
if (raw == null) return null;
131+
const arr = Array.isArray(raw) ? raw : [raw];
132+
return arr.map(c => {
133+
if (typeof c === 'string') return { url: resolveUrl(c, baseUrl), type: c.endsWith('/') ? 'container' : 'resource' };
134+
const url = resolveUrl(c['@id'], baseUrl);
135+
const types = [].concat(c['@type'] || []);
136+
const isC = types.some(t => /(?:#|\/)(?:BasicContainer|Container)$/.test(t)) || url.endsWith('/');
137+
return {
138+
url,
139+
type: isC ? 'container' : 'resource',
140+
size: c['stat:size'] ?? c['http://www.w3.org/ns/posix/stat#size'],
141+
modified: c['dcterms:modified'] ?? c['http://purl.org/dc/terms/modified'] ?? c['dc:modified'],
142+
};
143+
}).filter(x => x.url && x.url !== baseUrl);
144+
}
145+
146+
// ---- formatting helpers ----
147+
148+
function nameOf(url) {
149+
return decodeURIComponent(url.replace(/\/$/, '').split('/').pop() || url);
150+
}
151+
function typeLabel(it) {
152+
if (it.type === 'container') return 'Folder';
153+
const ext = nameOf(it.url).split('.').pop()?.toLowerCase() || '';
154+
const map = {
155+
html: 'HTML', htm: 'HTML',
156+
md: 'Markdown', markdown: 'Markdown',
157+
json: 'JSON', jsonld: 'JSON-LD',
158+
ttl: 'Turtle', n3: 'N3', rdf: 'RDF',
159+
css: 'CSS', js: 'JavaScript', ts: 'TypeScript',
160+
png: 'Image', jpg: 'Image', jpeg: 'Image', gif: 'Image', webp: 'Image', svg: 'SVG', avif: 'Image',
161+
mp3: 'Audio', ogg: 'Audio', wav: 'Audio', flac: 'Audio',
162+
mp4: 'Video', webm: 'Video', mov: 'Video',
163+
pdf: 'PDF',
164+
txt: 'Text', text: 'Text',
165+
acl: 'ACL',
166+
};
167+
return map[ext] || (ext ? ext.toUpperCase() : '—');
168+
}
169+
function iconFor(it) {
170+
if (it.type === 'container') return '📁';
171+
const ext = nameOf(it.url).split('.').pop()?.toLowerCase() || '';
172+
if (/^(html|htm)$/.test(ext)) return '🌐';
173+
if (/^(json|jsonld|ttl|n3|rdf)$/.test(ext)) return '🔗';
174+
if (/^(md|markdown|txt|text)$/.test(ext)) return '📝';
175+
if (/^(png|jpg|jpeg|gif|webp|svg|avif)$/.test(ext)) return '🖼';
176+
if (/^(mp3|ogg|wav|flac)$/.test(ext)) return '🎵';
177+
if (/^(mp4|webm|mov)$/.test(ext)) return '🎬';
178+
if (ext === 'pdf') return '📕';
179+
if (ext === 'acl') return '🔒';
180+
return '📄';
181+
}
182+
function fmtBytes(n) {
183+
if (n < 1024) return n + ' B';
184+
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB';
185+
if (n < 1024 * 1024 * 1024) return (n / 1024 / 1024).toFixed(1) + ' MB';
186+
return (n / 1024 / 1024 / 1024).toFixed(2) + ' GB';
187+
}
188+
function fmtDate(iso) {
189+
try {
190+
return new Date(iso).toLocaleString(undefined, {
191+
month: 'short', day: 'numeric', year: 'numeric',
192+
hour: 'numeric', minute: '2-digit'
193+
});
194+
} catch { return String(iso); }
195+
}
196+
function resolveUrl(href, base) { try { return new URL(href, base).href; } catch { return href; } }
197+
function escape(s) {
198+
return String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
199+
}
200+
})();

index.js

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,11 @@ const options = {
4242
multiuser: false,
4343
auth: true,
4444
open: true,
45-
git: true
45+
git: true,
46+
// 'json' (default) = minimal JSON-LD pretty-print. 'folder' = friendlier
47+
// container listing (table + breadcrumb) that falls back to JSON-LD when
48+
// the resource isn't a container.
49+
browser: 'json'
4650
};
4751

4852
// Auth-ladder rung-1 credentials. See issue #6: jspod ships a deliberately
@@ -119,6 +123,14 @@ for (let i = 0; i < args.length; i++) {
119123
options.open = false;
120124
} else if (arg === '--no-git') {
121125
options.git = false;
126+
} else if (arg === '--browser') {
127+
const raw = requireValue(arg, args[++i]);
128+
if (raw !== 'json' && raw !== 'folder') {
129+
console.error(chalk.red(`✗ Invalid --browser value: ${raw}`));
130+
console.error(chalk.dim('Must be one of: json, folder'));
131+
process.exit(1);
132+
}
133+
options.browser = raw;
122134
} else if (arg === '--version' || arg === '-v') {
123135
console.log(`jspod v${pkg.version}`);
124136
process.exit(0);
@@ -138,6 +150,7 @@ for (let i = 0; i < args.length; i++) {
138150
console.log(chalk.green(' --no-auth') + chalk.dim(' Disable authentication'));
139151
console.log(chalk.green(' --no-open') + chalk.dim(' Do not open the browser automatically'));
140152
console.log(chalk.green(' --no-git') + chalk.dim(' Disable JSS\'s git HTTP backend (it is on by default)'));
153+
console.log(chalk.green(' --browser ') + chalk.yellow('<json|folder>') + chalk.dim(' Data browser style (default: json)'));
141154
console.log(chalk.green(' -v, --version') + chalk.dim(' Show jspod version'));
142155
console.log(chalk.green(' --help') + chalk.dim(' Show this help message\n'));
143156
console.log(chalk.white('Examples:'));
@@ -347,7 +360,8 @@ console.log(chalk.yellow('⏳ Initializing server components...\n'));
347360
// of CSS, both shipped in this npm package). Version-pinned jsdelivr
348361
// URL is immutable per version, so a published jspod release will
349362
// always load the matching browser code.
350-
const dataBrowserUrl = `https://cdn.jsdelivr.net/npm/jspod@${pkg.version}/data-browser.js`;
363+
const browserFile = options.browser === 'folder' ? 'data-browser-folder.js' : 'data-browser.js';
364+
const dataBrowserUrl = `https://cdn.jsdelivr.net/npm/jspod@${pkg.version}/${browserFile}`;
351365

352366
// Build jss arguments
353367
const jssArgs = [

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
"index.js",
1212
"data-browser.js",
1313
"data-browser.css",
14+
"data-browser-folder.js",
15+
"data-browser-folder.css",
1416
"welcome.html",
1517
"signin.html",
1618
"signin.html.acl",

0 commit comments

Comments
 (0)