@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 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
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env node
2
+
3
+ const { runCli } = require("../lib/cli");
4
+
5
+ runCli().catch((error) => {
6
+ const message = error && error.message ? error.message : String(error);
7
+ process.stderr.write(`${message}\n`);
8
+ process.exitCode = 1;
9
+ });
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
+ };
@@ -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
+ };
@@ -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
+ };
@@ -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)}">&larr; ${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)}">&larr; 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("&", "&amp;")
44
+ .replaceAll("<", "&lt;")
45
+ .replaceAll(">", "&gt;")
46
+ .replaceAll('"', "&quot;")
47
+ .replaceAll("'", "&#39;");
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
+ }