projview 0.1.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,71 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <!-- we ship our own dark theme: tell the UA + Dark Reader to leave colors alone -->
7
+ <meta name="color-scheme" content="dark" />
8
+ <meta name="darkreader-lock" />
9
+ <title>projview</title>
10
+ <link rel="stylesheet" href="/vendor/hljs.css" />
11
+ <link rel="stylesheet" href="/style.css" />
12
+ <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📓</text></svg>" />
13
+ </head>
14
+ <body class="toc-empty">
15
+ <aside id="sidebar">
16
+ <header id="brand">
17
+ <span class="logo">📓</span>
18
+ <div class="brand-text">
19
+ <strong id="root-name">projview</strong>
20
+ <small id="live-status"><span class="dot"></span> live</small>
21
+ </div>
22
+ </header>
23
+ <button id="open-palette" class="open-palette" title="search (⌘K)">
24
+ <span class="op-ico">🔎</span><span class="op-text">search…</span><kbd>⌘K</kbd>
25
+ </button>
26
+ <div id="tree" class="tree"></div>
27
+ <footer id="side-foot"><span id="file-count">0</span> files · ctrl-c to stop</footer>
28
+ </aside>
29
+
30
+ <main id="viewer">
31
+ <nav id="breadcrumb" hidden></nav>
32
+ <div id="content" class="markdown-body">
33
+ <div class="empty-state">
34
+ <div class="empty-emoji">👈</div>
35
+ <p>pick a file from the tree to preview it</p>
36
+ </div>
37
+ </div>
38
+ </main>
39
+
40
+ <aside id="toc">
41
+ <header class="toc-head">
42
+ <span>on this page</span>
43
+ <button id="toc-collapse" title="collapse">⟩</button>
44
+ </header>
45
+ <div id="toc-list"></div>
46
+ </aside>
47
+ <button id="toc-reopen" title="show outline">☰</button>
48
+
49
+ <div id="palette" class="palette" hidden>
50
+ <div class="palette-box">
51
+ <input id="palette-input" type="text" placeholder="search files + contents…" autocomplete="off" spellcheck="false" />
52
+ <div id="palette-results" class="palette-results"></div>
53
+ <div class="palette-foot">
54
+ <span><kbd>↑</kbd><kbd>↓</kbd> navigate</span>
55
+ <span><kbd>↵</kbd> open</span>
56
+ <span><kbd>esc</kbd> close</span>
57
+ </div>
58
+ </div>
59
+ </div>
60
+
61
+ <div id="toast" class="toast" hidden></div>
62
+
63
+ <div id="zoom" class="zoom-overlay" hidden>
64
+ <div class="zoom-hint">scroll to zoom · drag to pan · esc to close</div>
65
+ <div id="zoom-inner" class="zoom-inner"></div>
66
+ </div>
67
+
68
+ <script src="/vendor/mermaid.min.js"></script>
69
+ <script type="module" src="/main.js"></script>
70
+ </body>
71
+ </html>
@@ -0,0 +1,31 @@
1
+ /* in-app link routing for clicks inside rendered content */
2
+
3
+ import { $ } from './dom.js';
4
+ import { state } from './store.js';
5
+ import { resolveRelative } from './paths.js';
6
+ import { openFile, scrollToFragment } from './viewer.js';
7
+ import { toast } from './toast.js';
8
+
9
+ const contentEl = $('content');
10
+
11
+ contentEl.addEventListener('click', (e) => {
12
+ const a = e.target.closest('a');
13
+ if (!a) return;
14
+ const href = a.getAttribute('href');
15
+ if (!href) return;
16
+
17
+ // external / protocol links -> new tab, keep projview alive
18
+ if (/^[a-z][a-z0-9+.-]*:/i.test(href) || href.startsWith('//')) {
19
+ e.preventDefault();
20
+ window.open(href, '_blank', 'noopener');
21
+ return;
22
+ }
23
+
24
+ e.preventDefault();
25
+ if (href.startsWith('#')) { scrollToFragment(href.slice(1)); return; } // same-doc anchor
26
+
27
+ const [pathPart, frag] = href.split('#');
28
+ const target = resolveRelative(state.currentPath, pathPart);
29
+ if (/\.(md|mmd)$/i.test(target)) openFile(target, frag);
30
+ else toast(`can't preview "${pathPart}" - only .md / .mmd for now`);
31
+ });
package/public/live.js ADDED
@@ -0,0 +1,31 @@
1
+ /* live reload via server-sent events */
2
+
3
+ import { $ } from './dom.js';
4
+ import { state } from './store.js';
5
+ import { loadTree } from './tree.js';
6
+ import { openFile } from './viewer.js';
7
+
8
+ const liveStatusEl = $('live-status');
9
+
10
+ export function connectEvents() {
11
+ const es = new EventSource('/api/events');
12
+ es.addEventListener('change', (e) => {
13
+ const { path } = JSON.parse(e.data);
14
+ if (path === state.currentPath) { pulse(); openFile(path); }
15
+ });
16
+ es.addEventListener('tree', async () => { pulse(); await loadTree(); });
17
+ es.onerror = () => setLive(false);
18
+ es.addEventListener('ready', () => setLive(true));
19
+ }
20
+
21
+ function setLive(on) {
22
+ liveStatusEl.classList.toggle('off', !on);
23
+ liveStatusEl.querySelector('.dot')?.classList.toggle('off', !on);
24
+ }
25
+
26
+ let pulseTimer;
27
+ function pulse() {
28
+ liveStatusEl.classList.add('pulse');
29
+ clearTimeout(pulseTimer);
30
+ pulseTimer = setTimeout(() => liveStatusEl.classList.remove('pulse'), 600);
31
+ }
package/public/main.js ADDED
@@ -0,0 +1,27 @@
1
+ /* projview client entry - wires the modules and boots */
2
+
3
+ import { state } from './store.js';
4
+ import { loadTree } from './tree.js';
5
+ import { openFile } from './viewer.js';
6
+ import { connectEvents } from './live.js';
7
+
8
+ // side-effect modules: each attaches its own listeners on import
9
+ import './links.js';
10
+ import './toc.js';
11
+ import './mermaid.js';
12
+ import './search.js';
13
+
14
+ // browser back / forward
15
+ window.addEventListener('hashchange', () => {
16
+ const p = decodeURIComponent(location.hash.slice(1));
17
+ if (p && p !== state.currentPath) openFile(p); // guard prevents self-trigger loop
18
+ });
19
+
20
+ async function boot() {
21
+ await loadTree();
22
+ const hash = decodeURIComponent(location.hash.slice(1));
23
+ if (hash) openFile(hash); // openFile -> revealInTree expands ancestors
24
+ connectEvents();
25
+ }
26
+
27
+ boot();
@@ -0,0 +1,77 @@
1
+ /* mermaid rendering (per-diagram, with error cards) + zoom overlay */
2
+
3
+ import { $, esc } from './dom.js';
4
+
5
+ const mermaid = window.mermaid; // UMD global, loaded via classic <script> before this module
6
+ const contentEl = $('content');
7
+ const zoomEl = $('zoom');
8
+ const zoomInnerEl = $('zoom-inner');
9
+
10
+ let mermaidSeq = 0; // unique id source for mermaid.render
11
+
12
+ mermaid.initialize({
13
+ startOnLoad: false,
14
+ theme: 'dark',
15
+ securityLevel: 'loose',
16
+ suppressErrorRendering: true // we render our own error card; don't inject the default bomb
17
+ });
18
+
19
+ // Render each .mermaid block individually so one bad diagram can't kill the rest.
20
+ export async function runMermaid() {
21
+ const nodes = [...contentEl.querySelectorAll('.mermaid')];
22
+ for (const node of nodes) {
23
+ const src = node.textContent.trim();
24
+ try {
25
+ const { svg, bindFunctions } = await mermaid.render(`pv-mmd-${++mermaidSeq}`, src);
26
+ node.innerHTML = svg;
27
+ bindFunctions?.(node);
28
+ node.classList.add('rendered');
29
+ node.title = 'click to zoom';
30
+ node.addEventListener('click', () => openZoom(node.querySelector('svg')?.outerHTML || ''));
31
+ } catch (err) {
32
+ node.classList.add('mermaid-error');
33
+ node.innerHTML =
34
+ `<div class="diagram-error"><strong>⚠ diagram failed to render</strong>` +
35
+ `<pre class="err-msg">${esc(String(err && err.message ? err.message : err))}</pre>` +
36
+ `<details><summary>source</summary><pre>${esc(src)}</pre></details></div>`;
37
+ }
38
+ }
39
+ }
40
+
41
+ /* ---------- zoom overlay ---------- */
42
+
43
+ let zoomState = { scale: 1, x: 0, y: 0 };
44
+ let zoomDrag = null;
45
+
46
+ function applyZoom() {
47
+ zoomInnerEl.style.transform =
48
+ `translate(${zoomState.x}px, ${zoomState.y}px) scale(${zoomState.scale})`;
49
+ }
50
+ function openZoom(svgHtml) {
51
+ if (!svgHtml) return;
52
+ zoomInnerEl.innerHTML = svgHtml;
53
+ zoomState = { scale: 1, x: 0, y: 0 };
54
+ applyZoom();
55
+ zoomEl.hidden = false;
56
+ }
57
+ function closeZoom() { zoomEl.hidden = true; zoomInnerEl.innerHTML = ''; }
58
+
59
+ zoomEl.addEventListener('click', (e) => { if (e.target === zoomEl) closeZoom(); });
60
+ zoomEl.addEventListener('wheel', (e) => {
61
+ e.preventDefault();
62
+ const factor = e.deltaY < 0 ? 1.12 : 1 / 1.12;
63
+ zoomState.scale = Math.min(8, Math.max(0.3, zoomState.scale * factor));
64
+ applyZoom();
65
+ }, { passive: false });
66
+ zoomInnerEl.addEventListener('pointerdown', (e) => {
67
+ zoomDrag = { x: e.clientX, y: e.clientY, ox: zoomState.x, oy: zoomState.y };
68
+ zoomInnerEl.setPointerCapture(e.pointerId);
69
+ });
70
+ zoomInnerEl.addEventListener('pointermove', (e) => {
71
+ if (!zoomDrag) return;
72
+ zoomState.x = zoomDrag.ox + (e.clientX - zoomDrag.x);
73
+ zoomState.y = zoomDrag.oy + (e.clientY - zoomDrag.y);
74
+ applyZoom();
75
+ });
76
+ zoomInnerEl.addEventListener('pointerup', () => { zoomDrag = null; });
77
+ window.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !zoomEl.hidden) closeZoom(); });
@@ -0,0 +1,14 @@
1
+ /* pure path helpers (no DOM) - importable by tests */
2
+
3
+ // Resolve an href relative to the directory of `fromFile` (both posix-style,
4
+ // repo-root-relative). Handles '.', '..', and bare names.
5
+ export function resolveRelative(fromFile, href) {
6
+ const baseDir = fromFile && fromFile.includes('/') ? fromFile.slice(0, fromFile.lastIndexOf('/')) : '';
7
+ const parts = baseDir ? baseDir.split('/') : [];
8
+ for (const seg of href.split('/')) {
9
+ if (seg === '' || seg === '.') continue;
10
+ if (seg === '..') parts.pop();
11
+ else parts.push(seg);
12
+ }
13
+ return parts.join('/');
14
+ }
@@ -0,0 +1,163 @@
1
+ /* projview cmd+K search palette - fuzzy filenames + full-text contents */
2
+
3
+ import { $, esc } from './dom.js';
4
+ import { SVG_FILE, SVG_DIAGRAM } from './icons.js';
5
+ import { openFile } from './viewer.js';
6
+
7
+ const paletteEl = $('palette');
8
+ const inputEl = $('palette-input');
9
+ const resultsEl = $('palette-results');
10
+ const openBtn = $('open-palette');
11
+
12
+ const icon = (name) => (name.endsWith('.mmd') ? SVG_DIAGRAM : SVG_FILE);
13
+
14
+ let selectable = []; // flat list of openable results
15
+ let selected = 0;
16
+ let debounce;
17
+ let seq = 0; // guards against out-of-order async results
18
+
19
+ /* ---------- open / close ---------- */
20
+
21
+ function openPalette() {
22
+ paletteEl.hidden = false;
23
+ inputEl.focus();
24
+ inputEl.select();
25
+ if (inputEl.value.trim()) run();
26
+ else showHint();
27
+ }
28
+ function closePalette() { paletteEl.hidden = true; }
29
+ function toggle() { paletteEl.hidden ? openPalette() : closePalette(); }
30
+
31
+ function showHint() {
32
+ selectable = [];
33
+ resultsEl.innerHTML = `<div class="palette-hint">type to search file names + contents</div>`;
34
+ }
35
+
36
+ /* ---------- query ---------- */
37
+
38
+ async function run() {
39
+ const q = inputEl.value.trim();
40
+ if (!q) { showHint(); return; }
41
+ const mySeq = ++seq;
42
+ let data;
43
+ try {
44
+ data = await (await fetch(`/api/search?q=${encodeURIComponent(q)}`)).json();
45
+ } catch {
46
+ if (mySeq === seq) resultsEl.innerHTML = `<div class="palette-hint">search unavailable</div>`;
47
+ return;
48
+ }
49
+ if (mySeq !== seq) return; // a newer query superseded this one
50
+ render(data, q);
51
+ }
52
+
53
+ function render(data, q) {
54
+ const terms = q.toLowerCase().split(/\s+/).filter(Boolean);
55
+ selectable = [];
56
+ let html = '';
57
+
58
+ if (data.files.length) {
59
+ html += `<div class="pg-head">files</div>`;
60
+ for (const f of data.files) {
61
+ const i = selectable.length;
62
+ selectable.push({ type: 'file', path: f.path });
63
+ html += row(i, icon(f.name), hl(f.name, terms), dimPath(f.path), '');
64
+ }
65
+ }
66
+
67
+ if (data.content.length) {
68
+ html += `<div class="pg-head">in contents</div>`;
69
+ for (const c of data.content) {
70
+ const i = selectable.length;
71
+ selectable.push({ type: 'content', path: c.path, term: q });
72
+ const snips = c.snippets
73
+ .map((s) => `<div class="snip"><span class="snip-ln">${s.line}</span>${hl(s.text, terms)}</div>`)
74
+ .join('');
75
+ html += row(i, icon(c.name), hl(c.name, terms), dimPath(c.path), snips, `${c.count}×`);
76
+ }
77
+ }
78
+
79
+ if (!selectable.length) {
80
+ resultsEl.innerHTML = `<div class="palette-hint">no matches for “${esc(q)}”</div>`;
81
+ return;
82
+ }
83
+ resultsEl.innerHTML = html;
84
+ selected = 0;
85
+ paint();
86
+ bindRows();
87
+ }
88
+
89
+ function row(i, ic, name, path, extra, badge = '') {
90
+ return (
91
+ `<div class="presult" data-i="${i}">` +
92
+ `<div class="pr-main"><span class="pr-ico">${ic}</span>` +
93
+ `<span class="pr-name">${name}</span>` +
94
+ (badge ? `<span class="pr-badge">${badge}</span>` : '') +
95
+ `<span class="pr-path">${path}</span></div>` +
96
+ extra +
97
+ `</div>`
98
+ );
99
+ }
100
+
101
+ function dimPath(p) {
102
+ const dir = p.includes('/') ? p.slice(0, p.lastIndexOf('/')) : '';
103
+ return esc(dir);
104
+ }
105
+
106
+ /* ---------- selection / activation ---------- */
107
+
108
+ function paint() {
109
+ const rows = resultsEl.querySelectorAll('.presult');
110
+ rows.forEach((r, i) => r.classList.toggle('sel', i === selected));
111
+ rows[selected]?.scrollIntoView({ block: 'nearest' });
112
+ }
113
+ function move(d) {
114
+ if (!selectable.length) return;
115
+ selected = (selected + d + selectable.length) % selectable.length;
116
+ paint();
117
+ }
118
+ function activate(item) {
119
+ if (!item) return;
120
+ closePalette();
121
+ if (item.type === 'content') openFile(item.path, null, item.term);
122
+ else openFile(item.path);
123
+ }
124
+ function bindRows() {
125
+ resultsEl.querySelectorAll('.presult').forEach((r) => {
126
+ const i = Number(r.dataset.i);
127
+ r.addEventListener('mousemove', () => { if (selected !== i) { selected = i; paint(); } });
128
+ r.addEventListener('click', () => activate(selectable[i]));
129
+ });
130
+ }
131
+
132
+ /* ---------- highlight helper ---------- */
133
+
134
+ function hl(text, terms) {
135
+ let out = esc(text);
136
+ for (const t of terms) {
137
+ if (!t) continue;
138
+ const re = new RegExp('(' + t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ')', 'ig');
139
+ out = out.replace(re, '<mark>$1</mark>');
140
+ }
141
+ return out;
142
+ }
143
+
144
+ /* ---------- wiring ---------- */
145
+
146
+ const isTyping = (el) =>
147
+ el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.isContentEditable);
148
+
149
+ window.addEventListener('keydown', (e) => {
150
+ if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { e.preventDefault(); toggle(); return; }
151
+ if (!paletteEl.hidden && e.key === 'Escape') { closePalette(); return; }
152
+ if (paletteEl.hidden && e.key === '/' && !isTyping(e.target)) { e.preventDefault(); openPalette(); }
153
+ });
154
+
155
+ inputEl.addEventListener('input', () => { clearTimeout(debounce); debounce = setTimeout(run, 120); });
156
+ inputEl.addEventListener('keydown', (e) => {
157
+ if (e.key === 'ArrowDown') { e.preventDefault(); move(1); }
158
+ else if (e.key === 'ArrowUp') { e.preventDefault(); move(-1); }
159
+ else if (e.key === 'Enter') { e.preventDefault(); activate(selectable[selected]); }
160
+ });
161
+
162
+ paletteEl.addEventListener('click', (e) => { if (e.target === paletteEl) closePalette(); });
163
+ openBtn.addEventListener('click', openPalette);
@@ -0,0 +1,8 @@
1
+ /* shared client state - one mutable object the modules read/write */
2
+
3
+ export const state = {
4
+ currentPath: null, // path of the file currently shown
5
+ expanded: new Set(), // dir paths the user has opened
6
+ treeData: [], // last tree payload from the server
7
+ rootName: 'projview' // indexed root's display name
8
+ };