summaryrefslogtreecommitdiffhomepage
path: root/src/features/markdown/logic
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-27 01:13:28 +0900
committerAdam Malczewski <[email protected]>2026-06-27 01:13:31 +0900
commit2fa03f8d7410c2b8d6be8e10ad088863e83d7177 (patch)
tree94e1923180ae38d571d34b578afecb0a18913c24 /src/features/markdown/logic
parent80f99665034a0e510300793205c162fc7a46769f (diff)
parent08b12478636f4a5c86a1f3c40a843f2906b7c82f (diff)
downloaddispatch-web-2fa03f8d7410c2b8d6be8e10ad088863e83d7177.tar.gz
dispatch-web-2fa03f8d7410c2b8d6be8e10ad088863e83d7177.zip
Merge branch 'dev' into feature/heartbeat
# Conflicts: # src/app/App.svelte # src/app/store.svelte.ts # src/app/store.test.ts # src/features/workspaces/ui/WorkspaceCard.test.ts
Diffstat (limited to 'src/features/markdown/logic')
-rw-r--r--src/features/markdown/logic/markdown.test.ts106
-rw-r--r--src/features/markdown/logic/markdown.ts174
2 files changed, 140 insertions, 140 deletions
diff --git a/src/features/markdown/logic/markdown.test.ts b/src/features/markdown/logic/markdown.test.ts
index 7dbb878..54b0086 100644
--- a/src/features/markdown/logic/markdown.test.ts
+++ b/src/features/markdown/logic/markdown.test.ts
@@ -2,57 +2,57 @@ import { describe, expect, it } from "vitest";
import { renderMarkdown } from "./markdown";
describe("renderMarkdown", () => {
- it("renders GFM markdown (headings, emphasis)", () => {
- const html = renderMarkdown("# Title\n\nSome **bold** text.");
- expect(html).toContain("<h1");
- expect(html).toContain("Title");
- expect(html).toContain("<strong>bold</strong>");
- });
-
- it("highlights fenced code for a known language", () => {
- const html = renderMarkdown("```javascript\nconst x = 1;\n```");
- expect(html).toContain("language-javascript");
- expect(html).toContain("hljs-keyword"); // `const` got highlighted
- });
-
- it("resolves language aliases (js -> javascript)", () => {
- const html = renderMarkdown("```js\nconst x = 1;\n```");
- expect(html).toContain("hljs-keyword");
- });
-
- it("escapes code for an unknown language without throwing", () => {
- const html = renderMarkdown("```nope\n<b>x</b>\n```");
- expect(html).toContain("&lt;b&gt;");
- });
-
- it("sanitizes dangerous HTML", () => {
- const html = renderMarkdown("Hi <script>alert(1)</script> there");
- expect(html).not.toContain("<script>");
- expect(html).toContain("Hi");
- });
-
- it("balances dangling bold emphasis while streaming", () => {
- expect(renderMarkdown("a **bold", { streaming: true })).toContain("<strong>bold</strong>");
- });
-
- it("does not balance delimiters when not streaming", () => {
- expect(renderMarkdown("a **bold")).not.toContain("<strong>");
- });
-
- it("wraps fenced code blocks with a copy button", () => {
- const html = renderMarkdown("```js\nconst x = 1;\n```");
- expect(html).toContain("code-block");
- expect(html).toContain("data-copy");
- expect(html).toContain("<pre>");
- });
-
- it("does not add a copy button to inline code", () => {
- const html = renderMarkdown("use `npm run dev` please");
- expect(html).not.toContain("data-copy");
- expect(html).toContain("<code>npm run dev</code>");
- });
-
- it("returns an empty string for empty input", () => {
- expect(renderMarkdown("")).toBe("");
- });
+ it("renders GFM markdown (headings, emphasis)", () => {
+ const html = renderMarkdown("# Title\n\nSome **bold** text.");
+ expect(html).toContain("<h1");
+ expect(html).toContain("Title");
+ expect(html).toContain("<strong>bold</strong>");
+ });
+
+ it("highlights fenced code for a known language", () => {
+ const html = renderMarkdown("```javascript\nconst x = 1;\n```");
+ expect(html).toContain("language-javascript");
+ expect(html).toContain("hljs-keyword"); // `const` got highlighted
+ });
+
+ it("resolves language aliases (js -> javascript)", () => {
+ const html = renderMarkdown("```js\nconst x = 1;\n```");
+ expect(html).toContain("hljs-keyword");
+ });
+
+ it("escapes code for an unknown language without throwing", () => {
+ const html = renderMarkdown("```nope\n<b>x</b>\n```");
+ expect(html).toContain("&lt;b&gt;");
+ });
+
+ it("sanitizes dangerous HTML", () => {
+ const html = renderMarkdown("Hi <script>alert(1)</script> there");
+ expect(html).not.toContain("<script>");
+ expect(html).toContain("Hi");
+ });
+
+ it("balances dangling bold emphasis while streaming", () => {
+ expect(renderMarkdown("a **bold", { streaming: true })).toContain("<strong>bold</strong>");
+ });
+
+ it("does not balance delimiters when not streaming", () => {
+ expect(renderMarkdown("a **bold")).not.toContain("<strong>");
+ });
+
+ it("wraps fenced code blocks with a copy button", () => {
+ const html = renderMarkdown("```js\nconst x = 1;\n```");
+ expect(html).toContain("code-block");
+ expect(html).toContain("data-copy");
+ expect(html).toContain("<pre>");
+ });
+
+ it("does not add a copy button to inline code", () => {
+ const html = renderMarkdown("use `npm run dev` please");
+ expect(html).not.toContain("data-copy");
+ expect(html).toContain("<code>npm run dev</code>");
+ });
+
+ it("returns an empty string for empty input", () => {
+ expect(renderMarkdown("")).toBe("");
+ });
});
diff --git a/src/features/markdown/logic/markdown.ts b/src/features/markdown/logic/markdown.ts
index 3a6e5a6..ad8a8bd 100644
--- a/src/features/markdown/logic/markdown.ts
+++ b/src/features/markdown/logic/markdown.ts
@@ -39,88 +39,88 @@ import { markedHighlight } from "marked-highlight";
// Hot set: registered eagerly so common code blocks highlight on first paint.
const HOT_LANGUAGES: Record<string, LanguageFn> = {
- bash,
- c,
- cpp,
- csharp,
- css,
- go,
- java,
- javascript,
- json,
- markdown: markdownLang,
- php,
- plaintext,
- python,
- ruby,
- rust,
- shell,
- sql,
- typescript,
- xml,
- yaml,
+ bash,
+ c,
+ cpp,
+ csharp,
+ css,
+ go,
+ java,
+ javascript,
+ json,
+ markdown: markdownLang,
+ php,
+ plaintext,
+ python,
+ ruby,
+ rust,
+ shell,
+ sql,
+ typescript,
+ xml,
+ yaml,
};
for (const [name, lang] of Object.entries(HOT_LANGUAGES)) {
- hljs.registerLanguage(name, lang);
+ hljs.registerLanguage(name, lang);
}
// Normalize common fence aliases to canonical highlight.js names.
const ALIASES: Record<string, string> = {
- js: "javascript",
- jsx: "javascript",
- mjs: "javascript",
- cjs: "javascript",
- ts: "typescript",
- tsx: "typescript",
- py: "python",
- py3: "python",
- rb: "ruby",
- sh: "bash",
- zsh: "bash",
- yml: "yaml",
- "c++": "cpp",
- cxx: "cpp",
- "c#": "csharp",
- cs: "csharp",
- htm: "xml",
- html: "xml",
- svg: "xml",
- md: "markdown",
- mdx: "markdown",
- golang: "go",
- rs: "rust",
+ js: "javascript",
+ jsx: "javascript",
+ mjs: "javascript",
+ cjs: "javascript",
+ ts: "typescript",
+ tsx: "typescript",
+ py: "python",
+ py3: "python",
+ rb: "ruby",
+ sh: "bash",
+ zsh: "bash",
+ yml: "yaml",
+ "c++": "cpp",
+ cxx: "cpp",
+ "c#": "csharp",
+ cs: "csharp",
+ htm: "xml",
+ html: "xml",
+ svg: "xml",
+ md: "markdown",
+ mdx: "markdown",
+ golang: "go",
+ rs: "rust",
};
function normalizeLang(lang: string): string {
- const lower = lang.toLowerCase().trim();
- return ALIASES[lower] ?? lower;
+ const lower = lang.toLowerCase().trim();
+ return ALIASES[lower] ?? lower;
}
function escapeHtml(s: string): string {
- return s
- .replace(/&/g, "&amp;")
- .replace(/</g, "&lt;")
- .replace(/>/g, "&gt;")
- .replace(/"/g, "&quot;")
- .replace(/'/g, "&#39;");
+ return s
+ .replace(/&/g, "&amp;")
+ .replace(/</g, "&lt;")
+ .replace(/>/g, "&gt;")
+ .replace(/"/g, "&quot;")
+ .replace(/'/g, "&#39;");
}
const md = new Marked(
- markedHighlight({
- emptyLangClass: "hljs",
- langPrefix: "hljs language-",
- highlight(code: string, lang: string): string {
- if (!lang) return escapeHtml(code);
- const name = normalizeLang(lang);
- if (!hljs.getLanguage(name)) return escapeHtml(code);
- try {
- return hljs.highlight(code, { language: name, ignoreIllegals: true }).value;
- } catch {
- return escapeHtml(code);
- }
- },
- }),
- { gfm: true, breaks: true },
+ markedHighlight({
+ emptyLangClass: "hljs",
+ langPrefix: "hljs language-",
+ highlight(code: string, lang: string): string {
+ if (!lang) return escapeHtml(code);
+ const name = normalizeLang(lang);
+ if (!hljs.getLanguage(name)) return escapeHtml(code);
+ try {
+ return hljs.highlight(code, { language: name, ignoreIllegals: true }).value;
+ } catch {
+ return escapeHtml(code);
+ }
+ },
+ }),
+ { gfm: true, breaks: true },
);
/**
@@ -128,14 +128,14 @@ const md = new Marked(
* partial text renders cleanly instead of flashing raw markers.
*/
function closeOpenDelimiters(src: string): string {
- let out = src;
- const fenceCount = (out.match(/^```/gm) ?? []).length;
- if (fenceCount % 2 !== 0) out += "\n```";
- const boldCount = (out.match(/\*\*/g) ?? []).length;
- if (boldCount % 2 !== 0) out += "**";
- const inlineCode = (out.match(/(?<!`)`(?!`)/g) ?? []).length;
- if (inlineCode % 2 !== 0) out += "`";
- return out;
+ let out = src;
+ const fenceCount = (out.match(/^```/gm) ?? []).length;
+ if (fenceCount % 2 !== 0) out += "\n```";
+ const boldCount = (out.match(/\*\*/g) ?? []).length;
+ if (boldCount % 2 !== 0) out += "**";
+ const inlineCode = (out.match(/(?<!`)`(?!`)/g) ?? []).length;
+ if (inlineCode % 2 !== 0) out += "`";
+ return out;
}
// Wrap each fenced code block (`<pre>…</pre>`) in a positioned container with a
@@ -144,22 +144,22 @@ function closeOpenDelimiters(src: string): string {
// `data-copy` is the delegation hook the component listens for; DOMPurify keeps
// `<button>` + `data-*` by default. Inline `<code>` has no `<pre>`, so it's untouched.
const COPY_BUTTON =
- '<button type="button" data-copy aria-label="Copy code"' +
- ' class="copy-btn btn btn-xs absolute right-2 top-2 opacity-0 transition-opacity group-hover:opacity-100">Copy</button>';
+ '<button type="button" data-copy aria-label="Copy code"' +
+ ' class="copy-btn btn btn-xs absolute right-2 top-2 opacity-0 transition-opacity group-hover:opacity-100">Copy</button>';
function addCopyButtons(html: string): string {
- return html
- .replace(/<pre>/g, `<div class="code-block group relative">${COPY_BUTTON}<pre>`)
- .replace(/<\/pre>/g, "</pre></div>");
+ return html
+ .replace(/<pre>/g, `<div class="code-block group relative">${COPY_BUTTON}<pre>`)
+ .replace(/<\/pre>/g, "</pre></div>");
}
/** Render Markdown to sanitized HTML. Returns `""` if parsing ever throws. */
export function renderMarkdown(text: string, opts?: { streaming?: boolean }): string {
- const src = opts?.streaming === true ? closeOpenDelimiters(text) : text;
- try {
- const raw = md.parse(src) as string;
- return DOMPurify.sanitize(addCopyButtons(raw));
- } catch {
- return "";
- }
+ const src = opts?.streaming === true ? closeOpenDelimiters(text) : text;
+ try {
+ const raw = md.parse(src) as string;
+ return DOMPurify.sanitize(addCopyButtons(raw));
+ } catch {
+ return "";
+ }
}