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.
- package/ARCHITECTURE.md +237 -0
- package/LICENSE +21 -0
- package/README.md +214 -0
- package/README_CN.md +214 -0
- package/package.json +68 -0
- package/src/cli/browser.ts +14 -0
- package/src/cli/command.ts +56 -0
- package/src/cli/index.ts +16 -0
- package/src/cli/lifecycle.ts +13 -0
- package/src/cli/listen.ts +27 -0
- package/src/cli/options.ts +52 -0
- package/src/cli/report.ts +40 -0
- package/src/server/assets/mermaid.ts +38 -0
- package/src/server/assets/web.ts +34 -0
- package/src/server/config.ts +99 -0
- package/src/server/constants.ts +33 -0
- package/src/server/context.ts +40 -0
- package/src/server/create-server.ts +69 -0
- package/src/server/file-kinds.ts +15 -0
- package/src/server/http/headers.ts +11 -0
- package/src/server/http/respond.ts +74 -0
- package/src/server/http/validators.ts +37 -0
- package/src/server/markdown/frontmatter.ts +29 -0
- package/src/server/markdown/highlight.ts +100 -0
- package/src/server/markdown/index.ts +8 -0
- package/src/server/markdown/mermaid.ts +30 -0
- package/src/server/markdown/render.ts +119 -0
- package/src/server/markdown/text.ts +30 -0
- package/src/server/markdown/toc.ts +29 -0
- package/src/server/path.ts +9 -0
- package/src/server/resolve/path.ts +43 -0
- package/src/server/resolve/resource.ts +88 -0
- package/src/server/routes/index.ts +91 -0
- package/src/server/routes/page-api.ts +88 -0
- package/src/server/routes/shell.ts +50 -0
- package/src/server/services/directories.ts +41 -0
- package/src/server/services/documents.ts +43 -0
- package/src/server/services/live/client-script.ts +12 -0
- package/src/server/services/live/hub.ts +50 -0
- package/src/server/services/live/index.ts +33 -0
- package/src/server/services/live/watcher.ts +126 -0
- package/src/server/types.ts +20 -0
- package/src/shared/breadcrumbs.ts +14 -0
- package/src/shared/constants.ts +20 -0
- package/src/shared/contracts.ts +49 -0
- package/src/shared/types.ts +33 -0
- package/web/dist/assets/index-BjX1ijWA.js +2 -0
- package/web/dist/assets/index-DRuRRv0z.css +1 -0
- package/web/dist/assets/live-CRaSJLxT.js +1 -0
- package/web/dist/index.html +18 -0
- package/web/dist/live-static.js +1 -0
- package/web/dist/theme-boot.js +16 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { ApiErrorPayload } from "../../shared/contracts.ts";
|
|
2
|
+
import type { ResolvedPath } from "../types.ts";
|
|
3
|
+
import { baseHeaders } from "./headers.ts";
|
|
4
|
+
import { applyValidators, computeValidators, isNotModified } from "./validators.ts";
|
|
5
|
+
|
|
6
|
+
export function htmlResponse(body: string, status: number, method: string, cors: boolean): Response {
|
|
7
|
+
const headers = baseHeaders(cors);
|
|
8
|
+
headers.set("Content-Type", "text/html; charset=utf-8");
|
|
9
|
+
headers.set("Cache-Control", "no-store");
|
|
10
|
+
if (method === "HEAD") {
|
|
11
|
+
headers.set("Content-Length", String(Buffer.byteLength(body)));
|
|
12
|
+
return new Response(null, { status, headers });
|
|
13
|
+
}
|
|
14
|
+
return new Response(body, { status, headers });
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Serve a file with validators. Bun's manual file responses handle Range
|
|
19
|
+
* requests but not ETag/Last-Modified, so add them (and 304s) here.
|
|
20
|
+
*/
|
|
21
|
+
export function fileResponse(file: ResolvedPath, request: Request, cors: boolean, contentType?: string): Response {
|
|
22
|
+
const headers = baseHeaders(cors);
|
|
23
|
+
if (contentType) headers.set("Content-Type", contentType);
|
|
24
|
+
|
|
25
|
+
const validators = computeValidators(file.stats);
|
|
26
|
+
applyValidators(headers, validators);
|
|
27
|
+
if (isNotModified(request, validators, file.stats)) {
|
|
28
|
+
return new Response(null, { status: 304, headers });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return new Response(Bun.file(file.path), { headers });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function redirectResponse(location: string, method: string, cors: boolean): Response {
|
|
35
|
+
const headers = baseHeaders(cors);
|
|
36
|
+
headers.set("Location", location);
|
|
37
|
+
return new Response(method === "HEAD" ? null : "", { status: 301, headers });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function preflightResponse(cors: boolean): Response {
|
|
41
|
+
return new Response(null, { status: 204, headers: baseHeaders(cors) });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function methodNotAllowedResponse(cors: boolean): Response {
|
|
45
|
+
const headers = baseHeaders(cors);
|
|
46
|
+
headers.set("Allow", "GET, HEAD, OPTIONS");
|
|
47
|
+
return new Response("Method Not Allowed", { status: 405, headers });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function bodyResponse(body: string, status: number, method: string, cors: boolean, contentType: string): Response {
|
|
51
|
+
const headers = baseHeaders(cors);
|
|
52
|
+
headers.set("Content-Type", contentType);
|
|
53
|
+
headers.set("Cache-Control", "no-store");
|
|
54
|
+
|
|
55
|
+
if (method === "HEAD") {
|
|
56
|
+
headers.set("Content-Length", String(Buffer.byteLength(body)));
|
|
57
|
+
return new Response(null, { status, headers });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return new Response(body, { status, headers });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function jsonResponse(body: unknown, status: number, method: string, cors: boolean): Response {
|
|
64
|
+
return bodyResponse(JSON.stringify(body), status, method, cors, "application/json; charset=utf-8");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function jsonError(status: number, message: string, method: string, cors: boolean): Response {
|
|
68
|
+
const payload: ApiErrorPayload = { error: { status, message } };
|
|
69
|
+
return jsonResponse(payload, status, method, cors);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function textResponse(body: string, status: number, method: string, cors: boolean): Response {
|
|
73
|
+
return bodyResponse(body, status, method, cors, "text/plain; charset=utf-8");
|
|
74
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { Stats } from "node:fs";
|
|
2
|
+
|
|
3
|
+
export type Validators = {
|
|
4
|
+
etag: string;
|
|
5
|
+
lastModified: string;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
/** Build the weak ETag / Last-Modified pair Bun's routes would otherwise add. */
|
|
9
|
+
export function computeValidators(stats: Stats): Validators {
|
|
10
|
+
return {
|
|
11
|
+
etag: `W/"${stats.size.toString(16)}-${stats.mtime.getTime().toString(16)}"`,
|
|
12
|
+
lastModified: stats.mtime.toUTCString(),
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function applyValidators(headers: Headers, validators: Validators): void {
|
|
17
|
+
headers.set("ETag", validators.etag);
|
|
18
|
+
headers.set("Last-Modified", validators.lastModified);
|
|
19
|
+
headers.set("Accept-Ranges", "bytes");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Evaluate If-None-Match first, then If-Modified-Since (the same precedence
|
|
24
|
+
* RFC 9110 requires). Returns true when the client already has this version.
|
|
25
|
+
*/
|
|
26
|
+
export function isNotModified(request: Request, validators: Validators, stats: Stats): boolean {
|
|
27
|
+
const noneMatch = request.headers.get("if-none-match");
|
|
28
|
+
if (noneMatch) {
|
|
29
|
+
return noneMatch.split(",").some(value => value.trim() === validators.etag);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const modifiedSince = request.headers.get("if-modified-since");
|
|
33
|
+
if (!modifiedSince) return false;
|
|
34
|
+
const since = Date.parse(modifiedSince);
|
|
35
|
+
if (!Number.isFinite(since)) return false;
|
|
36
|
+
return Math.floor(stats.mtime.getTime() / 1000) <= Math.floor(since / 1000);
|
|
37
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { FrontmatterData } from "../types.ts";
|
|
2
|
+
|
|
3
|
+
export type FrontmatterResult = {
|
|
4
|
+
data: FrontmatterData;
|
|
5
|
+
body: string;
|
|
6
|
+
raw?: string;
|
|
7
|
+
error?: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/** Split a leading YAML frontmatter block from the Markdown body. */
|
|
11
|
+
export function parseFrontmatter(source: string): FrontmatterResult {
|
|
12
|
+
const match = /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/.exec(source);
|
|
13
|
+
if (!match) return { data: {}, body: source };
|
|
14
|
+
|
|
15
|
+
const raw = match[1] ?? "";
|
|
16
|
+
let data: FrontmatterData = {};
|
|
17
|
+
let error: string | undefined;
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
const parsed = Bun.YAML.parse(raw);
|
|
21
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
22
|
+
data = parsed as FrontmatterData;
|
|
23
|
+
}
|
|
24
|
+
} catch (cause) {
|
|
25
|
+
error = `Invalid frontmatter: ${cause instanceof Error ? cause.message : String(cause)}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return { data, body: source.slice(match[0].length), raw, error };
|
|
29
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { type Highlighter, createHighlighter as shikiCreateHighlighter } from "shiki";
|
|
2
|
+
import { decodeEntities } from "./text.ts";
|
|
3
|
+
|
|
4
|
+
const THEMES = { light: "github-light", dark: "github-dark" } as const;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Languages bundled eagerly. Anything else is loaded lazily on first use,
|
|
8
|
+
* so docs only pay for what they contain.
|
|
9
|
+
*/
|
|
10
|
+
const PRELOADED_LANGUAGES = [
|
|
11
|
+
"bash",
|
|
12
|
+
"c",
|
|
13
|
+
"cpp",
|
|
14
|
+
"csharp",
|
|
15
|
+
"css",
|
|
16
|
+
"diff",
|
|
17
|
+
"docker",
|
|
18
|
+
"go",
|
|
19
|
+
"graphql",
|
|
20
|
+
"html",
|
|
21
|
+
"ini",
|
|
22
|
+
"java",
|
|
23
|
+
"javascript",
|
|
24
|
+
"json",
|
|
25
|
+
"jsx",
|
|
26
|
+
"markdown",
|
|
27
|
+
"php",
|
|
28
|
+
"python",
|
|
29
|
+
"ruby",
|
|
30
|
+
"rust",
|
|
31
|
+
"scss",
|
|
32
|
+
"sql",
|
|
33
|
+
"svelte",
|
|
34
|
+
"toml",
|
|
35
|
+
"tsx",
|
|
36
|
+
"typescript",
|
|
37
|
+
"vue",
|
|
38
|
+
"xml",
|
|
39
|
+
"yaml",
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
const CODE_BLOCK_RE = /<pre><code class="language-([^"]+)">([\s\S]*?)<\/code><\/pre>/g;
|
|
43
|
+
const FENCE_RE = /^(?:```|~~~)[ \t]*([A-Za-z0-9_+#.-]+)/gm;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Create a highlighter instance. Returns null when highlighting is
|
|
47
|
+
* unavailable so callers can continue without it. No module-level singleton:
|
|
48
|
+
* the instance is owned by the server context.
|
|
49
|
+
*/
|
|
50
|
+
export async function createHighlighterService(): Promise<Highlighter | null> {
|
|
51
|
+
try {
|
|
52
|
+
return await shikiCreateHighlighter({
|
|
53
|
+
themes: [THEMES.light, THEMES.dark],
|
|
54
|
+
langs: PRELOADED_LANGUAGES,
|
|
55
|
+
});
|
|
56
|
+
} catch (error) {
|
|
57
|
+
console.warn(`[srvmd] syntax highlighting disabled: ${error instanceof Error ? error.message : error}`);
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Load every language fenced in the document that is not loaded yet. */
|
|
63
|
+
export async function ensureLanguages(
|
|
64
|
+
highlighter: Highlighter,
|
|
65
|
+
markdown: string,
|
|
66
|
+
skipLanguage?: string,
|
|
67
|
+
): Promise<void> {
|
|
68
|
+
const requested = new Set<string>();
|
|
69
|
+
for (const match of markdown.matchAll(FENCE_RE)) {
|
|
70
|
+
const language = match[1]?.toLowerCase();
|
|
71
|
+
if (language && language !== skipLanguage) requested.add(language);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const loaded = new Set(highlighter.getLoadedLanguages());
|
|
75
|
+
await Promise.all(
|
|
76
|
+
[...requested].map(async language => {
|
|
77
|
+
if (loaded.has(language)) return;
|
|
78
|
+
try {
|
|
79
|
+
await highlighter.loadLanguage(language as never);
|
|
80
|
+
loaded.add(language);
|
|
81
|
+
} catch {
|
|
82
|
+
// Unknown language: the block falls back to plain <pre><code>.
|
|
83
|
+
}
|
|
84
|
+
}),
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function highlightCodeBlocks(html: string, highlighter: Highlighter): string {
|
|
89
|
+
return html.replace(CODE_BLOCK_RE, (match, language: string, escaped: string) => {
|
|
90
|
+
const code = decodeEntities(escaped).replace(/\n$/, "");
|
|
91
|
+
try {
|
|
92
|
+
return highlighter.codeToHtml(code, {
|
|
93
|
+
lang: language,
|
|
94
|
+
themes: { light: THEMES.light, dark: THEMES.dark },
|
|
95
|
+
});
|
|
96
|
+
} catch {
|
|
97
|
+
return match;
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export type { FrontmatterResult } from "./frontmatter.ts";
|
|
2
|
+
export { parseFrontmatter } from "./frontmatter.ts";
|
|
3
|
+
export { createHighlighterService, ensureLanguages, highlightCodeBlocks } from "./highlight.ts";
|
|
4
|
+
export type { MermaidTransform } from "./mermaid.ts";
|
|
5
|
+
export { renderMermaidBlocks } from "./mermaid.ts";
|
|
6
|
+
export type { RenderedDocument, Renderer, RendererOptions, RenderOptions } from "./render.ts";
|
|
7
|
+
export { createRenderer, renderMarkdown } from "./render.ts";
|
|
8
|
+
export { extractToc, injectHeadingAnchors } from "./toc.ts";
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { MermaidSource } from "../../shared/types.ts";
|
|
2
|
+
import { MERMAID_LANGUAGE } from "../constants.ts";
|
|
3
|
+
import { decodeEntities } from "./text.ts";
|
|
4
|
+
|
|
5
|
+
const MERMAID_BLOCK_RE = new RegExp(`<pre><code class="language-${MERMAID_LANGUAGE}">([\\s\\S]*?)</code></pre>`, "g");
|
|
6
|
+
|
|
7
|
+
export type MermaidTransform = {
|
|
8
|
+
html: string;
|
|
9
|
+
/** Raw diagram sources, decoded for the browser runtime. */
|
|
10
|
+
sources: MermaidSource[];
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Turn ```mermaid fences into empty slots plus structured sources. The Svelte
|
|
15
|
+
* client mounts a diagram component into each slot.
|
|
16
|
+
*
|
|
17
|
+
* The captured source is HTML-escaped by Bun's Markdown renderer; it is decoded
|
|
18
|
+
* once here so the API can hand raw Mermaid source to the browser.
|
|
19
|
+
*/
|
|
20
|
+
export function renderMermaidBlocks(html: string): MermaidTransform {
|
|
21
|
+
const sources: MermaidSource[] = [];
|
|
22
|
+
|
|
23
|
+
const output = html.replace(MERMAID_BLOCK_RE, (_match, source: string) => {
|
|
24
|
+
const id = `mermaid-${sources.length + 1}`;
|
|
25
|
+
sources.push({ id, source: decodeEntities(source) });
|
|
26
|
+
return `<div class="mermaid-slot" data-mermaid-slot="${id}"></div>`;
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
return { html: output, sources };
|
|
30
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import type { Highlighter } from "shiki";
|
|
2
|
+
import type { MermaidSource, TocItem } from "../../shared/types.ts";
|
|
3
|
+
import { MERMAID_LANGUAGE } from "../constants.ts";
|
|
4
|
+
import type { FrontmatterData, HtmlMode } from "../types.ts";
|
|
5
|
+
import { parseFrontmatter } from "./frontmatter.ts";
|
|
6
|
+
import { createHighlighterService, ensureLanguages, highlightCodeBlocks } from "./highlight.ts";
|
|
7
|
+
import { renderMermaidBlocks } from "./mermaid.ts";
|
|
8
|
+
import { extractToc, injectHeadingAnchors } from "./toc.ts";
|
|
9
|
+
|
|
10
|
+
export type RenderOptions = {
|
|
11
|
+
/** Syntax highlight fenced code blocks with shiki. Default: true. */
|
|
12
|
+
highlight?: boolean;
|
|
13
|
+
/** How to treat raw HTML inside Markdown. Default: "filter". */
|
|
14
|
+
html?: HtmlMode;
|
|
15
|
+
/** Enable $inline$ / $$display$$ math. Default: false. */
|
|
16
|
+
math?: boolean;
|
|
17
|
+
/** Render ```mermaid fences as client-side diagrams. Default: true. */
|
|
18
|
+
mermaid?: boolean;
|
|
19
|
+
/** Reuse an existing shiki instance (the server does this). */
|
|
20
|
+
highlighter?: Highlighter | null;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type RenderedDocument = {
|
|
24
|
+
html: string;
|
|
25
|
+
toc: TocItem[];
|
|
26
|
+
title?: string;
|
|
27
|
+
frontmatter: FrontmatterData;
|
|
28
|
+
warning?: string;
|
|
29
|
+
/** Structured diagram sources for the Svelte client. */
|
|
30
|
+
mermaid: MermaidSource[];
|
|
31
|
+
raw: string;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export type RendererOptions = {
|
|
35
|
+
highlighter: Highlighter | null;
|
|
36
|
+
html: HtmlMode;
|
|
37
|
+
math: boolean;
|
|
38
|
+
/** Render ```mermaid fences as client-side diagrams. Default: true. */
|
|
39
|
+
mermaid?: boolean;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export type Renderer = {
|
|
43
|
+
render(source: string): Promise<RenderedDocument>;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export function createRenderer(options: RendererOptions): Renderer {
|
|
47
|
+
return {
|
|
48
|
+
async render(source) {
|
|
49
|
+
const frontmatter = parseFrontmatter(source);
|
|
50
|
+
const { highlighter } = options;
|
|
51
|
+
const mermaidEnabled = options.mermaid !== false;
|
|
52
|
+
|
|
53
|
+
if (highlighter)
|
|
54
|
+
await ensureLanguages(highlighter, frontmatter.body, mermaidEnabled ? MERMAID_LANGUAGE : undefined);
|
|
55
|
+
|
|
56
|
+
let html = Bun.markdown.html(frontmatter.body, {
|
|
57
|
+
tables: true,
|
|
58
|
+
strikethrough: true,
|
|
59
|
+
tasklists: true,
|
|
60
|
+
autolinks: true,
|
|
61
|
+
headings: { ids: true },
|
|
62
|
+
latexMath: options.math,
|
|
63
|
+
tagFilter: options.html === "filter",
|
|
64
|
+
noHtmlBlocks: options.html === "off",
|
|
65
|
+
noHtmlSpans: options.html === "off",
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
let mermaidSources: MermaidSource[] = [];
|
|
69
|
+
if (mermaidEnabled) {
|
|
70
|
+
const mermaid = renderMermaidBlocks(html);
|
|
71
|
+
html = mermaid.html;
|
|
72
|
+
mermaidSources = mermaid.sources;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (highlighter) html = highlightCodeBlocks(html, highlighter);
|
|
76
|
+
|
|
77
|
+
const toc = extractToc(html);
|
|
78
|
+
html = injectHeadingAnchors(html);
|
|
79
|
+
|
|
80
|
+
const titleValue = frontmatter.data.title;
|
|
81
|
+
const title =
|
|
82
|
+
typeof titleValue === "string"
|
|
83
|
+
? titleValue
|
|
84
|
+
: titleValue == null
|
|
85
|
+
? toc.find(item => item.level === 1)?.text
|
|
86
|
+
: String(titleValue);
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
html,
|
|
90
|
+
toc,
|
|
91
|
+
title,
|
|
92
|
+
frontmatter: frontmatter.data,
|
|
93
|
+
warning: frontmatter.error,
|
|
94
|
+
mermaid: mermaidSources,
|
|
95
|
+
raw: source,
|
|
96
|
+
};
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Convenience API for one-off rendering. When no highlighter is supplied and
|
|
103
|
+
* highlighting is enabled, a temporary instance is created and disposed.
|
|
104
|
+
*/
|
|
105
|
+
export async function renderMarkdown(source: string, options: RenderOptions = {}): Promise<RenderedDocument> {
|
|
106
|
+
const ownsHighlighter = options.highlight !== false && !options.highlighter;
|
|
107
|
+
const highlighter = options.highlighter ?? (ownsHighlighter ? await createHighlighterService() : null);
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
return await createRenderer({
|
|
111
|
+
highlighter,
|
|
112
|
+
html: options.html ?? "filter",
|
|
113
|
+
math: options.math === true,
|
|
114
|
+
mermaid: options.mermaid !== false,
|
|
115
|
+
}).render(source);
|
|
116
|
+
} finally {
|
|
117
|
+
if (ownsHighlighter) highlighter?.dispose();
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
const ENTITIES: Record<string, string> = {
|
|
2
|
+
amp: "&",
|
|
3
|
+
lt: "<",
|
|
4
|
+
gt: ">",
|
|
5
|
+
quot: '"',
|
|
6
|
+
nbsp: "\u00a0",
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
/** Decode HTML entities emitted by Bun's markdown renderer. */
|
|
10
|
+
export function decodeEntities(input: string): string {
|
|
11
|
+
return input.replace(/&(#x[0-9a-f]+|#[0-9]+|[a-z][a-z0-9]+);/gi, (match, name: string) => {
|
|
12
|
+
const lower = name.toLowerCase();
|
|
13
|
+
|
|
14
|
+
if (lower.startsWith("#x") || lower.startsWith("#")) {
|
|
15
|
+
const code = Number.parseInt(lower.slice(lower.startsWith("#x") ? 2 : 1), lower.startsWith("#x") ? 16 : 10);
|
|
16
|
+
if (!Number.isFinite(code)) return match;
|
|
17
|
+
try {
|
|
18
|
+
return String.fromCodePoint(code);
|
|
19
|
+
} catch {
|
|
20
|
+
return match;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return ENTITIES[lower] ?? match;
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function stripTags(input: string): string {
|
|
29
|
+
return decodeEntities(input.replace(/<[^>]*>/g, ""));
|
|
30
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { TocItem } from "../../shared/types.ts";
|
|
2
|
+
import { stripTags } from "./text.ts";
|
|
3
|
+
|
|
4
|
+
const HEADING_RE = /<h([1-6])\b([^>]*)>([\s\S]*?)<\/h\1>/gi;
|
|
5
|
+
const ID_ATTR_RE = /\bid="([^"]*)"/;
|
|
6
|
+
|
|
7
|
+
/** Pull a table of contents out of the HTML Bun generated (ids come from Bun). */
|
|
8
|
+
export function extractToc(html: string): TocItem[] {
|
|
9
|
+
const toc: TocItem[] = [];
|
|
10
|
+
for (const match of html.matchAll(HEADING_RE)) {
|
|
11
|
+
const level = Number(match[1]);
|
|
12
|
+
const attrs = match[2] ?? "";
|
|
13
|
+
const id = ID_ATTR_RE.exec(attrs)?.[1];
|
|
14
|
+
if (!id) continue;
|
|
15
|
+
const text = stripTags(match[3] ?? "").trim();
|
|
16
|
+
if (!text) continue;
|
|
17
|
+
toc.push({ level, id, text });
|
|
18
|
+
}
|
|
19
|
+
return toc;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Add a hoverable # anchor next to every heading that has an id. */
|
|
23
|
+
export function injectHeadingAnchors(html: string): string {
|
|
24
|
+
return html.replace(HEADING_RE, (match, level: string, attrs: string, inner: string) => {
|
|
25
|
+
const id = ID_ATTR_RE.exec(attrs)?.[1];
|
|
26
|
+
if (!id || /\bclass="/.test(attrs)) return match;
|
|
27
|
+
return `<h${level}${attrs} class="heading"><a class="heading-anchor" href="#${id}" aria-hidden="true">#</a>${inner}</h${level}>`;
|
|
28
|
+
});
|
|
29
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { sep } from "node:path";
|
|
2
|
+
|
|
3
|
+
export function isInside(root: string, target: string): boolean {
|
|
4
|
+
return target === root || target.startsWith(root.endsWith(sep) ? root : root + sep);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function isHiddenRelativePath(relativePath: string): boolean {
|
|
8
|
+
return relativePath.split(/[\\/]/).some(segment => segment.startsWith(".") && segment !== "." && segment !== "..");
|
|
9
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { realpath, stat } from "node:fs/promises";
|
|
2
|
+
import { relative, resolve, sep } from "node:path";
|
|
3
|
+
import { isHiddenRelativePath, isInside } from "../path.ts";
|
|
4
|
+
import type { ResolvedPath } from "../types.ts";
|
|
5
|
+
|
|
6
|
+
export type ResolveResult =
|
|
7
|
+
| { ok: true; resolved: ResolvedPath }
|
|
8
|
+
| { ok: false; status: 400 | 403 | 404; reason: string };
|
|
9
|
+
|
|
10
|
+
/** Validate and resolve a request path inside the served root (symlink-safe). */
|
|
11
|
+
export async function resolveRequest(root: string, pathname: string): Promise<ResolveResult> {
|
|
12
|
+
let decoded: string;
|
|
13
|
+
try {
|
|
14
|
+
decoded = decodeURIComponent(pathname);
|
|
15
|
+
} catch {
|
|
16
|
+
return { ok: false, status: 400, reason: "Malformed URL" };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (decoded.includes("\0")) {
|
|
20
|
+
return { ok: false, status: 400, reason: "Malformed URL" };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const candidate = resolve(root, `.${decoded}`);
|
|
24
|
+
if (!isInside(root, candidate)) {
|
|
25
|
+
return { ok: false, status: 403, reason: "Path escapes the served directory" };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const real = await realpath(candidate).catch(() => null);
|
|
29
|
+
if (!real) return { ok: false, status: 404, reason: "Not found" };
|
|
30
|
+
if (!isInside(root, real)) {
|
|
31
|
+
return { ok: false, status: 403, reason: "Path escapes the served directory" };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const stats = await stat(real).catch(() => null);
|
|
35
|
+
if (!stats) return { ok: false, status: 404, reason: "Not found" };
|
|
36
|
+
|
|
37
|
+
const rel = relative(root, real).split(sep).join("/");
|
|
38
|
+
if (rel && isHiddenRelativePath(rel)) {
|
|
39
|
+
return { ok: false, status: 404, reason: "Hidden paths are not served" };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return { ok: true, resolved: { path: real, stats, relative: rel } };
|
|
43
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { extname } from "node:path";
|
|
2
|
+
import { CLEAN_URL_SUFFIXES } from "../constants.ts";
|
|
3
|
+
import { isHtmlFile, isMarkdownFile } from "../file-kinds.ts";
|
|
4
|
+
import type { ResolvedPath } from "../types.ts";
|
|
5
|
+
import { resolveRequest } from "./path.ts";
|
|
6
|
+
|
|
7
|
+
export type Resource =
|
|
8
|
+
| { kind: "markdown"; file: ResolvedPath }
|
|
9
|
+
| { kind: "html"; file: ResolvedPath }
|
|
10
|
+
| { kind: "file"; file: ResolvedPath }
|
|
11
|
+
| { kind: "directory"; directory: ResolvedPath }
|
|
12
|
+
| { kind: "redirect"; location: string }
|
|
13
|
+
| { kind: "error"; status: 400 | 403 | 404; reason: string };
|
|
14
|
+
|
|
15
|
+
export type ResolveOptions = {
|
|
16
|
+
root: string;
|
|
17
|
+
cleanUrls: boolean;
|
|
18
|
+
single: boolean;
|
|
19
|
+
directoryListing: boolean;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function classifyFile(file: ResolvedPath): Resource {
|
|
23
|
+
if (isMarkdownFile(file.path)) return { kind: "markdown", file };
|
|
24
|
+
if (isHtmlFile(file.path)) return { kind: "html", file };
|
|
25
|
+
return { kind: "file", file };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function resolveCleanUrl(root: string, pathname: string): Promise<ResolvedPath | null> {
|
|
29
|
+
if (extname(pathname) !== "") return null;
|
|
30
|
+
for (const suffix of CLEAN_URL_SUFFIXES) {
|
|
31
|
+
const result = await resolveRequest(root, pathname + suffix);
|
|
32
|
+
if (result.ok && result.resolved.stats.isFile()) return result.resolved;
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function resolveSingleFallback(root: string): Promise<ResolvedPath | null> {
|
|
38
|
+
for (const pathname of ["/index.md", "/index.html"]) {
|
|
39
|
+
const result = await resolveRequest(root, pathname);
|
|
40
|
+
if (result.ok && result.resolved.stats.isFile()) return result.resolved;
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function resolveDirectory(options: ResolveOptions, pathname: string, directory: ResolvedPath): Promise<Resource> {
|
|
46
|
+
for (const name of ["index.md", "index.html"]) {
|
|
47
|
+
const candidate = await resolveRequest(options.root, `${pathname}${name}`);
|
|
48
|
+
if (candidate.ok && candidate.resolved.stats.isFile()) return classifyFile(candidate.resolved);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (!options.directoryListing) {
|
|
52
|
+
return { kind: "error", status: 404, reason: "Directory listing is disabled." };
|
|
53
|
+
}
|
|
54
|
+
return { kind: "directory", directory };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Turn a request URL into a resource the response layer can render.
|
|
59
|
+
* This is the single place that knows about clean URLs, directory indexes
|
|
60
|
+
* and the SPA fallback.
|
|
61
|
+
*/
|
|
62
|
+
export async function resolveResource(options: ResolveOptions, url: URL): Promise<Resource> {
|
|
63
|
+
const result = await resolveRequest(options.root, url.pathname);
|
|
64
|
+
|
|
65
|
+
if (result.ok) {
|
|
66
|
+
const resolved = result.resolved;
|
|
67
|
+
if (resolved.stats.isDirectory()) {
|
|
68
|
+
if (!url.pathname.endsWith("/")) {
|
|
69
|
+
return { kind: "redirect", location: `${url.pathname}/${url.search}` };
|
|
70
|
+
}
|
|
71
|
+
return resolveDirectory(options, url.pathname, resolved);
|
|
72
|
+
}
|
|
73
|
+
return classifyFile(resolved);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (result.status === 404) {
|
|
77
|
+
if (options.cleanUrls) {
|
|
78
|
+
const clean = await resolveCleanUrl(options.root, url.pathname);
|
|
79
|
+
if (clean) return classifyFile(clean);
|
|
80
|
+
}
|
|
81
|
+
if (options.single) {
|
|
82
|
+
const fallback = await resolveSingleFallback(options.root);
|
|
83
|
+
if (fallback) return classifyFile(fallback);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return { kind: "error", status: result.status, reason: result.reason };
|
|
88
|
+
}
|