Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 72 additions & 29 deletions packages/ui/src/components/MarkdownRenderer.svelte
Original file line number Diff line number Diff line change
@@ -1,19 +1,6 @@
<script lang="ts">
/* eslint-disable svelte/no-at-html-tags */
import * as markedPkg from "marked";
import * as markedHighlightPkg from "marked-highlight";
import * as hljsPkg from "highlight.js";
import * as DOMPurifyPkg from "isomorphic-dompurify";

const Marked = markedPkg.Marked;
const markedHighlight = markedHighlightPkg.markedHighlight;
const hljsModule = hljsPkg.default || hljsPkg;
const DOMPurify = 'default' in DOMPurifyPkg ? DOMPurifyPkg.default : DOMPurifyPkg;

// Only safely import css if hljs exists
if (hljsModule && Object.keys(hljsModule).length > 0) {
import("highlight.js/styles/github-dark.css").catch(() => {});
}
import { onMount } from "svelte";

interface Props {
/** The raw markdown string to render */
Expand All @@ -26,25 +13,81 @@

let { content, streaming = false, class: className = "" }: Props = $props();

const hasMarkdownDeps = typeof Marked === "function" && typeof DOMPurify?.sanitize === "function";
type MarkedConstructor = typeof import("marked").Marked;
type MarkedHighlight = typeof import("marked-highlight").markedHighlight;
type HighlightApi = typeof import("highlight.js").default;
type Sanitizer = typeof import("isomorphic-dompurify").default;

// Lazily loaded optional peer dependencies (marked, marked-highlight,
// highlight.js, isomorphic-dompurify). They are declared as optional peer
// deps; statically importing them would crash consumers that have not
// installed them, even when MarkdownRenderer is never rendered. Load them
// dynamically so the module graph resolves without them and the component
// degrades to escaped-text rendering when they are absent.
let MarkedCtor = $state<MarkedConstructor | null>(null);
let markedHighlightFn = $state<MarkedHighlight | null>(null);
let hljs = $state<HighlightApi | null>(null);
let DOMPurify = $state<Sanitizer | null>(null);

onMount(() => {
let cancelled = false;
(async () => {
try {
const [markedPkg, markedHighlightPkg, hljsPkg, DOMPurifyPkg] = await Promise.all([
import("marked"),
import("marked-highlight"),
import("highlight.js"),
import("isomorphic-dompurify"),
]);
if (cancelled) return;
MarkedCtor = markedPkg.Marked;
markedHighlightFn = markedHighlightPkg.markedHighlight;
hljs = hljsPkg.default;
DOMPurify = DOMPurifyPkg.default;
// Only import the theme css when highlight.js is available
if (hljs && Object.keys(hljs).length > 0) {
import("highlight.js/styles/github-dark.css").catch(() => {});
}
} catch {
// Optional deps not installed; fall back to escaped-text rendering.
if (cancelled) return;
}
})();
return () => { cancelled = true; };
});

const hasMarkdownDeps = $derived(
typeof MarkedCtor === "function" &&
typeof DOMPurify?.sanitize === "function" &&
typeof markedHighlightFn === "function",
);

// Configure marked with syntax highlighting if available
const markedObj = hasMarkdownDeps ? new Marked(
markedHighlight({
langPrefix: "hljs language-",
highlight(code: string, lang: string) {
const language = hljsModule.getLanguage && hljsModule.getLanguage(lang) ? lang : "plaintext";
return hljsModule.highlight ? hljsModule.highlight(code, { language }).value : code;
},
}),
) : null;
const markedObj = $derived.by(() => {
const Constructor = MarkedCtor;
const highlightExtension = markedHighlightFn;
if (!Constructor || !highlightExtension) return null;

return new Constructor(
highlightExtension({
langPrefix: "hljs language-",
highlight(code: string, lang: string) {
const language = hljs?.getLanguage && hljs.getLanguage(lang) ? lang : "plaintext";
return hljs?.highlight ? hljs.highlight(code, { language }).value : code;
},
}),
);
});

// Render HTML safely
const html = $derived(
hasMarkdownDeps
? DOMPurify.sanitize(markedObj?.parse(content || "") as string)
: `<div style="white-space: pre-wrap">${String(content || "").replace(/</g, "&lt;").replace(/>/g, "&gt;")}</div>`
);
const html = $derived.by(() => {
const purifier = DOMPurify;
const parser = markedObj;
if (hasMarkdownDeps && purifier && parser) {
return purifier.sanitize(parser.parse(content || ""));
}
return `<div style="white-space: pre-wrap">${String(content || "").replace(/</g, "&lt;").replace(/>/g, "&gt;")}</div>`;
});

// Handle copy code blocks
let copiedBlock = $state<string | null>(null);
Expand Down
26 changes: 19 additions & 7 deletions packages/ui/src/components/markdown-renderer.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,17 @@
import { fireEvent, render } from '@testing-library/svelte';
import { tick } from 'svelte';
import { fireEvent, render, waitFor } from '@testing-library/svelte';
import { afterEach, describe, expect, it, vi } from 'vitest';
import MarkdownRenderer from './MarkdownRenderer.svelte';

// MarkdownRenderer loads its optional peer deps (marked, highlight.js,
// isomorphic-dompurify) dynamically on mount, so rendering is asynchronous.
// Wait until markdown has actually been parsed and rendered (presence of a
// rendered <a>/<pre> element signals the deps resolved and parsing ran).
async function waitForMarkdown(container: HTMLElement) {
await waitFor(() => {
expect(container.querySelector('a, pre, .code-block-wrapper')).not.toBeNull();
});
}

afterEach(() => {
vi.restoreAllMocks();
});
Expand All @@ -12,7 +21,7 @@ describe('MarkdownRenderer security and enhancement', () => {
const { container } = render(MarkdownRenderer, {
content: '<script>globalThis.__markdownXss = 1</script><img src="x" onerror="globalThis.__markdownXss = 2"><a href="javascript:globalThis.__markdownXss=3">unsafe link</a>',
});
await tick();
await waitForMarkdown(container);

expect(container.querySelector('script')).toBeNull();
expect(container.querySelector('[onerror]')).toBeNull();
Expand All @@ -30,7 +39,9 @@ describe('MarkdownRenderer security and enhancement', () => {
const { container } = render(MarkdownRenderer, {
content: '```js\nconst answer = 42;\n```',
});
await tick();
await waitFor(() => {
expect(container.querySelector('.code-block-wrapper')).not.toBeNull();
});

const wrapper = container.querySelector('.code-block-wrapper');
const code = wrapper?.querySelector('code');
Expand All @@ -55,7 +66,7 @@ describe('MarkdownRenderer security and enhancement', () => {
await rerender({
content: '```"><img/src=x/onerror=globalThis.__markdownXss=1>\nunsafe\n```',
});
await tick();
await waitForMarkdown(container);

const header = container.querySelector('.code-block-wrapper > div');
expect(header).not.toBeNull();
Expand All @@ -80,10 +91,11 @@ describe('MarkdownRenderer security and enhancement', () => {
originalAddEventListener.call(this, type, listener, options);
});

const { rerender } = render(MarkdownRenderer, { content: '```text\none\n```' });
const { container, rerender } = render(MarkdownRenderer, { content: '```text\none\n```' });
await waitForMarkdown(container);
await rerender({ content: '```text\ntwo\n```' });
await rerender({ content: '```text\nthree\n```' });
await tick();
await waitForMarkdown(container);

expect(delegatedClickBindings).toBe(1);
});
Expand Down
44 changes: 43 additions & 1 deletion scripts/check-package-packs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ interface PackageManifest {
bin?: string | Record<string, string>;
exports?: unknown;
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
peerDependencies?: Record<string, string>;
}

Expand All @@ -43,6 +44,12 @@ const repositoryRoot = resolve(import.meta.dir, '..');
const packagesRoot = join(repositoryRoot, 'packages');
const tscPath = join(repositoryRoot, 'node_modules', 'typescript', 'bin', 'tsc');
const pnpmVersion = '11.11.0';
const optionalMarkdownPeers = [
'highlight.js',
'isomorphic-dompurify',
'marked',
'marked-highlight',
] as const;

const expectations: PackageExpectation[] = [
{
Expand Down Expand Up @@ -291,9 +298,16 @@ async function verifyUiPnpmPeerTree(
await readFile(join(repositoryRoot, 'package.json'), 'utf8'),
) as { overrides?: Record<string, string> };
const svelteVersion = rootManifest.overrides?.svelte;
const viteVersion = rootManifest.overrides?.vite;
const queryVersion = uiManifest.peerDependencies?.['@tanstack/svelte-query'];
const sveltePluginVersion = uiManifest.devDependencies?.['@sveltejs/vite-plugin-svelte'];
assert(svelteVersion, 'root package.json: overrides.svelte is required for pnpm verification');
assert(viteVersion, 'root package.json: overrides.vite is required for pnpm verification');
assert(queryVersion, '@svadmin/ui: @tanstack/svelte-query peer range is required for pnpm verification');
assert(
sveltePluginVersion,
'@svadmin/ui: @sveltejs/vite-plugin-svelte dev dependency is required for pnpm verification',
);

const consumerDirectory = join(packDirectory, 'pnpm-peer-consumer');
await mkdir(consumerDirectory, { recursive: true });
Expand All @@ -309,6 +323,10 @@ async function verifyUiPnpmPeerTree(
'@tanstack/svelte-query': queryVersion,
svelte: svelteVersion,
},
devDependencies: {
'@sveltejs/vite-plugin-svelte': sveltePluginVersion,
vite: viteVersion,
},
}, null, 2)}\n`,
);

Expand Down Expand Up @@ -352,6 +370,12 @@ async function verifyUiPnpmPeerTree(
);

const virtualStoreEntries = await readdir(join(consumerDirectory, 'node_modules', '.pnpm'));
for (const optionalPeer of optionalMarkdownPeers) {
assert(
!virtualStoreEntries.some((entry) => entry.startsWith(`${optionalPeer}@`)),
`@svadmin/ui: pnpm strict consumer unexpectedly installed optional peer ${optionalPeer}`,
);
}
const svelteVersions = new Set(
virtualStoreEntries
.map((entry) => /^svelte@([^_]+)(?:_|$)/.exec(entry)?.[1])
Expand All @@ -372,7 +396,25 @@ async function verifyUiPnpmPeerTree(
['--yes', `pnpm@${pnpmVersion}`, 'list', 'svelte', '--depth', 'Infinity'],
consumerDirectory,
);
return `pnpm@${pnpmVersion} strict packed consumer passed\nforbidden dependencies absent: cmdk-sv, sonner-svelte, @melt-ui/svelte\n${dependencyTree.trim()}\nresolved Svelte versions: ${resolvedSvelteVersion}`;

const consumerEntry = join(consumerDirectory, 'markdown-import.ts');
await writeFile(
consumerEntry,
`import { MarkdownRenderer } from '@svadmin/ui';\nconsole.info(typeof MarkdownRenderer);\n`,
);
const viteConfig = join(consumerDirectory, 'vite.config.mjs');
await writeFile(
viteConfig,
`import { svelte } from '@sveltejs/vite-plugin-svelte';\nexport default { root: ${JSON.stringify(consumerDirectory)}, plugins: [svelte()], build: { lib: { entry: ${JSON.stringify(consumerEntry)}, formats: ['es'] } } };\n`,
);
const vitePath = join(repositoryRoot, 'node_modules', 'vite', 'bin', 'vite.js');
const optionalPeerBuild = run(
'node',
[vitePath, 'build', '--config', viteConfig],
consumerDirectory,
);

return `pnpm@${pnpmVersion} strict packed consumer passed\nforbidden dependencies absent: cmdk-sv, sonner-svelte, @melt-ui/svelte\noptional markdown peers absent: ${optionalMarkdownPeers.join(', ')}\n${optionalPeerBuild.trim()}\n${dependencyTree.trim()}\nresolved Svelte versions: ${resolvedSvelteVersion}`;
}

async function createConsumer(packDirectory: string, results: Map<string, PackResult>): Promise<string> {
Expand Down