peekmd 1.1.0 → 2.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 +20 -0
- package/package.json +20 -9
- package/src/cli.ts +63 -0
- package/src/index.ts +39 -0
- package/src/markdown/parser.ts +95 -0
- package/src/markdown/plugins/alerts.ts +42 -0
- package/src/markdown/plugins/anchors.ts +51 -0
- package/src/markdown/plugins/highlight.ts +21 -0
- package/src/markdown/plugins/mermaid.ts +33 -0
- package/src/markdown/plugins/strikethrough.ts +96 -0
- package/src/markdown/plugins/tasks.ts +88 -0
- package/src/server/assets.ts +58 -0
- package/src/server/index.ts +53 -0
- package/src/server/routes.ts +76 -0
- package/src/template/html.ts +209 -0
- package/src/template/scripts.ts +40 -0
- package/src/template/styles.css +1171 -0
- package/src/template/styles.ts +672 -0
- package/src/utils/browser.ts +22 -0
- package/src/utils/file-tree.ts +90 -0
- package/src/utils/paths.ts +98 -0
- package/cli.ts +0 -7
- package/index.ts +0 -1190
package/README.md
CHANGED
|
@@ -12,9 +12,11 @@ A CLI tool to preview markdown files with GitHub-style rendering in your browser
|
|
|
12
12
|
- GitHub Flavored Markdown (GFM) rendering
|
|
13
13
|
- Syntax highlighting for code blocks
|
|
14
14
|
- GitHub-style alerts (`[!NOTE]`, `[!TIP]`, `[!WARNING]`, `[!IMPORTANT]`, `[!CAUTION]`)
|
|
15
|
+
- Mermaid diagram rendering
|
|
15
16
|
- Task lists with checkboxes
|
|
16
17
|
- Anchor links on headings
|
|
17
18
|
- File tree sidebar
|
|
19
|
+
- Dark mode support
|
|
18
20
|
- Opens in your default browser automatically
|
|
19
21
|
- Auto-closes when you close the browser tab
|
|
20
22
|
- Cross-platform: macOS, Linux, Windows
|
|
@@ -161,6 +163,24 @@ bun run format
|
|
|
161
163
|
bun run compile
|
|
162
164
|
```
|
|
163
165
|
|
|
166
|
+
## Testing
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
# Run all tests
|
|
170
|
+
bun test
|
|
171
|
+
|
|
172
|
+
# Run visual regression tests
|
|
173
|
+
bun test:visual
|
|
174
|
+
|
|
175
|
+
# Update visual baselines (after intentional UI changes)
|
|
176
|
+
bun test:visual:update
|
|
177
|
+
|
|
178
|
+
# Run visual tests without GitHub gist comparison (faster)
|
|
179
|
+
bun test:visual:local
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
Visual regression tests capture screenshots across multiple viewports (desktop, tablet, mobile) and color modes (light, dark) to ensure consistent rendering.
|
|
183
|
+
|
|
164
184
|
## Why Bun?
|
|
165
185
|
|
|
166
186
|
peekmd uses Bun's built-in HTTP server (`Bun.serve()`) for its simplicity and performance. This means:
|
package/package.json
CHANGED
|
@@ -1,19 +1,25 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "peekmd",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "Quick markdown file previewer that looks like your GitHub README. Requires Bun.",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"main": "./index.ts",
|
|
6
|
+
"main": "./src/index.ts",
|
|
7
7
|
"bin": {
|
|
8
|
-
"peekmd": "
|
|
8
|
+
"peekmd": "src/cli.ts"
|
|
9
9
|
},
|
|
10
10
|
"files": [
|
|
11
|
-
"
|
|
12
|
-
"index.ts"
|
|
11
|
+
"src"
|
|
13
12
|
],
|
|
14
13
|
"scripts": {
|
|
15
|
-
"dev": "bun run ./cli.ts README.md",
|
|
16
|
-
"
|
|
14
|
+
"dev": "bun run ./src/cli.ts README.md",
|
|
15
|
+
"test:manual": "bun run ./src/cli.ts tests/fixtures/kitchen-sink.md",
|
|
16
|
+
"compile": "bun build ./src/cli.ts --compile --minify --bytecode --outfile peekmd",
|
|
17
|
+
"test": "bun test",
|
|
18
|
+
"test:watch": "bun test --watch",
|
|
19
|
+
"test:coverage": "bun test --coverage",
|
|
20
|
+
"test:visual": "bun test tests/visual/",
|
|
21
|
+
"test:visual:update": "UPDATE_BASELINES=true bun test tests/visual/",
|
|
22
|
+
"test:visual:local": "SKIP_GIST_COMPARISON=true bun test tests/visual/",
|
|
17
23
|
"format": "bunx prettier --write .",
|
|
18
24
|
"sort": "bunx sort-package-json"
|
|
19
25
|
},
|
|
@@ -38,9 +44,14 @@
|
|
|
38
44
|
"homepage": "https://github.com/HelgeSverre/peekmd#readme",
|
|
39
45
|
"dependencies": {
|
|
40
46
|
"highlight.js": "^11.11.1",
|
|
41
|
-
"
|
|
47
|
+
"markdown-it": "^14.0.0",
|
|
48
|
+
"markdown-it-footnote": "^4.0.0"
|
|
42
49
|
},
|
|
43
50
|
"devDependencies": {
|
|
44
|
-
"@types/bun": "latest"
|
|
51
|
+
"@types/bun": "latest",
|
|
52
|
+
"@types/pixelmatch": "^5.2.6",
|
|
53
|
+
"pixelmatch": "^6.0.0",
|
|
54
|
+
"playwright": "^1.50.0",
|
|
55
|
+
"pngjs": "^7.0.0"
|
|
45
56
|
}
|
|
46
57
|
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { existsSync } from "fs";
|
|
3
|
+
import { extname, dirname, resolve, sep } from "path";
|
|
4
|
+
import { createServer, type ServerState } from "./server/index.ts";
|
|
5
|
+
import { showToast, openBrowser } from "./utils/browser.ts";
|
|
6
|
+
import { getDirName, getRelativePath, getFilename } from "./utils/paths.ts";
|
|
7
|
+
|
|
8
|
+
export async function main(): Promise<void> {
|
|
9
|
+
const args = process.argv.slice(2);
|
|
10
|
+
|
|
11
|
+
if (args.length === 0) {
|
|
12
|
+
console.log("Usage: peekmd <file.md>");
|
|
13
|
+
console.log(
|
|
14
|
+
" Opens a GitHub-style preview of a markdown file in your default browser.",
|
|
15
|
+
);
|
|
16
|
+
process.exit(1);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const filePath = resolve(args[0]);
|
|
20
|
+
|
|
21
|
+
if (!existsSync(filePath)) {
|
|
22
|
+
console.error(`Error: File not found: ${filePath}`);
|
|
23
|
+
process.exit(1);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const ext = extname(filePath).toLowerCase();
|
|
27
|
+
if (ext !== ".md" && ext !== ".markdown" && ext !== ".mdown") {
|
|
28
|
+
console.warn(`Warning: File '${filePath}' may not be a markdown file.`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const content = await Bun.file(filePath).text();
|
|
32
|
+
const filename = getFilename(filePath);
|
|
33
|
+
const repoName = getDirName(filePath);
|
|
34
|
+
const dirPath = getRelativePath(filePath) || "";
|
|
35
|
+
const markdownDir = dirname(filePath);
|
|
36
|
+
|
|
37
|
+
const port = 3456;
|
|
38
|
+
const state: ServerState = { server: null, isOpen: false };
|
|
39
|
+
|
|
40
|
+
const server = createServer(
|
|
41
|
+
{
|
|
42
|
+
port,
|
|
43
|
+
filename,
|
|
44
|
+
content,
|
|
45
|
+
repoName,
|
|
46
|
+
dirPath,
|
|
47
|
+
markdownDir,
|
|
48
|
+
},
|
|
49
|
+
state,
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
const url = `http://localhost:${port}`;
|
|
53
|
+
showToast(`Serving ${filename} at ${url}`);
|
|
54
|
+
showToast("Press ESC or close this window to exit.");
|
|
55
|
+
|
|
56
|
+
await openBrowser(url);
|
|
57
|
+
state.isOpen = true;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
main().catch((err) => {
|
|
61
|
+
console.error("Error:", err);
|
|
62
|
+
process.exit(1);
|
|
63
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Public API exports
|
|
2
|
+
export {
|
|
3
|
+
renderMarkdown,
|
|
4
|
+
createParser,
|
|
5
|
+
extractDescription,
|
|
6
|
+
extractTopics,
|
|
7
|
+
} from "./markdown/parser.ts";
|
|
8
|
+
export {
|
|
9
|
+
processAlerts,
|
|
10
|
+
ALERT_ICONS,
|
|
11
|
+
type AlertType,
|
|
12
|
+
} from "./markdown/plugins/alerts.ts";
|
|
13
|
+
export { slugify } from "./markdown/plugins/anchors.ts";
|
|
14
|
+
export { highlightCode } from "./markdown/plugins/highlight.ts";
|
|
15
|
+
|
|
16
|
+
export {
|
|
17
|
+
getFileTree,
|
|
18
|
+
renderFileTree,
|
|
19
|
+
formatSize,
|
|
20
|
+
type FileNode,
|
|
21
|
+
} from "./utils/file-tree.ts";
|
|
22
|
+
export {
|
|
23
|
+
getDirName,
|
|
24
|
+
getRelativePath,
|
|
25
|
+
getFilename,
|
|
26
|
+
isMarkdownFile,
|
|
27
|
+
getContentType,
|
|
28
|
+
resolveAssetPath,
|
|
29
|
+
} from "./utils/paths.ts";
|
|
30
|
+
export { showToast, openBrowser } from "./utils/browser.ts";
|
|
31
|
+
|
|
32
|
+
export {
|
|
33
|
+
createServer,
|
|
34
|
+
type ServerOptions,
|
|
35
|
+
type ServerState,
|
|
36
|
+
} from "./server/index.ts";
|
|
37
|
+
export { getHtml, type TemplateData } from "./template/html.ts";
|
|
38
|
+
|
|
39
|
+
export { main } from "./cli.ts";
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import MarkdownIt from "markdown-it";
|
|
2
|
+
import footnotePlugin from "markdown-it-footnote";
|
|
3
|
+
import { createHighlightPlugin } from "./plugins/highlight.ts";
|
|
4
|
+
import { createAnchorsPlugin } from "./plugins/anchors.ts";
|
|
5
|
+
import { createMermaidPlugin } from "./plugins/mermaid.ts";
|
|
6
|
+
import { createTaskListPlugin } from "./plugins/tasks.ts";
|
|
7
|
+
import { createStrikethroughPlugin } from "./plugins/strikethrough.ts";
|
|
8
|
+
import { processAlerts } from "./plugins/alerts.ts";
|
|
9
|
+
|
|
10
|
+
export interface ParserOptions {
|
|
11
|
+
html?: boolean;
|
|
12
|
+
linkify?: boolean;
|
|
13
|
+
typographer?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function createParser(options: ParserOptions = {}): MarkdownIt {
|
|
17
|
+
const md = new MarkdownIt({
|
|
18
|
+
html: options.html ?? true,
|
|
19
|
+
linkify: options.linkify ?? true,
|
|
20
|
+
typographer: options.typographer ?? false,
|
|
21
|
+
breaks: false,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
// Apply plugins
|
|
25
|
+
md.use(createHighlightPlugin());
|
|
26
|
+
md.use(createMermaidPlugin());
|
|
27
|
+
md.use(createTaskListPlugin());
|
|
28
|
+
md.use(createStrikethroughPlugin());
|
|
29
|
+
md.use(footnotePlugin);
|
|
30
|
+
md.use(createAnchorsPlugin());
|
|
31
|
+
|
|
32
|
+
return md;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function renderMarkdown(
|
|
36
|
+
content: string,
|
|
37
|
+
options?: ParserOptions,
|
|
38
|
+
): string {
|
|
39
|
+
const md = createParser(options);
|
|
40
|
+
let html = md.render(content);
|
|
41
|
+
html = processAlerts(html);
|
|
42
|
+
return html;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function extractDescription(content: string): string {
|
|
46
|
+
const lines = content.split("\n");
|
|
47
|
+
let foundHeading = false;
|
|
48
|
+
let description = "";
|
|
49
|
+
|
|
50
|
+
for (const line of lines) {
|
|
51
|
+
const trimmed = line.trim();
|
|
52
|
+
if (trimmed.startsWith("#")) {
|
|
53
|
+
foundHeading = true;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
// Skip non-text content
|
|
57
|
+
if (
|
|
58
|
+
!foundHeading ||
|
|
59
|
+
!trimmed ||
|
|
60
|
+
trimmed.startsWith("#") ||
|
|
61
|
+
trimmed.startsWith("-") ||
|
|
62
|
+
trimmed.startsWith("*") ||
|
|
63
|
+
trimmed.startsWith("`") ||
|
|
64
|
+
trimmed.startsWith("!") || // Images
|
|
65
|
+
trimmed.startsWith("|") || // Tables
|
|
66
|
+
trimmed.startsWith(">") || // Blockquotes
|
|
67
|
+
trimmed.startsWith("[!") || // Alerts
|
|
68
|
+
/^\d+\./.test(trimmed) || // Ordered lists
|
|
69
|
+
/^</.test(trimmed) || // HTML tags
|
|
70
|
+
/^\[.*\]:/.test(trimmed) // Reference links
|
|
71
|
+
) {
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Found a text paragraph - extract it
|
|
76
|
+
description = trimmed
|
|
77
|
+
.replace(/!\[([^\]]*)\]\([^)]+\)/g, "") // Remove images
|
|
78
|
+
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") // Convert links to text
|
|
79
|
+
.replace(/\*\*([^*]+)\*\*/g, "$1") // Remove bold
|
|
80
|
+
.replace(/\*([^*]+)\*/g, "$1") // Remove italic
|
|
81
|
+
.replace(/`([^`]+)`/g, "$1") // Remove inline code
|
|
82
|
+
.replace(/\s+/g, " ") // Normalize whitespace
|
|
83
|
+
.trim();
|
|
84
|
+
|
|
85
|
+
if (description) {
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return description || "No description provided.";
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function extractTopics(_repoName: string): string[] {
|
|
94
|
+
return ["markdown", "preview", "documentation"];
|
|
95
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export type AlertType = "note" | "tip" | "important" | "warning" | "caution";
|
|
2
|
+
|
|
3
|
+
export const ALERT_ICONS: Record<AlertType, string> = {
|
|
4
|
+
note: '<svg viewBox="0 0 16 16" width="16" height="16"><path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.5 7.75A.75.75 0 0 1 7.25 7h1a.75.75 0 0 1 .75.75v2.75h.25a.75.75 0 0 1 0 1.5h-2a.75.75 0 0 1 0-1.5h.25v-2h-.25a.75.75 0 0 1-.75-.75ZM8 6a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z"></path></svg>',
|
|
5
|
+
tip: '<svg viewBox="0 0 16 16" width="16" height="16"><path d="M8 1.5c-2.363 0-4 1.69-4 3.75 0 .984.424 1.625.984 2.304l.214.253c.223.264.47.556.673.848.284.411.537.896.621 1.49a.75.75 0 0 1-1.484.211c-.04-.282-.163-.547-.37-.847a8.456 8.456 0 0 0-.542-.68c-.084-.1-.173-.205-.268-.32C3.201 7.75 2.5 6.766 2.5 5.25 2.5 2.31 4.863 0 8 0s5.5 2.31 5.5 5.25c0 1.516-.701 2.5-1.328 3.259-.095.115-.184.22-.268.319-.207.245-.383.453-.541.681-.208.3-.33.565-.37.847a.751.751 0 0 1-1.485-.212c.084-.593.337-1.078.621-1.489.203-.292.45-.584.673-.848.075-.088.147-.173.213-.253.561-.679.985-1.32.985-2.304 0-2.06-1.637-3.75-4-3.75ZM5.75 12h4.5a.75.75 0 0 1 0 1.5h-4.5a.75.75 0 0 1 0-1.5ZM6 15.25a.75.75 0 0 1 .75-.75h2.5a.75.75 0 0 1 0 1.5h-2.5a.75.75 0 0 1-.75-.75Z"></path></svg>',
|
|
6
|
+
important:
|
|
7
|
+
'<svg viewBox="0 0 16 16" width="16" height="16"><path d="M0 1.75C0 .784.784 0 1.75 0h12.5C15.216 0 16 .784 16 1.75v9.5A1.75 1.75 0 0 1 14.25 13H8.06l-2.573 2.573A1.458 1.458 0 0 1 3 14.543V13H1.75A1.75 1.75 0 0 1 0 11.25Zm1.75-.25a.25.25 0 0 0-.25.25v9.5c0 .138.112.25.25.25h2a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h6.5a.25.25 0 0 0 .25-.25v-9.5a.25.25 0 0 0-.25-.25Zm7 2.25v2.5a.75.75 0 0 1-1.5 0v-2.5a.75.75 0 0 1 1.5 0ZM9 9a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z"></path></svg>',
|
|
8
|
+
warning:
|
|
9
|
+
'<svg viewBox="0 0 16 16" width="16" height="16"><path d="M6.457 1.047c.659-1.234 2.427-1.234 3.086 0l6.082 11.378A1.75 1.75 0 0 1 14.082 15H1.918a1.75 1.75 0 0 1-1.543-2.575Zm1.763.707a.25.25 0 0 0-.44 0L1.698 13.132a.25.25 0 0 0 .22.368h12.164a.25.25 0 0 0 .22-.368Zm.53 3.996v2.5a.75.75 0 0 1-1.5 0v-2.5a.75.75 0 0 1 1.5 0ZM9 11a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z"></path></svg>',
|
|
10
|
+
caution:
|
|
11
|
+
'<svg viewBox="0 0 16 16" width="16" height="16"><path d="M4.47.22A.749.749 0 0 1 5 0h6c.199 0 .389.079.53.22l4.25 4.25c.141.14.22.331.22.53v6a.749.749 0 0 1-.22.53l-4.25 4.25A.749.749 0 0 1 11 16H5a.749.749 0 0 1-.53-.22L.22 11.53A.749.749 0 0 1 0 11V5c0-.199.079-.389.22-.53Zm.84 1.28L1.5 5.31v5.38l3.81 3.81h5.38l3.81-3.81V5.31L10.69 1.5ZM8 4a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5A.75.75 0 0 1 8 4Zm0 8a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z"></path></svg>',
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const ALERT_TYPES = ["note", "tip", "important", "warning", "caution"] as const;
|
|
15
|
+
|
|
16
|
+
export function processAlerts(html: string): string {
|
|
17
|
+
const alertBlockRegex = /<blockquote>([\s\S]*?)<\/blockquote>/gi;
|
|
18
|
+
const alertPrefixRegex =
|
|
19
|
+
/^\s*<p>\s*\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\](?:<br\s*\/?>(?:\s*)?|\s)*/i;
|
|
20
|
+
|
|
21
|
+
return html.replace(alertBlockRegex, (match, inner) => {
|
|
22
|
+
const prefixMatch = inner.match(alertPrefixRegex);
|
|
23
|
+
if (!prefixMatch) {
|
|
24
|
+
return match;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const typeKey = prefixMatch[1].toLowerCase() as AlertType;
|
|
28
|
+
const icon = ALERT_ICONS[typeKey] || "";
|
|
29
|
+
const title =
|
|
30
|
+
prefixMatch[1].charAt(0) + prefixMatch[1].slice(1).toLowerCase();
|
|
31
|
+
const cleanedContent = inner.replace(alertPrefixRegex, "<p>").trim();
|
|
32
|
+
|
|
33
|
+
return `<div class="markdown-alert markdown-alert-${typeKey}">
|
|
34
|
+
<p class="markdown-alert-title">${icon}${title}</p>
|
|
35
|
+
${cleanedContent}
|
|
36
|
+
</div>`;
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function isAlertType(type: string): type is AlertType {
|
|
41
|
+
return ALERT_TYPES.includes(type as AlertType);
|
|
42
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type MarkdownIt from "markdown-it";
|
|
2
|
+
import type StateCore from "markdown-it/lib/rules_core/state_core.mjs";
|
|
3
|
+
import type Token from "markdown-it/lib/token.mjs";
|
|
4
|
+
|
|
5
|
+
export function slugify(text: string): string {
|
|
6
|
+
return text
|
|
7
|
+
.toLowerCase()
|
|
8
|
+
.trim()
|
|
9
|
+
.replace(/<[^>]*>/g, "")
|
|
10
|
+
.replace(/[^\w\s-]/g, "")
|
|
11
|
+
.replace(/\s+/g, "-");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const ANCHOR_ICON = `<svg class="octicon" viewBox="0 0 16 16" width="16" height="16"><path d="m7.775 3.275 1.25-1.25a3.5 3.5 0 1 1 4.95 4.95l-2.5 2.5a3.5 3.5 0 0 1-4.95 0 .751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018 1.998 1.998 0 0 0 2.83 0l2.5-2.5a2.002 2.002 0 0 0-2.83-2.83l-1.25 1.25a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042Zm-4.69 9.64a1.998 1.998 0 0 0 2.83 0l1.25-1.25a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042l-1.25 1.25a3.5 3.5 0 1 1-4.95-4.95l2.5-2.5a3.5 3.5 0 0 1 4.95 0 .751.751 0 0 1-.018 1.042.751.751 0 0 1-1.042.018 1.998 1.998 0 0 0-2.83 0l-2.5 2.5a1.998 1.998 0 0 0 0 2.83Z"></path></svg>`;
|
|
15
|
+
|
|
16
|
+
export function createAnchorsPlugin(): (md: MarkdownIt) => void {
|
|
17
|
+
return (md: MarkdownIt) => {
|
|
18
|
+
md.core.ruler.push("github_anchors", (state: StateCore) => {
|
|
19
|
+
const tokens = state.tokens;
|
|
20
|
+
|
|
21
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
22
|
+
const token = tokens[i];
|
|
23
|
+
if (token.type === "heading_open") {
|
|
24
|
+
const inlineToken = tokens[i + 1];
|
|
25
|
+
if (inlineToken && inlineToken.type === "inline") {
|
|
26
|
+
const text = getHeadingText(inlineToken.children || []);
|
|
27
|
+
const slug = slugify(text);
|
|
28
|
+
|
|
29
|
+
// Add id to heading
|
|
30
|
+
token.attrSet("id", slug);
|
|
31
|
+
|
|
32
|
+
// Prepend anchor link to content
|
|
33
|
+
const anchorHtml = `<a class="anchor" href="#${slug}">${ANCHOR_ICON}</a>`;
|
|
34
|
+
if (inlineToken.children && inlineToken.children.length > 0) {
|
|
35
|
+
const anchorToken = new state.Token("html_inline", "", 0);
|
|
36
|
+
anchorToken.content = anchorHtml;
|
|
37
|
+
inlineToken.children.unshift(anchorToken);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function getHeadingText(tokens: Token[]): string {
|
|
47
|
+
return tokens
|
|
48
|
+
.filter((t) => t.type === "text" || t.type === "code_inline")
|
|
49
|
+
.map((t) => t.content)
|
|
50
|
+
.join("");
|
|
51
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import hljs from "highlight.js";
|
|
2
|
+
import type MarkdownIt from "markdown-it";
|
|
3
|
+
|
|
4
|
+
export function highlightCode(code: string, language: string): string {
|
|
5
|
+
if (language && hljs.getLanguage(language)) {
|
|
6
|
+
try {
|
|
7
|
+
return hljs.highlight(code, { language }).value;
|
|
8
|
+
} catch {
|
|
9
|
+
// Fall through to auto-highlight
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
return hljs.highlightAuto(code).value;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function createHighlightPlugin(): (md: MarkdownIt) => void {
|
|
16
|
+
return (md: MarkdownIt) => {
|
|
17
|
+
md.options.highlight = (code: string, lang: string): string => {
|
|
18
|
+
return highlightCode(code, lang);
|
|
19
|
+
};
|
|
20
|
+
};
|
|
21
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type MarkdownIt from "markdown-it";
|
|
2
|
+
|
|
3
|
+
export function createMermaidPlugin(): (md: MarkdownIt) => void {
|
|
4
|
+
return (md: MarkdownIt) => {
|
|
5
|
+
const defaultFence =
|
|
6
|
+
md.renderer.rules.fence ||
|
|
7
|
+
((tokens, idx, options, env, self) =>
|
|
8
|
+
self.renderToken(tokens, idx, options));
|
|
9
|
+
|
|
10
|
+
md.renderer.rules.fence = (tokens, idx, options, env, self) => {
|
|
11
|
+
const token = tokens[idx];
|
|
12
|
+
const info = token.info.trim();
|
|
13
|
+
|
|
14
|
+
if (info === "mermaid") {
|
|
15
|
+
// Don't escape mermaid content - it's parsed by mermaid.js, not rendered as HTML
|
|
16
|
+
// The content is plain text diagram definitions (arrows like --> would break if escaped)
|
|
17
|
+
const code = token.content.trim();
|
|
18
|
+
return `<pre class="mermaid">${code}</pre>\n`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return defaultFence(tokens, idx, options, env, self);
|
|
22
|
+
};
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function escapeHtml(str: string): string {
|
|
27
|
+
return str
|
|
28
|
+
.replace(/&/g, "&")
|
|
29
|
+
.replace(/</g, "<")
|
|
30
|
+
.replace(/>/g, ">")
|
|
31
|
+
.replace(/"/g, """)
|
|
32
|
+
.replace(/'/g, "'");
|
|
33
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type MarkdownIt from "markdown-it";
|
|
2
|
+
import type StateInline from "markdown-it/lib/rules_inline/state_inline.mjs";
|
|
3
|
+
|
|
4
|
+
export function createStrikethroughPlugin(): (md: MarkdownIt) => void {
|
|
5
|
+
return (md: MarkdownIt) => {
|
|
6
|
+
md.inline.ruler.before("emphasis", "strikethrough", strikethroughTokenize);
|
|
7
|
+
md.inline.ruler2.before(
|
|
8
|
+
"emphasis",
|
|
9
|
+
"strikethrough",
|
|
10
|
+
strikethroughPostProcess,
|
|
11
|
+
);
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function strikethroughTokenize(state: StateInline, silent: boolean): boolean {
|
|
16
|
+
const start = state.pos;
|
|
17
|
+
const max = state.posMax;
|
|
18
|
+
const marker = state.src.charCodeAt(start);
|
|
19
|
+
|
|
20
|
+
if (marker !== 0x7e /* ~ */) return false;
|
|
21
|
+
if (start + 1 >= max || state.src.charCodeAt(start + 1) !== 0x7e)
|
|
22
|
+
return false;
|
|
23
|
+
|
|
24
|
+
if (silent) return false;
|
|
25
|
+
|
|
26
|
+
const scanned = state.scanDelims(start, true);
|
|
27
|
+
if (scanned.length < 2) return false;
|
|
28
|
+
|
|
29
|
+
const count = scanned.length;
|
|
30
|
+
const token = state.push("text", "", 0);
|
|
31
|
+
token.content = "~".repeat(count);
|
|
32
|
+
|
|
33
|
+
state.delimiters.push({
|
|
34
|
+
marker: 0x7e,
|
|
35
|
+
length: count,
|
|
36
|
+
token: state.tokens.length - 1,
|
|
37
|
+
end: -1,
|
|
38
|
+
open: scanned.can_open,
|
|
39
|
+
close: scanned.can_close,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
state.pos += count;
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function strikethroughPostProcess(state: StateInline): boolean {
|
|
47
|
+
const delimiters = state.delimiters;
|
|
48
|
+
const max = delimiters.length;
|
|
49
|
+
|
|
50
|
+
// Find matching pairs
|
|
51
|
+
for (let i = max - 1; i >= 0; i--) {
|
|
52
|
+
const startDelim = delimiters[i];
|
|
53
|
+
if (startDelim.marker !== 0x7e || !startDelim.open) continue;
|
|
54
|
+
|
|
55
|
+
for (let j = i + 1; j < max; j++) {
|
|
56
|
+
const endDelim = delimiters[j];
|
|
57
|
+
if (endDelim.marker !== 0x7e || !endDelim.close || endDelim.end !== -1)
|
|
58
|
+
continue;
|
|
59
|
+
|
|
60
|
+
// Found a pair
|
|
61
|
+
const startToken = state.tokens[startDelim.token];
|
|
62
|
+
const endToken = state.tokens[endDelim.token];
|
|
63
|
+
|
|
64
|
+
startToken.type = "s_open";
|
|
65
|
+
startToken.tag = "s";
|
|
66
|
+
startToken.nesting = 1;
|
|
67
|
+
startToken.markup = "~~";
|
|
68
|
+
startToken.content = "";
|
|
69
|
+
|
|
70
|
+
endToken.type = "s_close";
|
|
71
|
+
endToken.tag = "s";
|
|
72
|
+
endToken.nesting = -1;
|
|
73
|
+
endToken.markup = "~~";
|
|
74
|
+
endToken.content = "";
|
|
75
|
+
|
|
76
|
+
// Handle extra tildes
|
|
77
|
+
if (startDelim.length > 2) {
|
|
78
|
+
startToken.content = "~".repeat(startDelim.length - 2);
|
|
79
|
+
state.tokens.splice(startDelim.token, 0, {
|
|
80
|
+
...startToken,
|
|
81
|
+
type: "text",
|
|
82
|
+
tag: "",
|
|
83
|
+
nesting: 0,
|
|
84
|
+
markup: "",
|
|
85
|
+
content: "~".repeat(startDelim.length - 2),
|
|
86
|
+
} as any);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
startDelim.end = j;
|
|
90
|
+
endDelim.end = i;
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type MarkdownIt from "markdown-it";
|
|
2
|
+
|
|
3
|
+
export function createTaskListPlugin(): (md: MarkdownIt) => void {
|
|
4
|
+
return (md: MarkdownIt) => {
|
|
5
|
+
md.core.ruler.after("inline", "task_list", (state) => {
|
|
6
|
+
const tokens = state.tokens;
|
|
7
|
+
|
|
8
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
9
|
+
const token = tokens[i];
|
|
10
|
+
|
|
11
|
+
if (token.type === "bullet_list_open") {
|
|
12
|
+
processTaskList(tokens, i);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function processTaskList(tokens: MarkdownIt.Token[], listIdx: number): void {
|
|
20
|
+
let hasTaskItem = false;
|
|
21
|
+
let depth = 0;
|
|
22
|
+
|
|
23
|
+
for (let i = listIdx; i < tokens.length; i++) {
|
|
24
|
+
const token = tokens[i];
|
|
25
|
+
|
|
26
|
+
if (token.type === "bullet_list_open") {
|
|
27
|
+
depth++;
|
|
28
|
+
} else if (token.type === "bullet_list_close") {
|
|
29
|
+
depth--;
|
|
30
|
+
if (depth === 0) break;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (depth === 1 && token.type === "list_item_open") {
|
|
34
|
+
const inlineIdx = findInlineToken(tokens, i);
|
|
35
|
+
if (inlineIdx !== -1) {
|
|
36
|
+
const inlineToken = tokens[inlineIdx];
|
|
37
|
+
const result = processTaskItem(inlineToken);
|
|
38
|
+
if (result) {
|
|
39
|
+
hasTaskItem = true;
|
|
40
|
+
token.attrSet("class", "task-list-item");
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (hasTaskItem) {
|
|
47
|
+
tokens[listIdx].attrJoin("class", "contains-task-list");
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function findInlineToken(tokens: MarkdownIt.Token[], startIdx: number): number {
|
|
52
|
+
for (let i = startIdx + 1; i < tokens.length; i++) {
|
|
53
|
+
if (tokens[i].type === "inline") return i;
|
|
54
|
+
if (tokens[i].type === "list_item_close") return -1;
|
|
55
|
+
}
|
|
56
|
+
return -1;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function processTaskItem(
|
|
60
|
+
inlineToken: MarkdownIt.Token,
|
|
61
|
+
): { checked: boolean } | null {
|
|
62
|
+
if (!inlineToken.children || inlineToken.children.length === 0) {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const firstChild = inlineToken.children[0];
|
|
67
|
+
if (firstChild.type !== "text") return null;
|
|
68
|
+
|
|
69
|
+
const match = firstChild.content.match(/^\[([ xX])\]\s*/);
|
|
70
|
+
if (!match) return null;
|
|
71
|
+
|
|
72
|
+
const checked = match[1].toLowerCase() === "x";
|
|
73
|
+
firstChild.content = firstChild.content.slice(match[0].length);
|
|
74
|
+
|
|
75
|
+
// Insert checkbox token at the beginning
|
|
76
|
+
const checkboxToken = new (inlineToken.constructor as any)(
|
|
77
|
+
"html_inline",
|
|
78
|
+
"",
|
|
79
|
+
0,
|
|
80
|
+
);
|
|
81
|
+
checkboxToken.content = checked
|
|
82
|
+
? '<input type="checkbox" class="task-list-item-checkbox" checked disabled>'
|
|
83
|
+
: '<input type="checkbox" class="task-list-item-checkbox" disabled>';
|
|
84
|
+
|
|
85
|
+
inlineToken.children.unshift(checkboxToken);
|
|
86
|
+
|
|
87
|
+
return { checked };
|
|
88
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { dirname } from "path";
|
|
2
|
+
import { resolveAssetPath, getContentType } from "../utils/paths.ts";
|
|
3
|
+
|
|
4
|
+
const ASSETS_PREFIX = "/__assets__/";
|
|
5
|
+
|
|
6
|
+
export function isAssetRequest(pathname: string): boolean {
|
|
7
|
+
return pathname.startsWith(ASSETS_PREFIX);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function extractAssetPath(pathname: string): string {
|
|
11
|
+
return decodeURIComponent(pathname.slice(ASSETS_PREFIX.length));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function serveAsset(
|
|
15
|
+
assetPath: string,
|
|
16
|
+
markdownDir: string,
|
|
17
|
+
): Promise<Response | null> {
|
|
18
|
+
const resolvedPath = resolveAssetPath(assetPath, markdownDir);
|
|
19
|
+
|
|
20
|
+
if (!resolvedPath) {
|
|
21
|
+
return new Response("Asset not found", { status: 404 });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
const file = Bun.file(resolvedPath);
|
|
26
|
+
const exists = await file.exists();
|
|
27
|
+
|
|
28
|
+
if (!exists) {
|
|
29
|
+
return new Response("Asset not found", { status: 404 });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const contentType = getContentType(resolvedPath);
|
|
33
|
+
|
|
34
|
+
return new Response(file, {
|
|
35
|
+
headers: {
|
|
36
|
+
"Content-Type": contentType,
|
|
37
|
+
"Cache-Control": "public, max-age=31536000",
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
} catch {
|
|
41
|
+
return new Response("Error serving asset", { status: 500 });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function rewriteAssetUrls(html: string, _markdownDir: string): string {
|
|
46
|
+
// Rewrite relative image src attributes to use the asset proxy
|
|
47
|
+
// Matches: src="./path" src="../path" src="path" (not http:// or data:)
|
|
48
|
+
return html.replace(
|
|
49
|
+
/(<img[^>]*\ssrc=["'])(?!https?:\/\/|data:|\/\/)([^"']+)(["'][^>]*>)/gi,
|
|
50
|
+
(match, prefix, src, suffix) => {
|
|
51
|
+
// Skip if already proxied
|
|
52
|
+
if (src.startsWith(ASSETS_PREFIX)) {
|
|
53
|
+
return match;
|
|
54
|
+
}
|
|
55
|
+
return `${prefix}${ASSETS_PREFIX}${encodeURIComponent(src)}${suffix}`;
|
|
56
|
+
},
|
|
57
|
+
);
|
|
58
|
+
}
|