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
@@ -0,0 +1,91 @@
1
+ import type { Server } from "bun";
2
+ import { API_PAGE_PATH, APP_ASSET_PREFIX, LIVE_PATH, MERMAID_RUNTIME_PATH } from "../../shared/constants.ts";
3
+ import { respondMermaidRuntime } from "../assets/mermaid.ts";
4
+ import { respondWebAsset } from "../assets/web.ts";
5
+ import { MAX_HTML_INJECTION_BYTES } from "../constants.ts";
6
+ import type { ServerContext } from "../context.ts";
7
+ import {
8
+ fileResponse,
9
+ htmlResponse,
10
+ methodNotAllowedResponse,
11
+ preflightResponse,
12
+ redirectResponse,
13
+ textResponse,
14
+ } from "../http/respond.ts";
15
+ import { resolveResource } from "../resolve/resource.ts";
16
+ import { injectLiveReload } from "../services/live/index.ts";
17
+ import { respondPageApi } from "./page-api.ts";
18
+ import { respondShell } from "./shell.ts";
19
+
20
+ export type RequestHandler = (request: Request, server: Server<undefined>) => Promise<Response | undefined>;
21
+
22
+ /** Convert `/__api/page/<path>` back into the user-facing URL it describes. */
23
+ function pageUrlFromApi(url: URL): URL {
24
+ const pageUrl = new URL(url);
25
+ const suffix = url.pathname.slice(API_PAGE_PATH.length);
26
+ pageUrl.pathname = suffix === "" ? "/" : suffix;
27
+ pageUrl.search = "";
28
+ return pageUrl;
29
+ }
30
+
31
+ /**
32
+ * HTTP entry point:
33
+ * live socket -> CORS preflight -> method guard -> internal routes -> resolve.
34
+ */
35
+ export function createRequestHandler(context: ServerContext): RequestHandler {
36
+ return async (request, server) => {
37
+ const url = new URL(request.url);
38
+
39
+ if (url.pathname === LIVE_PATH) {
40
+ if (!context.live?.upgrade(request, server)) {
41
+ return new Response("WebSocket upgrade required", { status: 426 });
42
+ }
43
+ return undefined;
44
+ }
45
+
46
+ const method = request.method.toUpperCase();
47
+ if (context.config.cors && method === "OPTIONS") return preflightResponse(true);
48
+ if (method !== "GET" && method !== "HEAD") return methodNotAllowedResponse(context.config.cors);
49
+
50
+ if (url.pathname === MERMAID_RUNTIME_PATH && context.config.mermaid) {
51
+ return respondMermaidRuntime(request, context.config.cors);
52
+ }
53
+
54
+ if (url.pathname === APP_ASSET_PREFIX || url.pathname.startsWith(`${APP_ASSET_PREFIX}/`)) {
55
+ return respondWebAsset(context.config.webRoot, url, request, context.config.cors);
56
+ }
57
+
58
+ if (url.pathname === API_PAGE_PATH || url.pathname.startsWith(`${API_PAGE_PATH}/`)) {
59
+ const pageUrl = pageUrlFromApi(url);
60
+ const resource = await resolveResource(context.config, pageUrl);
61
+ return respondPageApi(context, resource, request, pageUrl);
62
+ }
63
+
64
+ const resource = await resolveResource(context.config, url);
65
+
66
+ if (resource.kind === "markdown" && url.searchParams.has("raw")) {
67
+ return fileResponse(resource.file, request, context.config.cors, "text/markdown; charset=utf-8");
68
+ }
69
+
70
+ switch (resource.kind) {
71
+ case "markdown":
72
+ case "directory":
73
+ return respondShell(context.config, 200, request);
74
+ case "html": {
75
+ if (!context.config.live || resource.file.stats.size > MAX_HTML_INJECTION_BYTES) {
76
+ return fileResponse(resource.file, request, context.config.cors);
77
+ }
78
+ const source = await Bun.file(resource.file.path).text();
79
+ return htmlResponse(injectLiveReload(source), 200, request.method, context.config.cors);
80
+ }
81
+ case "file":
82
+ return fileResponse(resource.file, request, context.config.cors);
83
+ case "redirect":
84
+ return redirectResponse(resource.location, request.method, context.config.cors);
85
+ case "error":
86
+ return respondShell(context.config, resource.status, request);
87
+ default:
88
+ return textResponse("Unhandled resource", 500, request.method, context.config.cors);
89
+ }
90
+ };
91
+ }
@@ -0,0 +1,88 @@
1
+ import { basename } from "node:path";
2
+ import { buildBreadcrumbs } from "../../shared/breadcrumbs.ts";
3
+ import type { DirectoryPayload, DocumentPayload, RedirectPayload } from "../../shared/contracts.ts";
4
+ import type { ServerContext } from "../context.ts";
5
+ import { stripMarkdownExtension } from "../file-kinds.ts";
6
+ import { jsonError, jsonResponse } from "../http/respond.ts";
7
+ import type { RenderedDocument } from "../markdown/index.ts";
8
+ import type { Resource } from "../resolve/resource.ts";
9
+ import { listDirectory } from "../services/directories.ts";
10
+ import type { DirectoryEntry, ResolvedPath } from "../types.ts";
11
+
12
+ function documentPayload(
13
+ context: ServerContext,
14
+ file: ResolvedPath,
15
+ document: RenderedDocument,
16
+ url: URL,
17
+ ): DocumentPayload {
18
+ const directory = file.relative.includes("/") ? file.relative.slice(0, file.relative.lastIndexOf("/")) : "";
19
+ const lang = document.frontmatter.lang;
20
+
21
+ return {
22
+ kind: "document",
23
+ path: url.pathname,
24
+ title: document.title ?? stripMarkdownExtension(basename(file.path)),
25
+ html: document.html,
26
+ toc: document.toc,
27
+ mermaid: document.mermaid,
28
+ breadcrumbs: buildBreadcrumbs(context.config.rootLabel, directory),
29
+ rawHref: `${url.pathname}?raw`,
30
+ warning: document.warning,
31
+ lang: typeof lang === "string" ? lang : undefined,
32
+ };
33
+ }
34
+
35
+ function directoryPayload(
36
+ context: ServerContext,
37
+ directory: ResolvedPath,
38
+ entries: DirectoryEntry[],
39
+ ): DirectoryPayload {
40
+ const displayPath = directory.relative ? `/${directory.relative}/` : "/";
41
+
42
+ return {
43
+ kind: "directory",
44
+ path: displayPath,
45
+ heading: `Index of ${displayPath}`,
46
+ parentHref: directory.relative ? "../" : undefined,
47
+ entries: entries.map(entry => ({
48
+ name: entry.name,
49
+ kind: entry.kind,
50
+ size: entry.size,
51
+ mtime: entry.mtime?.toISOString(),
52
+ })),
53
+ breadcrumbs: buildBreadcrumbs(context.config.rootLabel, directory.relative),
54
+ };
55
+ }
56
+
57
+ /** Turn the resolver's Resource into the JSON contract consumed by the Svelte app. */
58
+ export async function respondPageApi(
59
+ context: ServerContext,
60
+ resource: Resource,
61
+ request: Request,
62
+ url: URL,
63
+ ): Promise<Response> {
64
+ const { config } = context;
65
+ const method = request.method.toUpperCase();
66
+
67
+ switch (resource.kind) {
68
+ case "markdown": {
69
+ const document = await context.documents.get(resource.file.path);
70
+ return jsonResponse(documentPayload(context, resource.file, document, url), 200, method, config.cors);
71
+ }
72
+ case "directory": {
73
+ const entries = await listDirectory(resource.directory.path, config.root);
74
+ return jsonResponse(directoryPayload(context, resource.directory, entries), 200, method, config.cors);
75
+ }
76
+ case "redirect": {
77
+ const payload: RedirectPayload = { kind: "redirect", location: resource.location };
78
+ return jsonResponse(payload, 200, method, config.cors);
79
+ }
80
+ case "error":
81
+ return jsonError(resource.status, resource.reason, method, config.cors);
82
+ case "html":
83
+ case "file":
84
+ return jsonError(404, "Not a page", method, config.cors);
85
+ default:
86
+ return jsonError(500, "Unhandled resource", method, config.cors);
87
+ }
88
+ }
@@ -0,0 +1,50 @@
1
+ import { join } from "node:path";
2
+ import { CONFIG_TOKEN } from "../../shared/constants.ts";
3
+ import type { AppConfig } from "../../shared/contracts.ts";
4
+ import type { ResolvedConfig } from "../config.ts";
5
+ import { htmlResponse, textResponse } from "../http/respond.ts";
6
+
7
+ function escapeForScript(json: string): string {
8
+ return json
9
+ .replace(/</g, "\\u003c")
10
+ .replace(/>/g, "\\u003e")
11
+ .replace(/&/g, "\\u0026")
12
+ .replace(/\u2028/g, "\\u2028")
13
+ .replace(/\u2029/g, "\\u2029");
14
+ }
15
+
16
+ export function appConfig(config: ResolvedConfig): AppConfig {
17
+ return {
18
+ rootLabel: config.rootLabel,
19
+ version: config.version,
20
+ theme: config.theme,
21
+ live: config.live,
22
+ mermaid: config.mermaid,
23
+ };
24
+ }
25
+
26
+ async function readShell(webRoot: string): Promise<string | null> {
27
+ const file = Bun.file(join(webRoot, "index.html"));
28
+ if (!(await file.exists())) return null;
29
+ return file.text();
30
+ }
31
+
32
+ /**
33
+ * Serve the Svelte shell for an app route. The only server-injected value is a
34
+ * JSON config object; all page structure and content rendering lives in the
35
+ * web app.
36
+ */
37
+ export async function respondShell(config: ResolvedConfig, status: number, request: Request): Promise<Response> {
38
+ const template = await readShell(config.webRoot);
39
+ if (!template) {
40
+ return textResponse(
41
+ `srvmd: web build not found at ${join(config.webRoot, "index.html")}.\nRun \`bun run build\` first.`,
42
+ 503,
43
+ request.method,
44
+ config.cors,
45
+ );
46
+ }
47
+
48
+ const json = escapeForScript(JSON.stringify(appConfig(config)));
49
+ return htmlResponse(template.split(CONFIG_TOKEN).join(json), status, request.method, config.cors);
50
+ }
@@ -0,0 +1,41 @@
1
+ import { readdir, realpath, stat } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { IGNORED_NAMES } from "../constants.ts";
4
+ import { isMarkdownFile } from "../file-kinds.ts";
5
+ import { isInside } from "../path.ts";
6
+ import type { DirectoryEntry } from "../types.ts";
7
+
8
+ /** Read a directory and return a sorted, filtered listing. */
9
+ export async function listDirectory(dir: string, root: string): Promise<DirectoryEntry[]> {
10
+ const dirents = await readdir(dir, { withFileTypes: true }).catch(() => []);
11
+ const entries: DirectoryEntry[] = [];
12
+
13
+ await Promise.all(
14
+ dirents.map(async dirent => {
15
+ if (dirent.name.startsWith(".") || IGNORED_NAMES.has(dirent.name)) return;
16
+
17
+ const full = join(dir, dirent.name);
18
+ const stats = await stat(full).catch(() => null);
19
+ if (!stats) return;
20
+
21
+ if (dirent.isSymbolicLink()) {
22
+ const real = await realpath(full).catch(() => null);
23
+ if (!real || !isInside(root, real)) return;
24
+ }
25
+
26
+ entries.push({
27
+ name: dirent.name,
28
+ kind: stats.isDirectory() ? "dir" : isMarkdownFile(dirent.name) ? "markdown" : "file",
29
+ size: stats.isDirectory() ? undefined : stats.size,
30
+ mtime: stats.mtime,
31
+ });
32
+ }),
33
+ );
34
+
35
+ entries.sort((a, b) => {
36
+ if ((a.kind === "dir") !== (b.kind === "dir")) return a.kind === "dir" ? -1 : 1;
37
+ return a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" });
38
+ });
39
+
40
+ return entries;
41
+ }
@@ -0,0 +1,43 @@
1
+ import { stat } from "node:fs/promises";
2
+ import { MAX_CACHED_DOCUMENTS } from "../constants.ts";
3
+ import type { RenderedDocument, Renderer } from "../markdown/render.ts";
4
+
5
+ type CachedDocument = {
6
+ mtimeMs: number;
7
+ size: number;
8
+ document: RenderedDocument;
9
+ };
10
+
11
+ export type DocumentStore = {
12
+ get(path: string): Promise<RenderedDocument>;
13
+ clear(): void;
14
+ };
15
+
16
+ /** Read + render Markdown, cached by (mtime, size) and invalidated by the watcher. */
17
+ export function createDocumentStore(renderer: Renderer): DocumentStore {
18
+ const cache = new Map<string, CachedDocument>();
19
+
20
+ return {
21
+ async get(path) {
22
+ const info = await stat(path);
23
+ const cached = cache.get(path);
24
+ if (cached && cached.mtimeMs === info.mtimeMs && cached.size === info.size) {
25
+ return cached.document;
26
+ }
27
+
28
+ const source = await Bun.file(path).text();
29
+ const document = await renderer.render(source);
30
+ cache.set(path, { mtimeMs: info.mtimeMs, size: info.size, document });
31
+
32
+ if (cache.size > MAX_CACHED_DOCUMENTS) {
33
+ const oldest = cache.keys().next().value;
34
+ if (oldest) cache.delete(oldest);
35
+ }
36
+
37
+ return document;
38
+ },
39
+ clear() {
40
+ cache.clear();
41
+ },
42
+ };
43
+ }
@@ -0,0 +1,12 @@
1
+ import { APP_ASSET_PREFIX } from "../../../shared/constants.ts";
2
+
3
+ /** Prebuilt static-page live-reload module, emitted by the Vite build. */
4
+ export const LIVE_CLIENT_SCRIPT_PATH = `${APP_ASSET_PREFIX}/live-static.js`;
5
+
6
+ /** Append the live-reload module to a static HTML document. */
7
+ export function injectLiveReload(html: string): string {
8
+ const snippet = `<script type="module" src="${LIVE_CLIENT_SCRIPT_PATH}"></script>`;
9
+ const match = /<\/body\s*>/i.exec(html);
10
+ if (!match || match.index === undefined) return html + snippet;
11
+ return html.slice(0, match.index) + snippet + html.slice(match.index);
12
+ }
@@ -0,0 +1,50 @@
1
+ import type { Server, ServerWebSocket } from "bun";
2
+
3
+ type Socket = ServerWebSocket<undefined>;
4
+
5
+ export type LiveHub = {
6
+ upgrade(request: Request, server: Server<undefined>): boolean;
7
+ open(socket: Socket): void;
8
+ close(socket: Socket): void;
9
+ message(socket: Socket, message: string | Buffer): void;
10
+ broadcast(files: string[]): void;
11
+ closeAll(): void;
12
+ };
13
+
14
+ /** Tracks connected browsers and pushes reload events. */
15
+ export function createLiveHub(): LiveHub {
16
+ const sockets = new Set<Socket>();
17
+
18
+ return {
19
+ upgrade(request, server) {
20
+ return server.upgrade(request);
21
+ },
22
+ open(socket) {
23
+ sockets.add(socket);
24
+ },
25
+ close(socket) {
26
+ sockets.delete(socket);
27
+ },
28
+ message() {
29
+ // Clients are read-only; ignore inbound messages.
30
+ },
31
+ broadcast(files) {
32
+ const payload = JSON.stringify({ type: "reload", files });
33
+ for (const socket of sockets) {
34
+ try {
35
+ socket.send(payload);
36
+ } catch {
37
+ sockets.delete(socket);
38
+ }
39
+ }
40
+ },
41
+ closeAll() {
42
+ for (const socket of sockets) {
43
+ try {
44
+ socket.close(1001, "Server shutting down");
45
+ } catch {}
46
+ }
47
+ sockets.clear();
48
+ },
49
+ };
50
+ }
@@ -0,0 +1,33 @@
1
+ import { createLiveHub, type LiveHub } from "./hub.ts";
2
+ import { createWatcher } from "./watcher.ts";
3
+
4
+ export type LiveReload = LiveHub;
5
+
6
+ export type LiveReloadOptions = {
7
+ root: string;
8
+ ignore?: string[];
9
+ onChange?: (files: string[]) => void;
10
+ };
11
+
12
+ /** Compose the filesystem watcher with the WebSocket hub. */
13
+ export async function createLiveReload(options: LiveReloadOptions): Promise<LiveReload> {
14
+ const hub = createLiveHub();
15
+ const watcher = await createWatcher({
16
+ root: options.root,
17
+ ignore: options.ignore,
18
+ onChange(files) {
19
+ options.onChange?.(files);
20
+ hub.broadcast(files);
21
+ },
22
+ });
23
+
24
+ return {
25
+ ...hub,
26
+ closeAll() {
27
+ watcher.close();
28
+ hub.closeAll();
29
+ },
30
+ };
31
+ }
32
+
33
+ export { injectLiveReload, LIVE_CLIENT_SCRIPT_PATH } from "./client-script.ts";
@@ -0,0 +1,126 @@
1
+ import { type Dirent, type FSWatcher, watch } from "node:fs";
2
+ import { readdir } from "node:fs/promises";
3
+ import { dirname, join, sep } from "node:path";
4
+ import { IGNORED_NAMES, WATCH_DEBOUNCE_MS, WATCHED_EXTENSIONS } from "../../constants.ts";
5
+
6
+ export type Watcher = {
7
+ close(): void;
8
+ };
9
+
10
+ export type WatcherOptions = {
11
+ root: string;
12
+ ignore?: string[];
13
+ onChange: (files: string[]) => void;
14
+ };
15
+
16
+ /**
17
+ * Watch every non-ignored directory under root (one inotify watcher per
18
+ * directory) and report debounced file changes.
19
+ *
20
+ * Watching directories individually avoids putting recursive watchers on
21
+ * node_modules/.git, which is expensive and can hit inotify limits.
22
+ */
23
+ export async function createWatcher(options: WatcherOptions): Promise<Watcher> {
24
+ const ignored = new Set([...IGNORED_NAMES, ...(options.ignore ?? [])]);
25
+ const watchers = new Map<string, FSWatcher>();
26
+ const pending = new Set<string>();
27
+ let timer: ReturnType<typeof setTimeout> | undefined;
28
+ let closed = false;
29
+
30
+ const shouldIgnore = (name: string) => name.startsWith(".") || ignored.has(name);
31
+
32
+ function flush(): void {
33
+ timer = undefined;
34
+ if (pending.size === 0) return;
35
+ const files = [...pending];
36
+ pending.clear();
37
+ options.onChange(files);
38
+ }
39
+
40
+ function schedule(): void {
41
+ if (timer) clearTimeout(timer);
42
+ timer = setTimeout(flush, WATCH_DEBOUNCE_MS);
43
+ }
44
+
45
+ async function addDirectory(directory: string): Promise<void> {
46
+ if (closed || watchers.has(directory)) return;
47
+ let dirents: Dirent[];
48
+ try {
49
+ dirents = await readdir(directory, { withFileTypes: true });
50
+ } catch {
51
+ return;
52
+ }
53
+
54
+ try {
55
+ const watcher = watch(directory, (event, filename) => {
56
+ if (closed) return;
57
+ const name = filename?.toString();
58
+ if (event === "rename") void reconcile(directory);
59
+ if (!name || shouldIgnore(name) || !WATCHED_EXTENSIONS.test(name)) return;
60
+ pending.add(join(directory, name));
61
+ schedule();
62
+ });
63
+ watcher.on("error", () => {
64
+ watcher.close();
65
+ watchers.delete(directory);
66
+ });
67
+ watchers.set(directory, watcher);
68
+ } catch {
69
+ return;
70
+ }
71
+
72
+ for (const dirent of dirents) {
73
+ if (dirent.isDirectory() && !shouldIgnore(dirent.name)) {
74
+ await addDirectory(join(directory, dirent.name));
75
+ }
76
+ }
77
+ }
78
+
79
+ function removeWatcherTree(target: string): void {
80
+ for (const [watched, watcher] of [...watchers]) {
81
+ if (watched === target || watched.startsWith(target + sep)) {
82
+ watcher.close();
83
+ watchers.delete(watched);
84
+ }
85
+ }
86
+ }
87
+
88
+ /** Add watchers for newly-created dirs and drop watchers for deleted ones. */
89
+ async function reconcile(directory: string): Promise<void> {
90
+ if (closed) return;
91
+ const dirents = await readdir(directory, { withFileTypes: true }).catch(() => null);
92
+ if (!dirents) {
93
+ removeWatcherTree(directory);
94
+ return;
95
+ }
96
+
97
+ const childDirs = new Set(
98
+ dirents
99
+ .filter(dirent => dirent.isDirectory() && !shouldIgnore(dirent.name))
100
+ .map(dirent => join(directory, dirent.name)),
101
+ );
102
+
103
+ for (const child of childDirs) {
104
+ if (!watchers.has(child)) await addDirectory(child);
105
+ }
106
+
107
+ for (const watched of [...watchers.keys()]) {
108
+ if (watched === directory) continue;
109
+ if (dirname(watched) === directory && !childDirs.has(watched)) {
110
+ removeWatcherTree(watched);
111
+ }
112
+ }
113
+ }
114
+
115
+ await addDirectory(options.root);
116
+
117
+ return {
118
+ close() {
119
+ closed = true;
120
+ if (timer) clearTimeout(timer);
121
+ timer = undefined;
122
+ for (const watcher of watchers.values()) watcher.close();
123
+ watchers.clear();
124
+ },
125
+ };
126
+ }
@@ -0,0 +1,20 @@
1
+ import type { Stats } from "node:fs";
2
+ import type { DirEntryKind } from "../shared/types.ts";
3
+
4
+ export type HtmlMode = "filter" | "allow" | "off";
5
+
6
+ /** A filesystem path that has been validated to live inside the served root. */
7
+ export type ResolvedPath = {
8
+ path: string;
9
+ stats: Stats;
10
+ relative: string;
11
+ };
12
+
13
+ export type DirectoryEntry = {
14
+ name: string;
15
+ kind: DirEntryKind;
16
+ size?: number;
17
+ mtime?: Date;
18
+ };
19
+
20
+ export type FrontmatterData = Record<string, unknown>;
@@ -0,0 +1,14 @@
1
+ import type { Breadcrumb } from "./types.ts";
2
+
3
+ /** Build root -> ... -> directory breadcrumbs from a relative directory path. */
4
+ export function buildBreadcrumbs(rootLabel: string, relativeDirectory: string): Breadcrumb[] {
5
+ const crumbs: Breadcrumb[] = [{ label: rootLabel, href: "/" }];
6
+ let href = "/";
7
+
8
+ for (const segment of relativeDirectory.split("/").filter(Boolean)) {
9
+ href += `${encodeURIComponent(segment)}/`;
10
+ crumbs.push({ label: segment, href });
11
+ }
12
+
13
+ return crumbs;
14
+ }
@@ -0,0 +1,20 @@
1
+ /** Protocol constants shared by the server and the browser. */
2
+
3
+ /** Internal page-data endpoint. The original URL path is appended verbatim. */
4
+ export const API_PAGE_PATH = "/__api/page";
5
+
6
+ /** Built Svelte assets (Vite `base`). Kept out of user-controlled `/assets`. */
7
+ export const APP_ASSET_PREFIX = "/__app";
8
+
9
+ /** WebSocket live-reload endpoint. */
10
+ export const LIVE_PATH = "/__serve_md_live";
11
+
12
+ /** Prebuilt Mermaid browser runtime, loaded lazily. */
13
+ export const MERMAID_RUNTIME_PATH = "/__serve_md_mermaid.js";
14
+
15
+ /** The shell contains this JSON string token; the server replaces it with a JSON config object. */
16
+ export const CONFIG_TOKEN = '"__SERVE_MD_CONFIG__"';
17
+
18
+ export const CONFIG_ELEMENT_ID = "serve-md-config";
19
+
20
+ export const THEME_STORAGE_KEY = "serve-md-theme";
@@ -0,0 +1,49 @@
1
+ import type { Breadcrumb, DirEntry, MermaidSource, Theme, TocItem } from "./types.ts";
2
+
3
+ /** Response of `GET /__api/page/<url path>`. */
4
+ export type DocumentPayload = {
5
+ kind: "document";
6
+ path: string;
7
+ title: string;
8
+ /** Rendered Markdown content. This is content, not a page shell. */
9
+ html: string;
10
+ toc: TocItem[];
11
+ mermaid: MermaidSource[];
12
+ breadcrumbs: Breadcrumb[];
13
+ rawHref: string;
14
+ warning?: string;
15
+ lang?: string;
16
+ };
17
+
18
+ export type DirectoryPayload = {
19
+ kind: "directory";
20
+ path: string;
21
+ heading: string;
22
+ parentHref?: string;
23
+ entries: DirEntry[];
24
+ breadcrumbs: Breadcrumb[];
25
+ };
26
+
27
+ /** A page that the server wants the browser to visit at another URL. */
28
+ export type RedirectPayload = {
29
+ kind: "redirect";
30
+ location: string;
31
+ };
32
+
33
+ export type PagePayload = DocumentPayload | DirectoryPayload | RedirectPayload;
34
+
35
+ export type ApiErrorPayload = {
36
+ error: {
37
+ status: number;
38
+ message: string;
39
+ };
40
+ };
41
+
42
+ /** Injected into the Svelte shell so the web app does not need a second config request. */
43
+ export type AppConfig = {
44
+ rootLabel: string;
45
+ version: string;
46
+ theme: Theme;
47
+ live: boolean;
48
+ mermaid: boolean;
49
+ };
@@ -0,0 +1,33 @@
1
+ /** Types shared by the Bun server and the Svelte web app. Keep this file runtime-free. */
2
+
3
+ export type Theme = "auto" | "light" | "dark";
4
+
5
+ /** The concrete theme applied to the document after "auto" has been resolved. */
6
+ export type ResolvedTheme = "light" | "dark";
7
+
8
+ export type TocItem = {
9
+ level: number;
10
+ id: string;
11
+ text: string;
12
+ };
13
+
14
+ export type Breadcrumb = {
15
+ label: string;
16
+ href: string;
17
+ };
18
+
19
+ /** A Mermaid diagram extracted from a Markdown document. */
20
+ export type MermaidSource = {
21
+ id: string;
22
+ source: string;
23
+ };
24
+
25
+ export type DirEntryKind = "dir" | "file" | "markdown";
26
+
27
+ export type DirEntry = {
28
+ name: string;
29
+ kind: DirEntryKind;
30
+ size?: number;
31
+ /** ISO 8601 timestamp. Formatting belongs to the web app. */
32
+ mtime?: string;
33
+ };