srvmd 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.
Files changed (52) hide show
  1. package/ARCHITECTURE.md +237 -0
  2. package/LICENSE +21 -0
  3. package/README.md +214 -0
  4. package/README_CN.md +214 -0
  5. package/package.json +68 -0
  6. package/src/cli/browser.ts +14 -0
  7. package/src/cli/command.ts +56 -0
  8. package/src/cli/index.ts +16 -0
  9. package/src/cli/lifecycle.ts +13 -0
  10. package/src/cli/listen.ts +27 -0
  11. package/src/cli/options.ts +52 -0
  12. package/src/cli/report.ts +40 -0
  13. package/src/server/assets/mermaid.ts +38 -0
  14. package/src/server/assets/web.ts +34 -0
  15. package/src/server/config.ts +99 -0
  16. package/src/server/constants.ts +33 -0
  17. package/src/server/context.ts +40 -0
  18. package/src/server/create-server.ts +69 -0
  19. package/src/server/file-kinds.ts +15 -0
  20. package/src/server/http/headers.ts +11 -0
  21. package/src/server/http/respond.ts +74 -0
  22. package/src/server/http/validators.ts +37 -0
  23. package/src/server/markdown/frontmatter.ts +29 -0
  24. package/src/server/markdown/highlight.ts +100 -0
  25. package/src/server/markdown/index.ts +8 -0
  26. package/src/server/markdown/mermaid.ts +30 -0
  27. package/src/server/markdown/render.ts +119 -0
  28. package/src/server/markdown/text.ts +30 -0
  29. package/src/server/markdown/toc.ts +29 -0
  30. package/src/server/path.ts +9 -0
  31. package/src/server/resolve/path.ts +43 -0
  32. package/src/server/resolve/resource.ts +88 -0
  33. package/src/server/routes/index.ts +91 -0
  34. package/src/server/routes/page-api.ts +88 -0
  35. package/src/server/routes/shell.ts +50 -0
  36. package/src/server/services/directories.ts +41 -0
  37. package/src/server/services/documents.ts +43 -0
  38. package/src/server/services/live/client-script.ts +12 -0
  39. package/src/server/services/live/hub.ts +50 -0
  40. package/src/server/services/live/index.ts +33 -0
  41. package/src/server/services/live/watcher.ts +126 -0
  42. package/src/server/types.ts +20 -0
  43. package/src/shared/breadcrumbs.ts +14 -0
  44. package/src/shared/constants.ts +20 -0
  45. package/src/shared/contracts.ts +49 -0
  46. package/src/shared/types.ts +33 -0
  47. package/web/dist/assets/index-BjX1ijWA.js +2 -0
  48. package/web/dist/assets/index-DRuRRv0z.css +1 -0
  49. package/web/dist/assets/live-CRaSJLxT.js +1 -0
  50. package/web/dist/index.html +18 -0
  51. package/web/dist/live-static.js +1 -0
  52. package/web/dist/theme-boot.js +16 -0
