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 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": "1.1.0",
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": "./cli.ts"
8
+ "peekmd": "src/cli.ts"
9
9
  },
10
10
  "files": [
11
- "cli.ts",
12
- "index.ts"
11
+ "src"
13
12
  ],
14
13
  "scripts": {
15
- "dev": "bun run ./cli.ts README.md",
16
- "compile": "bun build ./cli.ts --compile --outfile peekmd",
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
- "marked": "^17.0.1"
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, "&amp;")
29
+ .replace(/</g, "&lt;")
30
+ .replace(/>/g, "&gt;")
31
+ .replace(/"/g, "&quot;")
32
+ .replace(/'/g, "&#39;");
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
+ }