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,83 @@
1
+ /* the document viewer: fetch + render a file, anchors, search highlight */
2
+
3
+ import { $, esc, cssEsc } from './dom.js';
4
+ import { state } from './store.js';
5
+ import { revealInTree } from './tree.js';
6
+ import { renderBreadcrumb } from './breadcrumb.js';
7
+ import { runMermaid } from './mermaid.js';
8
+ import { buildToc } from './toc.js';
9
+ import { toast } from './toast.js';
10
+
11
+ const contentEl = $('content');
12
+ const viewerEl = $('viewer');
13
+
14
+ export async function openFile(p, fragment, highlight) {
15
+ state.currentPath = p;
16
+ location.hash = encodeURIComponent(p);
17
+ revealInTree(p);
18
+ renderBreadcrumb(p);
19
+
20
+ let res;
21
+ try {
22
+ res = await fetch(`/api/file?p=${encodeURIComponent(p)}`);
23
+ } catch {
24
+ res = null;
25
+ }
26
+ if (!res || !res.ok) {
27
+ showNotFound(p);
28
+ return;
29
+ }
30
+ const { html } = await res.json();
31
+ contentEl.innerHTML = html;
32
+ await runMermaid();
33
+ buildToc();
34
+ if (fragment) scrollToFragment(fragment);
35
+ else if (highlight) highlightInContent(highlight);
36
+ else viewerEl.scrollTop = 0;
37
+ }
38
+
39
+ export function scrollToFragment(frag) {
40
+ const el = document.getElementById(frag);
41
+ // instant, not smooth: smooth scrollIntoView is a no-op on this overflow container
42
+ if (el) el.scrollIntoView({ block: 'start' });
43
+ else toast(`no section "#${frag}" on this page`);
44
+ }
45
+
46
+ // Find the first occurrence of a search term in the rendered content, briefly
47
+ // highlight it, and scroll it into view. Used when opening from a content match.
48
+ function highlightInContent(term) {
49
+ if (!term) { viewerEl.scrollTop = 0; return; }
50
+ const t = term.trim().toLowerCase();
51
+ const candidates = [t, ...t.split(/\s+/)].filter(Boolean);
52
+ const walker = document.createTreeWalker(contentEl, NodeFilter.SHOW_TEXT);
53
+ let node;
54
+ while ((node = walker.nextNode())) {
55
+ const hay = node.nodeValue.toLowerCase();
56
+ for (const needle of candidates) {
57
+ const idx = hay.indexOf(needle);
58
+ if (idx === -1) continue;
59
+ const range = document.createRange();
60
+ range.setStart(node, idx);
61
+ range.setEnd(node, idx + needle.length);
62
+ const mark = document.createElement('mark');
63
+ mark.className = 'search-hit';
64
+ try { range.surroundContents(mark); } catch { viewerEl.scrollTop = 0; return; }
65
+ mark.scrollIntoView({ block: 'center' });
66
+ setTimeout(() => {
67
+ const parent = mark.parentNode;
68
+ if (parent) { parent.replaceChild(document.createTextNode(mark.textContent), mark); parent.normalize(); }
69
+ }, 3000);
70
+ return;
71
+ }
72
+ }
73
+ viewerEl.scrollTop = 0; // nothing matched in the rendered text
74
+ }
75
+
76
+ function showNotFound(p) {
77
+ contentEl.innerHTML =
78
+ `<div class="empty-state"><div class="empty-emoji">🤷</div>` +
79
+ `<p>can't find <code>${esc(p)}</code></p>` +
80
+ `<p class="muted">the link may be broken, or the file was renamed / removed</p></div>`;
81
+ buildToc();
82
+ toast(`not found: ${p}`);
83
+ }
package/src/render.js ADDED
@@ -0,0 +1,95 @@
1
+ import MarkdownIt from 'markdown-it';
2
+ import hljs from 'highlight.js';
3
+
4
+ function escapeHtml(str) {
5
+ return str
6
+ .replace(/&/g, '&amp;')
7
+ .replace(/</g, '&lt;')
8
+ .replace(/>/g, '&gt;');
9
+ }
10
+
11
+ const md = new MarkdownIt({
12
+ html: true,
13
+ linkify: true,
14
+ typographer: true,
15
+ highlight(code, lang) {
16
+ if (lang && hljs.getLanguage(lang)) {
17
+ try {
18
+ return `<pre class="hljs"><code>${hljs.highlight(code, { language: lang }).value}</code></pre>`;
19
+ } catch { /* fall through */ }
20
+ }
21
+ return `<pre class="hljs"><code>${escapeHtml(code)}</code></pre>`;
22
+ }
23
+ });
24
+
25
+ // Intercept ```mermaid fences -> emit a div the client lib renders.
26
+ // Everything else keeps the default (highlighted) fence rendering.
27
+ const defaultFence = md.renderer.rules.fence.bind(md.renderer.rules);
28
+ md.renderer.rules.fence = (tokens, idx, options, env, self) => {
29
+ const info = tokens[idx].info.trim().toLowerCase();
30
+ if (info === 'mermaid') {
31
+ return `<div class="mermaid">${escapeHtml(tokens[idx].content)}</div>\n`;
32
+ }
33
+ return defaultFence(tokens, idx, options, env, self);
34
+ };
35
+
36
+ // kebab-case slug from heading text, stripped of punctuation.
37
+ function slugify(text) {
38
+ return text
39
+ .trim()
40
+ .toLowerCase()
41
+ .replace(/[^\w\s-]/g, '')
42
+ .replace(/[\s_]+/g, '-')
43
+ .replace(/-+/g, '-')
44
+ .replace(/^-+|-+$/g, '') || 'section';
45
+ }
46
+
47
+ const EXPLICIT_ID = /\s*\{#([\w-]+)\}\s*$/;
48
+
49
+ // Give every heading a stable id so #fragment links can target it.
50
+ // Honors an explicit trailing {#id}, otherwise slugs the heading text.
51
+ md.core.ruler.push('heading_anchors', (state) => {
52
+ const used = Object.create(null);
53
+ const tokens = state.tokens;
54
+ for (let i = 0; i < tokens.length; i++) {
55
+ if (tokens[i].type !== 'heading_open') continue;
56
+ const inline = tokens[i + 1];
57
+ if (!inline || inline.type !== 'inline') continue;
58
+
59
+ let id;
60
+ const explicit = inline.content.match(EXPLICIT_ID);
61
+ if (explicit) {
62
+ id = explicit[1];
63
+ inline.content = inline.content.replace(EXPLICIT_ID, '');
64
+ // strip the visible {#id} from the rendered text token too
65
+ const kids = inline.children || [];
66
+ for (let j = kids.length - 1; j >= 0; j--) {
67
+ if (kids[j].type === 'text' && EXPLICIT_ID.test(kids[j].content)) {
68
+ kids[j].content = kids[j].content.replace(EXPLICIT_ID, '');
69
+ break;
70
+ }
71
+ }
72
+ } else {
73
+ id = slugify(inline.content);
74
+ }
75
+
76
+ let unique = id;
77
+ let n = 1;
78
+ while (used[unique]) unique = `${id}-${n++}`;
79
+ used[unique] = true;
80
+ tokens[i].attrSet('id', unique);
81
+ }
82
+ });
83
+
84
+ export function renderMarkdown(content) {
85
+ return md.render(content);
86
+ }
87
+
88
+ // A standalone .mmd file is one whole diagram.
89
+ export function renderMermaid(content) {
90
+ return `<div class="mermaid">${escapeHtml(content)}</div>\n`;
91
+ }
92
+
93
+ export function renderFile(ext, content) {
94
+ return ext === '.mmd' ? renderMermaid(content) : renderMarkdown(content);
95
+ }
package/src/search.js ADDED
@@ -0,0 +1,101 @@
1
+ import fsp from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { buildTree } from './walk.js';
4
+
5
+ // In-memory index: relPath -> { path, name, content, lower }. Rebuilt per run,
6
+ // kept fresh by the watcher. Nothing is persisted to disk.
7
+ const index = new Map();
8
+ let theRoot = null;
9
+
10
+ function flattenFiles(nodes, acc = []) {
11
+ for (const n of nodes) {
12
+ if (n.type === 'dir') flattenFiles(n.children, acc);
13
+ else acc.push(n);
14
+ }
15
+ return acc;
16
+ }
17
+
18
+ function addToIndex(rel, content) {
19
+ index.set(rel, { path: rel, name: rel.split('/').pop(), content, lower: content.toLowerCase() });
20
+ }
21
+
22
+ // Read + index every previewable file under root.
23
+ export async function buildIndex(root) {
24
+ theRoot = root;
25
+ index.clear();
26
+ const files = flattenFiles(buildTree(root));
27
+ await Promise.all(files.map(async (f) => {
28
+ try { addToIndex(f.path, await fsp.readFile(path.join(root, f.path), 'utf8')); }
29
+ catch { /* unreadable - skip */ }
30
+ }));
31
+ }
32
+
33
+ export async function updateFile(rel) {
34
+ if (!theRoot) return;
35
+ try { addToIndex(rel, await fsp.readFile(path.join(theRoot, rel), 'utf8')); }
36
+ catch { /* gone between event and read - ignore */ }
37
+ }
38
+
39
+ export function removeFile(rel) { index.delete(rel); }
40
+
41
+ // Subsequence fuzzy match. null if `q` isn't a subsequence of `text`, else a
42
+ // score where contiguous runs and word-boundary hits are rewarded.
43
+ export function fuzzyScore(text, q) {
44
+ if (!q) return 0;
45
+ let ti = 0, score = 0, run = 0, prev = -2;
46
+ for (let qi = 0; qi < q.length; qi++) {
47
+ const c = q[qi];
48
+ let found = -1;
49
+ while (ti < text.length) { if (text[ti] === c) { found = ti; break; } ti++; }
50
+ if (found === -1) return null;
51
+ if (found === prev + 1) { run++; score += 4 + run; } else { run = 0; score += 1; }
52
+ if (found === 0 || /[\/\-_. ]/.test(text[found - 1])) score += 5; // word boundary
53
+ prev = found;
54
+ ti = found + 1;
55
+ }
56
+ if (text.includes(q)) score += 12; // exact substring
57
+ score += Math.max(0, 8 - Math.floor((text.length - q.length) / 4)); // prefer concise
58
+ return score;
59
+ }
60
+
61
+ export function search(q, { maxFiles = 8, maxContent = 10, snippetsPerFile = 3 } = {}) {
62
+ const query = (q || '').trim();
63
+ if (!query) return { query: '', files: [], content: [] };
64
+ const ql = query.toLowerCase();
65
+ const terms = ql.split(/\s+/).filter(Boolean);
66
+
67
+ // --- filename: fuzzy, ranked ---
68
+ const files = [];
69
+ for (const item of index.values()) {
70
+ const s = fuzzyScore(item.name.toLowerCase(), ql);
71
+ const sp = fuzzyScore(item.path.toLowerCase(), ql);
72
+ const score = Math.max(s ?? -1, sp ?? -1);
73
+ if (score >= 0) files.push({ path: item.path, name: item.name, score });
74
+ }
75
+ files.sort((a, b) => b.score - a.score);
76
+
77
+ // --- contents: every term must appear (AND), ranked by frequency ---
78
+ const content = [];
79
+ for (const item of index.values()) {
80
+ if (!terms.every((t) => item.lower.includes(t))) continue;
81
+ const lines = item.content.split(/\r?\n/);
82
+ const snippets = [];
83
+ let freq = 0;
84
+ for (let i = 0; i < lines.length; i++) {
85
+ const ll = lines[i].toLowerCase();
86
+ let lineHas = false;
87
+ for (const t of terms) {
88
+ let idx = 0;
89
+ while ((idx = ll.indexOf(t, idx)) !== -1) { freq++; idx += t.length; lineHas = true; }
90
+ }
91
+ if (lineHas && snippets.length < snippetsPerFile) {
92
+ snippets.push({ line: i + 1, text: lines[i].trim().slice(0, 160) });
93
+ }
94
+ }
95
+ const nameBonus = item.name.toLowerCase().includes(ql) ? 6 : 0;
96
+ content.push({ path: item.path, name: item.name, score: freq + nameBonus, count: freq, snippets });
97
+ }
98
+ content.sort((a, b) => b.score - a.score);
99
+
100
+ return { query, files: files.slice(0, maxFiles), content: content.slice(0, maxContent) };
101
+ }
package/src/server.js ADDED
@@ -0,0 +1,160 @@
1
+ import http from 'node:http';
2
+ import fs from 'node:fs';
3
+ import fsp from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import chokidar from 'chokidar';
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
+
12
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
13
+ const PUBLIC_DIR = path.join(__dirname, '..', 'public');
14
+ // vendored client libs (mermaid bundle, hljs theme) live in public/vendor/ and
15
+ // are served by the static handler below - no node_modules resolution needed.
16
+
17
+ const MIME = {
18
+ '.html': 'text/html; charset=utf-8',
19
+ '.js': 'text/javascript; charset=utf-8',
20
+ '.css': 'text/css; charset=utf-8',
21
+ '.json': 'application/json; charset=utf-8'
22
+ };
23
+
24
+ function sendFile(res, filePath, type) {
25
+ fs.readFile(filePath, (err, data) => {
26
+ if (err) {
27
+ res.writeHead(404).end('Not found');
28
+ return;
29
+ }
30
+ res.writeHead(200, { 'Content-Type': type || MIME[path.extname(filePath)] || 'application/octet-stream' });
31
+ res.end(data);
32
+ });
33
+ }
34
+
35
+ function sendJson(res, obj, status = 200) {
36
+ res.writeHead(status, { 'Content-Type': MIME['.json'] });
37
+ res.end(JSON.stringify(obj));
38
+ }
39
+
40
+ // Resolve a client-supplied relative path safely inside root.
41
+ function resolveInRoot(root, relPath) {
42
+ const abs = path.resolve(root, relPath);
43
+ const rel = path.relative(root, abs);
44
+ if (rel.startsWith('..') || path.isAbsolute(rel)) return null;
45
+ return abs;
46
+ }
47
+
48
+ export async function startServer(root, { port = 4321, host = '127.0.0.1' } = {}) {
49
+ const sseClients = new Set();
50
+
51
+ function broadcast(event, payload) {
52
+ const frame = `event: ${event}\ndata: ${JSON.stringify(payload || {})}\n\n`;
53
+ for (const res of sseClients) res.write(frame);
54
+ }
55
+
56
+ const server = http.createServer(async (req, res) => {
57
+ const url = new URL(req.url, 'http://localhost');
58
+ const pathname = url.pathname;
59
+
60
+ // --- live-reload stream ---
61
+ if (pathname === '/api/events') {
62
+ res.writeHead(200, {
63
+ 'Content-Type': 'text/event-stream',
64
+ 'Cache-Control': 'no-cache',
65
+ Connection: 'keep-alive'
66
+ });
67
+ res.write('event: ready\ndata: {}\n\n');
68
+ sseClients.add(res);
69
+ req.on('close', () => sseClients.delete(res));
70
+ return;
71
+ }
72
+
73
+ // --- file tree ---
74
+ if (pathname === '/api/tree') {
75
+ sendJson(res, { root: path.basename(root) || root, tree: buildTree(root) });
76
+ return;
77
+ }
78
+
79
+ // --- search (fuzzy filenames + full-text contents) ---
80
+ if (pathname === '/api/search') {
81
+ sendJson(res, search(url.searchParams.get('q') || ''));
82
+ return;
83
+ }
84
+
85
+ // --- rendered file contents ---
86
+ if (pathname === '/api/file') {
87
+ const rel = url.searchParams.get('p') || '';
88
+ const abs = resolveInRoot(root, rel);
89
+ if (!abs || !isPreviewable(abs)) {
90
+ sendJson(res, { error: 'invalid path' }, 400);
91
+ return;
92
+ }
93
+ try {
94
+ const content = await fsp.readFile(abs, 'utf8');
95
+ sendJson(res, { path: rel, ext: path.extname(abs).toLowerCase(), html: renderFile(path.extname(abs).toLowerCase(), content) });
96
+ } catch {
97
+ sendJson(res, { error: 'not found' }, 404);
98
+ }
99
+ return;
100
+ }
101
+
102
+ // --- static SPA assets (incl. /vendor/*) ---
103
+ const assetName = pathname === '/' ? 'index.html' : pathname.slice(1);
104
+ const assetPath = resolveInRoot(PUBLIC_DIR, assetName);
105
+ if (assetPath && fs.existsSync(assetPath) && fs.statSync(assetPath).isFile()) {
106
+ sendFile(res, assetPath);
107
+ return;
108
+ }
109
+
110
+ res.writeHead(404).end('Not found');
111
+ });
112
+
113
+ // --- file watcher -> live reload ---
114
+ const watcher = chokidar.watch(root, {
115
+ ignoreInitial: true,
116
+ ignored: (p) => p.split(path.sep).some(isIgnoredDir)
117
+ });
118
+ const rel = (p) => path.relative(root, p).split(path.sep).join('/');
119
+ watcher
120
+ .on('change', (p) => {
121
+ if (!isPreviewable(p)) return;
122
+ updateFile(rel(p));
123
+ broadcast('change', { path: rel(p) });
124
+ })
125
+ .on('add', (p) => { if (isPreviewable(p)) { updateFile(rel(p)); broadcast('tree'); } })
126
+ .on('unlink', (p) => { if (isPreviewable(p)) { removeFile(rel(p)); broadcast('tree'); } })
127
+ .on('addDir', () => broadcast('tree'))
128
+ .on('unlinkDir', () => broadcast('tree'));
129
+
130
+ await buildIndex(root); // in-memory content index for search
131
+ const listenPort = await listenWithRetry(server, port, host);
132
+
133
+ const close = () => new Promise((resolve) => {
134
+ watcher.close();
135
+ for (const res of sseClients) res.end();
136
+ server.close(() => resolve());
137
+ });
138
+
139
+ return { server, port: listenPort, close };
140
+ }
141
+
142
+ // Try `port`, then climb until one is free (give up after a sane range).
143
+ function listenWithRetry(server, port, host = '127.0.0.1', attempts = 20) {
144
+ return new Promise((resolve, reject) => {
145
+ let current = port;
146
+ let left = attempts;
147
+ const tryListen = () => {
148
+ server.once('error', (err) => {
149
+ if (err.code === 'EADDRINUSE' && left-- > 0) {
150
+ current += 1;
151
+ tryListen();
152
+ } else {
153
+ reject(err);
154
+ }
155
+ });
156
+ server.listen(current, host, () => resolve(current));
157
+ };
158
+ tryListen();
159
+ });
160
+ }
package/src/walk.js ADDED
@@ -0,0 +1,57 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ // Directories we never descend into - noise or huge.
5
+ const IGNORED_DIRS = new Set([
6
+ 'node_modules', 'dist', 'build', 'out', 'coverage',
7
+ '.cache', '.turbo', 'vendor', '.venv', '__pycache__'
8
+ ]);
9
+
10
+ export const PREVIEWABLE_EXTS = new Set(['.md', '.mmd']);
11
+
12
+ // True if a path segment should stop the walk (ignored name or any dot-dir).
13
+ export function isIgnoredDir(name) {
14
+ return IGNORED_DIRS.has(name) || name.startsWith('.');
15
+ }
16
+
17
+ export function isPreviewable(filePath) {
18
+ return PREVIEWABLE_EXTS.has(path.extname(filePath).toLowerCase());
19
+ }
20
+
21
+ // Dirs first, then files, each alphabetical (case-insensitive).
22
+ function byNameDirsFirst(a, b) {
23
+ if (a.isDirectory() !== b.isDirectory()) return a.isDirectory() ? -1 : 1;
24
+ return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' });
25
+ }
26
+
27
+ // Recursively build a tree of only the dirs that (transitively) contain
28
+ // previewable files. Returns the root's children array.
29
+ export function buildTree(root) {
30
+ function walk(absDir) {
31
+ let entries;
32
+ try {
33
+ entries = fs.readdirSync(absDir, { withFileTypes: true });
34
+ } catch {
35
+ return [];
36
+ }
37
+ const nodes = [];
38
+ for (const entry of entries.sort(byNameDirsFirst)) {
39
+ const abs = path.join(absDir, entry.name);
40
+ const rel = path.relative(root, abs).split(path.sep).join('/');
41
+ if (entry.isDirectory()) {
42
+ if (isIgnoredDir(entry.name)) continue;
43
+ const children = walk(abs);
44
+ if (children.length) nodes.push({ name: entry.name, path: rel, type: 'dir', children });
45
+ } else if (entry.isFile() && isPreviewable(entry.name)) {
46
+ nodes.push({ name: entry.name, path: rel, type: 'file', ext: path.extname(entry.name).toLowerCase() });
47
+ }
48
+ }
49
+ return nodes;
50
+ }
51
+ return walk(root);
52
+ }
53
+
54
+ // Flat count of file nodes in a tree (for the boot summary).
55
+ export function countFiles(nodes) {
56
+ return nodes.reduce((n, node) => n + (node.type === 'file' ? 1 : countFiles(node.children)), 0);
57
+ }