diff --git a/app/src/lib/render/markdown.ts b/app/src/lib/render/markdown.ts index d36bf64..0f6f2ff 100644 --- a/app/src/lib/render/markdown.ts +++ b/app/src/lib/render/markdown.ts @@ -1,4 +1,4 @@ -import { marked } from 'marked'; +import { Marked } from 'marked'; import DOMPurify from 'dompurify'; import hljs from 'highlight.js/lib/core'; import javascript from 'highlight.js/lib/languages/javascript'; @@ -14,28 +14,40 @@ hljs.registerLanguage('bash', bash); hljs.registerLanguage('sh', bash); hljs.registerLanguage('json', json); -marked.use({ - breaks: true, - gfm: true, - renderer: { - code({ text, lang }) { - const language = lang && hljs.getLanguage(lang) ? lang : undefined; - const highlighted = language - ? hljs.highlight(text, { language }).value - : hljs.highlightAuto(text).value; - const cls = language ? ` language-${language}` : ''; - return `
${highlighted}`;
- },
- },
+/**
+ * Lokaler Marked-Instance, damit die globale `marked`-Singleton nicht
+ * mutiert wird — andere Module können `marked` unbeeinflusst weiterverwenden.
+ * (Spec §3: lokale Ersetzbarkeit der Engine.)
+ */
+const markedInstance = new Marked({
+ breaks: true,
+ gfm: true,
+ renderer: {
+ code({ text, lang }) {
+ const language = lang && hljs.getLanguage(lang) ? lang : undefined;
+ const highlighted = language
+ ? hljs.highlight(text, { language }).value
+ : hljs.highlightAuto(text).value;
+ const cls = language ? ` language-${language}` : '';
+ return `${highlighted}`;
+ }
+ }
});
/**
* Rendert einen Markdown-String zu sanitized HTML.
* Einziger Export des Moduls — so bleibt Austausch der Engine lokal.
+ *
+ * Nur im Browser/jsdom aufrufen: DOMPurify braucht ein DOM. Die SPA
+ * hat SSR global ausgeschaltet (`+layout.ts: ssr = false`), Vitest läuft
+ * in jsdom — beide Szenarien sind abgedeckt. Ein Aufruf in reiner
+ * Node-Umgebung würde hier laut fehlschlagen statt stumm unsicher
+ * durchzulaufen.
*/
export function renderMarkdown(md: string): string {
- const raw = marked.parse(md, { async: false }) as string;
- return DOMPurify.sanitize(raw, {
- ADD_ATTR: ['target', 'rel'],
- });
+ if (typeof window === 'undefined') {
+ throw new Error('renderMarkdown: DOM-Kontext erforderlich (Browser oder jsdom).');
+ }
+ const raw = markedInstance.parse(md, { async: false }) as string;
+ return DOMPurify.sanitize(raw);
}
diff --git a/app/tests/unit/markdown.test.ts b/app/tests/unit/markdown.test.ts
index 3cddab0..5d05f81 100644
--- a/app/tests/unit/markdown.test.ts
+++ b/app/tests/unit/markdown.test.ts
@@ -28,10 +28,11 @@ describe('renderMarkdown', () => {
expect(html).toContain('');
expect(html).toContain(' {
@@ -46,4 +47,35 @@ describe('renderMarkdown', () => {
expect(html).toContain('
{
+ const html = renderMarkdown('
');
+ expect(html.toLowerCase()).not.toContain('onerror');
+ });
+
+ it('entfernt onclick-Attribute auf inline-HTML', () => {
+ const html = renderMarkdown('x');
+ expect(html.toLowerCase()).not.toContain('onclick');
+ });
+
+ it('entfernt iframe-Tags', () => {
+ const html = renderMarkdown('');
+ expect(html.toLowerCase()).not.toContain('