package/package.json ADDED
@@ -0,0 +1,68 @@
1
+ {
2
+ "name": "srvmd",
3
+ "version": "0.1.0",
4
+ "description": "Static file server with live-reloading Markdown rendering, built on Bun",
5
+ "type": "module",
6
+ "bin": {
7
+ "srvmd": "src/cli/index.ts"
8
+ },
9
+ "files": [
10
+ "src",
11
+ "web/dist",
12
+ "!src/**/*.test.ts",
13
+ "README.md",
14
+ "README_CN.md",
15
+ "ARCHITECTURE.md",
16
+ "LICENSE"
17
+ ],
18
+ "scripts": {
19
+ "dev": "bun --watch src/cli/index.ts",
20
+ "dev:web": "vite build --watch --config web/vite.config.ts",
21
+ "build": "vite build --config web/vite.config.ts",
22
+ "start": "bun src/cli/index.ts",
23
+ "test": "bun run test:server && bun run test:web",
24
+ "test:server": "bun test",
25
+ "test:web": "vitest run --config web/vitest.config.ts",
26
+ "test:watch": "vitest --config web/vitest.config.ts",
27
+ "typecheck": "tsc --noEmit && svelte-check --tsconfig web/tsconfig.json",
28
+ "check": "biome check . && prettier --check \"web/**/*.svelte\"",
29
+ "check:fix": "biome check --write . && prettier --write \"web/**/*.svelte\"",
30
+ "lint": "biome lint .",
31
+ "format": "biome format --write . && prettier --write \"web/**/*.svelte\"",
32
+ "prepack": "bun run build",
33
+ "test:e2e": "playwright test"
34
+ },
35
+ "keywords": [
36
+ "bun",
37
+ "markdown",
38
+ "static-server",
39
+ "live-reload",
40
+ "preview",
41
+ "serve-markdown",
42
+ "md"
43
+ ],
44
+ "license": "MIT",
45
+ "engines": {
46
+ "bun": ">=1.3.8"
47
+ },
48
+ "devDependencies": {
49
+ "@biomejs/biome": "2.5.15",
50
+ "@playwright/test": "^1.64.1",
51
+ "@sveltejs/vite-plugin-svelte": "^7.3.1",
52
+ "@testing-library/svelte": "^5.4.2",
53
+ "@types/bun": "latest",
54
+ "jsdom": "^30.1.3",
55
+ "prettier": "^3.9.10",
56
+ "prettier-plugin-svelte": "^4.1.1",
57
+ "svelte": "^5.57.2",
58
+ "svelte-check": "^4.7.7",
59
+ "typescript": "6.0.3",
60
+ "vite": "^8.3.5",
61
+ "vitest": "^5.0.5"
62
+ },
63
+ "dependencies": {
64
+ "commander": "^15.0.0",
65
+ "mermaid": "^12.2.0",
66
+ "shiki": "^4.5.0"
67
+ }
68
+ }
@@ -0,0 +1,14 @@
1
+ export async function openInBrowser(url: string): Promise<void> {
2
+ const command =
3
+ process.platform === "darwin"
4
+ ? ["open", url]
5
+ : process.platform === "win32"
6
+ ? ["cmd", "/c", "start", "", url]
7
+ : ["xdg-open", url];
8
+
9
+ try {
10
+ Bun.spawn({ cmd: command, stdout: "ignore", stderr: "ignore" }).unref();
11
+ } catch {
12
+ // Opening the browser is best-effort.
13
+ }
14
+ }
@@ -0,0 +1,56 @@
1
+ import { Command } from "commander";
2
+ import pkg from "../../package.json";
3
+ import { startServer } from "../server/create-server.ts";
4
+ import { openInBrowser } from "./browser.ts";
5
+ import { installSignalHandlers } from "./lifecycle.ts";
6
+ import { type RawCliOptions, toServerOptions } from "./options.ts";
7
+ import { printStartupReport } from "./report.ts";
8
+
9
+ function collect(value: string, previous: string[] = []): string[] {
10
+ return [...previous, value];
11
+ }
12
+
13
+ export function createCommand(): Command {
14
+ return new Command()
15
+ .name("srvmd")
16
+ .description("A static file server that renders Markdown, with live reload.")
17
+ .usage("[directory] [options]")
18
+ .argument("[directory]", "directory to serve", ".")
19
+ .option("-l, --listen <value>", "port or interface to listen on", "3000")
20
+ .option("-o, --open", "open the served URL in the default browser")
21
+ .option("-s, --single", "serve index.html (or index.md) for unmatched routes")
22
+ .option("-c, --cors", "send permissive CORS headers")
23
+ .option("--theme <theme>", "default theme: auto, light or dark", "auto")
24
+ .option("--html <mode>", "raw HTML handling: filter, allow or off", "filter")
25
+ .option("--math", "enable $inline$ and $$display$$ math")
26
+ .option("--ignore <name>", "extra names the watcher should ignore (repeatable)", collect, [])
27
+ .option("--no-live", "disable live reload over WebSocket")
28
+ .option("--no-highlight", "disable syntax highlighting")
29
+ .option("--no-mermaid", "disable Mermaid diagram rendering")
30
+ .option("--no-clean-urls", "disable resolving /page to page.md")
31
+ .option("--no-directory-listing", "disable generated directory indexes")
32
+ .version(pkg.version, "-v, --version", "print the version")
33
+ .helpOption("-h, --help", "display help for command")
34
+ .showHelpAfterError()
35
+ .action(async (directory: string, raw: RawCliOptions) => {
36
+ const options = toServerOptions(directory, raw);
37
+ const running = await startServer(options);
38
+
39
+ printStartupReport({
40
+ root: running.root,
41
+ url: running.url,
42
+ port: running.port,
43
+ features: [
44
+ options.live !== false ? "live reload" : null,
45
+ options.highlight !== false ? "highlight" : null,
46
+ options.mermaid !== false ? "mermaid" : null,
47
+ options.cleanUrls !== false ? "clean urls" : null,
48
+ options.directoryListing !== false ? "directory listing" : null,
49
+ options.single ? "single page" : null,
50
+ ].filter((feature): feature is string => feature !== null),
51
+ });
52
+
53
+ if (raw.open) await openInBrowser(running.url);
54
+ installSignalHandlers(() => running.close());
55
+ });
56
+ }
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env bun
2
+ import { createCommand } from "./command.ts";
3
+
4
+ async function main(): Promise<void> {
5
+ const program = createCommand();
6
+ await program.parseAsync(Bun.argv.slice(2), { from: "user" });
7
+ }
8
+
9
+ main().catch(error => {
10
+ const message = error instanceof Error ? error.message : String(error);
11
+ process.stderr.write(`srvmd: ${message}\n`);
12
+ if (/EADDRINUSE|address already in use/i.test(message)) {
13
+ process.stderr.write("Hint: choose another port with --listen.\n");
14
+ }
15
+ process.exit(1);
16
+ });
@@ -0,0 +1,13 @@
1
+ export function installSignalHandlers(close: () => void): void {
2
+ let stopping = false;
3
+
4
+ const shutdown = () => {
5
+ if (stopping) return;
6
+ stopping = true;
7
+ close();
8
+ process.exit(0);
9
+ };
10
+
11
+ process.on("SIGINT", shutdown);
12
+ process.on("SIGTERM", shutdown);
13
+ }
@@ -0,0 +1,27 @@
1
+ export type ListenOptions = {
2
+ hostname?: string;
3
+ port: number;
4
+ };
5
+
6
+ function assertPort(port: number, source: string): number {
7
+ if (!Number.isInteger(port) || port < 0 || port > 65535) {
8
+ throw new Error(`Invalid port in --listen value: ${source}`);
9
+ }
10
+ return port;
11
+ }
12
+
13
+ /** Parse `3000`, `host:3000`, or `[::1]:3000`. */
14
+ export function parseListen(value: string | undefined): ListenOptions {
15
+ if (!value) return { port: 3000 };
16
+ if (/^\d+$/.test(value)) return { port: assertPort(Number(value), value) };
17
+
18
+ const bracketed = /^\[([^\]]+)\]:(\d+)$/.exec(value);
19
+ if (bracketed) return { hostname: bracketed[1], port: assertPort(Number(bracketed[2]), value) };
20
+
21
+ const separator = value.lastIndexOf(":");
22
+ if (separator === -1) throw new Error(`Invalid --listen value: ${value}`);
23
+
24
+ const hostname = value.slice(0, separator) || undefined;
25
+ const port = assertPort(Number(value.slice(separator + 1)), value);
26
+ return { hostname, port };
27
+ }
@@ -0,0 +1,52 @@
1
+ import { resolve } from "node:path";
2
+ import type { ServeMarkdownOptions } from "../server/config.ts";
3
+ import type { HtmlMode } from "../server/types.ts";
4
+ import type { Theme } from "../shared/types.ts";
5
+ import { parseListen } from "./listen.ts";
6
+
7
+ export type RawCliOptions = {
8
+ listen: string;
9
+ open?: boolean;
10
+ single?: boolean;
11
+ cors?: boolean;
12
+ theme: string;
13
+ html: string;
14
+ math?: boolean;
15
+ ignore?: string[];
16
+ live: boolean;
17
+ highlight: boolean;
18
+ mermaid: boolean;
19
+ cleanUrls: boolean;
20
+ directoryListing: boolean;
21
+ };
22
+
23
+ const THEMES: Theme[] = ["auto", "light", "dark"];
24
+ const HTML_MODES: HtmlMode[] = ["filter", "allow", "off"];
25
+
26
+ /** Map Commander's raw values onto the public server options contract. */
27
+ export function toServerOptions(directory: string, raw: RawCliOptions): ServeMarkdownOptions {
28
+ const theme = raw.theme as Theme;
29
+ if (!THEMES.includes(theme)) throw new Error(`Invalid --theme value: ${raw.theme}`);
30
+
31
+ const html = raw.html as HtmlMode;
32
+ if (!HTML_MODES.includes(html)) throw new Error(`Invalid --html value: ${raw.html}`);
33
+
34
+ const { hostname, port } = parseListen(raw.listen);
35
+
36
+ return {
37
+ root: resolve(directory),
38
+ port,
39
+ hostname,
40
+ live: raw.live !== false,
41
+ highlight: raw.highlight !== false,
42
+ cleanUrls: raw.cleanUrls !== false,
43
+ directoryListing: raw.directoryListing !== false,
44
+ single: raw.single === true,
45
+ cors: raw.cors === true,
46
+ theme,
47
+ html,
48
+ math: raw.math === true,
49
+ mermaid: raw.mermaid !== false,
50
+ ignore: raw.ignore,
51
+ };
52
+ }
@@ -0,0 +1,40 @@
1
+ import { networkInterfaces } from "node:os";
2
+
3
+ export type StartupReport = {
4
+ root: string;
5
+ url: string;
6
+ port: number;
7
+ features: string[];
8
+ };
9
+
10
+ function color(enabled: boolean, code: string, text: string): string {
11
+ return enabled ? `\u001b[${code}m${text}\u001b[0m` : text;
12
+ }
13
+
14
+ function localNetworkUrls(port: number): string[] {
15
+ const urls: string[] = [];
16
+ for (const addresses of Object.values(networkInterfaces())) {
17
+ for (const address of addresses ?? []) {
18
+ const family = String(address.family);
19
+ if ((family === "IPv4" || family === "4") && !address.internal) {
20
+ urls.push(`http://${address.address}:${port}`);
21
+ }
22
+ }
23
+ }
24
+ return urls;
25
+ }
26
+
27
+ export function printStartupReport(report: StartupReport): void {
28
+ const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
29
+ const lines = [
30
+ color(useColor, "1", "srvmd"),
31
+ ` Serving ${report.root}`,
32
+ ` Local ${color(useColor, "36", report.url)}`,
33
+ ];
34
+
35
+ for (const networkUrl of localNetworkUrls(report.port)) lines.push(` Network ${networkUrl}`);
36
+ lines.push(` Features ${report.features.join(", ")}`);
37
+ lines.push(" Watched markdown, html and css files (Ctrl+C to stop)");
38
+
39
+ process.stdout.write(`${lines.join("\n")}\n`);
40
+ }
@@ -0,0 +1,38 @@
1
+ import { stat } from "node:fs/promises";
2
+ import { fileURLToPath } from "node:url";
3
+ import { baseHeaders } from "../http/headers.ts";
4
+ import { fileResponse } from "../http/respond.ts";
5
+
6
+ let bundlePath: string | null | undefined;
7
+
8
+ /** Resolve Mermaid's prebuilt browser bundle once, from the local dependency. */
9
+ function resolveBundle(): string | null {
10
+ if (bundlePath !== undefined) return bundlePath;
11
+ try {
12
+ bundlePath = fileURLToPath(import.meta.resolve("mermaid/dist/mermaid.min.js"));
13
+ } catch {
14
+ bundlePath = null;
15
+ }
16
+ return bundlePath;
17
+ }
18
+
19
+ function unavailable(cors: boolean): Response {
20
+ return new Response("Mermaid bundle is unavailable", { status: 503, headers: baseHeaders(cors) });
21
+ }
22
+
23
+ export async function respondMermaidRuntime(request: Request, cors: boolean): Promise<Response> {
24
+ const path = resolveBundle();
25
+ if (!path) return unavailable(cors);
26
+
27
+ const stats = await stat(path).catch(() => null);
28
+ if (!stats?.isFile()) return unavailable(cors);
29
+
30
+ const response = fileResponse(
31
+ { path, stats, relative: "mermaid.min.js" },
32
+ request,
33
+ cors,
34
+ "text/javascript; charset=utf-8",
35
+ );
36
+ response.headers.set("Cache-Control", "public, max-age=3600");
37
+ return response;
38
+ }
@@ -0,0 +1,34 @@
1
+ import { stat } from "node:fs/promises";
2
+ import { resolve } from "node:path";
3
+ import { APP_ASSET_PREFIX } from "../../shared/constants.ts";
4
+ import { fileResponse, textResponse } from "../http/respond.ts";
5
+ import { isInside } from "../path.ts";
6
+
7
+ /** Serve the Vite build under `/__app/*`, outside user-controlled static paths. */
8
+ export async function respondWebAsset(webRoot: string, url: URL, request: Request, cors: boolean): Promise<Response> {
9
+ let pathname: string;
10
+ try {
11
+ pathname = decodeURIComponent(url.pathname);
12
+ } catch {
13
+ return textResponse("Malformed URL", 400, request.method, cors);
14
+ }
15
+
16
+ const relative = pathname.slice(APP_ASSET_PREFIX.length).replace(/^\/+/, "");
17
+ if (!relative || relative.includes("\0")) {
18
+ return textResponse("Not found", 404, request.method, cors);
19
+ }
20
+
21
+ const filePath = resolve(webRoot, relative);
22
+ if (!isInside(webRoot, filePath)) {
23
+ return textResponse("Forbidden", 403, request.method, cors);
24
+ }
25
+
26
+ const stats = await stat(filePath).catch(() => null);
27
+ if (!stats?.isFile()) {
28
+ return textResponse("Not found", 404, request.method, cors);
29
+ }
30
+
31
+ const response = fileResponse({ path: filePath, stats, relative }, request, cors);
32
+ response.headers.set("Cache-Control", "public, max-age=3600");
33
+ return response;
34
+ }
@@ -0,0 +1,99 @@
1
+ import { realpath, stat } from "node:fs/promises";
2
+ import { basename, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import pkg from "../../package.json";
5
+ import type { Theme } from "../shared/types.ts";
6
+ import type { HtmlMode } from "./types.ts";
7
+
8
+ export type ServeMarkdownOptions = {
9
+ /** Directory to serve. Default: process.cwd() */
10
+ root?: string;
11
+ /** Listening port. Use 0 for a random free port. Default: 3000 */
12
+ port?: number;
13
+ /** Interface to bind. Default: 0.0.0.0 */
14
+ hostname?: string;
15
+ /** Push reload events over WebSocket. Default: true */
16
+ live?: boolean;
17
+ /** Highlight fenced code blocks. Default: true */
18
+ highlight?: boolean;
19
+ /** Resolve /page to page.md / page.html. Default: true */
20
+ cleanUrls?: boolean;
21
+ /** Render directory indexes. Default: true */
22
+ directoryListing?: boolean;
23
+ /** Serve index.html (or index.md) for unmatched routes. Default: false */
24
+ single?: boolean;
25
+ /** Send permissive CORS headers. Default: false */
26
+ cors?: boolean;
27
+ /** Default color theme. Default: "auto" */
28
+ theme?: Theme;
29
+ /** Raw HTML handling: "filter" (GFM tag filter), "allow", or "off". Default: "filter" */
30
+ html?: HtmlMode;
31
+ /** Enable LaTeX math. Default: false */
32
+ math?: boolean;
33
+ /** Render ```mermaid fences as interactive diagrams. Default: true */
34
+ mermaid?: boolean;
35
+ /** Extra directory/file names the watcher should ignore. */
36
+ ignore?: string[];
37
+ /** Built Svelte assets. Defaults to `<package>/web/dist`. */
38
+ webRoot?: string;
39
+ };
40
+
41
+ export type ResolvedConfig = {
42
+ root: string;
43
+ rootLabel: string;
44
+ port: number;
45
+ hostname: string;
46
+ live: boolean;
47
+ highlight: boolean;
48
+ cleanUrls: boolean;
49
+ directoryListing: boolean;
50
+ single: boolean;
51
+ cors: boolean;
52
+ theme: Theme;
53
+ html: HtmlMode;
54
+ math: boolean;
55
+ mermaid: boolean;
56
+ ignore: string[];
57
+ webRoot: string;
58
+ version: string;
59
+ };
60
+
61
+ const THEMES: Theme[] = ["auto", "light", "dark"];
62
+ const HTML_MODES: HtmlMode[] = ["filter", "allow", "off"];
63
+ const DEFAULT_WEB_ROOT = fileURLToPath(new URL("../../web/dist", import.meta.url));
64
+
65
+ /** Normalize user options into a fully resolved, validated config. */
66
+ export async function resolveConfig(options: ServeMarkdownOptions = {}): Promise<ResolvedConfig> {
67
+ const requestedRoot = resolve(options.root ?? process.cwd());
68
+ const root = await realpath(requestedRoot).catch(() => null);
69
+ if (!root) throw new Error(`Directory not found: ${requestedRoot}`);
70
+
71
+ const info = await stat(root);
72
+ if (!info.isDirectory()) throw new Error(`Not a directory: ${requestedRoot}`);
73
+
74
+ const theme = options.theme ?? "auto";
75
+ if (!THEMES.includes(theme)) throw new Error(`Invalid theme: ${theme}. Use auto, light or dark.`);
76
+
77
+ const html = options.html ?? "filter";
78
+ if (!HTML_MODES.includes(html)) throw new Error(`Invalid html mode: ${html}. Use filter, allow or off.`);
79
+
80
+ return {
81
+ root,
82
+ rootLabel: basename(root) || "/",
83
+ port: options.port ?? 3000,
84
+ hostname: options.hostname ?? "0.0.0.0",
85
+ live: options.live !== false,
86
+ highlight: options.highlight !== false,
87
+ cleanUrls: options.cleanUrls !== false,
88
+ directoryListing: options.directoryListing !== false,
89
+ single: options.single === true,
90
+ cors: options.cors === true,
91
+ theme,
92
+ html,
93
+ math: options.math === true,
94
+ mermaid: options.mermaid !== false,
95
+ ignore: options.ignore ?? [],
96
+ webRoot: resolve(options.webRoot ?? DEFAULT_WEB_ROOT),
97
+ version: pkg.version,
98
+ };
99
+ }
@@ -0,0 +1,33 @@
1
+ /** Constants used by the Bun server. Protocol paths live in `shared/constants.ts`. */
2
+
3
+ /** Fenced code language rendered in the browser by Mermaid. */
4
+ export const MERMAID_LANGUAGE = "mermaid";
5
+
6
+ /** Directories that are never watched, listed, or served implicitly. */
7
+ export const IGNORED_NAMES = new Set([
8
+ "node_modules",
9
+ ".git",
10
+ ".hg",
11
+ ".svn",
12
+ ".cache",
13
+ ".next",
14
+ ".nuxt",
15
+ ".svelte-kit",
16
+ ".venv",
17
+ "__pycache__",
18
+ ]);
19
+
20
+ export const MARKDOWN_EXTENSIONS = new Set([".md", ".markdown", ".mdown", ".mkd"]);
21
+
22
+ export const MARKDOWN_EXTENSION_RE = /\.(md|markdown|mdown|mkd)$/i;
23
+
24
+ export const WATCHED_EXTENSIONS = /\.(md|markdown|mdown|mkd|html?|css)$/i;
25
+
26
+ export const MAX_CACHED_DOCUMENTS = 64;
27
+
28
+ export const MAX_HTML_INJECTION_BYTES = 5 * 1024 * 1024;
29
+
30
+ export const WATCH_DEBOUNCE_MS = 80;
31
+
32
+ /** Suffixes tried when resolving extensionless (clean) URLs, in order. */
33
+ export const CLEAN_URL_SUFFIXES = [".md", ".markdown", ".html", ".htm"] as const;
@@ -0,0 +1,40 @@
1
+ import type { Highlighter } from "shiki";
2
+ import type { ResolvedConfig } from "./config.ts";
3
+ import { createHighlighterService, createRenderer, type Renderer } from "./markdown/index.ts";
4
+ import { createDocumentStore, type DocumentStore } from "./services/documents.ts";
5
+ import { createLiveReload, type LiveReload } from "./services/live/index.ts";
6
+
7
+ export type ServerContext = {
8
+ config: ResolvedConfig;
9
+ renderer: Renderer;
10
+ documents: DocumentStore;
11
+ highlighter: Highlighter | null;
12
+ live: LiveReload | null;
13
+ };
14
+
15
+ /** Build the long-lived services a server needs (highlighter, cache, watcher). */
16
+ export async function createContext(config: ResolvedConfig): Promise<ServerContext> {
17
+ const highlighter = config.highlight ? await createHighlighterService() : null;
18
+ const renderer = createRenderer({
19
+ highlighter,
20
+ html: config.html,
21
+ math: config.math,
22
+ mermaid: config.mermaid,
23
+ });
24
+ const documents = createDocumentStore(renderer);
25
+
26
+ const live = config.live
27
+ ? await createLiveReload({
28
+ root: config.root,
29
+ ignore: config.ignore,
30
+ onChange: () => documents.clear(),
31
+ })
32
+ : null;
33
+
34
+ return { config, renderer, documents, highlighter, live };
35
+ }
36
+
37
+ export function disposeContext(context: ServerContext): void {
38
+ context.live?.closeAll();
39
+ context.highlighter?.dispose();
40
+ }
@@ -0,0 +1,69 @@
1
+ import type { Server } from "bun";
2
+ import { resolveConfig, type ServeMarkdownOptions } from "./config.ts";
3
+ import { createContext, disposeContext } from "./context.ts";
4
+ import { textResponse } from "./http/respond.ts";
5
+ import { createRequestHandler } from "./routes/index.ts";
6
+
7
+ export type { ServeMarkdownOptions } from "./config.ts";
8
+
9
+ export type RunningServer = {
10
+ server: Server<undefined>;
11
+ port: number;
12
+ url: string;
13
+ root: string;
14
+ close(): void;
15
+ };
16
+
17
+ export async function startServer(options: ServeMarkdownOptions = {}): Promise<RunningServer> {
18
+ if (typeof Bun.markdown?.html !== "function") {
19
+ throw new Error("Bun 1.3.8 or newer is required (Bun.markdown is unavailable). Run `bun upgrade`.");
20
+ }
21
+
22
+ const config = await resolveConfig(options);
23
+ const context = await createContext(config);
24
+ const handler = createRequestHandler(context);
25
+ const live = context.live;
26
+
27
+ const listenOptions = {
28
+ port: config.port,
29
+ hostname: config.hostname,
30
+ development: true,
31
+ idleTimeout: 255,
32
+ error: (cause: Error) => {
33
+ console.error("[srvmd]", cause);
34
+ return textResponse(cause.message, 500, "GET", config.cors);
35
+ },
36
+ } as const;
37
+
38
+ let server: Server<undefined>;
39
+ if (live) {
40
+ server = Bun.serve({
41
+ ...listenOptions,
42
+ fetch: handler,
43
+ websocket: {
44
+ open: socket => live.open(socket),
45
+ close: socket => live.close(socket),
46
+ message: (socket, message) => live.message(socket, message),
47
+ },
48
+ });
49
+ } else {
50
+ server = Bun.serve({
51
+ ...listenOptions,
52
+ fetch: async (request, bunServer) =>
53
+ (await handler(request, bunServer)) ?? new Response("Not found", { status: 404 }),
54
+ });
55
+ }
56
+
57
+ const port = server.port ?? config.port;
58
+
59
+ return {
60
+ server,
61
+ port,
62
+ root: config.root,
63
+ url: `http://localhost:${port}`,
64
+ close() {
65
+ disposeContext(context);
66
+ server.stop(true);
67
+ },
68
+ };
69
+ }
@@ -0,0 +1,15 @@
1
+ import { MARKDOWN_EXTENSION_RE, MARKDOWN_EXTENSIONS } from "./constants.ts";
2
+
3
+ export function isMarkdownFile(path: string): boolean {
4
+ const index = path.lastIndexOf(".");
5
+ return index !== -1 && MARKDOWN_EXTENSIONS.has(path.slice(index).toLowerCase());
6
+ }
7
+
8
+ export function isHtmlFile(path: string): boolean {
9
+ const lower = path.toLowerCase();
10
+ return lower.endsWith(".html") || lower.endsWith(".htm");
11
+ }
12
+
13
+ export function stripMarkdownExtension(name: string): string {
14
+ return name.replace(MARKDOWN_EXTENSION_RE, "");
15
+ }
@@ -0,0 +1,11 @@
1
+ /** Common response headers for every response this server produces. */
2
+ export function baseHeaders(cors: boolean): Headers {
3
+ const headers = new Headers();
4
+ headers.set("X-Content-Type-Options", "nosniff");
5
+ if (cors) {
6
+ headers.set("Access-Control-Allow-Origin", "*");
7
+ headers.set("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS");
8
+ headers.set("Access-Control-Allow-Headers", "*");
9
+ }
10
+ return headers;
11
+ }