@ayushshanker/mdo 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 +102 -0
- package/bin/mdo.js +9 -0
- package/lib/cli.js +116 -0
- package/lib/constants.js +32 -0
- package/lib/file-mode.js +40 -0
- package/lib/folder-mode.js +229 -0
- package/lib/renderer.js +357 -0
- package/lib/utils.js +92 -0
- package/package.json +39 -0
package/README.md
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# @ayushshanker/mdo
|
|
2
|
+
|
|
3
|
+
`@ayushshanker/mdo` is the npm package name. It installs the `mdo` CLI for previewing Markdown files and folders in your browser.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
Global install:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install -g @ayushshanker/mdo
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
One-off usage without installing:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npx @ayushshanker/mdo README.md
|
|
17
|
+
pnpm dlx @ayushshanker/mdo README.md
|
|
18
|
+
yarn dlx @ayushshanker/mdo README.md
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Command Name
|
|
22
|
+
|
|
23
|
+
The package is published as `@ayushshanker/mdo`, but the executable command is:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
mdo
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Usage
|
|
30
|
+
|
|
31
|
+
Open the current directory in folder mode:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
mdo
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Open a specific Markdown file:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
mdo README.md
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Open a folder on a fixed port:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
mdo docs --port 3000
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Export a Markdown file to HTML instead of opening the browser:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
mdo README.md --output README.html
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Use a theme:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
mdo README.md --theme earthsong
|
|
59
|
+
mdo README.md --theme gruvbox
|
|
60
|
+
mdo README.md --dark
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Modes
|
|
64
|
+
|
|
65
|
+
### File Mode
|
|
66
|
+
|
|
67
|
+
When you pass a Markdown file, `mdo` renders it to HTML.
|
|
68
|
+
|
|
69
|
+
Examples:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
mdo README.md
|
|
73
|
+
mdo docs/intro.markdown
|
|
74
|
+
mdo README.md --output README.html
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### Folder Mode
|
|
78
|
+
|
|
79
|
+
When you pass a directory, `mdo` starts a local preview server and opens a directory listing in your browser.
|
|
80
|
+
|
|
81
|
+
Examples:
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
mdo .
|
|
85
|
+
mdo docs
|
|
86
|
+
mdo docs --port 3000
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Flags
|
|
90
|
+
|
|
91
|
+
- `--dark` uses the GitHub dark theme
|
|
92
|
+
- `--theme <name>` selects `github`, `github-dark`, `sepia`, `earth`, `earthsong`, `gruvbox`, or `dracula`
|
|
93
|
+
- `--output <file>` writes HTML to disk instead of opening the browser; file mode only
|
|
94
|
+
- `--port <port>` uses a fixed port for folder mode
|
|
95
|
+
- `--help` prints usage information
|
|
96
|
+
- `--version` prints the package version
|
|
97
|
+
|
|
98
|
+
## Notes
|
|
99
|
+
|
|
100
|
+
- `mdo` with no path is the same as `mdo .`
|
|
101
|
+
- `--output` only works for file mode
|
|
102
|
+
- Folder mode binds to loopback only and is intended for local preview
|
package/bin/mdo.js
ADDED
package/lib/cli.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("node:fs/promises");
|
|
4
|
+
const path = require("node:path");
|
|
5
|
+
const { Command } = require("commander");
|
|
6
|
+
const packageJson = require("../package.json");
|
|
7
|
+
const { runFileMode } = require("./file-mode");
|
|
8
|
+
const { runFolderMode } = require("./folder-mode");
|
|
9
|
+
const { THEMES } = require("./renderer");
|
|
10
|
+
const {
|
|
11
|
+
ensureFileIsReadableMarkdown,
|
|
12
|
+
validatePort
|
|
13
|
+
} = require("./utils");
|
|
14
|
+
|
|
15
|
+
function resolveThemeOption(options) {
|
|
16
|
+
if (options.theme) {
|
|
17
|
+
if (!Object.hasOwn(THEMES, options.theme)) {
|
|
18
|
+
throw new Error(`Theme must be one of: ${Object.keys(THEMES).join(", ")}.`);
|
|
19
|
+
}
|
|
20
|
+
return options.theme;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return options.dark ? "github-dark" : "github";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function buildProgram() {
|
|
27
|
+
const program = new Command();
|
|
28
|
+
|
|
29
|
+
program
|
|
30
|
+
.name("mdo")
|
|
31
|
+
.description("Open markdown files or folders in your browser.")
|
|
32
|
+
.version(packageJson.version, "--version", "Print package version and exit.")
|
|
33
|
+
.argument("[target]", "Markdown file or folder to open.", ".")
|
|
34
|
+
.option("--dark", "Use GitHub dark theme.")
|
|
35
|
+
.option(
|
|
36
|
+
"--theme <name>",
|
|
37
|
+
`Use a named theme: ${Object.keys(THEMES).join(", ")}.`
|
|
38
|
+
)
|
|
39
|
+
.option("--output <file>", "Write HTML to a file instead of opening the browser.")
|
|
40
|
+
.option("--port <port>", "Use a fixed port for folder mode.", validatePort)
|
|
41
|
+
.addHelpText(
|
|
42
|
+
"after",
|
|
43
|
+
`
|
|
44
|
+
Examples:
|
|
45
|
+
npx mdopen
|
|
46
|
+
npx mdopen README.md
|
|
47
|
+
npx mdopen README.md --theme dracula
|
|
48
|
+
mdo
|
|
49
|
+
mdo .
|
|
50
|
+
mdo README.md
|
|
51
|
+
mdo docs --port 3000
|
|
52
|
+
`
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
return program;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function runCli(argv = process.argv) {
|
|
59
|
+
const program = buildProgram();
|
|
60
|
+
program.exitOverride();
|
|
61
|
+
|
|
62
|
+
let parsed;
|
|
63
|
+
try {
|
|
64
|
+
parsed = program.parse(argv, { from: "node" });
|
|
65
|
+
} catch (error) {
|
|
66
|
+
if (typeof error.exitCode === "number") {
|
|
67
|
+
process.exitCode = error.exitCode;
|
|
68
|
+
if (
|
|
69
|
+
error.code !== "commander.helpDisplayed" &&
|
|
70
|
+
error.code !== "commander.version"
|
|
71
|
+
) {
|
|
72
|
+
process.stderr.write(`${error.message}\n`);
|
|
73
|
+
}
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const options = parsed.opts();
|
|
80
|
+
const themeName = resolveThemeOption(options);
|
|
81
|
+
const targetPath = path.resolve(parsed.args[0] || ".");
|
|
82
|
+
const cwdRealPath = await fs.realpath(process.cwd());
|
|
83
|
+
const stats = await fs.stat(targetPath).catch(() => null);
|
|
84
|
+
|
|
85
|
+
if (!stats) {
|
|
86
|
+
throw new Error("Path does not exist.");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (stats.isDirectory()) {
|
|
90
|
+
if (options.output) {
|
|
91
|
+
throw new Error("--output is only supported for file mode.");
|
|
92
|
+
}
|
|
93
|
+
await runFolderMode({
|
|
94
|
+
directoryPath: targetPath,
|
|
95
|
+
fixedPort: options.port,
|
|
96
|
+
themeName
|
|
97
|
+
});
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (options.port !== undefined) {
|
|
102
|
+
throw new Error("--port is only supported for folder mode.");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
await ensureFileIsReadableMarkdown(targetPath);
|
|
106
|
+
await runFileMode({
|
|
107
|
+
cwdRealPath,
|
|
108
|
+
filePath: targetPath,
|
|
109
|
+
outputPath: options.output ? path.resolve(options.output) : undefined,
|
|
110
|
+
themeName
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
module.exports = {
|
|
115
|
+
runCli
|
|
116
|
+
};
|
package/lib/constants.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const path = require("node:path");
|
|
4
|
+
|
|
5
|
+
const MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024;
|
|
6
|
+
const MIN_PORT = 20001;
|
|
7
|
+
const MAX_PORT = 30000;
|
|
8
|
+
const PORT_RETRIES = 3;
|
|
9
|
+
const LOOPBACK_HOST = "127.0.0.1";
|
|
10
|
+
const MERMAID_VERSION = "11.12.0";
|
|
11
|
+
const MERMAID_CDN_URL = `https://cdn.jsdelivr.net/npm/mermaid@${MERMAID_VERSION}/dist/mermaid.min.js`;
|
|
12
|
+
const LIGHT_HLJS_THEME = "github.css";
|
|
13
|
+
const DARK_HLJS_THEME = "github-dark.css";
|
|
14
|
+
const GITHUB_MARKDOWN_CSS = "github-markdown.css";
|
|
15
|
+
const GITHUB_MARKDOWN_DARK_CSS = "github-markdown-dark.css";
|
|
16
|
+
const DEFAULT_ARTICLE_TITLE = "Markdown Preview";
|
|
17
|
+
const PACKAGE_ROOT = path.resolve(__dirname, "..");
|
|
18
|
+
|
|
19
|
+
module.exports = {
|
|
20
|
+
DARK_HLJS_THEME,
|
|
21
|
+
DEFAULT_ARTICLE_TITLE,
|
|
22
|
+
GITHUB_MARKDOWN_CSS,
|
|
23
|
+
GITHUB_MARKDOWN_DARK_CSS,
|
|
24
|
+
LIGHT_HLJS_THEME,
|
|
25
|
+
LOOPBACK_HOST,
|
|
26
|
+
MAX_FILE_SIZE_BYTES,
|
|
27
|
+
MAX_PORT,
|
|
28
|
+
MERMAID_CDN_URL,
|
|
29
|
+
MIN_PORT,
|
|
30
|
+
PACKAGE_ROOT,
|
|
31
|
+
PORT_RETRIES
|
|
32
|
+
};
|
package/lib/file-mode.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("node:fs/promises");
|
|
4
|
+
const path = require("node:path");
|
|
5
|
+
const open = require("open");
|
|
6
|
+
const { renderMarkdownFile } = require("./renderer");
|
|
7
|
+
const {
|
|
8
|
+
humanUrlPath,
|
|
9
|
+
tempHtmlPath,
|
|
10
|
+
writeUtf8File
|
|
11
|
+
} = require("./utils");
|
|
12
|
+
|
|
13
|
+
async function runFileMode({ filePath, outputPath, cwdRealPath, themeName }) {
|
|
14
|
+
const { html } = await renderMarkdownFile(filePath, {
|
|
15
|
+
themeName,
|
|
16
|
+
title: path.basename(filePath)
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
if (outputPath) {
|
|
20
|
+
await writeUtf8File(outputPath, html);
|
|
21
|
+
return { outputPath, opened: false };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const htmlPath = tempHtmlPath(cwdRealPath, filePath);
|
|
25
|
+
await fs.mkdir(path.dirname(htmlPath), { recursive: true });
|
|
26
|
+
await writeUtf8File(htmlPath, html);
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
await open(htmlPath);
|
|
30
|
+
} catch (error) {
|
|
31
|
+
process.stderr.write(`Browser open failed. File available at ${htmlPath}\n`);
|
|
32
|
+
throw new Error(`Failed to open browser for ${humanUrlPath(cwdRealPath, filePath)}.`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return { outputPath: htmlPath, opened: true };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
module.exports = {
|
|
39
|
+
runFileMode
|
|
40
|
+
};
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const http = require("node:http");
|
|
4
|
+
const fs = require("node:fs/promises");
|
|
5
|
+
const path = require("node:path");
|
|
6
|
+
const open = require("open");
|
|
7
|
+
const { LOOPBACK_HOST, MAX_PORT, MIN_PORT, PORT_RETRIES } = require("./constants");
|
|
8
|
+
const { renderDirectoryListing, renderMarkdownFile } = require("./renderer");
|
|
9
|
+
const { isMarkdownPath, toPosixPath } = require("./utils");
|
|
10
|
+
|
|
11
|
+
async function resolveInsideRoot(rootRealPath, requestPath) {
|
|
12
|
+
const normalized = decodeURIComponent(requestPath || "/");
|
|
13
|
+
const candidatePath = path.resolve(rootRealPath, `.${normalized}`);
|
|
14
|
+
const candidateRelativePath = path.relative(rootRealPath, candidatePath);
|
|
15
|
+
|
|
16
|
+
if (candidateRelativePath.startsWith("..") || path.isAbsolute(candidateRelativePath)) {
|
|
17
|
+
return { type: "forbidden", candidatePath };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
let realCandidatePath;
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
realCandidatePath = await fs.realpath(candidatePath);
|
|
24
|
+
} catch (error) {
|
|
25
|
+
return { type: "missing", candidatePath };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const relativePath = path.relative(rootRealPath, realCandidatePath);
|
|
29
|
+
if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
|
|
30
|
+
return { type: "forbidden", candidatePath: realCandidatePath };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return { type: "ok", realPath: realCandidatePath };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function buildDirectoryEntries(rootRealPath, realDirectoryPath, requestPath) {
|
|
37
|
+
const children = await fs.readdir(realDirectoryPath, { withFileTypes: true });
|
|
38
|
+
const visibleEntries = [];
|
|
39
|
+
|
|
40
|
+
for (const child of children) {
|
|
41
|
+
if (child.name.startsWith(".")) {
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const absolutePath = path.join(realDirectoryPath, child.name);
|
|
46
|
+
let realChildPath;
|
|
47
|
+
try {
|
|
48
|
+
realChildPath = await fs.realpath(absolutePath);
|
|
49
|
+
} catch {
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const relativeToRoot = path.relative(rootRealPath, realChildPath);
|
|
54
|
+
if (relativeToRoot.startsWith("..") || path.isAbsolute(relativeToRoot)) {
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const isDirectory = child.isDirectory();
|
|
59
|
+
const isFile = child.isFile();
|
|
60
|
+
|
|
61
|
+
if (!isDirectory && !(isFile && isMarkdownPath(child.name))) {
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const baseHref = requestPath.endsWith("/") ? requestPath : `${requestPath}/`;
|
|
66
|
+
visibleEntries.push({
|
|
67
|
+
href: `${baseHref}${encodeURIComponent(child.name)}${isDirectory ? "/" : ""}`,
|
|
68
|
+
name: child.name,
|
|
69
|
+
type: isDirectory ? "directory" : "file"
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return visibleEntries.sort((left, right) => {
|
|
74
|
+
if (left.type !== right.type) {
|
|
75
|
+
return left.type === "directory" ? -1 : 1;
|
|
76
|
+
}
|
|
77
|
+
return left.name.localeCompare(right.name);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function parentHrefFor(requestPath) {
|
|
82
|
+
if (requestPath === "/") {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
const trimmed = requestPath.endsWith("/") ? requestPath.slice(0, -1) : requestPath;
|
|
86
|
+
const parentPath = path.posix.dirname(trimmed);
|
|
87
|
+
return parentPath === "/" ? "/" : `${parentPath}/`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function chooseRandomPort() {
|
|
91
|
+
return Math.floor(Math.random() * (MAX_PORT - MIN_PORT + 1)) + MIN_PORT;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function listen(server, port) {
|
|
95
|
+
return new Promise((resolve, reject) => {
|
|
96
|
+
server.once("error", reject);
|
|
97
|
+
server.listen(port, LOOPBACK_HOST, () => {
|
|
98
|
+
server.off("error", reject);
|
|
99
|
+
resolve();
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function startServerWithRetries(server, fixedPort) {
|
|
105
|
+
let lastError;
|
|
106
|
+
const attempts = fixedPort ? 1 : PORT_RETRIES;
|
|
107
|
+
|
|
108
|
+
for (let index = 0; index < attempts; index += 1) {
|
|
109
|
+
const port = fixedPort || chooseRandomPort();
|
|
110
|
+
try {
|
|
111
|
+
await listen(server, port);
|
|
112
|
+
return port;
|
|
113
|
+
} catch (error) {
|
|
114
|
+
lastError = error;
|
|
115
|
+
if (fixedPort) {
|
|
116
|
+
throw new Error(`Port ${fixedPort} is unavailable.`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
throw new Error(lastError ? "Failed to start preview server." : "Failed to choose a port.");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function sendHtml(response, statusCode, html) {
|
|
125
|
+
response.writeHead(statusCode, {
|
|
126
|
+
"content-type": "text/html; charset=utf-8",
|
|
127
|
+
"cache-control": "no-store"
|
|
128
|
+
});
|
|
129
|
+
response.end(html);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function sendText(response, statusCode, message) {
|
|
133
|
+
response.writeHead(statusCode, {
|
|
134
|
+
"content-type": "text/plain; charset=utf-8",
|
|
135
|
+
"cache-control": "no-store"
|
|
136
|
+
});
|
|
137
|
+
response.end(message);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function installShutdownHandlers(server) {
|
|
141
|
+
let shuttingDown = false;
|
|
142
|
+
|
|
143
|
+
const shutdown = (signal) => {
|
|
144
|
+
if (shuttingDown) {
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
shuttingDown = true;
|
|
148
|
+
const timeout = setTimeout(() => {
|
|
149
|
+
process.exit(0);
|
|
150
|
+
}, 2000);
|
|
151
|
+
timeout.unref();
|
|
152
|
+
|
|
153
|
+
server.close(() => {
|
|
154
|
+
clearTimeout(timeout);
|
|
155
|
+
process.exit(0);
|
|
156
|
+
});
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
process.on("SIGINT", shutdown);
|
|
160
|
+
process.on("SIGTERM", shutdown);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function runFolderMode({ directoryPath, fixedPort, themeName }) {
|
|
164
|
+
const rootRealPath = await fs.realpath(directoryPath);
|
|
165
|
+
const server = http.createServer(async (request, response) => {
|
|
166
|
+
try {
|
|
167
|
+
const requestPath = (request.url || "/").split("?")[0] || "/";
|
|
168
|
+
const resolved = await resolveInsideRoot(rootRealPath, requestPath);
|
|
169
|
+
|
|
170
|
+
if (resolved.type === "forbidden") {
|
|
171
|
+
sendText(response, 403, "Forbidden");
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (resolved.type === "missing") {
|
|
176
|
+
sendText(response, 404, "Not found");
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const stats = await fs.stat(resolved.realPath);
|
|
181
|
+
|
|
182
|
+
if (stats.isDirectory()) {
|
|
183
|
+
const entries = await buildDirectoryEntries(rootRealPath, resolved.realPath, requestPath);
|
|
184
|
+
const html = renderDirectoryListing({
|
|
185
|
+
directoryName: path.basename(rootRealPath),
|
|
186
|
+
entries,
|
|
187
|
+
themeName,
|
|
188
|
+
parentHref: parentHrefFor(requestPath),
|
|
189
|
+
requestPath
|
|
190
|
+
});
|
|
191
|
+
sendHtml(response, 200, html);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (!isMarkdownPath(resolved.realPath)) {
|
|
196
|
+
sendText(response, 404, "Not found");
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const parentHref = parentHrefFor(requestPath);
|
|
201
|
+
const { html } = await renderMarkdownFile(resolved.realPath, {
|
|
202
|
+
themeName,
|
|
203
|
+
title: path.basename(resolved.realPath),
|
|
204
|
+
backHref: parentHref,
|
|
205
|
+
backLabel: "Back"
|
|
206
|
+
});
|
|
207
|
+
sendHtml(response, 200, html);
|
|
208
|
+
} catch {
|
|
209
|
+
sendText(response, 500, "Internal server error");
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
const port = await startServerWithRetries(server, fixedPort);
|
|
214
|
+
installShutdownHandlers(server);
|
|
215
|
+
|
|
216
|
+
const url = `http://${LOOPBACK_HOST}:${port}/`;
|
|
217
|
+
try {
|
|
218
|
+
await open(url);
|
|
219
|
+
} catch {
|
|
220
|
+
process.stderr.write(`Browser open failed. Server available at ${url}\n`);
|
|
221
|
+
throw new Error("Failed to open browser.");
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return { port, rootRealPath, url, server };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
module.exports = {
|
|
228
|
+
runFolderMode
|
|
229
|
+
};
|
package/lib/renderer.js
ADDED
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("node:fs");
|
|
4
|
+
const fsp = require("node:fs/promises");
|
|
5
|
+
const path = require("node:path");
|
|
6
|
+
const MarkdownIt = require("markdown-it");
|
|
7
|
+
const markdownItAnchor = require("markdown-it-anchor");
|
|
8
|
+
const markdownItTaskLists = require("markdown-it-task-lists");
|
|
9
|
+
const hljs = require("highlight.js");
|
|
10
|
+
const {
|
|
11
|
+
DARK_HLJS_THEME,
|
|
12
|
+
DEFAULT_ARTICLE_TITLE,
|
|
13
|
+
GITHUB_MARKDOWN_CSS,
|
|
14
|
+
GITHUB_MARKDOWN_DARK_CSS,
|
|
15
|
+
LIGHT_HLJS_THEME,
|
|
16
|
+
MERMAID_CDN_URL,
|
|
17
|
+
PACKAGE_ROOT
|
|
18
|
+
} = require("./constants");
|
|
19
|
+
const {
|
|
20
|
+
ensureFileIsReadableMarkdown,
|
|
21
|
+
escapeHtml
|
|
22
|
+
} = require("./utils");
|
|
23
|
+
|
|
24
|
+
function loadCss(relativePath) {
|
|
25
|
+
const cssPath = require.resolve(relativePath, { paths: [PACKAGE_ROOT] });
|
|
26
|
+
return fs.readFileSync(cssPath, "utf8");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const markdownCssLight = loadCss(`github-markdown-css/${GITHUB_MARKDOWN_CSS}`);
|
|
30
|
+
const markdownCssDark = loadCss(`github-markdown-css/${GITHUB_MARKDOWN_DARK_CSS}`);
|
|
31
|
+
const hljsCssLight = loadCss(`highlight.js/styles/${LIGHT_HLJS_THEME}`);
|
|
32
|
+
const hljsCssDark = loadCss(`highlight.js/styles/${DARK_HLJS_THEME}`);
|
|
33
|
+
|
|
34
|
+
function createMarkdownRenderer() {
|
|
35
|
+
return new MarkdownIt({
|
|
36
|
+
html: true,
|
|
37
|
+
linkify: true,
|
|
38
|
+
highlight(code, language) {
|
|
39
|
+
if (language && language.toLowerCase() === "mermaid") {
|
|
40
|
+
return `<pre class="mermaid">${escapeHtml(code)}</pre>`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (language && hljs.getLanguage(language)) {
|
|
44
|
+
const highlighted = hljs.highlight(code, { language, ignoreIllegals: true }).value;
|
|
45
|
+
return `<pre><code class="hljs language-${escapeHtml(language)}">${highlighted}</code></pre>`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return `<pre><code class="hljs">${escapeHtml(code)}</code></pre>`;
|
|
49
|
+
}
|
|
50
|
+
})
|
|
51
|
+
.enable(["table", "strikethrough"])
|
|
52
|
+
.use(markdownItAnchor)
|
|
53
|
+
.use(markdownItTaskLists, { enabled: true, label: true, labelAfter: true });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const renderer = createMarkdownRenderer();
|
|
57
|
+
|
|
58
|
+
const THEMES = {
|
|
59
|
+
github: {
|
|
60
|
+
accent: "#0969da",
|
|
61
|
+
background: "#ffffff",
|
|
62
|
+
color: "#1f2328",
|
|
63
|
+
darkMode: false,
|
|
64
|
+
hljsCss: hljsCssLight,
|
|
65
|
+
markdownCss: markdownCssLight,
|
|
66
|
+
mermaidTheme: "default",
|
|
67
|
+
navFontColor: "#57606a",
|
|
68
|
+
overlayCss: ""
|
|
69
|
+
},
|
|
70
|
+
"github-dark": {
|
|
71
|
+
accent: "#58a6ff",
|
|
72
|
+
background: "#0d1117",
|
|
73
|
+
color: "#c9d1d9",
|
|
74
|
+
darkMode: true,
|
|
75
|
+
hljsCss: hljsCssDark,
|
|
76
|
+
markdownCss: markdownCssDark,
|
|
77
|
+
mermaidTheme: "dark",
|
|
78
|
+
navFontColor: "#8b949e",
|
|
79
|
+
overlayCss: ""
|
|
80
|
+
},
|
|
81
|
+
sepia: {
|
|
82
|
+
accent: "#8b5e34",
|
|
83
|
+
background: "#f6efe2",
|
|
84
|
+
color: "#433422",
|
|
85
|
+
darkMode: false,
|
|
86
|
+
hljsCss: hljsCssLight,
|
|
87
|
+
markdownCss: markdownCssLight,
|
|
88
|
+
mermaidTheme: "neutral",
|
|
89
|
+
navFontColor: "#6e5a43",
|
|
90
|
+
overlayCss: `
|
|
91
|
+
body { background-image: linear-gradient(180deg, rgba(139, 94, 52, 0.06), rgba(139, 94, 52, 0)); }
|
|
92
|
+
.markdown-body {
|
|
93
|
+
color: #433422;
|
|
94
|
+
}
|
|
95
|
+
.markdown-body h1, .markdown-body h2, .markdown-body h3, .markdown-body h4, .markdown-body h5, .markdown-body h6 {
|
|
96
|
+
color: #2f2418;
|
|
97
|
+
}
|
|
98
|
+
.markdown-body pre, .markdown-body code, .markdown-body blockquote {
|
|
99
|
+
background-color: rgba(114, 87, 56, 0.08);
|
|
100
|
+
}
|
|
101
|
+
.markdown-body table tr {
|
|
102
|
+
background-color: rgba(255, 255, 255, 0.35);
|
|
103
|
+
}
|
|
104
|
+
`
|
|
105
|
+
},
|
|
106
|
+
earth: {
|
|
107
|
+
accent: "#8c5a2b",
|
|
108
|
+
background: "#efe4d0",
|
|
109
|
+
color: "#39281a",
|
|
110
|
+
darkMode: false,
|
|
111
|
+
hljsCss: hljsCssLight,
|
|
112
|
+
markdownCss: markdownCssLight,
|
|
113
|
+
mermaidTheme: "neutral",
|
|
114
|
+
navFontColor: "#6f5840",
|
|
115
|
+
overlayCss: `
|
|
116
|
+
body { background-image: linear-gradient(135deg, rgba(140, 90, 43, 0.08), rgba(95, 124, 84, 0.06)); }
|
|
117
|
+
.markdown-body {
|
|
118
|
+
color: #39281a;
|
|
119
|
+
}
|
|
120
|
+
.markdown-body a, .mdo-nav a {
|
|
121
|
+
color: #8c5a2b;
|
|
122
|
+
}
|
|
123
|
+
.markdown-body h1, .markdown-body h2, .markdown-body h3, .markdown-body h4, .markdown-body h5, .markdown-body h6 {
|
|
124
|
+
color: #2d1f14;
|
|
125
|
+
}
|
|
126
|
+
.markdown-body pre, .markdown-body code, .markdown-body blockquote {
|
|
127
|
+
background-color: rgba(115, 84, 52, 0.1);
|
|
128
|
+
}
|
|
129
|
+
.markdown-body hr, .markdown-body table th, .markdown-body table td {
|
|
130
|
+
border-color: rgba(88, 63, 39, 0.22);
|
|
131
|
+
}
|
|
132
|
+
`
|
|
133
|
+
},
|
|
134
|
+
earthsong: {
|
|
135
|
+
accent: "#b97732",
|
|
136
|
+
background: "#2a211b",
|
|
137
|
+
color: "#eadfce",
|
|
138
|
+
darkMode: true,
|
|
139
|
+
hljsCss: hljsCssDark,
|
|
140
|
+
markdownCss: markdownCssDark,
|
|
141
|
+
mermaidTheme: "dark",
|
|
142
|
+
navFontColor: "#c6b49f",
|
|
143
|
+
overlayCss: `
|
|
144
|
+
body { background-image: radial-gradient(circle at top, rgba(185, 119, 50, 0.2), rgba(42, 33, 27, 0) 42%); }
|
|
145
|
+
.markdown-body {
|
|
146
|
+
color: #eadfce;
|
|
147
|
+
}
|
|
148
|
+
.markdown-body a, .mdo-nav a {
|
|
149
|
+
color: #d89a51;
|
|
150
|
+
}
|
|
151
|
+
.markdown-body h1, .markdown-body h2, .markdown-body h3, .markdown-body h4, .markdown-body h5, .markdown-body h6 {
|
|
152
|
+
color: #f2d7b1;
|
|
153
|
+
}
|
|
154
|
+
.markdown-body pre, .markdown-body code, .markdown-body blockquote {
|
|
155
|
+
background-color: rgba(77, 58, 45, 0.94);
|
|
156
|
+
}
|
|
157
|
+
.markdown-body table tr {
|
|
158
|
+
background-color: rgba(90, 66, 49, 0.48);
|
|
159
|
+
}
|
|
160
|
+
`
|
|
161
|
+
},
|
|
162
|
+
gruvbox: {
|
|
163
|
+
accent: "#d79921",
|
|
164
|
+
background: "#282828",
|
|
165
|
+
color: "#ebdbb2",
|
|
166
|
+
darkMode: true,
|
|
167
|
+
hljsCss: hljsCssDark,
|
|
168
|
+
markdownCss: markdownCssDark,
|
|
169
|
+
mermaidTheme: "dark",
|
|
170
|
+
navFontColor: "#d5c4a1",
|
|
171
|
+
overlayCss: `
|
|
172
|
+
body { background-image: linear-gradient(180deg, rgba(215, 153, 33, 0.08), rgba(40, 40, 40, 0)); }
|
|
173
|
+
.markdown-body {
|
|
174
|
+
color: #ebdbb2;
|
|
175
|
+
}
|
|
176
|
+
.markdown-body a, .mdo-nav a {
|
|
177
|
+
color: #d79921;
|
|
178
|
+
}
|
|
179
|
+
.markdown-body h1, .markdown-body h2, .markdown-body h3, .markdown-body h4, .markdown-body h5, .markdown-body h6 {
|
|
180
|
+
color: #fabd2f;
|
|
181
|
+
}
|
|
182
|
+
.markdown-body pre, .markdown-body code, .markdown-body blockquote {
|
|
183
|
+
background-color: rgba(60, 56, 54, 0.95);
|
|
184
|
+
}
|
|
185
|
+
.markdown-body table tr {
|
|
186
|
+
background-color: rgba(80, 73, 69, 0.45);
|
|
187
|
+
}
|
|
188
|
+
`
|
|
189
|
+
},
|
|
190
|
+
dracula: {
|
|
191
|
+
accent: "#bd93f9",
|
|
192
|
+
background: "#1e1f29",
|
|
193
|
+
color: "#f8f8f2",
|
|
194
|
+
darkMode: true,
|
|
195
|
+
hljsCss: hljsCssDark,
|
|
196
|
+
markdownCss: markdownCssDark,
|
|
197
|
+
mermaidTheme: "dark",
|
|
198
|
+
navFontColor: "#c0b8d8",
|
|
199
|
+
overlayCss: `
|
|
200
|
+
body { background-image: radial-gradient(circle at top, rgba(189, 147, 249, 0.18), rgba(30, 31, 41, 0) 45%); }
|
|
201
|
+
.markdown-body {
|
|
202
|
+
color: #f8f8f2;
|
|
203
|
+
}
|
|
204
|
+
.markdown-body a, .mdo-nav a {
|
|
205
|
+
color: #bd93f9;
|
|
206
|
+
}
|
|
207
|
+
.markdown-body pre, .markdown-body code, .markdown-body blockquote {
|
|
208
|
+
background-color: rgba(68, 71, 90, 0.95);
|
|
209
|
+
}
|
|
210
|
+
.markdown-body table tr {
|
|
211
|
+
background-color: rgba(68, 71, 90, 0.45);
|
|
212
|
+
}
|
|
213
|
+
`
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
function resolveTheme(themeName) {
|
|
218
|
+
return THEMES[themeName] || THEMES.github;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function buildStyles(themeName) {
|
|
222
|
+
const theme = resolveTheme(themeName);
|
|
223
|
+
const markdownCss = theme.markdownCss;
|
|
224
|
+
const hljsCss = theme.hljsCss;
|
|
225
|
+
|
|
226
|
+
return `
|
|
227
|
+
${markdownCss}
|
|
228
|
+
${hljsCss}
|
|
229
|
+
body {
|
|
230
|
+
margin: 0;
|
|
231
|
+
background: ${theme.background};
|
|
232
|
+
color: ${theme.color};
|
|
233
|
+
}
|
|
234
|
+
.markdown-body {
|
|
235
|
+
box-sizing: border-box;
|
|
236
|
+
max-width: 980px;
|
|
237
|
+
margin: 0 auto;
|
|
238
|
+
padding: 45px;
|
|
239
|
+
}
|
|
240
|
+
.mdo-nav {
|
|
241
|
+
max-width: 980px;
|
|
242
|
+
margin: 0 auto;
|
|
243
|
+
padding: 24px 45px 0;
|
|
244
|
+
font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
|
245
|
+
color: ${theme.navFontColor};
|
|
246
|
+
}
|
|
247
|
+
.mdo-nav a {
|
|
248
|
+
color: ${theme.accent};
|
|
249
|
+
text-decoration: none;
|
|
250
|
+
}
|
|
251
|
+
.mdo-nav a:hover {
|
|
252
|
+
text-decoration: underline;
|
|
253
|
+
}
|
|
254
|
+
@media (max-width: 767px) {
|
|
255
|
+
.markdown-body {
|
|
256
|
+
padding: 15px;
|
|
257
|
+
}
|
|
258
|
+
.mdo-nav {
|
|
259
|
+
padding: 15px 15px 0;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
${theme.overlayCss}
|
|
263
|
+
`;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function buildMermaidBootstrap(themeName) {
|
|
267
|
+
const theme = resolveTheme(themeName);
|
|
268
|
+
return `
|
|
269
|
+
<script src="${MERMAID_CDN_URL}"></script>
|
|
270
|
+
<script>
|
|
271
|
+
mermaid.initialize({ startOnLoad: true, theme: "${theme.mermaidTheme}" });
|
|
272
|
+
</script>`;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function buildDocument({ bodyHtml, themeName, title, backHref, backLabel, hasMermaid }) {
|
|
276
|
+
const navHtml = backHref
|
|
277
|
+
? `<nav class="mdo-nav"><a href="${escapeHtml(backHref)}">← ${escapeHtml(backLabel || "Back")}</a></nav>`
|
|
278
|
+
: "";
|
|
279
|
+
const mermaidHtml = hasMermaid ? buildMermaidBootstrap(themeName) : "";
|
|
280
|
+
|
|
281
|
+
return `<!doctype html>
|
|
282
|
+
<html lang="en">
|
|
283
|
+
<head>
|
|
284
|
+
<meta charset="utf-8">
|
|
285
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
286
|
+
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data: https: http:; font-src data: https: http:; script-src 'unsafe-inline' https://cdn.jsdelivr.net; connect-src https://cdn.jsdelivr.net; media-src data: https: http:;">
|
|
287
|
+
<title>${escapeHtml(title || DEFAULT_ARTICLE_TITLE)}</title>
|
|
288
|
+
<style>${buildStyles(themeName)}</style>
|
|
289
|
+
</head>
|
|
290
|
+
<body>
|
|
291
|
+
${navHtml}
|
|
292
|
+
<article class="markdown-body">
|
|
293
|
+
${bodyHtml}
|
|
294
|
+
</article>
|
|
295
|
+
${mermaidHtml}
|
|
296
|
+
</body>
|
|
297
|
+
</html>`;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async function renderMarkdownFile(filePath, options = {}) {
|
|
301
|
+
await ensureFileIsReadableMarkdown(filePath);
|
|
302
|
+
const source = await fsp.readFile(filePath, "utf8");
|
|
303
|
+
const bodyHtml = renderer.render(source);
|
|
304
|
+
const title = options.title || path.basename(filePath);
|
|
305
|
+
|
|
306
|
+
return {
|
|
307
|
+
html: buildDocument({
|
|
308
|
+
bodyHtml,
|
|
309
|
+
themeName: options.themeName || "github",
|
|
310
|
+
title,
|
|
311
|
+
backHref: options.backHref,
|
|
312
|
+
backLabel: options.backLabel,
|
|
313
|
+
hasMermaid: /<pre class="mermaid">/.test(bodyHtml)
|
|
314
|
+
}),
|
|
315
|
+
title
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function renderDirectoryListing({ directoryName, entries, themeName, requestPath, parentHref }) {
|
|
320
|
+
const listItems = entries
|
|
321
|
+
.map((entry) => {
|
|
322
|
+
const suffix = entry.type === "directory" ? "/" : "";
|
|
323
|
+
return `<li><a href="${escapeHtml(entry.href)}">${escapeHtml(entry.name + suffix)}</a></li>`;
|
|
324
|
+
})
|
|
325
|
+
.join("\n");
|
|
326
|
+
|
|
327
|
+
const heading = requestPath === "/" ? directoryName : `${directoryName} · ${requestPath}`;
|
|
328
|
+
const backHref = parentHref
|
|
329
|
+
? `<nav class="mdo-nav"><a href="${escapeHtml(parentHref)}">← Up</a></nav>`
|
|
330
|
+
: "";
|
|
331
|
+
|
|
332
|
+
return `<!doctype html>
|
|
333
|
+
<html lang="en">
|
|
334
|
+
<head>
|
|
335
|
+
<meta charset="utf-8">
|
|
336
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
337
|
+
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline';">
|
|
338
|
+
<title>${escapeHtml(heading)}</title>
|
|
339
|
+
<style>${buildStyles(themeName || "github")}</style>
|
|
340
|
+
</head>
|
|
341
|
+
<body>
|
|
342
|
+
${backHref}
|
|
343
|
+
<article class="markdown-body">
|
|
344
|
+
<h1>${escapeHtml(heading)}</h1>
|
|
345
|
+
<ul>
|
|
346
|
+
${listItems}
|
|
347
|
+
</ul>
|
|
348
|
+
</article>
|
|
349
|
+
</body>
|
|
350
|
+
</html>`;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
module.exports = {
|
|
354
|
+
THEMES,
|
|
355
|
+
renderDirectoryListing,
|
|
356
|
+
renderMarkdownFile
|
|
357
|
+
};
|
package/lib/utils.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("node:fs/promises");
|
|
4
|
+
const path = require("node:path");
|
|
5
|
+
const os = require("node:os");
|
|
6
|
+
const crypto = require("node:crypto");
|
|
7
|
+
const {
|
|
8
|
+
MAX_FILE_SIZE_BYTES,
|
|
9
|
+
MAX_PORT
|
|
10
|
+
} = require("./constants");
|
|
11
|
+
|
|
12
|
+
function isMarkdownPath(targetPath) {
|
|
13
|
+
return /\.(md|markdown)$/i.test(targetPath);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function ensureFileIsReadableMarkdown(filePath) {
|
|
17
|
+
const stats = await fs.stat(filePath);
|
|
18
|
+
if (!stats.isFile()) {
|
|
19
|
+
throw new Error("Path must be a markdown file.");
|
|
20
|
+
}
|
|
21
|
+
if (!isMarkdownPath(filePath)) {
|
|
22
|
+
throw new Error("Only .md and .markdown files are supported.");
|
|
23
|
+
}
|
|
24
|
+
if (stats.size > MAX_FILE_SIZE_BYTES) {
|
|
25
|
+
throw new Error("Markdown file exceeds the 10 MiB limit.");
|
|
26
|
+
}
|
|
27
|
+
return stats;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function validatePort(portValue) {
|
|
31
|
+
if (portValue === undefined) {
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
const port = Number(portValue);
|
|
35
|
+
if (!Number.isInteger(port) || port < 1 || port > MAX_PORT) {
|
|
36
|
+
throw new Error(`Port must be an integer between 1 and ${MAX_PORT}.`);
|
|
37
|
+
}
|
|
38
|
+
return port;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function escapeHtml(value) {
|
|
42
|
+
return String(value)
|
|
43
|
+
.replaceAll("&", "&")
|
|
44
|
+
.replaceAll("<", "<")
|
|
45
|
+
.replaceAll(">", ">")
|
|
46
|
+
.replaceAll('"', """)
|
|
47
|
+
.replaceAll("'", "'");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function toPosixPath(filePath) {
|
|
51
|
+
return filePath.split(path.sep).join("/");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function safeRelativePath(fromPath, targetPath) {
|
|
55
|
+
const relativePath = path.relative(fromPath, targetPath);
|
|
56
|
+
if (!relativePath || relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
return relativePath;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function humanUrlPath(cwdRealPath, fileRealPath) {
|
|
63
|
+
const relativePath = safeRelativePath(cwdRealPath, fileRealPath);
|
|
64
|
+
const visiblePath = relativePath || path.basename(fileRealPath);
|
|
65
|
+
return `/${toPosixPath(visiblePath).split("/").map(encodeURIComponent).join("/")}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function tempHtmlPath(cwdRealPath, fileRealPath) {
|
|
69
|
+
const relativePath = safeRelativePath(cwdRealPath, fileRealPath) || path.basename(fileRealPath);
|
|
70
|
+
const sanitizedPath = relativePath
|
|
71
|
+
.split(path.sep)
|
|
72
|
+
.join(path.sep)
|
|
73
|
+
.replace(/[<>:"|?*]/g, "_");
|
|
74
|
+
const uniqueId = crypto.randomBytes(6).toString("hex");
|
|
75
|
+
return path.join(os.tmpdir(), "mdopen", uniqueId, `${sanitizedPath}.html`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function writeUtf8File(targetPath, content) {
|
|
79
|
+
await fs.writeFile(targetPath, content, "utf8");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
module.exports = {
|
|
83
|
+
ensureFileIsReadableMarkdown,
|
|
84
|
+
escapeHtml,
|
|
85
|
+
humanUrlPath,
|
|
86
|
+
isMarkdownPath,
|
|
87
|
+
safeRelativePath,
|
|
88
|
+
tempHtmlPath,
|
|
89
|
+
toPosixPath,
|
|
90
|
+
validatePort,
|
|
91
|
+
writeUtf8File
|
|
92
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ayushshanker/mdo",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Preview Markdown files and folders in the browser",
|
|
5
|
+
"bin": {
|
|
6
|
+
"mdo": "bin/mdo.js"
|
|
7
|
+
},
|
|
8
|
+
"type": "commonjs",
|
|
9
|
+
"files": [
|
|
10
|
+
"bin",
|
|
11
|
+
"lib"
|
|
12
|
+
],
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=20"
|
|
15
|
+
},
|
|
16
|
+
"publishConfig": {
|
|
17
|
+
"access": "public"
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"start": "node bin/mdo.js",
|
|
21
|
+
"test": "node --test"
|
|
22
|
+
},
|
|
23
|
+
"keywords": [
|
|
24
|
+
"markdown",
|
|
25
|
+
"cli",
|
|
26
|
+
"preview",
|
|
27
|
+
"browser"
|
|
28
|
+
],
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"commander": "^14.0.1",
|
|
32
|
+
"github-markdown-css": "^5.8.1",
|
|
33
|
+
"highlight.js": "^11.11.1",
|
|
34
|
+
"markdown-it": "^14.1.0",
|
|
35
|
+
"markdown-it-anchor": "^9.2.0",
|
|
36
|
+
"markdown-it-task-lists": "^2.1.1",
|
|
37
|
+
"open": "^10.2.0"
|
|
38
|
+
}
|
|
39
|
+
}
|