projview 1.0.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -4
- package/docs/README.md +3 -0
- package/docs/data/minified.json +1 -0
- package/docs/data/not-strict.jsonc.json +4 -0
- package/docs/data/values.yaml +36 -0
- package/docs/reference/api.md +32 -1
- package/package.json +1 -1
- package/public/comments.js +4 -4
- package/public/editor.js +306 -0
- package/public/history.js +162 -0
- package/public/icons.js +7 -1
- package/public/index.html +20 -10
- package/public/links.js +3 -2
- package/public/live.js +7 -1
- package/public/main.js +7 -1
- package/public/mermaid.js +17 -10
- package/public/search.js +1 -3
- package/public/store.js +3 -1
- package/public/style.css +350 -122
- package/public/theme.js +52 -0
- package/public/toc.js +13 -5
- package/public/tree.js +4 -3
- package/public/viewer.js +14 -3
- package/src/render.js +161 -1
- package/src/search.js +17 -4
- package/src/server.js +131 -19
- package/src/store.js +88 -3
- package/src/walk.js +11 -1
package/src/store.js
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
* entries - indexed files { path, name, ext, mtime, content }
|
|
6
6
|
* comments - notes on a file or a portion of it (see anchor model below)
|
|
7
7
|
* events - usage signals { ts, type, file?, query?, count? }
|
|
8
|
+
* versions - earlier text of a file { id, file, mtime, replacedAt, content },
|
|
9
|
+
* newest VERSION_LIMIT kept per file (older ones are pruned)
|
|
8
10
|
*
|
|
9
11
|
* Backends:
|
|
10
12
|
* memory - no disk persistence (ephemeral default; lives only this session)
|
|
@@ -69,6 +71,17 @@ const newEvent = (e) => ({
|
|
|
69
71
|
count: e.count ?? null
|
|
70
72
|
});
|
|
71
73
|
|
|
74
|
+
// How many earlier versions of each file are kept.
|
|
75
|
+
export const VERSION_LIMIT = 5;
|
|
76
|
+
const newVersion = (v) => ({
|
|
77
|
+
id: randomUUID(),
|
|
78
|
+
file: v.file,
|
|
79
|
+
mtime: v.mtime ?? null, // when this text was written (its file mtime)
|
|
80
|
+
replacedAt: v.replacedAt || Date.now(),
|
|
81
|
+
content: v.content
|
|
82
|
+
});
|
|
83
|
+
const versionMeta = ({ id, file, mtime, replacedAt, content }) => ({ id, file, mtime, replacedAt, size: Buffer.byteLength(content) });
|
|
84
|
+
|
|
72
85
|
// Aggregate raw events into something a human / agent can read at a glance.
|
|
73
86
|
function summarizeEvents(events) {
|
|
74
87
|
const byType = {};
|
|
@@ -137,12 +150,30 @@ function fileFor(root, persist, ext) {
|
|
|
137
150
|
|
|
138
151
|
// Holds comments + events in memory; calls flush() after each mutation so a
|
|
139
152
|
// disk-backed wrapper (superlite) can persist. memory passes a no-op flush.
|
|
140
|
-
|
|
153
|
+
// Versions get their own flush: they're large, and shouldn't be rewritten on
|
|
154
|
+
// every page view or search event.
|
|
155
|
+
function inMemoryDocs(flush = async () => {}, flushVersions = flush) {
|
|
141
156
|
const comments = new Map(); // id -> comment
|
|
142
157
|
const events = []; // append-only (stable ref - never reassigned)
|
|
158
|
+
const versions = []; // oldest first (stable ref - pruned in place)
|
|
143
159
|
return {
|
|
144
160
|
comments,
|
|
145
161
|
events,
|
|
162
|
+
versions,
|
|
163
|
+
async addVersion(v) {
|
|
164
|
+
const version = newVersion(v);
|
|
165
|
+
versions.push(version);
|
|
166
|
+
const mine = versions.filter((x) => x.file === version.file);
|
|
167
|
+
for (const old of mine.slice(0, Math.max(0, mine.length - VERSION_LIMIT))) versions.splice(versions.indexOf(old), 1);
|
|
168
|
+
await flushVersions();
|
|
169
|
+
return versionMeta(version);
|
|
170
|
+
},
|
|
171
|
+
async listVersions(file) {
|
|
172
|
+
return versions.filter((x) => x.file === file).reverse().map(versionMeta);
|
|
173
|
+
},
|
|
174
|
+
async getVersion(id) {
|
|
175
|
+
return versions.find((x) => x.id === id) || null;
|
|
176
|
+
},
|
|
146
177
|
async addComment(c) {
|
|
147
178
|
const comment = newComment(c);
|
|
148
179
|
comments.set(comment.id, comment);
|
|
@@ -189,6 +220,9 @@ function memoryStore() {
|
|
|
189
220
|
deleteComment: docs.deleteComment,
|
|
190
221
|
recordEvent: docs.recordEvent,
|
|
191
222
|
usageStats: docs.usageStats,
|
|
223
|
+
addVersion: docs.addVersion,
|
|
224
|
+
listVersions: docs.listVersions,
|
|
225
|
+
getVersion: docs.getVersion,
|
|
192
226
|
async close() {},
|
|
193
227
|
describe() { return 'in-memory (ephemeral - nothing persisted)'; }
|
|
194
228
|
};
|
|
@@ -196,6 +230,7 @@ function memoryStore() {
|
|
|
196
230
|
|
|
197
231
|
function superliteStore(root, persist) {
|
|
198
232
|
const file = fileFor(root, persist, 'json');
|
|
233
|
+
const versionsFile = file.replace(/\.json$/, '.versions.json'); // big, so written only when versions change
|
|
199
234
|
const entries = new Map(); // path -> entry
|
|
200
235
|
let loaded = false;
|
|
201
236
|
const flush = () => fsp.writeFile(file, JSON.stringify({
|
|
@@ -204,7 +239,8 @@ function superliteStore(root, persist) {
|
|
|
204
239
|
comments: [...docs.comments.values()],
|
|
205
240
|
events: docs.events
|
|
206
241
|
}));
|
|
207
|
-
const
|
|
242
|
+
const flushVersions = () => fsp.writeFile(versionsFile, JSON.stringify(docs.versions));
|
|
243
|
+
const docs = inMemoryDocs(() => (loaded ? flush() : Promise.resolve()), () => (loaded ? flushVersions() : Promise.resolve()));
|
|
208
244
|
const ensure = async () => {
|
|
209
245
|
if (loaded) return;
|
|
210
246
|
try {
|
|
@@ -213,11 +249,15 @@ function superliteStore(root, persist) {
|
|
|
213
249
|
for (const c of d.comments || []) docs.comments.set(c.id, c);
|
|
214
250
|
for (const ev of d.events || []) docs.events.push(ev);
|
|
215
251
|
} catch { /* fresh */ }
|
|
252
|
+
try {
|
|
253
|
+
for (const v of JSON.parse(await fsp.readFile(versionsFile, 'utf8'))) docs.versions.push(v);
|
|
254
|
+
} catch { /* no history yet */ }
|
|
216
255
|
loaded = true;
|
|
217
256
|
};
|
|
218
257
|
return {
|
|
219
258
|
kind: 'superlite',
|
|
220
259
|
file,
|
|
260
|
+
versionsFile,
|
|
221
261
|
async load() { await ensure(); return entries.size ? { entries: [...entries.values()] } : null; },
|
|
222
262
|
async save(es) { await ensure(); entries.clear(); for (const e of es) entries.set(e.path, e); await flush(); },
|
|
223
263
|
async update(entry) { await ensure(); entries.set(entry.path, entry); await flush(); },
|
|
@@ -228,8 +268,13 @@ function superliteStore(root, persist) {
|
|
|
228
268
|
async deleteComment(id) { await ensure(); return docs.deleteComment(id); },
|
|
229
269
|
async recordEvent(e) { await ensure(); return docs.recordEvent(e); },
|
|
230
270
|
async usageStats() { await ensure(); return docs.usageStats(); },
|
|
271
|
+
async addVersion(v) { await ensure(); return docs.addVersion(v); },
|
|
272
|
+
async listVersions(f) { await ensure(); return docs.listVersions(f); },
|
|
273
|
+
async getVersion(id) { await ensure(); return docs.getVersion(id); },
|
|
231
274
|
async close({ keep } = {}) {
|
|
232
|
-
if (!persist && !keep) {
|
|
275
|
+
if (!persist && !keep) {
|
|
276
|
+
for (const f of [file, versionsFile]) { try { await fsp.unlink(f); } catch { /* gone */ } }
|
|
277
|
+
}
|
|
233
278
|
},
|
|
234
279
|
describe() { return `superlite/json (${persist ? 'persisted' : 'ephemeral'}) ${file}`; }
|
|
235
280
|
};
|
|
@@ -249,6 +294,7 @@ function sqliteAt(file, { cleanup }) {
|
|
|
249
294
|
db.exec('CREATE TABLE IF NOT EXISTS files (path TEXT PRIMARY KEY, name TEXT, ext TEXT, mtime REAL, content TEXT)');
|
|
250
295
|
db.exec('CREATE TABLE IF NOT EXISTS comments (id TEXT PRIMARY KEY, file TEXT, anchor TEXT, body TEXT, author TEXT, createdAt REAL, resolved INTEGER)');
|
|
251
296
|
db.exec('CREATE TABLE IF NOT EXISTS events (id TEXT PRIMARY KEY, ts REAL, type TEXT, file TEXT, query TEXT, count INTEGER)');
|
|
297
|
+
db.exec('CREATE TABLE IF NOT EXISTS versions (id TEXT PRIMARY KEY, file TEXT, mtime REAL, replacedAt REAL, content TEXT)');
|
|
252
298
|
return db;
|
|
253
299
|
};
|
|
254
300
|
const writeComment = (d, c) => d
|
|
@@ -314,6 +360,23 @@ function sqliteAt(file, { cleanup }) {
|
|
|
314
360
|
const d = await open();
|
|
315
361
|
return summarizeEvents(d.prepare('SELECT type, file, query FROM events').all());
|
|
316
362
|
},
|
|
363
|
+
async addVersion(v) {
|
|
364
|
+
const d = await open();
|
|
365
|
+
const version = newVersion(v);
|
|
366
|
+
d.prepare('INSERT INTO versions (id, file, mtime, replacedAt, content) VALUES (?, ?, ?, ?, ?)')
|
|
367
|
+
.run(version.id, version.file, version.mtime, version.replacedAt, version.content);
|
|
368
|
+
d.prepare('DELETE FROM versions WHERE file = ? AND id NOT IN (SELECT id FROM versions WHERE file = ? ORDER BY replacedAt DESC LIMIT ?)')
|
|
369
|
+
.run(version.file, version.file, VERSION_LIMIT);
|
|
370
|
+
return versionMeta(version);
|
|
371
|
+
},
|
|
372
|
+
async listVersions(file) {
|
|
373
|
+
const d = await open();
|
|
374
|
+
return d.prepare('SELECT * FROM versions WHERE file = ? ORDER BY replacedAt DESC').all(file).map(versionMeta);
|
|
375
|
+
},
|
|
376
|
+
async getVersion(id) {
|
|
377
|
+
const d = await open();
|
|
378
|
+
return d.prepare('SELECT * FROM versions WHERE id = ?').get(id) || null;
|
|
379
|
+
},
|
|
317
380
|
async close({ keep } = {}) {
|
|
318
381
|
if (db) { db.close(); db = null; }
|
|
319
382
|
if (cleanup && !keep) { try { await fsp.unlink(file); } catch { /* gone */ } }
|
|
@@ -353,8 +416,11 @@ function postgresStore(url) {
|
|
|
353
416
|
await client.query('CREATE TABLE IF NOT EXISTS projview_files (path TEXT PRIMARY KEY, name TEXT, ext TEXT, mtime DOUBLE PRECISION, content TEXT)');
|
|
354
417
|
await client.query('CREATE TABLE IF NOT EXISTS projview_comments (id TEXT PRIMARY KEY, file TEXT, anchor TEXT, body TEXT, author TEXT, createdat DOUBLE PRECISION, resolved BOOLEAN)');
|
|
355
418
|
await client.query('CREATE TABLE IF NOT EXISTS projview_events (id TEXT PRIMARY KEY, ts DOUBLE PRECISION, type TEXT, file TEXT, query TEXT, count INTEGER)');
|
|
419
|
+
await client.query('CREATE TABLE IF NOT EXISTS projview_versions (id TEXT PRIMARY KEY, file TEXT, mtime DOUBLE PRECISION, replacedat DOUBLE PRECISION, content TEXT)');
|
|
356
420
|
return client;
|
|
357
421
|
};
|
|
422
|
+
// postgres folds unquoted identifiers to lower case
|
|
423
|
+
const toVersion = (r) => ({ id: r.id, file: r.file, mtime: r.mtime == null ? null : Number(r.mtime), replacedAt: Number(r.replacedat), content: r.content });
|
|
358
424
|
const writeComment = (c) => client.query(
|
|
359
425
|
'INSERT INTO projview_comments (id, file, anchor, body, author, createdat, resolved) VALUES ($1, $2, $3, $4, $5, $6, $7)' +
|
|
360
426
|
' ON CONFLICT (id) DO UPDATE SET file = $2, anchor = $3, body = $4, author = $5, createdat = $6, resolved = $7',
|
|
@@ -428,6 +494,25 @@ function postgresStore(url) {
|
|
|
428
494
|
const { rows } = await c.query('SELECT type, file, query FROM projview_events');
|
|
429
495
|
return summarizeEvents(rows);
|
|
430
496
|
},
|
|
497
|
+
async addVersion(v) {
|
|
498
|
+
const c = await open();
|
|
499
|
+
const version = newVersion(v);
|
|
500
|
+
await c.query('INSERT INTO projview_versions (id, file, mtime, replacedat, content) VALUES ($1, $2, $3, $4, $5)',
|
|
501
|
+
[version.id, version.file, version.mtime, version.replacedAt, version.content]);
|
|
502
|
+
await c.query('DELETE FROM projview_versions WHERE file = $1 AND id NOT IN (SELECT id FROM projview_versions WHERE file = $1 ORDER BY replacedat DESC LIMIT $2)',
|
|
503
|
+
[version.file, VERSION_LIMIT]);
|
|
504
|
+
return versionMeta(version);
|
|
505
|
+
},
|
|
506
|
+
async listVersions(file) {
|
|
507
|
+
const c = await open();
|
|
508
|
+
const { rows } = await c.query('SELECT * FROM projview_versions WHERE file = $1 ORDER BY replacedat DESC', [file]);
|
|
509
|
+
return rows.map((r) => versionMeta(toVersion(r)));
|
|
510
|
+
},
|
|
511
|
+
async getVersion(id) {
|
|
512
|
+
const c = await open();
|
|
513
|
+
const { rows } = await c.query('SELECT * FROM projview_versions WHERE id = $1', [id]);
|
|
514
|
+
return rows.length ? toVersion(rows[0]) : null;
|
|
515
|
+
},
|
|
431
516
|
async close() { if (client) { await client.end(); client = null; } }, // never drop the user's tables
|
|
432
517
|
describe() { return `custom/postgres ${redact(url)}`; }
|
|
433
518
|
};
|
package/src/walk.js
CHANGED
|
@@ -7,14 +7,24 @@ const IGNORED_DIRS = new Set([
|
|
|
7
7
|
'.cache', '.turbo', 'vendor', '.venv', '__pycache__'
|
|
8
8
|
]);
|
|
9
9
|
|
|
10
|
-
export const PREVIEWABLE_EXTS = new Set(['.md', '.mmd']);
|
|
10
|
+
export const PREVIEWABLE_EXTS = new Set(['.md', '.mmd', '.json', '.yml', '.yaml']);
|
|
11
|
+
|
|
12
|
+
// Previewable by extension but machine-generated noise - huge, never read.
|
|
13
|
+
const IGNORED_FILES = new Set(['package-lock.json', 'pnpm-lock.yaml']);
|
|
11
14
|
|
|
12
15
|
// True if a path segment should stop the walk (ignored name or any dot-dir).
|
|
13
16
|
export function isIgnoredDir(name) {
|
|
14
17
|
return IGNORED_DIRS.has(name) || name.startsWith('.');
|
|
15
18
|
}
|
|
16
19
|
|
|
20
|
+
// True if any directory in a root-relative path is one the walk skips - so a
|
|
21
|
+
// file hidden from the tree can't be fetched by typing its path either.
|
|
22
|
+
export function isHiddenPath(relPath) {
|
|
23
|
+
return relPath.split(/[\\/]/).slice(0, -1).some(isIgnoredDir);
|
|
24
|
+
}
|
|
25
|
+
|
|
17
26
|
export function isPreviewable(filePath) {
|
|
27
|
+
if (IGNORED_FILES.has(path.basename(filePath).toLowerCase())) return false;
|
|
18
28
|
return PREVIEWABLE_EXTS.has(path.extname(filePath).toLowerCase());
|
|
19
29
|
}
|
|
20
30
|
|