skimmd 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 +181 -0
- package/bin/skimmd.js +379 -0
- package/package.json +44 -0
- package/public/index.html +1107 -0
package/README.md
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
# skimmd
|
|
2
|
+
|
|
3
|
+
**One command. Zero install. Your markdown, beautifully rendered.**
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npx skimmd
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
That's it. Browser opens. Your markdown files look like GitHub. Edit them live.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## The Problem
|
|
14
|
+
|
|
15
|
+
You're writing a README. You want to see how it looks on GitHub. Your options:
|
|
16
|
+
|
|
17
|
+
1. Push to GitHub, refresh, fix typo, push again, refresh... (we've all been there)
|
|
18
|
+
2. Install a 200MB Electron app
|
|
19
|
+
3. Use VS Code's preview that looks nothing like GitHub
|
|
20
|
+
4. Use `grip` but hit GitHub API rate limits
|
|
21
|
+
|
|
22
|
+
**skimmd**: One command, works offline, looks like GitHub, updates as you type.
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## 30-Second Demo
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
# You have Node.js? You're done.
|
|
30
|
+
npx skimmd README.md
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Your browser opens. You see your README rendered beautifully. Now open that file in VS Code and make a change. Save. **The browser updates instantly.**
|
|
34
|
+
|
|
35
|
+
No reload button. No refresh. Just save and see.
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## Features
|
|
40
|
+
|
|
41
|
+
### Live Reload
|
|
42
|
+
Edit in VS Code, Vim, or any editor. Save. Browser updates. No plugins, no extensions, no configuration.
|
|
43
|
+
|
|
44
|
+
### Inline Editing
|
|
45
|
+
Click "Edit" in the browser. Make changes. Hit Ctrl+S. **Saves directly to your .md file.** Your editor picks up the change. Round-trip editing.
|
|
46
|
+
|
|
47
|
+
### GitHub-Flavored Markdown
|
|
48
|
+
- Tables render correctly
|
|
49
|
+
- Task lists work (`- [x] like this`)
|
|
50
|
+
- Syntax highlighting for 190+ languages
|
|
51
|
+
- Fenced code blocks
|
|
52
|
+
- Strikethrough, autolinks, all of it
|
|
53
|
+
|
|
54
|
+
### Dark Mode
|
|
55
|
+
Light, Dark, or Auto (follows your system). Because it's 2024 and we're not animals.
|
|
56
|
+
|
|
57
|
+
### Browse Entire Folders
|
|
58
|
+
```bash
|
|
59
|
+
npx skimmd ./docs
|
|
60
|
+
```
|
|
61
|
+
Sidebar shows all your markdown files. Filter them. Jump between them. Table of contents auto-generated from headings.
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
## Why Not Just Use...
|
|
66
|
+
|
|
67
|
+
| Tool | The Problem |
|
|
68
|
+
|------|-------------|
|
|
69
|
+
| **VS Code Preview** | Doesn't look like GitHub. No live reload in browser. |
|
|
70
|
+
| **grip** | Hits GitHub API. Rate limited. Requires auth for private repos. |
|
|
71
|
+
| **Typora** | $15. Electron app. Doesn't look like GitHub. |
|
|
72
|
+
| **MacDown** | macOS only. No live reload. Dated UI. |
|
|
73
|
+
| **Obsidian** | Overkill for previewing a README. Different styling. |
|
|
74
|
+
| **glow** | Terminal only. Can't share screen with non-terminal people. |
|
|
75
|
+
| **peekmd** | Requires Bun. No editing. No live reload. |
|
|
76
|
+
|
|
77
|
+
**skimmd**: Works with Node (you already have it). Zero config. Live reload. Inline editing. Looks like GitHub.
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
## Installation
|
|
82
|
+
|
|
83
|
+
**Option 1: No install (recommended)**
|
|
84
|
+
```bash
|
|
85
|
+
npx skimmd
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
**Option 2: Global install**
|
|
89
|
+
```bash
|
|
90
|
+
npm install -g skimmd
|
|
91
|
+
skimmd
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
---
|
|
95
|
+
|
|
96
|
+
## Usage
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
# Current directory
|
|
100
|
+
skimmd
|
|
101
|
+
|
|
102
|
+
# Specific file
|
|
103
|
+
skimmd README.md
|
|
104
|
+
|
|
105
|
+
# Documentation folder
|
|
106
|
+
skimmd ./docs
|
|
107
|
+
|
|
108
|
+
# Custom port
|
|
109
|
+
skimmd --port 8080
|
|
110
|
+
|
|
111
|
+
# Don't auto-open browser
|
|
112
|
+
skimmd --no-open
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
---
|
|
116
|
+
|
|
117
|
+
## Keyboard Shortcuts
|
|
118
|
+
|
|
119
|
+
| Shortcut | Action |
|
|
120
|
+
|----------|--------|
|
|
121
|
+
| `Ctrl/Cmd + S` | Save (when editing) |
|
|
122
|
+
| `Ctrl/Cmd + K` | Focus file filter |
|
|
123
|
+
|
|
124
|
+
---
|
|
125
|
+
|
|
126
|
+
## How It Works
|
|
127
|
+
|
|
128
|
+
1. Starts a local Express server
|
|
129
|
+
2. Watches your markdown files with chokidar
|
|
130
|
+
3. Renders with `marked` (GitHub-flavored)
|
|
131
|
+
4. Syntax highlighting with `highlight.js`
|
|
132
|
+
5. Sends file changes via Server-Sent Events
|
|
133
|
+
6. Browser updates without refresh
|
|
134
|
+
|
|
135
|
+
All local. Nothing leaves your machine. Works on a plane.
|
|
136
|
+
|
|
137
|
+
---
|
|
138
|
+
|
|
139
|
+
## FAQ
|
|
140
|
+
|
|
141
|
+
**Does it work offline?**
|
|
142
|
+
Yes. Everything runs locally. No API calls.
|
|
143
|
+
|
|
144
|
+
**Can I use it for a presentation?**
|
|
145
|
+
Yes. Dark mode + clean UI + live reload = great for live coding demos.
|
|
146
|
+
|
|
147
|
+
**Does it support Mermaid diagrams?**
|
|
148
|
+
Not yet. PRs welcome.
|
|
149
|
+
|
|
150
|
+
**What about MDX?**
|
|
151
|
+
Just markdown for now. Keep it simple.
|
|
152
|
+
|
|
153
|
+
**Windows support?**
|
|
154
|
+
Yes. Works anywhere Node.js runs.
|
|
155
|
+
|
|
156
|
+
---
|
|
157
|
+
|
|
158
|
+
## Contributing
|
|
159
|
+
|
|
160
|
+
Found a bug? Want a feature? PRs welcome.
|
|
161
|
+
|
|
162
|
+
```bash
|
|
163
|
+
git clone https://github.com/lil-Zlang/skimmd
|
|
164
|
+
cd skimmd
|
|
165
|
+
npm install
|
|
166
|
+
npm link
|
|
167
|
+
skimmd ./docs
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
---
|
|
171
|
+
|
|
172
|
+
## License
|
|
173
|
+
|
|
174
|
+
MIT
|
|
175
|
+
|
|
176
|
+
---
|
|
177
|
+
|
|
178
|
+
<p align="center">
|
|
179
|
+
<b>Stop pushing to GitHub just to preview your README.</b><br>
|
|
180
|
+
<code>npx skimmd</code>
|
|
181
|
+
</p>
|
package/bin/skimmd.js
ADDED
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import path from "path";
|
|
4
|
+
import fs from "fs";
|
|
5
|
+
import fsp from "fs/promises";
|
|
6
|
+
import { fileURLToPath } from "url";
|
|
7
|
+
import express from "express";
|
|
8
|
+
import open from "open";
|
|
9
|
+
import getPort from "get-port";
|
|
10
|
+
import fg from "fast-glob";
|
|
11
|
+
import { program } from "commander";
|
|
12
|
+
import { marked } from "marked";
|
|
13
|
+
import hljs from "highlight.js";
|
|
14
|
+
import TurndownService from "turndown";
|
|
15
|
+
import { watch } from "chokidar";
|
|
16
|
+
|
|
17
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
18
|
+
const __dirname = path.dirname(__filename);
|
|
19
|
+
|
|
20
|
+
function normalizeToPosix(filePath) {
|
|
21
|
+
return filePath.split(path.sep).join("/");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function resolveSafePath(root, target) {
|
|
25
|
+
const safeTarget = target.replace(/^\/+/, "");
|
|
26
|
+
const resolvedRoot = path.resolve(root);
|
|
27
|
+
const resolvedTarget = path.resolve(resolvedRoot, safeTarget);
|
|
28
|
+
if (resolvedTarget === resolvedRoot) {
|
|
29
|
+
return resolvedTarget;
|
|
30
|
+
}
|
|
31
|
+
if (!resolvedTarget.startsWith(resolvedRoot + path.sep)) {
|
|
32
|
+
throw new Error("Invalid file path");
|
|
33
|
+
}
|
|
34
|
+
return resolvedTarget;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
marked.setOptions({
|
|
38
|
+
gfm: true,
|
|
39
|
+
breaks: false,
|
|
40
|
+
highlight: (code, lang) => {
|
|
41
|
+
if (lang && hljs.getLanguage(lang)) {
|
|
42
|
+
return hljs.highlight(code, { language: lang }).value;
|
|
43
|
+
}
|
|
44
|
+
return hljs.highlightAuto(code).value;
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const turndownService = new TurndownService({
|
|
49
|
+
codeBlockStyle: "fenced",
|
|
50
|
+
headingStyle: "atx",
|
|
51
|
+
bulletListMarker: "-",
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
async function scanFiles(folderPath) {
|
|
55
|
+
const entries = await fg(["**/*.{md,markdown,txt}"], {
|
|
56
|
+
cwd: folderPath,
|
|
57
|
+
onlyFiles: true,
|
|
58
|
+
caseSensitiveMatch: false,
|
|
59
|
+
ignore: ["**/node_modules/**"],
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const files = await Promise.all(
|
|
63
|
+
entries.map(async (relativePath) => {
|
|
64
|
+
const fullPath = path.join(folderPath, relativePath);
|
|
65
|
+
const stats = await fsp.stat(fullPath);
|
|
66
|
+
const relativePosix = normalizeToPosix(relativePath);
|
|
67
|
+
return {
|
|
68
|
+
id: relativePosix,
|
|
69
|
+
name: path.basename(relativePosix),
|
|
70
|
+
path: fullPath,
|
|
71
|
+
relativePath: relativePosix,
|
|
72
|
+
metadata: {
|
|
73
|
+
size: stats.size,
|
|
74
|
+
modifiedAt: stats.mtime,
|
|
75
|
+
createdAt: stats.birthtime,
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
})
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
files.sort((a, b) => a.relativePath.localeCompare(b.relativePath));
|
|
82
|
+
return files;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function ensureSingleFileEntry(folderPath, singleFile) {
|
|
86
|
+
try {
|
|
87
|
+
const stats = await fsp.stat(singleFile.absolutePath);
|
|
88
|
+
const relativePath = normalizeToPosix(
|
|
89
|
+
path.relative(folderPath, singleFile.absolutePath)
|
|
90
|
+
);
|
|
91
|
+
return {
|
|
92
|
+
id: relativePath,
|
|
93
|
+
name: singleFile.name,
|
|
94
|
+
path: singleFile.absolutePath,
|
|
95
|
+
relativePath,
|
|
96
|
+
metadata: {
|
|
97
|
+
size: stats.size,
|
|
98
|
+
modifiedAt: stats.mtime,
|
|
99
|
+
createdAt: stats.birthtime,
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
} catch {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function applySingleFileFilter(files, singleFile) {
|
|
108
|
+
if (!singleFile) {
|
|
109
|
+
return files;
|
|
110
|
+
}
|
|
111
|
+
const filtered = files.filter((file) => {
|
|
112
|
+
return (
|
|
113
|
+
file.name === singleFile.name ||
|
|
114
|
+
file.relativePath === singleFile.relativePath ||
|
|
115
|
+
file.path === singleFile.absolutePath ||
|
|
116
|
+
file.relativePath.endsWith("/" + singleFile.name) ||
|
|
117
|
+
file.relativePath === singleFile.name
|
|
118
|
+
);
|
|
119
|
+
});
|
|
120
|
+
return filtered;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Store SSE clients for live reload
|
|
124
|
+
const sseClients = new Set();
|
|
125
|
+
|
|
126
|
+
function broadcastReload(filePath) {
|
|
127
|
+
const message = JSON.stringify({ type: "reload", file: filePath });
|
|
128
|
+
for (const client of sseClients) {
|
|
129
|
+
client.write(`data: ${message}\n\n`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function setupFileWatcher(folderPath, singleFile) {
|
|
134
|
+
const watchPath = singleFile ? singleFile.absolutePath : folderPath;
|
|
135
|
+
|
|
136
|
+
const watcher = watch(watchPath, {
|
|
137
|
+
ignored: [/node_modules/, /\.git/],
|
|
138
|
+
persistent: true,
|
|
139
|
+
ignoreInitial: true,
|
|
140
|
+
depth: 10,
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
function isMarkdownFile(filePath) {
|
|
144
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
145
|
+
return [".md", ".markdown", ".txt"].includes(ext);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
watcher.on("change", (filePath) => {
|
|
149
|
+
if (!isMarkdownFile(filePath)) return;
|
|
150
|
+
const relative = path.relative(folderPath, filePath);
|
|
151
|
+
console.log(`File changed: ${relative}`);
|
|
152
|
+
broadcastReload(relative);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
watcher.on("add", (filePath) => {
|
|
156
|
+
if (!isMarkdownFile(filePath)) return;
|
|
157
|
+
const relative = path.relative(folderPath, filePath);
|
|
158
|
+
console.log(`File added: ${relative}`);
|
|
159
|
+
broadcastReload(relative);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
watcher.on("unlink", (filePath) => {
|
|
163
|
+
if (!isMarkdownFile(filePath)) return;
|
|
164
|
+
const relative = path.relative(folderPath, filePath);
|
|
165
|
+
console.log(`File removed: ${relative}`);
|
|
166
|
+
broadcastReload(relative);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
watcher.on("ready", () => {
|
|
170
|
+
console.log("File watcher ready");
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
watcher.on("error", (error) => {
|
|
174
|
+
console.error("Watcher error:", error);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
return watcher;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function createServer(options) {
|
|
181
|
+
const { folderPath, singleFile, initialFile } = options;
|
|
182
|
+
const app = express();
|
|
183
|
+
|
|
184
|
+
app.use(express.json({ limit: "2mb" }));
|
|
185
|
+
|
|
186
|
+
// SSE endpoint for live reload
|
|
187
|
+
app.get("/api/events", (req, res) => {
|
|
188
|
+
res.setHeader("Content-Type", "text/event-stream");
|
|
189
|
+
res.setHeader("Cache-Control", "no-cache");
|
|
190
|
+
res.setHeader("Connection", "keep-alive");
|
|
191
|
+
res.flushHeaders();
|
|
192
|
+
|
|
193
|
+
sseClients.add(res);
|
|
194
|
+
|
|
195
|
+
req.on("close", () => {
|
|
196
|
+
sseClients.delete(res);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
app.get("/api/config", (_req, res) => {
|
|
201
|
+
res.json({
|
|
202
|
+
folderPath,
|
|
203
|
+
singleFile: singleFile ? singleFile.relativePath : null,
|
|
204
|
+
initialFile: initialFile || null,
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
app.get("/api/files", async (_req, res) => {
|
|
209
|
+
try {
|
|
210
|
+
let files = await scanFiles(folderPath);
|
|
211
|
+
if (singleFile) {
|
|
212
|
+
files = applySingleFileFilter(files, singleFile);
|
|
213
|
+
if (files.length === 0) {
|
|
214
|
+
const entry = await ensureSingleFileEntry(folderPath, singleFile);
|
|
215
|
+
if (entry) {
|
|
216
|
+
files = [entry];
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
res.json(files);
|
|
221
|
+
} catch (error) {
|
|
222
|
+
res.status(500).json({ error: error instanceof Error ? error.message : "Failed to list files" });
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
app.get("/api/file", async (req, res) => {
|
|
227
|
+
const fileId = req.query.path;
|
|
228
|
+
if (!fileId || typeof fileId !== "string") {
|
|
229
|
+
return res.status(400).json({ error: "Missing file path" });
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
try {
|
|
233
|
+
const safePath = resolveSafePath(folderPath, fileId);
|
|
234
|
+
const content = await fsp.readFile(safePath, "utf-8");
|
|
235
|
+
const stats = await fsp.stat(safePath);
|
|
236
|
+
const html = marked.parse(content);
|
|
237
|
+
|
|
238
|
+
res.json({
|
|
239
|
+
content,
|
|
240
|
+
html,
|
|
241
|
+
metadata: {
|
|
242
|
+
size: stats.size,
|
|
243
|
+
modifiedAt: stats.mtime,
|
|
244
|
+
createdAt: stats.birthtime,
|
|
245
|
+
},
|
|
246
|
+
});
|
|
247
|
+
} catch (error) {
|
|
248
|
+
res.status(404).json({ error: error instanceof Error ? error.message : "File not found" });
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
app.put("/api/file", async (req, res) => {
|
|
253
|
+
const fileId = req.query.path;
|
|
254
|
+
const { content, html } = req.body || {};
|
|
255
|
+
if (!fileId || typeof fileId !== "string") {
|
|
256
|
+
return res.status(400).json({ error: "Missing file path" });
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
let finalContent = content;
|
|
260
|
+
if (typeof html === "string") {
|
|
261
|
+
finalContent = turndownService.turndown(html);
|
|
262
|
+
}
|
|
263
|
+
if (typeof finalContent !== "string") {
|
|
264
|
+
return res.status(400).json({ error: "Missing content" });
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
try {
|
|
268
|
+
const safePath = resolveSafePath(folderPath, fileId);
|
|
269
|
+
await fsp.writeFile(safePath, finalContent, "utf-8");
|
|
270
|
+
const stats = await fsp.stat(safePath);
|
|
271
|
+
const renderedHtml = marked.parse(finalContent);
|
|
272
|
+
|
|
273
|
+
res.json({
|
|
274
|
+
content: finalContent,
|
|
275
|
+
html: renderedHtml,
|
|
276
|
+
metadata: {
|
|
277
|
+
size: stats.size,
|
|
278
|
+
modifiedAt: stats.mtime,
|
|
279
|
+
createdAt: stats.birthtime,
|
|
280
|
+
},
|
|
281
|
+
});
|
|
282
|
+
} catch (error) {
|
|
283
|
+
res.status(500).json({ error: error instanceof Error ? error.message : "Failed to save file" });
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
app.post("/api/render", async (req, res) => {
|
|
288
|
+
const { content } = req.body || {};
|
|
289
|
+
if (typeof content !== "string") {
|
|
290
|
+
return res.status(400).json({ error: "Missing content" });
|
|
291
|
+
}
|
|
292
|
+
try {
|
|
293
|
+
const html = marked.parse(content);
|
|
294
|
+
res.json({ html });
|
|
295
|
+
} catch (error) {
|
|
296
|
+
res.status(500).json({ error: error instanceof Error ? error.message : "Failed to render markdown" });
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
app.use(express.static(path.join(__dirname, "..", "public")));
|
|
301
|
+
|
|
302
|
+
app.get("*", (_req, res) => {
|
|
303
|
+
res.sendFile(path.join(__dirname, "..", "public", "index.html"));
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
return app;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
program
|
|
310
|
+
.name("skimmd")
|
|
311
|
+
.description("Instant markdown preview with GitHub-style rendering")
|
|
312
|
+
.argument("[path]", "Path to markdown file or folder", ".")
|
|
313
|
+
.option("-p, --port <number>", "Port to run on (default: auto-detect)")
|
|
314
|
+
.option("--no-open", "Don't open browser automatically")
|
|
315
|
+
.action(async (inputPath, options) => {
|
|
316
|
+
try {
|
|
317
|
+
const resolvedPath = path.resolve(inputPath);
|
|
318
|
+
|
|
319
|
+
if (!fs.existsSync(resolvedPath)) {
|
|
320
|
+
console.error(`Error: Path does not exist: ${resolvedPath}`);
|
|
321
|
+
process.exit(1);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const stats = fs.statSync(resolvedPath);
|
|
325
|
+
let folderPath;
|
|
326
|
+
let initialFile = null;
|
|
327
|
+
let singleFile = null;
|
|
328
|
+
|
|
329
|
+
if (stats.isDirectory()) {
|
|
330
|
+
folderPath = resolvedPath;
|
|
331
|
+
} else if (stats.isFile()) {
|
|
332
|
+
folderPath = path.dirname(resolvedPath);
|
|
333
|
+
initialFile = normalizeToPosix(path.relative(folderPath, resolvedPath));
|
|
334
|
+
singleFile = {
|
|
335
|
+
name: path.basename(resolvedPath),
|
|
336
|
+
absolutePath: resolvedPath,
|
|
337
|
+
relativePath: initialFile,
|
|
338
|
+
};
|
|
339
|
+
} else {
|
|
340
|
+
console.error(`Error: Invalid path: ${resolvedPath}`);
|
|
341
|
+
process.exit(1);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const port = options.port
|
|
345
|
+
? Number(options.port)
|
|
346
|
+
: await getPort({ port: [3000, 3001, 3002, 3003, 3004, 3005] });
|
|
347
|
+
|
|
348
|
+
const app = await createServer({ folderPath, singleFile, initialFile });
|
|
349
|
+
const watcher = setupFileWatcher(folderPath, singleFile);
|
|
350
|
+
|
|
351
|
+
const server = app.listen(port, () => {
|
|
352
|
+
const url = new URL(`http://localhost:${port}`);
|
|
353
|
+
if (initialFile) {
|
|
354
|
+
url.searchParams.set("file", initialFile);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
console.log(`skimmd running at ${url.toString()}`);
|
|
358
|
+
console.log(`Serving markdown from: ${folderPath}`);
|
|
359
|
+
console.log(`Watching for changes...`);
|
|
360
|
+
|
|
361
|
+
if (options.open) {
|
|
362
|
+
open(url.toString());
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
const shutdown = () => {
|
|
367
|
+
watcher.close();
|
|
368
|
+
server.close(() => process.exit(0));
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
process.on("SIGINT", shutdown);
|
|
372
|
+
process.on("SIGTERM", shutdown);
|
|
373
|
+
} catch (error) {
|
|
374
|
+
console.error("Error:", error instanceof Error ? error.message : error);
|
|
375
|
+
process.exit(1);
|
|
376
|
+
}
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
program.parse(process.argv);
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "skimmd",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Instant markdown preview in your browser. Zero config, GitHub-style rendering, inline editing.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"skimmd": "./bin/skimmd.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"start": "node bin/skimmd.js"
|
|
11
|
+
},
|
|
12
|
+
"keywords": [
|
|
13
|
+
"markdown",
|
|
14
|
+
"preview",
|
|
15
|
+
"github",
|
|
16
|
+
"readme",
|
|
17
|
+
"cli",
|
|
18
|
+
"viewer",
|
|
19
|
+
"editor",
|
|
20
|
+
"md",
|
|
21
|
+
"gfm"
|
|
22
|
+
],
|
|
23
|
+
"author": "",
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "git+https://github.com/lil-Zlang/skimmd.git"
|
|
28
|
+
},
|
|
29
|
+
"homepage": "https://github.com/lil-Zlang/skimmd#readme",
|
|
30
|
+
"bugs": {
|
|
31
|
+
"url": "https://github.com/lil-Zlang/skimmd/issues"
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"chokidar": "^5.0.0",
|
|
35
|
+
"commander": "^12.1.0",
|
|
36
|
+
"express": "^4.19.2",
|
|
37
|
+
"fast-glob": "^3.3.3",
|
|
38
|
+
"get-port": "^7.1.0",
|
|
39
|
+
"highlight.js": "^11.11.1",
|
|
40
|
+
"marked": "^12.0.2",
|
|
41
|
+
"open": "^10.1.0",
|
|
42
|
+
"turndown": "^7.2.0"
|
|
43
|
+
}
|
|
44
|
+
}
|