projview 1.1.1 → 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 +5 -3
- 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/index.html +20 -10
- package/public/live.js +7 -1
- package/public/main.js +7 -1
- package/public/mermaid.js +17 -10
- package/public/store.js +3 -1
- package/public/style.css +318 -125
- package/public/theme.js +52 -0
- package/public/toc.js +13 -5
- package/public/tree.js +2 -1
- package/public/viewer.js +14 -3
- package/src/render.js +24 -2
- package/src/search.js +15 -5
- package/src/server.js +130 -18
- package/src/store.js +88 -3
package/public/theme.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/* light / dark theme: the user's choice (system / light / dark) resolved to
|
|
2
|
+
* data-theme="light|dark" on <html>, which the CSS keys off. The inline script
|
|
3
|
+
* in index.html applies the same resolution before first paint. */
|
|
4
|
+
|
|
5
|
+
import { $ } from './dom.js';
|
|
6
|
+
|
|
7
|
+
const KEY = 'pv-theme'; // also read by the pre-paint script in index.html - keep in step
|
|
8
|
+
const PREFS = ['system', 'light', 'dark'];
|
|
9
|
+
const osLight = matchMedia('(prefers-color-scheme: light)');
|
|
10
|
+
const listeners = new Set();
|
|
11
|
+
const btn = $('theme-toggle');
|
|
12
|
+
|
|
13
|
+
const ICONS = {
|
|
14
|
+
system: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="12" rx="2"/><path d="M8 20h8M12 16v4"/></svg>',
|
|
15
|
+
light: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>',
|
|
16
|
+
dark: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>'
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function readPref() {
|
|
20
|
+
try { const p = localStorage.getItem(KEY); return PREFS.includes(p) ? p : 'system'; } catch { return 'system'; }
|
|
21
|
+
}
|
|
22
|
+
function writePref(p) {
|
|
23
|
+
try { localStorage.setItem(KEY, p); } catch { /* private window etc. - choice lasts this page only */ }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let pref = readPref();
|
|
27
|
+
|
|
28
|
+
export const currentTheme = () => document.documentElement.dataset.theme === 'light' ? 'light' : 'dark';
|
|
29
|
+
|
|
30
|
+
// Diagrams are baked SVG, so their owners re-render when the theme flips.
|
|
31
|
+
export function onThemeChange(fn) { listeners.add(fn); }
|
|
32
|
+
|
|
33
|
+
function apply() {
|
|
34
|
+
const before = currentTheme();
|
|
35
|
+
const theme = pref === 'system' ? (osLight.matches ? 'light' : 'dark') : pref;
|
|
36
|
+
document.documentElement.dataset.theme = theme;
|
|
37
|
+
btn.innerHTML = ICONS[pref];
|
|
38
|
+
btn.title = `theme: ${pref} (click to change)`;
|
|
39
|
+
btn.setAttribute('aria-label', `theme: ${pref}`);
|
|
40
|
+
if (theme !== before) for (const fn of listeners) fn(theme);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
btn.addEventListener('click', () => {
|
|
44
|
+
pref = PREFS[(PREFS.indexOf(pref) + 1) % PREFS.length];
|
|
45
|
+
writePref(pref);
|
|
46
|
+
apply();
|
|
47
|
+
});
|
|
48
|
+
osLight.addEventListener('change', () => { if (pref === 'system') apply(); });
|
|
49
|
+
// another projview tab changed the theme: follow it
|
|
50
|
+
window.addEventListener('storage', (e) => { if (e.key === KEY) { pref = readPref(); apply(); } });
|
|
51
|
+
|
|
52
|
+
apply();
|
package/public/toc.js
CHANGED
|
@@ -15,7 +15,8 @@ function setActive(current) {
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
export function buildToc() {
|
|
18
|
-
|
|
18
|
+
// rendered headings, or raw-view source lines tagged as headings (data-toc)
|
|
19
|
+
const headings = [...contentEl.querySelectorAll('h1, h2, h3, [data-toc]')].filter((h) => h.id);
|
|
19
20
|
tocEntries = [];
|
|
20
21
|
if (!headings.length) {
|
|
21
22
|
document.body.classList.add('toc-empty');
|
|
@@ -26,8 +27,8 @@ export function buildToc() {
|
|
|
26
27
|
const frag = document.createDocumentFragment();
|
|
27
28
|
for (const h of headings) {
|
|
28
29
|
const link = document.createElement('button');
|
|
29
|
-
link.className = `toc-item lvl-${h.tagName.toLowerCase()}`;
|
|
30
|
-
link.textContent = h.textContent;
|
|
30
|
+
link.className = `toc-item lvl-${h.dataset.toc || h.tagName.toLowerCase()}`;
|
|
31
|
+
link.textContent = h.dataset.tocLabel || h.textContent;
|
|
31
32
|
const entry = { id: h.id, el: h, link };
|
|
32
33
|
link.addEventListener('click', () => {
|
|
33
34
|
scrollToFragment(h.id);
|
|
@@ -57,5 +58,12 @@ viewerEl.addEventListener('scroll', () => {
|
|
|
57
58
|
spyRaf = requestAnimationFrame(() => { spyRaf = 0; updateScrollSpy(); });
|
|
58
59
|
});
|
|
59
60
|
|
|
60
|
-
|
|
61
|
-
$('toc-
|
|
61
|
+
// wide: collapse / reopen the side panel. narrow: open / close it as an overlay.
|
|
62
|
+
$('toc-collapse').addEventListener('click', () => {
|
|
63
|
+
document.body.classList.add('toc-collapsed');
|
|
64
|
+
document.body.classList.remove('toc-open');
|
|
65
|
+
});
|
|
66
|
+
$('toc-reopen').addEventListener('click', () => {
|
|
67
|
+
document.body.classList.remove('toc-collapsed');
|
|
68
|
+
document.body.classList.add('toc-open');
|
|
69
|
+
});
|
package/public/tree.js
CHANGED
|
@@ -12,10 +12,11 @@ let fileCount = 0;
|
|
|
12
12
|
|
|
13
13
|
export async function loadTree() {
|
|
14
14
|
const res = await fetch('/api/tree');
|
|
15
|
-
const { root, tree, version, writable } = await res.json();
|
|
15
|
+
const { root, tree, version, writable, persistent } = await res.json();
|
|
16
16
|
state.treeData = tree;
|
|
17
17
|
state.rootName = root;
|
|
18
18
|
state.writable = !!writable;
|
|
19
|
+
state.persistent = !!persistent;
|
|
19
20
|
document.title = `${root} · projview`;
|
|
20
21
|
if (version) versionEl.textContent = `version ${version}`;
|
|
21
22
|
renderTree();
|
package/public/viewer.js
CHANGED
|
@@ -8,19 +8,28 @@ import { runMermaid } from './mermaid.js';
|
|
|
8
8
|
import { buildToc } from './toc.js';
|
|
9
9
|
import { toast } from './toast.js';
|
|
10
10
|
import { onFileRendered } from './comments.js';
|
|
11
|
+
import { isEditing, confirmLeave, onFileShown, hasRawView } from './editor.js';
|
|
12
|
+
import { leaveHistory } from './history.js';
|
|
11
13
|
|
|
12
14
|
const contentEl = $('content');
|
|
13
15
|
const viewerEl = $('viewer');
|
|
14
16
|
|
|
15
17
|
export async function openFile(p, fragment, highlight) {
|
|
18
|
+
// leaving an editor with unsaved changes needs a yes; on no, put the URL back
|
|
19
|
+
if (isEditing() && !confirmLeave()) {
|
|
20
|
+
location.hash = encodeURIComponent(state.currentPath);
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
leaveHistory();
|
|
16
24
|
state.currentPath = p;
|
|
17
25
|
location.hash = encodeURIComponent(p);
|
|
18
26
|
revealInTree(p);
|
|
19
27
|
renderBreadcrumb(p);
|
|
20
28
|
|
|
29
|
+
const raw = state.view === 'raw' && hasRawView(p);
|
|
21
30
|
let res;
|
|
22
31
|
try {
|
|
23
|
-
res = await fetch(`/api/file?p=${encodeURIComponent(p)}`);
|
|
32
|
+
res = await fetch(`/api/file?p=${encodeURIComponent(p)}${raw ? '&view=raw' : ''}`);
|
|
24
33
|
} catch {
|
|
25
34
|
res = null;
|
|
26
35
|
}
|
|
@@ -32,7 +41,9 @@ export async function openFile(p, fragment, highlight) {
|
|
|
32
41
|
contentEl.innerHTML = html;
|
|
33
42
|
await runMermaid();
|
|
34
43
|
buildToc();
|
|
35
|
-
|
|
44
|
+
// comments quote the rendered text, so they live in the rendered view only
|
|
45
|
+
onFileRendered(raw ? null : p);
|
|
46
|
+
onFileShown(p); // rendered / raw / edit switcher
|
|
36
47
|
if (fragment) scrollToFragment(fragment);
|
|
37
48
|
else if (highlight) highlightInContent(highlight);
|
|
38
49
|
else viewerEl.scrollTop = 0;
|
|
@@ -79,7 +90,7 @@ function highlightInContent(term) {
|
|
|
79
90
|
|
|
80
91
|
function showNotFound(p) {
|
|
81
92
|
contentEl.innerHTML =
|
|
82
|
-
`<div class="empty-state"
|
|
93
|
+
`<div class="empty-state">` +
|
|
83
94
|
`<p>can't find <code>${esc(p)}</code></p>` +
|
|
84
95
|
`<p class="muted">the link may be broken, or the file was renamed / removed</p></div>`;
|
|
85
96
|
buildToc();
|
package/src/render.js
CHANGED
|
@@ -171,7 +171,8 @@ export const LINE_LIMIT = 50_000;
|
|
|
171
171
|
|
|
172
172
|
// A code view with line numbers and indent guides. Each line carries its
|
|
173
173
|
// indent depth (--i) so CSS can draw one guide per level (--step columns apart).
|
|
174
|
-
|
|
174
|
+
// lineAttrs: optional Map of line index -> extra attributes for that line's span.
|
|
175
|
+
function renderCodeView(code, lang, note = '', lineAttrs = null) {
|
|
175
176
|
let rawLines = code.replace(/\r\n?/g, '\n').replace(/\n$/, '').split('\n');
|
|
176
177
|
if (rawLines.length > LINE_LIMIT) {
|
|
177
178
|
note += notice(`showing the first ${LINE_LIMIT.toLocaleString('en')} of ${rawLines.length.toLocaleString('en')} lines`);
|
|
@@ -194,7 +195,7 @@ function renderCodeView(code, lang, note = '') {
|
|
|
194
195
|
}
|
|
195
196
|
|
|
196
197
|
const body = lines
|
|
197
|
-
.map((l, i) => `<span class="cl" style="--i:${indents[i]}"><span class="lc">${l}</span></span>`)
|
|
198
|
+
.map((l, i) => `<span class="cl" style="--i:${indents[i]}"${lineAttrs?.get(i) || ''}><span class="lc">${l}</span></span>`)
|
|
198
199
|
.join('');
|
|
199
200
|
return `${note}<pre class="hljs code-view" data-lang="${lang}" style="--step:${step}"><code>${body}</code></pre>\n`;
|
|
200
201
|
}
|
|
@@ -227,6 +228,27 @@ export function renderYaml(content) {
|
|
|
227
228
|
return renderCodeView(content, 'yaml');
|
|
228
229
|
}
|
|
229
230
|
|
|
231
|
+
// The "raw" view of a markdown / mermaid file: its source in the code view
|
|
232
|
+
// (highlighted, numbered, with indent guides), exactly as written.
|
|
233
|
+
const SOURCE_LANG = { '.md': 'markdown', '.mmd': 'plaintext' };
|
|
234
|
+
export function renderSource(ext, content) {
|
|
235
|
+
return renderCodeView(content, SOURCE_LANG[ext] || 'plaintext', '', ext === '.md' ? headingLines(content) : null);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// h1-h3 source lines of a markdown file, tagged with the same ids the rendered
|
|
239
|
+
// view gives its headings - so the outline and #fragment links work in raw too.
|
|
240
|
+
function headingLines(content) {
|
|
241
|
+
const attrs = new Map();
|
|
242
|
+
const tokens = md.parse(content, {}); // core rules run here, heading_anchors included
|
|
243
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
244
|
+
const t = tokens[i];
|
|
245
|
+
if (t.type !== 'heading_open' || !t.map || !['h1', 'h2', 'h3'].includes(t.tag)) continue;
|
|
246
|
+
const label = escapeHtml(tokens[i + 1]?.content || '').replace(/"/g, '"');
|
|
247
|
+
attrs.set(t.map[0], ` id="${t.attrGet('id')}" data-toc="${t.tag}" data-toc-label="${label}"`);
|
|
248
|
+
}
|
|
249
|
+
return attrs;
|
|
250
|
+
}
|
|
251
|
+
|
|
230
252
|
export function renderFile(ext, content) {
|
|
231
253
|
if (ext === '.mmd') return renderMermaid(content);
|
|
232
254
|
if (ext === '.json') return renderJson(content);
|
package/src/search.js
CHANGED
|
@@ -17,10 +17,20 @@ function flattenFiles(nodes, acc = []) {
|
|
|
17
17
|
return acc;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
function addToIndex(rel,
|
|
20
|
+
function addToIndex(rel, source, mtime = null) {
|
|
21
21
|
// index JSON as displayed (re-indented) so snippet line numbers match the viewer
|
|
22
|
-
|
|
23
|
-
index.set(rel, {
|
|
22
|
+
const content = rel.toLowerCase().endsWith('.json') ? jsonDisplayText(source).text : source;
|
|
23
|
+
index.set(rel, {
|
|
24
|
+
path: rel, name: rel.split('/').pop(), content, lower: content.toLowerCase(), mtime,
|
|
25
|
+
raw: content === source ? undefined : source // kept only when it differs, for versions
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// The file's text as last indexed - i.e. before the change being handled now.
|
|
30
|
+
// The server turns it into a version when the file changes. null if never indexed.
|
|
31
|
+
export function indexedSource(rel) {
|
|
32
|
+
const item = index.get(rel);
|
|
33
|
+
return item ? { content: item.raw ?? item.content, mtime: item.mtime } : null;
|
|
24
34
|
}
|
|
25
35
|
|
|
26
36
|
// Build the index for `root`. With a persisted store, loads the cached entries
|
|
@@ -51,7 +61,7 @@ export async function buildIndex(root, store) {
|
|
|
51
61
|
return { path: f.path, name: f.name, ext: f.ext, mtime, content };
|
|
52
62
|
}));
|
|
53
63
|
|
|
54
|
-
for (const e of entries) addToIndex(e.path, e.content);
|
|
64
|
+
for (const e of entries) addToIndex(e.path, e.content, e.mtime);
|
|
55
65
|
await store.save(entries);
|
|
56
66
|
return { store, total: entries.length, reindexed, warm: !!cached };
|
|
57
67
|
}
|
|
@@ -62,7 +72,7 @@ export async function updateFile(rel) {
|
|
|
62
72
|
const abs = path.join(theRoot, rel);
|
|
63
73
|
try {
|
|
64
74
|
const [content, stat] = await Promise.all([fsp.readFile(abs, 'utf8'), fsp.stat(abs)]);
|
|
65
|
-
addToIndex(rel, content);
|
|
75
|
+
addToIndex(rel, content, stat.mtimeMs);
|
|
66
76
|
return { path: rel, name: rel.split('/').pop(), ext: path.extname(rel).toLowerCase(), mtime: stat.mtimeMs, content };
|
|
67
77
|
} catch { return null; }
|
|
68
78
|
}
|
package/src/server.js
CHANGED
|
@@ -6,9 +6,9 @@ import { fileURLToPath } from 'node:url';
|
|
|
6
6
|
import chokidar from 'chokidar';
|
|
7
7
|
|
|
8
8
|
import { buildTree, isHiddenPath, isIgnoredDir, isPreviewable } from './walk.js';
|
|
9
|
-
import { renderFile } from './render.js';
|
|
10
|
-
import { buildIndex, updateFile, removeFile, search } from './search.js';
|
|
11
|
-
import { createStore, gitOrOsUser } from './store.js';
|
|
9
|
+
import { renderFile, renderSource } from './render.js';
|
|
10
|
+
import { buildIndex, updateFile, removeFile, search, indexedSource } from './search.js';
|
|
11
|
+
import { createStore, gitOrOsUser, isPersistent, VERSION_LIMIT } from './store.js';
|
|
12
12
|
|
|
13
13
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
14
14
|
const PUBLIC_DIR = path.join(__dirname, '..', 'public');
|
|
@@ -16,6 +16,9 @@ const VERSION = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.j
|
|
|
16
16
|
// vendored client libs (mermaid bundle, hljs theme) live in public/vendor/ and
|
|
17
17
|
// are served by the static handler below - no node_modules resolution needed.
|
|
18
18
|
|
|
19
|
+
// Earlier versions above this size aren't kept (generated bundles, data dumps).
|
|
20
|
+
const VERSION_MAX_BYTES = 5_000_000;
|
|
21
|
+
|
|
19
22
|
const MIME = {
|
|
20
23
|
'.html': 'text/html; charset=utf-8',
|
|
21
24
|
'.js': 'text/javascript; charset=utf-8',
|
|
@@ -53,6 +56,13 @@ export function isLoopbackHost(host) {
|
|
|
53
56
|
return ['127.0.0.1', '::1', 'localhost', ''].includes(host);
|
|
54
57
|
}
|
|
55
58
|
|
|
59
|
+
// The Host header of a request that may write. A DNS-rebinding page reaches a
|
|
60
|
+
// loopback port under its own domain, so the bind address alone isn't enough.
|
|
61
|
+
export function isLoopbackHostHeader(hostHeader) {
|
|
62
|
+
const name = (hostHeader || '').replace(/:\d+$/, '').replace(/^\[(.*)\]$/, '$1').toLowerCase();
|
|
63
|
+
return ['127.0.0.1', '::1', 'localhost'].includes(name) || name.endsWith('.localhost');
|
|
64
|
+
}
|
|
65
|
+
|
|
56
66
|
// Read + JSON-parse a request body, capped to guard against abuse.
|
|
57
67
|
// Resolves null on parse error or oversize (handler treats that as a 400).
|
|
58
68
|
function readBody(req, limit = 1_000_000) {
|
|
@@ -75,6 +85,20 @@ export async function startServer(root, { port = 4321, host = '127.0.0.1', store
|
|
|
75
85
|
const denyWrite = (res) => sendJson(res, { error: 'read-only: projview is exposed on a non-loopback host' }, 403);
|
|
76
86
|
const localAuthor = gitOrOsUser(); // default author for UI-created comments
|
|
77
87
|
|
|
88
|
+
// A client path that may be read (or, behind the write guards, saved): inside
|
|
89
|
+
// root, a previewable type, and not under a dir the tree hides. Else null.
|
|
90
|
+
// Checked on the real path too, so a symlink named notes.md can't reach a file
|
|
91
|
+
// outside the root (e.g. ~/.zshrc) - for reads, and above all for saves.
|
|
92
|
+
const realRoot = fs.realpathSync(root);
|
|
93
|
+
const editablePath = async (rel) => {
|
|
94
|
+
const abs = resolveInRoot(root, rel || '');
|
|
95
|
+
if (!abs || !isPreviewable(abs) || isHiddenPath(path.relative(root, abs))) return null;
|
|
96
|
+
let real;
|
|
97
|
+
try { real = await fsp.realpath(abs); } catch { return abs; } // missing: the caller answers 404
|
|
98
|
+
if (!resolveInRoot(realRoot, path.relative(realRoot, real))) return null;
|
|
99
|
+
return abs;
|
|
100
|
+
};
|
|
101
|
+
|
|
78
102
|
function broadcast(event, payload) {
|
|
79
103
|
const frame = `event: ${event}\ndata: ${JSON.stringify(payload || {})}\n\n`;
|
|
80
104
|
for (const res of sseClients) res.write(frame);
|
|
@@ -99,7 +123,7 @@ export async function startServer(root, { port = 4321, host = '127.0.0.1', store
|
|
|
99
123
|
|
|
100
124
|
// --- file tree ---
|
|
101
125
|
if (pathname === '/api/tree') {
|
|
102
|
-
sendJson(res, { root: path.basename(root) || root, tree: buildTree(root), version: VERSION, writable: allowWrites });
|
|
126
|
+
sendJson(res, { root: path.basename(root) || root, tree: buildTree(root), version: VERSION, writable: allowWrites, persistent: isPersistent(storeConfig) });
|
|
103
127
|
return;
|
|
104
128
|
}
|
|
105
129
|
|
|
@@ -115,21 +139,83 @@ export async function startServer(root, { port = 4321, host = '127.0.0.1', store
|
|
|
115
139
|
// --- rendered file contents ---
|
|
116
140
|
if (pathname === '/api/file') {
|
|
117
141
|
const rel = url.searchParams.get('p') || '';
|
|
118
|
-
const abs =
|
|
119
|
-
if (!abs
|
|
142
|
+
const abs = await editablePath(rel);
|
|
143
|
+
if (!abs) {
|
|
120
144
|
sendJson(res, { error: 'invalid path' }, 400);
|
|
121
145
|
return;
|
|
122
146
|
}
|
|
123
147
|
try {
|
|
124
148
|
const content = await fsp.readFile(abs, 'utf8');
|
|
125
149
|
try { await store.recordEvent({ type: 'view', file: rel }); } catch { /* usage is best-effort */ }
|
|
126
|
-
|
|
150
|
+
const ext = path.extname(abs).toLowerCase();
|
|
151
|
+
const raw = url.searchParams.get('view') === 'raw'; // md / mmd source instead of the render
|
|
152
|
+
sendJson(res, { path: rel, ext, html: raw ? renderSource(ext, content) : renderFile(ext, content) });
|
|
127
153
|
} catch {
|
|
128
154
|
sendJson(res, { error: 'not found' }, 404);
|
|
129
155
|
}
|
|
130
156
|
return;
|
|
131
157
|
}
|
|
132
158
|
|
|
159
|
+
// --- live preview: render unsaved editor text (never touches disk) ---
|
|
160
|
+
if (pathname === '/api/preview' && req.method === 'POST') {
|
|
161
|
+
const body = await readBody(req, 10_000_000);
|
|
162
|
+
if (!body || typeof body.path !== 'string' || typeof body.content !== 'string' || !isPreviewable(body.path)) {
|
|
163
|
+
sendJson(res, { error: 'path and content are required' }, 400);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
sendJson(res, { html: renderFile(path.extname(body.path).toLowerCase(), body.content) });
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// --- raw source: read (GET) + save (PUT) for the in-browser editor ---
|
|
171
|
+
if (pathname === '/api/source') {
|
|
172
|
+
if (req.method === 'PUT') {
|
|
173
|
+
if (!allowWrites) return denyWrite(res);
|
|
174
|
+
// a DNS-rebound page would arrive with its own Host; only a local name may write
|
|
175
|
+
if (!isLoopbackHostHeader(req.headers.host)) { sendJson(res, { error: 'writes need a localhost Host header' }, 403); return; }
|
|
176
|
+
const body = await readBody(req, 10_000_000);
|
|
177
|
+
if (!body || typeof body.path !== 'string' || typeof body.content !== 'string') { sendJson(res, { error: 'path and content are required' }, 400); return; }
|
|
178
|
+
const abs = await editablePath(body.path);
|
|
179
|
+
if (!abs) { sendJson(res, { error: 'invalid path' }, 400); return; }
|
|
180
|
+
let stat;
|
|
181
|
+
try { stat = await fsp.stat(abs); } catch { sendJson(res, { error: 'not found' }, 404); return; }
|
|
182
|
+
// optimistic concurrency: refuse to clobber a file that changed since it was opened
|
|
183
|
+
if (!body.force && body.mtime !== stat.mtimeMs) { sendJson(res, { error: 'changed on disk', mtime: stat.mtimeMs }, 409); return; }
|
|
184
|
+
try {
|
|
185
|
+
await fsp.writeFile(abs, body.content, 'utf8');
|
|
186
|
+
sendJson(res, { path: body.path, mtime: (await fsp.stat(abs)).mtimeMs });
|
|
187
|
+
} catch (err) {
|
|
188
|
+
sendJson(res, { error: `could not save: ${err.code || err.message}` }, 500);
|
|
189
|
+
}
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
const rel = url.searchParams.get('p') || '';
|
|
193
|
+
const abs = await editablePath(rel);
|
|
194
|
+
if (!abs) { sendJson(res, { error: 'invalid path' }, 400); return; }
|
|
195
|
+
try {
|
|
196
|
+
const [content, stat] = await Promise.all([fsp.readFile(abs, 'utf8'), fsp.stat(abs)]);
|
|
197
|
+
sendJson(res, { path: rel, content, mtime: stat.mtimeMs });
|
|
198
|
+
} catch {
|
|
199
|
+
sendJson(res, { error: 'not found' }, 404);
|
|
200
|
+
}
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// --- earlier versions of a file: list (newest first) + fetch one ---
|
|
205
|
+
// read-only everywhere; restoring goes through PUT /api/source and its guards
|
|
206
|
+
if (pathname === '/api/versions') {
|
|
207
|
+
const rel = url.searchParams.get('p') || '';
|
|
208
|
+
if (!(await editablePath(rel))) { sendJson(res, { error: 'invalid path' }, 400); return; }
|
|
209
|
+
sendJson(res, { path: rel, limit: VERSION_LIMIT, versions: await store.listVersions(rel) });
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
if (pathname.startsWith('/api/versions/')) {
|
|
213
|
+
const v = await store.getVersion(decodeURIComponent(pathname.slice('/api/versions/'.length)));
|
|
214
|
+
if (!v) { sendJson(res, { error: 'not found' }, 404); return; }
|
|
215
|
+
sendJson(res, v);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
|
|
133
219
|
// --- comments: list (GET) + create (POST) ---
|
|
134
220
|
if (pathname === '/api/comments') {
|
|
135
221
|
if (req.method === 'POST') {
|
|
@@ -191,30 +277,55 @@ export async function startServer(root, { port = 4321, host = '127.0.0.1', store
|
|
|
191
277
|
|
|
192
278
|
// --- file watcher -> live reload ---
|
|
193
279
|
const store = createStore(storeConfig, root);
|
|
194
|
-
const indexInfo = await buildIndex(root, store); // load-or-build the search index
|
|
195
280
|
|
|
196
281
|
// file watcher -> keep the in-memory index, the persisted store, AND the
|
|
197
|
-
// browser in sync as files change during a session
|
|
282
|
+
// browser in sync as files change during a session. Started before the index
|
|
283
|
+
// build so its initial scan (the slow part on big trees) overlaps it.
|
|
198
284
|
const watcher = chokidar.watch(root, {
|
|
199
285
|
ignoreInitial: true,
|
|
200
286
|
ignored: (p) => p.split(path.sep).some(isIgnoredDir)
|
|
201
287
|
});
|
|
288
|
+
// resolves once the scan is done; changes before then aren't seen. Not awaited
|
|
289
|
+
// here so startup stays fast - tests (and anything that must not miss an
|
|
290
|
+
// early change) wait on it via the returned `watching`.
|
|
291
|
+
const watching = new Promise((resolve) => watcher.once('ready', resolve));
|
|
292
|
+
const indexInfo = await buildIndex(root, store); // load-or-build the search index
|
|
202
293
|
const rel = (p) => path.relative(root, p).split(path.sep).join('/');
|
|
294
|
+
// keep text a change replaced (or a delete removed) as a version - any source
|
|
295
|
+
// counts: projview's editor, an IDE, git, agents
|
|
296
|
+
const keepVersion = async (file, before) => {
|
|
297
|
+
if (!before || Buffer.byteLength(before.content) > VERSION_MAX_BYTES) return;
|
|
298
|
+
try { await store.addVersion({ file, content: before.content, mtime: before.mtime }); } catch { /* versions are best-effort */ }
|
|
299
|
+
};
|
|
203
300
|
const touch = async (p, event) => {
|
|
204
|
-
|
|
301
|
+
// same rule as the API: previewable, not hidden, real path inside the root
|
|
302
|
+
// (a symlink to an outside file is neither indexed nor versioned)
|
|
303
|
+
if (!(await editablePath(rel(p)))) return;
|
|
304
|
+
const before = indexedSource(rel(p)); // the text this change replaces
|
|
205
305
|
const entry = await updateFile(rel(p)); // refresh in-memory index, get the entry
|
|
206
306
|
if (entry) await store.update(entry); // mirror the change into the store
|
|
307
|
+
if (entry && before && before.content !== entry.content) await keepVersion(rel(p), before);
|
|
207
308
|
broadcast(event, { path: rel(p) });
|
|
208
309
|
};
|
|
310
|
+
const unlink = async (p) => {
|
|
311
|
+
if (!isPreviewable(p)) return;
|
|
312
|
+
await keepVersion(rel(p), indexedSource(rel(p))); // a delete is a change too
|
|
313
|
+
removeFile(rel(p));
|
|
314
|
+
await store.remove(rel(p));
|
|
315
|
+
broadcast('tree');
|
|
316
|
+
};
|
|
317
|
+
// one file's events run in order: concurrent handlers would read the same
|
|
318
|
+
// "before" text, losing the in-between version of two quick changes
|
|
319
|
+
const queues = new Map();
|
|
320
|
+
const serial = (fn) => (p, ...args) => {
|
|
321
|
+
const next = (queues.get(p) || Promise.resolve()).then(() => fn(p, ...args)).catch(() => {});
|
|
322
|
+
queues.set(p, next);
|
|
323
|
+
next.then(() => { if (queues.get(p) === next) queues.delete(p); });
|
|
324
|
+
};
|
|
209
325
|
watcher
|
|
210
|
-
.on('change', (p) => touch(p, 'change'))
|
|
211
|
-
.on('add', (p) => touch(p, 'tree'))
|
|
212
|
-
.on('unlink',
|
|
213
|
-
if (!isPreviewable(p)) return;
|
|
214
|
-
removeFile(rel(p));
|
|
215
|
-
await store.remove(rel(p));
|
|
216
|
-
broadcast('tree');
|
|
217
|
-
})
|
|
326
|
+
.on('change', serial((p) => touch(p, 'change')))
|
|
327
|
+
.on('add', serial((p) => touch(p, 'tree')))
|
|
328
|
+
.on('unlink', serial(unlink))
|
|
218
329
|
.on('addDir', () => broadcast('tree'))
|
|
219
330
|
.on('unlinkDir', () => broadcast('tree'));
|
|
220
331
|
const listenPort = await listenWithRetry(server, port, host);
|
|
@@ -229,6 +340,7 @@ export async function startServer(root, { port = 4321, host = '127.0.0.1', store
|
|
|
229
340
|
server,
|
|
230
341
|
port: listenPort,
|
|
231
342
|
close,
|
|
343
|
+
watching,
|
|
232
344
|
writable: allowWrites,
|
|
233
345
|
storeInfo: { describe: store.describe(), warm: indexInfo.warm, reindexed: indexInfo.reindexed, total: indexInfo.total }
|
|
234
346
|
};
|
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
|
};
|