@wipcomputer/markdown-viewer 1.0.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/CLAUDE.md +138 -0
- package/LICENSE +21 -0
- package/README.md +136 -0
- package/bbedit-preview-template.html +559 -0
- package/demo/demo-image.png +0 -0
- package/demo/demo.md +165 -0
- package/images/01.png +0 -0
- package/images/02.png +0 -0
- package/images/03.png +0 -0
- package/markdown-viewer.html +1440 -0
- package/package.json +22 -0
- package/server.js +321 -0
package/package.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@wipcomputer/markdown-viewer",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Simple markdown viewer with live reload. Works in all browsers including Safari.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "server.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"mdview": "./server.js"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"start": "node server.js"
|
|
12
|
+
},
|
|
13
|
+
"keywords": ["markdown", "viewer", "live-reload", "safari", "ai-agents", "claude-code"],
|
|
14
|
+
"author": "Parker Todd Brooks",
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/wipcomputer/wip-markdown-viewer.git"
|
|
19
|
+
},
|
|
20
|
+
"homepage": "https://github.com/wipcomputer/wip-markdown-viewer#readme",
|
|
21
|
+
"bugs": "https://github.com/wipcomputer/wip-markdown-viewer/issues"
|
|
22
|
+
}
|
package/server.js
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// simple-web-markdown-viewer/server.js
|
|
3
|
+
// Multi-file markdown viewer with live reload. Works in all browsers.
|
|
4
|
+
//
|
|
5
|
+
// Usage:
|
|
6
|
+
// mdview Start server, open homepage
|
|
7
|
+
// mdview --port 8080 Use custom port
|
|
8
|
+
//
|
|
9
|
+
// Opens browser to http://127.0.0.1:3000/ — pick files, view with live reload.
|
|
10
|
+
|
|
11
|
+
import { createServer } from "node:http";
|
|
12
|
+
import { readFileSync, watchFile, unwatchFile, existsSync, statSync } from "node:fs";
|
|
13
|
+
import { resolve, basename, dirname, join, extname } from "node:path";
|
|
14
|
+
import { exec } from "node:child_process";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
|
|
17
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
18
|
+
const __dirname = dirname(__filename);
|
|
19
|
+
|
|
20
|
+
// ── Parse args ───────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
let port = 3000;
|
|
23
|
+
|
|
24
|
+
const args = process.argv.slice(2);
|
|
25
|
+
for (let i = 0; i < args.length; i++) {
|
|
26
|
+
if (args[i] === "--port" && args[i + 1]) {
|
|
27
|
+
port = parseInt(args[i + 1], 10);
|
|
28
|
+
i++;
|
|
29
|
+
} else if (args[i] === "--help" || args[i] === "-h") {
|
|
30
|
+
console.log(`mdview: live markdown viewer
|
|
31
|
+
|
|
32
|
+
Usage:
|
|
33
|
+
mdview Start server, open homepage
|
|
34
|
+
mdview --port 8080 Use custom port
|
|
35
|
+
|
|
36
|
+
Opens browser to http://127.0.0.1:PORT/ — pick files, view with live reload.
|
|
37
|
+
Works in all browsers (Safari, Chrome, Firefox).`);
|
|
38
|
+
process.exit(0);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ── Multi-file watcher ──────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
// Map<absolutePath, { clients: Set<res>, lastMtime: number }>
|
|
45
|
+
const watchers = new Map();
|
|
46
|
+
|
|
47
|
+
function startWatching(filePath) {
|
|
48
|
+
if (watchers.has(filePath)) return;
|
|
49
|
+
|
|
50
|
+
let lastMtime = 0;
|
|
51
|
+
try { lastMtime = statSync(filePath).mtimeMs; } catch {}
|
|
52
|
+
|
|
53
|
+
const entry = { clients: new Set(), lastMtime };
|
|
54
|
+
watchers.set(filePath, entry);
|
|
55
|
+
|
|
56
|
+
watchFile(filePath, { interval: 500 }, (curr) => {
|
|
57
|
+
if (curr.mtimeMs > entry.lastMtime) {
|
|
58
|
+
entry.lastMtime = curr.mtimeMs;
|
|
59
|
+
console.log(`File changed: ${basename(filePath)}`);
|
|
60
|
+
for (const client of entry.clients) {
|
|
61
|
+
try { client.write(`data: reload\n\n`); }
|
|
62
|
+
catch { entry.clients.delete(client); }
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function stopWatching(filePath) {
|
|
69
|
+
const entry = watchers.get(filePath);
|
|
70
|
+
if (entry && entry.clients.size === 0) {
|
|
71
|
+
unwatchFile(filePath);
|
|
72
|
+
watchers.delete(filePath);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function addClient(filePath, res) {
|
|
77
|
+
startWatching(filePath);
|
|
78
|
+
const entry = watchers.get(filePath);
|
|
79
|
+
entry.clients.add(res);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function removeClient(filePath, res) {
|
|
83
|
+
const entry = watchers.get(filePath);
|
|
84
|
+
if (entry) {
|
|
85
|
+
entry.clients.delete(res);
|
|
86
|
+
stopWatching(filePath);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ── Viewer HTML with SSE injection ──────────────────────────────────
|
|
91
|
+
|
|
92
|
+
function getViewerHtml(filePath) {
|
|
93
|
+
const viewerPath = join(__dirname, "markdown-viewer.html");
|
|
94
|
+
let html = readFileSync(viewerPath, "utf-8");
|
|
95
|
+
|
|
96
|
+
const script = `
|
|
97
|
+
<script>
|
|
98
|
+
// Server mode: auto-load, refresh from server, SSE live reload
|
|
99
|
+
(function() {
|
|
100
|
+
const filePath = ${JSON.stringify(filePath)};
|
|
101
|
+
const fileName = ${JSON.stringify(basename(filePath))};
|
|
102
|
+
const encodedPath = encodeURIComponent(filePath);
|
|
103
|
+
let showingFullPath = false;
|
|
104
|
+
|
|
105
|
+
async function serverLoad() {
|
|
106
|
+
const res = await fetch('/api/file?path=' + encodedPath);
|
|
107
|
+
if (!res.ok) throw new Error(res.statusText);
|
|
108
|
+
const text = await res.text();
|
|
109
|
+
const content = document.getElementById('markdown-content');
|
|
110
|
+
const scrollTop = content ? content.parentElement.scrollTop : 0;
|
|
111
|
+
document.getElementById('drop-zone').classList.add('hidden');
|
|
112
|
+
document.getElementById('viewer-container').style.display = 'flex';
|
|
113
|
+
const nameEl = document.getElementById('file-name');
|
|
114
|
+
nameEl.textContent = fileName;
|
|
115
|
+
nameEl.title = filePath;
|
|
116
|
+
nameEl.style.cursor = 'pointer';
|
|
117
|
+
nameEl.onclick = function() {
|
|
118
|
+
showingFullPath = !showingFullPath;
|
|
119
|
+
nameEl.textContent = showingFullPath ? filePath : fileName;
|
|
120
|
+
};
|
|
121
|
+
await displayMarkdown(text);
|
|
122
|
+
lastModified = Date.now();
|
|
123
|
+
updateLastModified();
|
|
124
|
+
if (content) content.parentElement.scrollTop = scrollTop;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
window.addEventListener('DOMContentLoaded', async function() {
|
|
128
|
+
await new Promise(r => setTimeout(r, 50));
|
|
129
|
+
try { await serverLoad(); setLiveStatus('live'); } catch (err) { console.error('Load failed:', err); }
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
window.refreshContent = async function() {
|
|
133
|
+
try {
|
|
134
|
+
await serverLoad();
|
|
135
|
+
showStatus('Refreshed', 1500);
|
|
136
|
+
} catch (err) {
|
|
137
|
+
showStatus('Error refreshing', 2000);
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
function connectSSE() {
|
|
142
|
+
const evtSource = new EventSource('/api/events?path=' + encodedPath);
|
|
143
|
+
evtSource.onmessage = async function(event) {
|
|
144
|
+
if (event.data === 'reload') {
|
|
145
|
+
try { await serverLoad(); showStatus('Auto-refreshed', 1500); }
|
|
146
|
+
catch (err) { console.error('Reload failed:', err); }
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
evtSource.onerror = function() {
|
|
150
|
+
evtSource.close();
|
|
151
|
+
setTimeout(connectSSE, 2000);
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
connectSSE();
|
|
155
|
+
})();
|
|
156
|
+
</script>`;
|
|
157
|
+
|
|
158
|
+
const lastIndex = html.lastIndexOf("</body>");
|
|
159
|
+
html = html.slice(0, lastIndex) + script + "\n" + html.slice(lastIndex);
|
|
160
|
+
return html;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ── Session viewer HTML (drag-and-drop, no live reload) ─────────────
|
|
164
|
+
|
|
165
|
+
function getSessionViewerHtml(fileName) {
|
|
166
|
+
const viewerPath = join(__dirname, "markdown-viewer.html");
|
|
167
|
+
let html = readFileSync(viewerPath, "utf-8");
|
|
168
|
+
|
|
169
|
+
const script = `
|
|
170
|
+
<script>
|
|
171
|
+
// Session mode: restore from sessionStorage, no live reload
|
|
172
|
+
(function() {
|
|
173
|
+
const fileName = ${JSON.stringify(fileName)};
|
|
174
|
+
|
|
175
|
+
window.addEventListener('DOMContentLoaded', async function() {
|
|
176
|
+
await new Promise(r => setTimeout(r, 50));
|
|
177
|
+
const content = sessionStorage.getItem('mdview-content');
|
|
178
|
+
if (!content) {
|
|
179
|
+
document.getElementById('file-name').textContent = 'File not found in session';
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
document.getElementById('drop-zone').classList.add('hidden');
|
|
183
|
+
document.getElementById('viewer-container').style.display = 'flex';
|
|
184
|
+
document.getElementById('file-name').textContent = fileName;
|
|
185
|
+
setLiveStatus('static');
|
|
186
|
+
await displayMarkdown(content);
|
|
187
|
+
lastModified = Date.now();
|
|
188
|
+
updateLastModified();
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
window.refreshContent = async function() {
|
|
192
|
+
const content = sessionStorage.getItem('mdview-content');
|
|
193
|
+
if (content) {
|
|
194
|
+
await displayMarkdown(content);
|
|
195
|
+
showStatus('Refreshed from session', 1500);
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
})();
|
|
199
|
+
</script>`;
|
|
200
|
+
|
|
201
|
+
const lastIndex = html.lastIndexOf("</body>");
|
|
202
|
+
html = html.slice(0, lastIndex) + script + "\n" + html.slice(lastIndex);
|
|
203
|
+
return html;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ── HTTP server ─────────────────────────────────────────────────────
|
|
207
|
+
|
|
208
|
+
const mimeTypes = {
|
|
209
|
+
".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
|
|
210
|
+
".gif": "image/gif", ".svg": "image/svg+xml", ".webp": "image/webp",
|
|
211
|
+
".ico": "image/x-icon", ".css": "text/css", ".js": "application/javascript",
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const server = createServer((req, res) => {
|
|
215
|
+
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
216
|
+
|
|
217
|
+
// Homepage — always the picker
|
|
218
|
+
if (url.pathname === "/" || url.pathname === "/index.html") {
|
|
219
|
+
const viewerPath = join(__dirname, "markdown-viewer.html");
|
|
220
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
221
|
+
res.end(readFileSync(viewerPath, "utf-8"));
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Viewer — file loaded with live reload (path) or sessionStorage (name)
|
|
226
|
+
if (url.pathname === "/view") {
|
|
227
|
+
const filePath = url.searchParams.get("path");
|
|
228
|
+
const fileName = url.searchParams.get("name");
|
|
229
|
+
|
|
230
|
+
if (filePath && existsSync(filePath)) {
|
|
231
|
+
// Server-backed viewer with live reload
|
|
232
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
233
|
+
res.end(getViewerHtml(filePath));
|
|
234
|
+
} else if (fileName) {
|
|
235
|
+
// sessionStorage-backed viewer (drag-and-drop, no live reload)
|
|
236
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
237
|
+
res.end(getSessionViewerHtml(fileName));
|
|
238
|
+
} else {
|
|
239
|
+
res.writeHead(302, { Location: "/" });
|
|
240
|
+
res.end();
|
|
241
|
+
}
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// API: read a specific file
|
|
246
|
+
if (url.pathname === "/api/file") {
|
|
247
|
+
const filePath = url.searchParams.get("path");
|
|
248
|
+
if (!filePath || !existsSync(filePath)) {
|
|
249
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
250
|
+
res.end("File not found");
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
try {
|
|
254
|
+
const content = readFileSync(filePath, "utf-8");
|
|
255
|
+
res.writeHead(200, { "Content-Type": "text/plain", "Cache-Control": "no-cache" });
|
|
256
|
+
res.end(content);
|
|
257
|
+
} catch (err) {
|
|
258
|
+
res.writeHead(500, { "Content-Type": "text/plain" });
|
|
259
|
+
res.end(`Error: ${err.message}`);
|
|
260
|
+
}
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// API: SSE events for a specific file
|
|
265
|
+
if (url.pathname === "/api/events") {
|
|
266
|
+
const filePath = url.searchParams.get("path");
|
|
267
|
+
if (!filePath || !existsSync(filePath)) {
|
|
268
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
269
|
+
res.end("File not found");
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
res.writeHead(200, {
|
|
273
|
+
"Content-Type": "text/event-stream",
|
|
274
|
+
"Cache-Control": "no-cache",
|
|
275
|
+
Connection: "keep-alive",
|
|
276
|
+
});
|
|
277
|
+
res.write(`data: connected\n\n`);
|
|
278
|
+
addClient(filePath, res);
|
|
279
|
+
req.on("close", () => { removeClient(filePath, res); });
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Serve static files relative to a viewed file's directory
|
|
284
|
+
const referer = req.headers.referer;
|
|
285
|
+
if (referer) {
|
|
286
|
+
try {
|
|
287
|
+
const refUrl = new URL(referer);
|
|
288
|
+
const refPath = refUrl.searchParams.get("path");
|
|
289
|
+
if (refPath) {
|
|
290
|
+
const fileDir = dirname(refPath);
|
|
291
|
+
const requestedPath = resolve(fileDir, url.pathname.slice(1));
|
|
292
|
+
if (requestedPath.startsWith(fileDir) && existsSync(requestedPath) && statSync(requestedPath).isFile()) {
|
|
293
|
+
const ext = extname(requestedPath).toLowerCase();
|
|
294
|
+
res.writeHead(200, { "Content-Type": mimeTypes[ext] || "application/octet-stream" });
|
|
295
|
+
res.end(readFileSync(requestedPath));
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
} catch {}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
303
|
+
res.end("Not found");
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
// ── Start ────────────────────────────────────────────────────────────
|
|
307
|
+
|
|
308
|
+
server.listen(port, "127.0.0.1", () => {
|
|
309
|
+
const url = `http://127.0.0.1:${port}`;
|
|
310
|
+
console.log(`mdview: ${url}`);
|
|
311
|
+
console.log(`Press Ctrl+C to stop.\n`);
|
|
312
|
+
|
|
313
|
+
const openCmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
314
|
+
exec(`${openCmd} "${url}"`);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
process.on("SIGINT", () => {
|
|
318
|
+
for (const [path] of watchers) { unwatchFile(path); }
|
|
319
|
+
server.close();
|
|
320
|
+
process.exit(0);
|
|
321
|
+
});
|