From 9af357304820bebd134c77a10dd38e7de89179ac Mon Sep 17 00:00:00 2001 From: cnb Date: Wed, 5 Aug 2026 20:13:32 +0800 Subject: [PATCH 1/4] fix(ui): lazy-load optional markdown peer deps to prevent import crashes MarkdownRenderer now dynamically imports marked, highlight.js, and isomorphic-dompurify inside onMount instead of at module scope. This prevents consumers that haven't installed these optional peer deps from crashing when MarkdownRenderer is bundled but never rendered. Tests updated to use waitFor/waitForMarkdown for async rendering. --- .../ui/src/components/MarkdownRenderer.svelte | 95 ++++++++++++++----- .../src/components/markdown-renderer.test.ts | 25 +++-- 2 files changed, 88 insertions(+), 32 deletions(-) diff --git a/packages/ui/src/components/MarkdownRenderer.svelte b/packages/ui/src/components/MarkdownRenderer.svelte index 79a30f0a..d1aecce7 100644 --- a/packages/ui/src/components/MarkdownRenderer.svelte +++ b/packages/ui/src/components/MarkdownRenderer.svelte @@ -1,19 +1,6 @@ unsafe link', }); - await tick(); + await waitForMarkdown(container); expect(container.querySelector('script')).toBeNull(); expect(container.querySelector('[onerror]')).toBeNull(); @@ -30,7 +40,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'); @@ -55,7 +67,7 @@ describe('MarkdownRenderer security and enhancement', () => { await rerender({ content: '```">\nunsafe\n```', }); - await tick(); + await waitForMarkdown(container); const header = container.querySelector('.code-block-wrapper > div'); expect(header).not.toBeNull(); @@ -80,10 +92,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); }); From 389229916b5217505b0368a3db01adc531d8a311 Mon Sep 17 00:00:00 2001 From: zuo <159921208@qq.com> Date: Wed, 5 Aug 2026 22:56:34 +0800 Subject: [PATCH 2/4] fix(ui): validate lazy markdown peer loading --- .../ui/src/components/MarkdownRenderer.svelte | 72 +++++++++---------- .../src/components/markdown-renderer.test.ts | 1 - scripts/check-package-packs.ts | 36 +++++++++- 3 files changed, 71 insertions(+), 38 deletions(-) diff --git a/packages/ui/src/components/MarkdownRenderer.svelte b/packages/ui/src/components/MarkdownRenderer.svelte index d1aecce7..0dca3d18 100644 --- a/packages/ui/src/components/MarkdownRenderer.svelte +++ b/packages/ui/src/components/MarkdownRenderer.svelte @@ -13,23 +13,21 @@ let { content, streaming = false, class: className = "" }: Props = $props(); + 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. - // Hold the dynamically loaded markdown deps. Typed loosely (any) because the - // concrete shapes come from optional peer packages and svelte-check would - // otherwise narrow `$state(null)` to `never` on first render. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let MarkedCtor: any = $state(null); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let markedHighlightFn: any = $state(null); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let hljs: any = $state(null); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let DOMPurify: any = $state(null); + let MarkedCtor = $state(null); + let markedHighlightFn = $state(null); + let hljs = $state(null); + let DOMPurify = $state(null); onMount(() => { let cancelled = false; @@ -42,13 +40,10 @@ import("isomorphic-dompurify"), ]); if (cancelled) return; - MarkedCtor = (markedPkg as any).Marked ?? null; - markedHighlightFn = (markedHighlightPkg as any).markedHighlight ?? null; - hljs = (hljsPkg as any).default ?? (hljsPkg as any) ?? null; - DOMPurify = - "default" in (DOMPurifyPkg as any) - ? (DOMPurifyPkg as any).default - : (DOMPurifyPkg as any); + 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(() => {}); @@ -68,26 +63,31 @@ ); // Configure marked with syntax highlighting if available - const markedObj = $derived( - hasMarkdownDeps - ? new MarkedCtor( - markedHighlightFn({ - 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; - }, - }), - ) - : 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) - : `
${String(content || "").replace(//g, ">")}
`, - ); + const html = $derived.by(() => { + const purifier = DOMPurify; + const parser = markedObj; + if (hasMarkdownDeps && purifier && parser) { + return purifier.sanitize(parser.parse(content || "")); + } + return `
${String(content || "").replace(//g, ">")}
`; + }); // Handle copy code blocks let copiedBlock = $state(null); diff --git a/packages/ui/src/components/markdown-renderer.test.ts b/packages/ui/src/components/markdown-renderer.test.ts index a652a50c..c38d124e 100644 --- a/packages/ui/src/components/markdown-renderer.test.ts +++ b/packages/ui/src/components/markdown-renderer.test.ts @@ -1,5 +1,4 @@ import { fireEvent, render, waitFor } from '@testing-library/svelte'; -import { tick } from 'svelte'; import { afterEach, describe, expect, it, vi } from 'vitest'; import MarkdownRenderer from './MarkdownRenderer.svelte'; diff --git a/scripts/check-package-packs.ts b/scripts/check-package-packs.ts index 1cfb7dbb..0491a3c3 100644 --- a/scripts/check-package-packs.ts +++ b/scripts/check-package-packs.ts @@ -2,6 +2,7 @@ import { spawnSync } from 'node:child_process'; import { access, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; interface PackFile { path: string; @@ -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[] = [ { @@ -352,6 +359,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]) @@ -372,7 +385,28 @@ 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'); + const sveltePluginUrl = pathToFileURL( + join(repositoryRoot, 'node_modules', '@sveltejs', 'vite-plugin-svelte', 'dist', 'index.js'), + ).href; + await writeFile( + viteConfig, + `import { svelte } from ${JSON.stringify(sveltePluginUrl)};\nexport default { 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, '--root', consumerDirectory], + 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): Promise { From 7193488d2459650f22af9a2e603b5aed4719cfa3 Mon Sep 17 00:00:00 2001 From: zuo <159921208@qq.com> Date: Wed, 5 Aug 2026 23:03:02 +0800 Subject: [PATCH 3/4] fix(ci): configure packed consumer root --- scripts/check-package-packs.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/check-package-packs.ts b/scripts/check-package-packs.ts index 0491a3c3..d80a7db8 100644 --- a/scripts/check-package-packs.ts +++ b/scripts/check-package-packs.ts @@ -397,12 +397,12 @@ async function verifyUiPnpmPeerTree( ).href; await writeFile( viteConfig, - `import { svelte } from ${JSON.stringify(sveltePluginUrl)};\nexport default { plugins: [svelte()], build: { lib: { entry: ${JSON.stringify(consumerEntry)}, formats: ['es'] } } };\n`, + `import { svelte } from ${JSON.stringify(sveltePluginUrl)};\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, '--root', consumerDirectory], + [vitePath, 'build', '--config', viteConfig], consumerDirectory, ); From 77fb2435727ca76f4dc5d93752cac8bd200aff10 Mon Sep 17 00:00:00 2001 From: zuo <159921208@qq.com> Date: Wed, 5 Aug 2026 23:09:14 +0800 Subject: [PATCH 4/4] fix(ci): install packed consumer build tools --- scripts/check-package-packs.ts | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/scripts/check-package-packs.ts b/scripts/check-package-packs.ts index d80a7db8..d4751501 100644 --- a/scripts/check-package-packs.ts +++ b/scripts/check-package-packs.ts @@ -2,7 +2,6 @@ import { spawnSync } from 'node:child_process'; import { access, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; -import { pathToFileURL } from 'node:url'; interface PackFile { path: string; @@ -32,6 +31,7 @@ interface PackageManifest { bin?: string | Record; exports?: unknown; dependencies?: Record; + devDependencies?: Record; peerDependencies?: Record; } @@ -298,9 +298,16 @@ async function verifyUiPnpmPeerTree( await readFile(join(repositoryRoot, 'package.json'), 'utf8'), ) as { overrides?: Record }; 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 }); @@ -316,6 +323,10 @@ async function verifyUiPnpmPeerTree( '@tanstack/svelte-query': queryVersion, svelte: svelteVersion, }, + devDependencies: { + '@sveltejs/vite-plugin-svelte': sveltePluginVersion, + vite: viteVersion, + }, }, null, 2)}\n`, ); @@ -392,12 +403,9 @@ async function verifyUiPnpmPeerTree( `import { MarkdownRenderer } from '@svadmin/ui';\nconsole.info(typeof MarkdownRenderer);\n`, ); const viteConfig = join(consumerDirectory, 'vite.config.mjs'); - const sveltePluginUrl = pathToFileURL( - join(repositoryRoot, 'node_modules', '@sveltejs', 'vite-plugin-svelte', 'dist', 'index.js'), - ).href; await writeFile( viteConfig, - `import { svelte } from ${JSON.stringify(sveltePluginUrl)};\nexport default { root: ${JSON.stringify(consumerDirectory)}, plugins: [svelte()], build: { lib: { entry: ${JSON.stringify(consumerEntry)}, formats: ['es'] } } };\n`, + `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(