specv 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.
- package/README.md +79 -0
- package/dist/client/assets/index-2hQ6H2zK.css +1 -0
- package/dist/client/assets/index-CHUTKwt0.js +41 -0
- package/dist/client/index.html +13 -0
- package/dist/server/cli.d.ts +1 -0
- package/dist/server/cli.js +203 -0
- package/package.json +67 -0
|
@@ -0,0 +1,13 @@
|
|
|
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
|
+
<title>mdv</title>
|
|
7
|
+
<script type="module" crossorigin src="/assets/index-CHUTKwt0.js"></script>
|
|
8
|
+
<link rel="stylesheet" crossorigin href="/assets/index-2hQ6H2zK.css">
|
|
9
|
+
</head>
|
|
10
|
+
<body>
|
|
11
|
+
<div id="root"></div>
|
|
12
|
+
</body>
|
|
13
|
+
</html>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/server/cli.ts
|
|
4
|
+
import path3 from "path";
|
|
5
|
+
import { program } from "commander";
|
|
6
|
+
import express from "express";
|
|
7
|
+
|
|
8
|
+
// src/server/api.ts
|
|
9
|
+
import fs3 from "fs/promises";
|
|
10
|
+
import { Router } from "express";
|
|
11
|
+
|
|
12
|
+
// src/server/files.ts
|
|
13
|
+
import fs from "fs/promises";
|
|
14
|
+
import path from "path";
|
|
15
|
+
var IGNORED_DIRS = /* @__PURE__ */ new Set([
|
|
16
|
+
"node_modules",
|
|
17
|
+
".git",
|
|
18
|
+
".hg",
|
|
19
|
+
".svn",
|
|
20
|
+
"__pycache__",
|
|
21
|
+
".next",
|
|
22
|
+
".nuxt"
|
|
23
|
+
]);
|
|
24
|
+
var MAX_DEPTH = 10;
|
|
25
|
+
var sortEntries = (entries) => entries.toSorted((a, b) => {
|
|
26
|
+
if (a.isDirectory() && !b.isDirectory()) {
|
|
27
|
+
return -1;
|
|
28
|
+
}
|
|
29
|
+
if (!a.isDirectory() && b.isDirectory()) {
|
|
30
|
+
return 1;
|
|
31
|
+
}
|
|
32
|
+
return a.name.localeCompare(b.name);
|
|
33
|
+
});
|
|
34
|
+
var buildFileNode = (entry, relativePath) => ({
|
|
35
|
+
name: entry.name,
|
|
36
|
+
path: path.join(relativePath, entry.name)
|
|
37
|
+
});
|
|
38
|
+
var buildDirNode = (entry, relativePath, children) => ({
|
|
39
|
+
children,
|
|
40
|
+
name: entry.name,
|
|
41
|
+
path: path.join(relativePath, entry.name)
|
|
42
|
+
});
|
|
43
|
+
var readAndSortEntries = async (baseDir, relativePath) => {
|
|
44
|
+
const entries = await fs.readdir(path.join(baseDir, relativePath), {
|
|
45
|
+
withFileTypes: true
|
|
46
|
+
});
|
|
47
|
+
return sortEntries(entries);
|
|
48
|
+
};
|
|
49
|
+
var collectNodes = async (sorted, baseDir, relativePath, depth, scan) => {
|
|
50
|
+
const result = [];
|
|
51
|
+
for (const entry of sorted) {
|
|
52
|
+
if (entry.isDirectory() && !IGNORED_DIRS.has(entry.name)) {
|
|
53
|
+
const children = await scan(
|
|
54
|
+
baseDir,
|
|
55
|
+
path.join(relativePath, entry.name),
|
|
56
|
+
depth + 1
|
|
57
|
+
);
|
|
58
|
+
if (children.length > 0) {
|
|
59
|
+
result.push(buildDirNode(entry, relativePath, children));
|
|
60
|
+
}
|
|
61
|
+
} else if (!entry.isDirectory() && entry.name.endsWith(".md")) {
|
|
62
|
+
result.push(buildFileNode(entry, relativePath));
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return result;
|
|
66
|
+
};
|
|
67
|
+
var scanMarkdownFiles = async (baseDir, relativePath = "", depth = 0) => {
|
|
68
|
+
if (depth >= MAX_DEPTH) {
|
|
69
|
+
return [];
|
|
70
|
+
}
|
|
71
|
+
const sorted = await readAndSortEntries(baseDir, relativePath);
|
|
72
|
+
return collectNodes(sorted, baseDir, relativePath, depth, scanMarkdownFiles);
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
// src/server/security.ts
|
|
76
|
+
import fs2 from "fs";
|
|
77
|
+
import path2 from "path";
|
|
78
|
+
var isWithinBaseDir = (targetPath, baseDir) => targetPath.startsWith(baseDir + path2.sep) || targetPath === baseDir;
|
|
79
|
+
var validateExtension = (filePath) => {
|
|
80
|
+
if (!filePath.endsWith(".md")) {
|
|
81
|
+
throw new Error(`Only .md files are allowed: ${filePath}`);
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
var resolveRealPath = (resolved, baseDir, filePath) => {
|
|
85
|
+
try {
|
|
86
|
+
const realPath = fs2.realpathSync(resolved);
|
|
87
|
+
if (!isWithinBaseDir(realPath, baseDir)) {
|
|
88
|
+
throw new Error(`Path traversal detected: ${filePath}`);
|
|
89
|
+
}
|
|
90
|
+
return realPath;
|
|
91
|
+
} catch (error) {
|
|
92
|
+
if (error instanceof Error && error.message.includes("traversal")) {
|
|
93
|
+
throw error;
|
|
94
|
+
}
|
|
95
|
+
return resolved;
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
var validatePath = (filePath, baseDir) => {
|
|
99
|
+
const decoded = decodeURIComponent(filePath);
|
|
100
|
+
const resolved = path2.resolve(baseDir, decoded);
|
|
101
|
+
if (!isWithinBaseDir(resolved, baseDir)) {
|
|
102
|
+
throw new Error(`Path traversal detected: ${filePath}`);
|
|
103
|
+
}
|
|
104
|
+
validateExtension(filePath);
|
|
105
|
+
return resolveRealPath(resolved, baseDir, filePath);
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
// src/server/api.ts
|
|
109
|
+
var isSecurityError = (error) => error instanceof Error && (error.message.includes("traversal") || error.message.includes("Only .md"));
|
|
110
|
+
var readAndSendFile = async (filePath, baseDir, res) => {
|
|
111
|
+
const resolvedPath = validatePath(filePath, baseDir);
|
|
112
|
+
const content = await fs3.readFile(resolvedPath, "utf8");
|
|
113
|
+
res.type("text/plain; charset=utf-8").send(content);
|
|
114
|
+
};
|
|
115
|
+
var handleFileError = (error, res) => {
|
|
116
|
+
if (isSecurityError(error)) {
|
|
117
|
+
res.status(400).json({ error: error.message });
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
res.status(404).json({ error: "File not found" });
|
|
121
|
+
};
|
|
122
|
+
var handleFiles = async (_req, res, baseDir) => {
|
|
123
|
+
try {
|
|
124
|
+
const files = await scanMarkdownFiles(baseDir);
|
|
125
|
+
res.json({ files });
|
|
126
|
+
} catch {
|
|
127
|
+
res.status(500).json({ error: "Failed to scan files" });
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
var handleFile = async (req, res, baseDir) => {
|
|
131
|
+
const filePath = req.query.path;
|
|
132
|
+
if (!filePath || typeof filePath !== "string") {
|
|
133
|
+
res.status(400).json({ error: "path query parameter is required" });
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
try {
|
|
137
|
+
await readAndSendFile(filePath, baseDir, res);
|
|
138
|
+
} catch (error) {
|
|
139
|
+
handleFileError(error, res);
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
var wrapAsync = (handler) => (req, res) => {
|
|
143
|
+
const safeHandler = async () => {
|
|
144
|
+
try {
|
|
145
|
+
await handler(req, res);
|
|
146
|
+
} catch {
|
|
147
|
+
if (!res.headersSent) {
|
|
148
|
+
res.status(500).json({ error: "Internal server error" });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
safeHandler();
|
|
153
|
+
};
|
|
154
|
+
var createApiRouter = (baseDir) => {
|
|
155
|
+
const router = Router();
|
|
156
|
+
router.get(
|
|
157
|
+
"/api/files",
|
|
158
|
+
wrapAsync((req, res) => handleFiles(req, res, baseDir))
|
|
159
|
+
);
|
|
160
|
+
router.get(
|
|
161
|
+
"/api/file",
|
|
162
|
+
wrapAsync((req, res) => handleFile(req, res, baseDir))
|
|
163
|
+
);
|
|
164
|
+
return router;
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
// src/server/cli.ts
|
|
168
|
+
var currentDir = import.meta.dirname;
|
|
169
|
+
var openInBrowser = async (url) => {
|
|
170
|
+
const mod = await import("open");
|
|
171
|
+
await mod.default(url);
|
|
172
|
+
};
|
|
173
|
+
program.name("specv").description("Local Markdown preview with GitHub-style rendering").version("0.1.0").option("-p, --port <number>", "Port number", "4649").action((options) => {
|
|
174
|
+
const baseDir = process.cwd();
|
|
175
|
+
const startPort = Number.parseInt(options.port, 10);
|
|
176
|
+
const app = express();
|
|
177
|
+
app.use(createApiRouter(baseDir));
|
|
178
|
+
const clientDir = path3.join(currentDir, "../client");
|
|
179
|
+
app.use(express.static(clientDir));
|
|
180
|
+
app.get("/{*splat}", (_req, res) => {
|
|
181
|
+
res.sendFile(path3.join(clientDir, "index.html"));
|
|
182
|
+
});
|
|
183
|
+
const tryListen = (port) => {
|
|
184
|
+
const server = app.listen(port, "127.0.0.1", () => {
|
|
185
|
+
const url = `http://localhost:${port}`;
|
|
186
|
+
console.log(`specv running at ${url}`);
|
|
187
|
+
console.log(`Serving: ${baseDir}`);
|
|
188
|
+
console.log("Press Ctrl+C to stop");
|
|
189
|
+
openInBrowser(url);
|
|
190
|
+
});
|
|
191
|
+
server.on("error", (err) => {
|
|
192
|
+
if (err.code === "EADDRINUSE" && port < startPort + 10) {
|
|
193
|
+
console.log(`Port ${port} is in use, trying ${port + 1}...`);
|
|
194
|
+
tryListen(port + 1);
|
|
195
|
+
} else {
|
|
196
|
+
console.error(`Failed to start server: ${err.message}`);
|
|
197
|
+
process.exit(1);
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
};
|
|
201
|
+
tryListen(startPort);
|
|
202
|
+
});
|
|
203
|
+
program.parse();
|
package/package.json
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "specv",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Local Markdown preview with GitHub-style rendering",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"cli",
|
|
7
|
+
"markdown",
|
|
8
|
+
"preview"
|
|
9
|
+
],
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"bin": {
|
|
12
|
+
"specv": "./dist/server/cli.js"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist/"
|
|
16
|
+
],
|
|
17
|
+
"type": "module",
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"commander": "^14.0.3",
|
|
20
|
+
"express": "^5.2.1",
|
|
21
|
+
"open": "^11.0.0"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@base-ui/react": "^1.2.0",
|
|
25
|
+
"@tailwindcss/typography": "^0.5.19",
|
|
26
|
+
"@tailwindcss/vite": "^4.2.1",
|
|
27
|
+
"@tanstack/react-hotkeys": "^0.4.1",
|
|
28
|
+
"@types/express": "^5.0.6",
|
|
29
|
+
"@types/react": "^19.2.14",
|
|
30
|
+
"@types/react-dom": "^19.2.3",
|
|
31
|
+
"@vitejs/plugin-react": "^5.1.4",
|
|
32
|
+
"class-variance-authority": "^0.7.1",
|
|
33
|
+
"clsx": "^2.1.1",
|
|
34
|
+
"fzf": "^0.5.2",
|
|
35
|
+
"knip": "^5.86.0",
|
|
36
|
+
"lefthook": "^2.1.3",
|
|
37
|
+
"lucide-react": "^0.577.0",
|
|
38
|
+
"oxfmt": "^0.39.0",
|
|
39
|
+
"oxlint": "^1.54.0",
|
|
40
|
+
"prism-react-renderer": "^2.4.1",
|
|
41
|
+
"react": "^19.2.4",
|
|
42
|
+
"react-dom": "^19.2.4",
|
|
43
|
+
"react-markdown": "^10.1.0",
|
|
44
|
+
"remark-gfm": "^4.0.1",
|
|
45
|
+
"shadcn": "^4.0.5",
|
|
46
|
+
"tailwind-merge": "^3.5.0",
|
|
47
|
+
"tailwindcss": "^4.2.1",
|
|
48
|
+
"tsup": "^8.5.1",
|
|
49
|
+
"tsx": "^4.21.0",
|
|
50
|
+
"tw-animate-css": "^1.4.0",
|
|
51
|
+
"typescript": "^5.9.3",
|
|
52
|
+
"ultracite": "7.2.5",
|
|
53
|
+
"vite": "^7.3.1",
|
|
54
|
+
"vitest": "^4.0.18"
|
|
55
|
+
},
|
|
56
|
+
"scripts": {
|
|
57
|
+
"dev:client": "vite",
|
|
58
|
+
"dev:server": "tsx src/server/cli.ts",
|
|
59
|
+
"build:client": "vite build",
|
|
60
|
+
"build:server": "tsup src/server/cli.ts --format esm --out-dir dist/server --dts",
|
|
61
|
+
"build": "nr build:client && nr build:server",
|
|
62
|
+
"test": "vitest run",
|
|
63
|
+
"check": "ultracite check",
|
|
64
|
+
"fix": "ultracite fix",
|
|
65
|
+
"knip": "knip"
|
|
66
|
+
}
|
|
67
|
+
}
|