Skip to content
File

Blob: src/client/components/MarkdownContent.tsx

typescript112 lines
1import { Marked } from "marked";
2 
3import { highlightCode } from "@/client/components/highlight";
4import { sanitizeMarkdownHtml } from "@/client/components/markdownHtmlSanitizer";
5import { escapeHtml } from "@/shared/web/format";
6 
7type MarkdownContext = {
8 owner: string;
9 repo: string;
10 ref: string;
11 baseDir?: string;
12};
13 
14type MarkdownContentProps = {
15 markdown: string;
16 context: MarkdownContext;
17};
18 
19function isAbsoluteUrl(href: string): boolean {
20 return /^(?:[a-z]+:)?\/\//i.test(href) || href.startsWith("/") || href.startsWith("#");
21}
22 
23function joinRelativePath(baseDir: string, relativePath: string): string {
24 const [pathPart, hash = ""] = relativePath.split("#", 2);
25 const baseParts = baseDir.split("/").filter(Boolean);
26 
27 for (const part of pathPart.split("/").filter(Boolean)) {
28 if (part === ".") {
29 continue;
30 }
31 if (part === "..") {
32 baseParts.pop();
33 continue;
34 }
35 baseParts.push(part);
36 }
37 
38 const joined = baseParts.join("/");
39 return hash ? `${joined}#${hash}` : joined;
40}
41 
42function rewriteMarkdownHref(href: string, context: MarkdownContext): string {
43 if (!href || isAbsoluteUrl(href)) {
44 return href;
45 }
46 
47 const hashIndex = href.indexOf("#");
48 const fragment = hashIndex >= 0 ? href.slice(hashIndex) : "";
49 const resolved = joinRelativePath(
50 context.baseDir || "",
51 hashIndex >= 0 ? href.slice(0, hashIndex) : href
52 );
53 
54 return `/${encodeURIComponent(context.owner)}/${encodeURIComponent(context.repo)}/blob?ref=${encodeURIComponent(context.ref)}&path=${encodeURIComponent(resolved)}${fragment}`;
55}
56 
57function rewriteMarkdownImage(href: string, context: MarkdownContext): string {
58 if (!href || isAbsoluteUrl(href)) {
59 return href;
60 }
61 
62 const resolved = joinRelativePath(context.baseDir || "", href);
63 const name = resolved.split("/").pop() || "file";
64 return `/${encodeURIComponent(context.owner)}/${encodeURIComponent(context.repo)}/rawpath?ref=${encodeURIComponent(context.ref)}&path=${encodeURIComponent(resolved)}&name=${encodeURIComponent(name)}`;
65}
66 
67function escapeAttribute(value: string): string {
68 return escapeHtml(value).replace(/'/g, "'");
69}
70 
71function highlightBlock(code: string, language?: string | null): string {
72 const highlighted = highlightCode(code, language);
73 return `<pre class="markdown-code-block"><code class="hljs ${highlighted.languageClass}">${highlighted.html}</code></pre>`;
74}
75 
76export function renderMarkdownToHtml(markdown: string, context: MarkdownContext): string {
77 const marked = new Marked({ gfm: true, async: false });
78 
79 marked.use({
80 renderer: {
81 code({ text, lang }) {
82 return highlightBlock(text, lang || null);
83 },
84 link({ href, title, tokens }) {
85 const content = this.parser.parseInline(tokens);
86 const resolvedHref = rewriteMarkdownHref(href || "", context);
87 const titleAttr = title ? ` title="${escapeAttribute(title)}"` : "";
88 return `<a href="${escapeAttribute(resolvedHref)}"${titleAttr}>${content}</a>`;
89 },
90 image({ href, title, text }) {
91 const resolvedHref = rewriteMarkdownImage(href || "", context);
92 const alt = escapeAttribute(text || "");
93 const titleAttr = title ? ` title="${escapeAttribute(title)}"` : "";
94 return `<img src="${escapeAttribute(resolvedHref)}" alt="${alt}"${titleAttr} loading="lazy" />`;
95 },
96 },
97 });
98 
99 // async: false is set in the constructor, so parse() returns string synchronously.
100 const raw = marked.parse(markdown);
101 if (typeof raw !== "string") {
102 throw new Error("marked returned an async render result despite async: false");
103 }
104 
105 return sanitizeMarkdownHtml(raw);
106}
107 
108export function MarkdownContent({ markdown, context }: MarkdownContentProps) {
109 const html = renderMarkdownToHtml(markdown, context);
110 return <div className="markdown-content" dangerouslySetInnerHTML={{ __html: html }} />;
111}