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.
@@ -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
- const headings = [...contentEl.querySelectorAll('h1, h2, h3')].filter((h) => h.id);
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
- $('toc-collapse').addEventListener('click', () => document.body.classList.add('toc-collapsed'));
61
- $('toc-reopen').addEventListener('click', () => document.body.classList.remove('toc-collapsed'));
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
@@ -1,7 +1,7 @@
1
1
  /* left-nav file tree: render, expand/collapse, reveal */
2
2
 
3
3
  import { $, esc, cssEsc } from './dom.js';
4
- import { SVG_FOLDER, fileIcon } from './icons.js';
4
+ import { SVG_FOLDER, fileIcon, fileKind } from './icons.js';
5
5
  import { state } from './store.js';
6
6
  import { openFile } from './viewer.js';
7
7
 
@@ -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();
@@ -70,7 +71,7 @@ function renderFileRow(node) {
70
71
  row.dataset.path = node.path;
71
72
  row.innerHTML =
72
73
  `<span class="twist"></span>` +
73
- `<span class="ico ico-${node.ext === '.mmd' ? 'mmd' : 'md'}">${fileIcon(node.ext)}</span>` +
74
+ `<span class="ico ico-${fileKind(node.name)}">${fileIcon(node.name)}</span>` +
74
75
  `<span class="label">${esc(node.name)}</span>`;
75
76
  row.addEventListener('click', () => openFile(node.path));
76
77
  return row;
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
- onFileRendered(p); // load + anchor + paint comments for this file
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"><div class="empty-emoji">🤷</div>` +
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
@@ -92,6 +92,166 @@ export function renderMermaid(content) {
92
92
  return `<div class="mermaid">${escapeHtml(content)}</div>\n`;
93
93
  }
94
94
 
95
+ // Re-indent JSON by walking its tokens rather than parse -> stringify, so the
96
+ // original literals survive untouched (big ints, 1.0, duplicate keys, key order).
97
+ // Assumes the input is already valid JSON (callers check with JSON.parse first).
98
+ export function formatJson(src, indent = 2) {
99
+ const pad = (n) => '\n' + ' '.repeat(n * indent);
100
+ let out = '';
101
+ let depth = 0;
102
+ for (let i = 0; i < src.length; i++) {
103
+ const c = src[i];
104
+ if (c === '"') {
105
+ let j = i + 1;
106
+ while (j < src.length && src[j] !== '"') j += src[j] === '\\' ? 2 : 1;
107
+ out += src.slice(i, j + 1);
108
+ i = j;
109
+ } else if (c === '{' || c === '[') {
110
+ // keep empty containers compact: {} / []
111
+ let j = i + 1;
112
+ while (/\s/.test(src[j] || '')) j++;
113
+ if (src[j] === (c === '{' ? '}' : ']')) { out += c + src[j]; i = j; continue; }
114
+ out += c + pad(++depth);
115
+ } else if (c === '}' || c === ']') {
116
+ out += pad(--depth) + c;
117
+ } else if (c === ',') {
118
+ out += ',' + pad(depth);
119
+ } else if (c === ':') {
120
+ out += ': ';
121
+ } else if (!/\s/.test(c)) {
122
+ out += c;
123
+ }
124
+ }
125
+ return out;
126
+ }
127
+
128
+ // Split highlight.js output into lines, closing + reopening any <span> that
129
+ // crosses a newline so each line is self-contained HTML.
130
+ function splitHighlightedLines(html) {
131
+ const lines = [];
132
+ const open = [];
133
+ let cur = '';
134
+ for (const m of html.matchAll(/<span[^>]*>|<\/span>|\n|[^<\n]+/g)) {
135
+ const tok = m[0];
136
+ if (tok === '\n') {
137
+ lines.push(cur + '</span>'.repeat(open.length));
138
+ cur = open.join('');
139
+ } else {
140
+ if (tok.startsWith('<span')) open.push(tok);
141
+ else if (tok === '</span>') open.pop();
142
+ cur += tok;
143
+ }
144
+ }
145
+ lines.push(cur + '</span>'.repeat(open.length));
146
+ return lines;
147
+ }
148
+
149
+ const leadingIndent = (line) => line.match(/^[ \t]*/)[0].replace(/\t/g, ' ').length;
150
+
151
+ // The file's indent unit: the most common step between a line and a deeper next
152
+ // line. A mode, not a gcd, so one oddly indented line can't skew it. Default 2.
153
+ function indentStep(indents) {
154
+ const counts = new Map();
155
+ let prev = 0;
156
+ for (const n of indents) {
157
+ if (n === -1) continue;
158
+ const d = n - prev;
159
+ if (d >= 2 && d <= 8) counts.set(d, (counts.get(d) || 0) + 1);
160
+ prev = n;
161
+ }
162
+ let best = 2, bestCount = 0;
163
+ for (const [d, c] of counts) if (c > bestCount || (c === bestCount && d < best)) { best = d; bestCount = c; }
164
+ return best;
165
+ }
166
+
167
+ // Big files skip syntax highlighting - hljs gets slow and nobody reads 2MB of JSON by colour.
168
+ const HIGHLIGHT_LIMIT = 2_000_000;
169
+ // Past this many lines only the head is rendered - the DOM, not the server, is the limit.
170
+ export const LINE_LIMIT = 50_000;
171
+
172
+ // A code view with line numbers and indent guides. Each line carries its
173
+ // indent depth (--i) so CSS can draw one guide per level (--step columns apart).
174
+ // lineAttrs: optional Map of line index -> extra attributes for that line's span.
175
+ function renderCodeView(code, lang, note = '', lineAttrs = null) {
176
+ let rawLines = code.replace(/\r\n?/g, '\n').replace(/\n$/, '').split('\n');
177
+ if (rawLines.length > LINE_LIMIT) {
178
+ note += notice(`showing the first ${LINE_LIMIT.toLocaleString('en')} of ${rawLines.length.toLocaleString('en')} lines`);
179
+ rawLines = rawLines.slice(0, LINE_LIMIT);
180
+ }
181
+ const raw = rawLines.join('\n');
182
+ const html = raw.length <= HIGHLIGHT_LIMIT && hljs.getLanguage(lang)
183
+ ? hljs.highlight(raw, { language: lang, ignoreIllegals: true }).value
184
+ : escapeHtml(raw);
185
+ const lines = splitHighlightedLines(html);
186
+
187
+ // blank lines borrow the shallower neighbour's indent so guides don't break
188
+ const indents = rawLines.map((l) => (l.trim() ? leadingIndent(l) : -1));
189
+ const step = indentStep(indents);
190
+ const next = new Array(indents.length);
191
+ for (let i = indents.length - 1, n = 0; i >= 0; i--) next[i] = n = indents[i] === -1 ? n : indents[i];
192
+ for (let i = 0, prev = 0; i < indents.length; i++) {
193
+ if (indents[i] === -1) indents[i] = Math.min(prev, next[i]);
194
+ else prev = indents[i];
195
+ }
196
+
197
+ const body = lines
198
+ .map((l, i) => `<span class="cl" style="--i:${indents[i]}"${lineAttrs?.get(i) || ''}><span class="lc">${l}</span></span>`)
199
+ .join('');
200
+ return `${note}<pre class="hljs code-view" data-lang="${lang}" style="--step:${step}"><code>${body}</code></pre>\n`;
201
+ }
202
+
203
+ const notice = (msg) => `<div class="data-notice">${escapeHtml(msg)}</div>\n`;
204
+
205
+ // The text a JSON file is displayed as: re-indented when valid, as written when
206
+ // not (e.g. JSONC). Shared with the search index so snippet line numbers match
207
+ // the viewer's. A leading BOM is dropped - JSON.parse rejects it.
208
+ export function jsonDisplayText(content) {
209
+ const src = content.replace(/^\uFEFF/, '');
210
+ if (!src.trim()) return { text: '' };
211
+ try {
212
+ JSON.parse(src);
213
+ } catch (err) {
214
+ return { text: src, error: err.message };
215
+ }
216
+ return { text: formatJson(src) };
217
+ }
218
+
219
+ // JSON: pretty-printed when valid; shown as-is (with a note) when it isn't.
220
+ export function renderJson(content) {
221
+ const { text, error } = jsonDisplayText(content);
222
+ return renderCodeView(text, 'json', error ? notice(`not strict JSON, shown as-is - ${error}`) : '');
223
+ }
224
+
225
+ // YAML: indentation is meaning, so it's shown exactly as written (highlighted,
226
+ // with guides) rather than re-serialised - which would also choke on Helm templates.
227
+ export function renderYaml(content) {
228
+ return renderCodeView(content, 'yaml');
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, '&quot;');
247
+ attrs.set(t.map[0], ` id="${t.attrGet('id')}" data-toc="${t.tag}" data-toc-label="${label}"`);
248
+ }
249
+ return attrs;
250
+ }
251
+
95
252
  export function renderFile(ext, content) {
96
- return ext === '.mmd' ? renderMermaid(content) : renderMarkdown(content);
253
+ if (ext === '.mmd') return renderMermaid(content);
254
+ if (ext === '.json') return renderJson(content);
255
+ if (ext === '.yml' || ext === '.yaml') return renderYaml(content);
256
+ return renderMarkdown(content);
97
257
  }
