@astlw/peekmd 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/README.md ADDED
@@ -0,0 +1,102 @@
1
+ # PeekMD
2
+
3
+ A CLI tool that renders Markdown files in the browser and live-reloads on every file save.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install -g peekmd
9
+ ```
10
+
11
+ Requires Node.js 18+.
12
+
13
+ ## CLI Commands
14
+
15
+ ```bash
16
+ # Server
17
+ peekmd start # start server (daemon)
18
+ peekmd stop # stop server
19
+ peekmd status # check server status
20
+ PORT=3000 peekmd start # custom port (default: 4000)
21
+
22
+ # Folder management
23
+ peekmd link <dir> [dir2] ... # persist folders to config
24
+ peekmd unlink <dir> [dir2] ... # remove folders from config
25
+ peekmd list # show linked folders
26
+
27
+ # Ignore patterns (glob syntax)
28
+ peekmd ignore <pattern> ... # ignore folders/files by glob pattern
29
+ peekmd unignore <pattern> ... # remove an ignore pattern
30
+ peekmd ignored # show all active ignore patterns
31
+
32
+ # Structured Output - making it easy to integrate with scripts or other tools.
33
+ peekmd list --json # linked folders as JSON
34
+ peekmd ignored --json # ignore patterns as JSON
35
+ peekmd status --json # server status as JSON
36
+ peekmd search <query> # search files and content (JSON)
37
+ peekmd files # list all markdown files (JSON)
38
+
39
+ peekmd --help # print help
40
+ ```
41
+
42
+ ### Ignore Patterns
43
+
44
+ Ignore patterns let you exclude folders or files from the file tree, search,
45
+ and live-reload. Patterns are stored in `~/.peekmd.json` and apply globally.
46
+ All patterns use **glob syntax** (picomatch).
47
+
48
+ **Examples:**
49
+
50
+ ```bash
51
+ peekmd ignore "**/node_modules/**" # ignores all node_modules folders
52
+ peekmd ignore "**/dist/**" "**/build/**" # ignore multiple patterns
53
+ peekmd ignore "**/*.draft.md" # ignores all .draft.md files
54
+ peekmd ignore "docs/private/**" # ignores everything under docs/private
55
+ peekmd ignore "*.tmp" # ignores .tmp files at any depth
56
+ ```
57
+
58
+ **Default patterns** (applied automatically on first use):
59
+
60
+ - `**/node_modules/**`
61
+ - `**/.git/**`
62
+ - `**/dist/**`
63
+ - `**/build/**`
64
+ - `**/.next/**`
65
+ - `**/__pycache__/**`
66
+
67
+ ## Features
68
+
69
+ | Feature | Summary | Why it matters |
70
+ | ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------: | ------------------------------------------------------------------- |
71
+ | Live reload | Real-time file events over WebSocket; open file preview refreshes on `add`/`change`/`unlink`. | Instant feedback while editing files. |
72
+ | Folder groups (multi-folder) | Sidebar shows each linked folder as a separate group; when folder basenames collide a compact path is shown to disambiguate. | Keep multiple projects side-by-side without confusion. |
73
+ | File tree | Per-folder collapsible file tree with relative paths; click a file to render Markdown in the preview. | Fast navigation and focused previews. |
74
+ | Ignore patterns (glob) | Glob-only patterns (picomatch) applied to watcher, search, and tree. | Simple, consistent filtering across the app. Quote globs in shells. |
75
+ | Search | Lightweight full-text substring search across filenames and file contents. | Fast, low-overhead lookup for most small/medium projects. |
76
+ | Markdown + Mermaid | Renders GFM and client-side Mermaid diagrams inside `mermaid` fences. | Rich previews without server-side rendering. |
77
+ | Daemon & CLI | Start/stop/status plus `--json` machine-readable output for scripts and agents. | Integrates with workflows and automation. |
78
+ | Theming & UX | Light/dark themes, instant floating tooltips appended to `body`, compact path badges for long names. | Tooltips avoid clipping; UI scales to longer path. |
79
+
80
+ ### Limitations
81
+
82
+ - Ignore patterns are glob-only (picomatch). Regex/exact-name modes are not supported.
83
+ - Search is intentionally simple (substring per-line) for clarity and low resource usage.
84
+ - Initial scan of very large directories may take noticeable time; prefer linking smaller subfolders if needed.
85
+
86
+ ## Config
87
+
88
+ Linked folders and ignore patterns are stored in:
89
+
90
+ ```
91
+ ~/.peekmd.json
92
+
93
+ ```
94
+
95
+ ```json
96
+ {
97
+ "folders": ["/Users/you/docs", "/Users/you/notes"],
98
+ "ignore": ["**/node_modules/**", "**/dist/**", "**/*.draft.md"]
99
+ }
100
+ ```
101
+
102
+ Created automatically on first use. Can be edited manually.
package/bin/peekmd.js ADDED
@@ -0,0 +1,372 @@
1
+ #!/usr/bin/env node
2
+
3
+ "use strict";
4
+
5
+ const path = require("node:path");
6
+ const fs = require("node:fs");
7
+ const os = require("node:os");
8
+ const config = require("../src/config");
9
+ const { createServer } = require("../src/server");
10
+
11
+ const [cmd, ...rest] = process.argv.slice(2);
12
+ const PID_FILE = path.join(os.tmpdir(), "peekmd.pid");
13
+
14
+ /* ── PID management ────────────────────────────────────────────────── */
15
+
16
+ const pid = {
17
+ read() {
18
+ try {
19
+ return parseInt(fs.readFileSync(PID_FILE, "utf-8"), 10);
20
+ } catch {
21
+ return null;
22
+ }
23
+ },
24
+ write(id) {
25
+ fs.writeFileSync(PID_FILE, String(id));
26
+ },
27
+ clear() {
28
+ try {
29
+ fs.unlinkSync(PID_FILE);
30
+ } catch {}
31
+ },
32
+ alive(id) {
33
+ try {
34
+ process.kill(id, 0);
35
+ return true;
36
+ } catch {
37
+ return false;
38
+ }
39
+ },
40
+ };
41
+
42
+ /* ── Output ────────────────────────────────────────────────────────── */
43
+
44
+ const BANNER = `
45
+ ┌─────────────────────┐
46
+ │ │
47
+ │ p e e k M D │
48
+ │ ───────────── │
49
+ │ │ │ │ │ │ │ │
50
+ │ │
51
+ └─────────────────────┘
52
+ `;
53
+
54
+ function printTable(headers, rows) {
55
+ const widths = headers.map(
56
+ (h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)) + 2,
57
+ );
58
+ const line = " +" + widths.map((w) => "-".repeat(w)).join("+") + "+";
59
+
60
+ console.log(line);
61
+ console.log(
62
+ " |" +
63
+ headers.map((h, i) => (" " + h).padEnd(widths[i])).join("|") +
64
+ "|",
65
+ );
66
+ console.log(line);
67
+ for (const row of rows)
68
+ console.log(
69
+ " |" +
70
+ row.map((c, i) => (" " + c).padEnd(widths[i])).join("|") +
71
+ "|",
72
+ );
73
+ console.log(line);
74
+ }
75
+
76
+ const HELP = `
77
+ peekmd — Preview Markdown files in the browser
78
+
79
+ Server:
80
+ peekmd start [dir] [dir2] ... Start server (daemon)
81
+ peekmd stop Stop server
82
+ peekmd status Check if server is running
83
+
84
+ Folders:
85
+ peekmd link <dir> [dir2] ... Link folders for persistent tracking
86
+ peekmd unlink <dir> [dir2] ... Remove folders from tracking
87
+ peekmd list Show all linked folders
88
+
89
+ Ignore:
90
+ peekmd ignore <pattern> ... Ignore folders/files by glob pattern
91
+ peekmd unignore <pattern> ... Remove an ignore pattern
92
+ peekmd ignored Show all active ignore patterns
93
+
94
+ Glob Pattern Examples:
95
+ **/node_modules/** Ignore all node_modules folders
96
+ **/*.draft.md Ignore all .draft.md files
97
+ docs/private/** Ignore everything under docs/private
98
+ *.tmp Ignore .tmp files at any depth
99
+
100
+ AI / Machine-readable output:
101
+ peekmd list --json Linked folders as JSON
102
+ peekmd ignored --json Ignore patterns as JSON
103
+ peekmd status --json Server status as JSON
104
+ peekmd search <query> Search files and content (JSON)
105
+ peekmd files List all markdown files (JSON)
106
+
107
+ Other:
108
+ peekmd --help Show this help
109
+
110
+ Environment:
111
+ PORT=<number> Server port (default: 4000)
112
+
113
+ Config file: ~/.peekmd.json
114
+ `;
115
+
116
+ /* ── Helpers ───────────────────────────────────────────────────────── */
117
+
118
+ const resolveDirs = (dirs) => dirs.map((d) => path.resolve(d));
119
+ const getPort = () => Number(process.env.PORT) || 4000;
120
+
121
+ function openBrowser(url) {
122
+ const open =
123
+ process.platform === "darwin"
124
+ ? "open"
125
+ : process.platform === "win32"
126
+ ? "start"
127
+ : "xdg-open";
128
+ require("node:child_process").exec(`${open} ${url}`);
129
+ }
130
+
131
+ /* ── Commands ──────────────────────────────────────────────────────── */
132
+
133
+ switch (cmd) {
134
+ case "--help":
135
+ case "-h":
136
+ console.log(HELP);
137
+ break;
138
+
139
+ case "link": {
140
+ if (!rest.length) {
141
+ console.error(" Usage: peekmd link <dir> [dir2] ...");
142
+ process.exit(1);
143
+ }
144
+ for (const dir of rest) {
145
+ const r = config.linkFolder(dir);
146
+ if (r.error) console.error(" ✗ %s — %s", r.path, r.error);
147
+ else if (r.added) console.log(" ✓ Linked %s", r.path);
148
+ else console.log(" · Already linked %s", r.path);
149
+ }
150
+ break;
151
+ }
152
+
153
+ case "unlink": {
154
+ if (!rest.length) {
155
+ console.error(" Usage: peekmd unlink <dir> [dir2] ...");
156
+ process.exit(1);
157
+ }
158
+ for (const dir of rest) {
159
+ const r = config.unlinkFolder(dir);
160
+ console.log(
161
+ r.removed ? " ✓ Unlinked %s" : " · Not linked %s",
162
+ r.path,
163
+ );
164
+ }
165
+ break;
166
+ }
167
+
168
+ case "list": {
169
+ const folders = config.getFolders();
170
+ if (rest.includes("--json")) {
171
+ const names = config.getDisplayNames(folders);
172
+ console.log(
173
+ JSON.stringify(
174
+ folders.map((f, i) => ({ name: names[i], path: f })),
175
+ null,
176
+ 2,
177
+ ),
178
+ );
179
+ break;
180
+ }
181
+ if (!folders.length) {
182
+ console.log(" No folders linked. Run: peekmd link <dir>");
183
+ break;
184
+ }
185
+ const names = config.getDisplayNames(folders);
186
+ console.log("\n Linked folders:\n");
187
+ printTable(
188
+ ["Name", "Path"],
189
+ folders.map((f, i) => [names[i], f]),
190
+ );
191
+ console.log();
192
+ break;
193
+ }
194
+
195
+ case "start": {
196
+ const existing = pid.read();
197
+ if (existing && pid.alive(existing)) {
198
+ console.log(" Server already running (PID: %d)", existing);
199
+ process.exit(0);
200
+ }
201
+ pid.clear();
202
+
203
+ const child = require("node:child_process").spawn(
204
+ process.execPath,
205
+ [__filename, "__serve__", ...rest],
206
+ {
207
+ stdio: "ignore",
208
+ detached: true,
209
+ env: { ...process.env, PORT: String(getPort()) },
210
+ },
211
+ );
212
+ child.unref();
213
+ pid.write(child.pid);
214
+
215
+ const url = `http://localhost:${getPort()}`;
216
+ console.log(BANNER);
217
+ console.log(" %s\n", url);
218
+ console.log(" To stop: peekmd stop\n");
219
+ openBrowser(url);
220
+ break;
221
+ }
222
+
223
+ case "stop": {
224
+ const id = pid.read();
225
+ if (!id || !pid.alive(id)) {
226
+ console.log(
227
+ id
228
+ ? " PID %d is not running"
229
+ : " No background server running",
230
+ id,
231
+ );
232
+ pid.clear();
233
+ break;
234
+ }
235
+ try {
236
+ process.kill(id, "SIGTERM");
237
+ } catch (e) {
238
+ console.error(" ✗ Failed to stop: %s", e.message);
239
+ break;
240
+ }
241
+ pid.clear();
242
+ console.log(" ✓ Server stopped");
243
+ break;
244
+ }
245
+
246
+ case "status": {
247
+ const id = pid.read();
248
+ const running = !!(id && pid.alive(id));
249
+ if (!running && id) pid.clear();
250
+ if (rest.includes("--json")) {
251
+ console.log(
252
+ JSON.stringify({
253
+ running,
254
+ pid: running ? id : null,
255
+ port: Number(process.env.PORT) || 4000,
256
+ }),
257
+ );
258
+ break;
259
+ }
260
+ if (running) {
261
+ console.log(
262
+ " ✓ Server running (PID: %d, port: %s)",
263
+ id,
264
+ process.env.PORT || 4000,
265
+ );
266
+ } else {
267
+ console.log(" Server not running");
268
+ }
269
+ break;
270
+ }
271
+
272
+ case "ignore": {
273
+ if (!rest.length) {
274
+ console.error(" Usage: peekmd ignore <pattern> [pattern2] ...");
275
+ console.error(
276
+ " Examples: peekmd ignore '**/node_modules/**' '**/dist/**'",
277
+ );
278
+ console.error(" peekmd ignore '**/*.draft.md'");
279
+ process.exit(1);
280
+ }
281
+ for (const p of rest) {
282
+ const r = config.addIgnorePattern(p);
283
+ console.log(
284
+ r.added
285
+ ? " \u2713 Ignoring %s"
286
+ : " \u00b7 Already ignored %s",
287
+ p,
288
+ );
289
+ }
290
+ break;
291
+ }
292
+
293
+ case "unignore": {
294
+ if (!rest.length) {
295
+ console.error(" Usage: peekmd unignore <pattern> [pattern2] ...");
296
+ process.exit(1);
297
+ }
298
+ for (const p of rest) {
299
+ const r = config.removeIgnorePattern(p);
300
+ console.log(
301
+ r.removed
302
+ ? " \u2713 Removed %s"
303
+ : " \u00b7 Not in ignore list %s",
304
+ p,
305
+ );
306
+ }
307
+ break;
308
+ }
309
+
310
+ case "ignored": {
311
+ const patterns = config.getIgnorePatterns();
312
+ if (rest.includes("--json")) {
313
+ console.log(
314
+ JSON.stringify(
315
+ patterns.map((p) => ({ pattern: p })),
316
+ null,
317
+ 2,
318
+ ),
319
+ );
320
+ break;
321
+ }
322
+ if (!patterns.length) {
323
+ console.log(
324
+ " No ignore patterns set. Run: peekmd ignore <pattern>",
325
+ );
326
+ break;
327
+ }
328
+ console.log("\n Ignore patterns (glob):\n");
329
+ for (const p of patterns) {
330
+ console.log(" %s", p);
331
+ }
332
+ console.log();
333
+ break;
334
+ }
335
+
336
+ case "search": {
337
+ /* Search across linked folders — outputs JSON for AI agents */
338
+ const query = rest.filter((a) => !a.startsWith("--")).join(" ");
339
+ if (!query) {
340
+ console.error(" Usage: peekmd search <query>");
341
+ process.exit(1);
342
+ }
343
+ const { searchCli } = require("../src/server");
344
+ searchCli(config.getFolders(), query)
345
+ .then((r) => console.log(JSON.stringify(r, null, 2)))
346
+ .catch(() => process.exit(1));
347
+ break;
348
+ }
349
+
350
+ case "files": {
351
+ /* List all markdown files across linked folders — JSON output */
352
+ const { listFilesCli } = require("../src/server");
353
+ listFilesCli(config.getFolders())
354
+ .then((r) => console.log(JSON.stringify(r, null, 2)))
355
+ .catch(() => process.exit(1));
356
+ break;
357
+ }
358
+
359
+ case "__serve__": {
360
+ /* Internal: spawned by 'start' as a detached daemon */
361
+ const extra = rest.length ? resolveDirs(rest) : [];
362
+ const port = getPort();
363
+
364
+ createServer({ port, extraDirs: extra }).catch(() => process.exit(1));
365
+ break;
366
+ }
367
+
368
+ default: {
369
+ console.log(HELP);
370
+ break;
371
+ }
372
+ }
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@astlw/peekmd",
3
+ "version": "1.0.0",
4
+ "description": "A CLI tool that renders Markdown files in the browser and live-reloads on every file save.",
5
+ "author": "AST-LW",
6
+ "license": "MIT",
7
+ "private": false,
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/AST-LW/peekmd.git"
11
+ },
12
+ "homepage": "https://github.com/AST-LW/peekmd#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/AST-LW/peekmd/issues"
15
+ },
16
+ "bin": {
17
+ "peekmd": "./bin/peekmd.js"
18
+ },
19
+ "files": [
20
+ "bin",
21
+ "src",
22
+ "README.md"
23
+ ],
24
+ "engines": {
25
+ "node": ">=18"
26
+ },
27
+ "keywords": [
28
+ "markdown",
29
+ "markdown-server",
30
+ "markdown-preview",
31
+ "mermaid",
32
+ "cli",
33
+ "viewer",
34
+ "documentation",
35
+ "live-reload"
36
+ ],
37
+ "dependencies": {
38
+ "chokidar": "^3.6.0",
39
+ "express": "^4.21.0",
40
+ "picomatch": "^4.0.3",
41
+ "ws": "^8.18.0"
42
+ }
43
+ }
package/src/config.js ADDED
@@ -0,0 +1,143 @@
1
+ "use strict";
2
+
3
+ const fs = require("node:fs");
4
+ const path = require("node:path");
5
+ const picomatch = require("picomatch");
6
+
7
+ const CONFIG_PATH = path.join(require("node:os").homedir(), ".peekmd.json");
8
+
9
+ function read() {
10
+ try {
11
+ return JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8"));
12
+ } catch {
13
+ return { folders: [] };
14
+ }
15
+ }
16
+
17
+ function write(data) {
18
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(data, null, 2) + "\n");
19
+ }
20
+
21
+ function getFolders() {
22
+ return read().folders;
23
+ }
24
+
25
+ function linkFolder(dir) {
26
+ const abs = path.resolve(dir);
27
+ if (!fs.existsSync(abs))
28
+ return { added: false, path: abs, error: "path does not exist" };
29
+ if (!fs.statSync(abs).isDirectory())
30
+ return { added: false, path: abs, error: "not a directory" };
31
+
32
+ const data = read();
33
+ if (data.folders.includes(abs)) return { added: false, path: abs };
34
+ data.folders.push(abs);
35
+ write(data);
36
+ return { added: true, path: abs };
37
+ }
38
+
39
+ function unlinkFolder(dir) {
40
+ const abs = path.resolve(dir);
41
+ const data = read();
42
+ const idx = data.folders.indexOf(abs);
43
+ if (idx === -1) return { removed: false, path: abs };
44
+ data.folders.splice(idx, 1);
45
+ write(data);
46
+ return { removed: true, path: abs };
47
+ }
48
+
49
+ function getDisplayNames(folders) {
50
+ const counts = {};
51
+ for (const f of folders) {
52
+ const b = path.basename(f);
53
+ counts[b] = (counts[b] || 0) + 1;
54
+ }
55
+ return folders.map((f) => {
56
+ const b = path.basename(f);
57
+ return counts[b] > 1 ? `${path.basename(path.dirname(f))}/${b}` : b;
58
+ });
59
+ }
60
+
61
+ /* ── Ignore patterns ───────────────────────────────────────────────── */
62
+
63
+ const DEFAULT_IGNORE = [
64
+ "**/node_modules/**",
65
+ "**/.git/**",
66
+ "**/dist/**",
67
+ "**/build/**",
68
+ "**/.next/**",
69
+ "**/__pycache__/**",
70
+ ];
71
+
72
+ function ensureDefaults() {
73
+ const data = read();
74
+ if (!data.ignore) {
75
+ data.ignore = [...DEFAULT_IGNORE];
76
+ write(data);
77
+ }
78
+ }
79
+
80
+ function getIgnorePatterns() {
81
+ return read().ignore || DEFAULT_IGNORE;
82
+ }
83
+
84
+ function addIgnorePattern(pattern) {
85
+ const data = read();
86
+ if (!data.ignore) data.ignore = [];
87
+ if (data.ignore.includes(pattern)) return { added: false, pattern };
88
+ data.ignore.push(pattern);
89
+ write(data);
90
+ clearGlobCache();
91
+ return { added: true, pattern };
92
+ }
93
+
94
+ function removeIgnorePattern(pattern) {
95
+ const data = read();
96
+ if (!data.ignore) return { removed: false, pattern };
97
+ const idx = data.ignore.indexOf(pattern);
98
+ if (idx === -1) return { removed: false, pattern };
99
+ data.ignore.splice(idx, 1);
100
+ write(data);
101
+ clearGlobCache();
102
+ return { removed: true, pattern };
103
+ }
104
+
105
+ /* pre-compiled glob cache so we don't re-parse every call */
106
+ const _globCache = new Map();
107
+
108
+ function clearGlobCache() {
109
+ _globCache.clear();
110
+ }
111
+
112
+ function getGlobMatcher(pattern) {
113
+ if (!_globCache.has(pattern)) {
114
+ _globCache.set(pattern, picomatch(pattern, { dot: true }));
115
+ }
116
+ return _globCache.get(pattern);
117
+ }
118
+
119
+ /**
120
+ * Test whether a relative path should be ignored.
121
+ * All patterns are treated as glob patterns (picomatch syntax).
122
+ */
123
+ function isIgnored(relPath) {
124
+ const patterns = getIgnorePatterns();
125
+ for (const p of patterns) {
126
+ if (getGlobMatcher(p)(relPath)) return true;
127
+ }
128
+ return false;
129
+ }
130
+
131
+ module.exports = {
132
+ getFolders,
133
+ linkFolder,
134
+ unlinkFolder,
135
+ getDisplayNames,
136
+ ensureDefaults,
137
+ getIgnorePatterns,
138
+ addIgnorePattern,
139
+ removeIgnorePattern,
140
+ isIgnored,
141
+ clearGlobCache,
142
+ CONFIG_PATH,
143
+ };