package/src/search.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import fsp from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { buildTree } from './walk.js';
4
+ import { jsonDisplayText } from './render.js';
4
5
  import { createStore } from './store.js';
5
6
 
6
7
  // In-memory index: relPath -> { path, name, content, lower }. Search always runs
@@ -16,8 +17,20 @@ function flattenFiles(nodes, acc = []) {
16
17
  return acc;
17
18
  }
18
19
 
19
- function addToIndex(rel, content) {
20
- index.set(rel, { path: rel, name: rel.split('/').pop(), content, lower: content.toLowerCase() });
20
+ function addToIndex(rel, source, mtime = null) {
21
+ // index JSON as displayed (re-indented) so snippet line numbers match the viewer
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;
21
34
  }
22
35
 
23
36
  // Build the index for `root`. With a persisted store, loads the cached entries
@@ -48,7 +61,7 @@ export async function buildIndex(root, store) {
48
61
  return { path: f.path, name: f.name, ext: f.ext, mtime, content };
49
62
  }));
50
63
 
51
- for (const e of entries) addToIndex(e.path, e.content);
64
+ for (const e of entries) addToIndex(e.path, e.content, e.mtime);
52
65
  await store.save(entries);
53
66
  return { store, total: entries.length, reindexed, warm: !!cached };
54
67
  }
@@ -59,7 +72,7 @@ export async function updateFile(rel) {
59
72
  const abs = path.join(theRoot, rel);
60
73
  try {
61
74
  const [content, stat] = await Promise.all([fsp.readFile(abs, 'utf8'), fsp.stat(abs)]);
62
- addToIndex(rel, content);
75
+ addToIndex(rel, content, stat.mtimeMs);
63
76
  return { path: rel, name: rel.split('/').pop(), ext: path.extname(rel).toLowerCase(), mtime: stat.mtimeMs, content };
64
77
  } catch { return null; }
65
78
  }
package/src/server.js CHANGED
@@ -5,10 +5,10 @@ import path from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import chokidar from 'chokidar';
7
7
 
8
- import { buildTree, 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';
8
+ import { buildTree, isHiddenPath, isIgnoredDir, isPreviewable } from './walk.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 = resolveInRoot(root, rel);
119
- if (!abs || !isPreviewable(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
- sendJson(res, { path: rel, ext: path.extname(abs).toLowerCase(), html: renderFile(path.extname(abs).toLowerCase(), content) });
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
- if (!isPreviewable(p)) return;
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', async (p) => {
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
  };