diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0282267 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,34 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + name: Test + Typecheck + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '18' + cache: pnpm + + - name: Install pnpm + run: corepack enable && corepack prepare pnpm@latest --activate + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Typecheck + run: pnpm run typecheck + + - name: Run tests (including SSR import smoke test) + run: pnpm test -- --run diff --git a/README.md b/README.md index ee41a1b..bfa2193 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,115 @@ Notes: - `vue-i18n` is optional: the library provides a synchronous fallback translator. If your app uses `vue-i18n`, the library will automatically wire into it at runtime when available. - If you're installing this library inside a monorepo or using pnpm workspaces, install peers at the workspace root so they are available to consuming packages. +## Server-Side Rendering (SSR) + +This library is designed to be safe to import in SSR builds, but several features depend on browser-only APIs (DOM, Clipboard, Web Workers, Monaco, Mermaid, KaTeX). To avoid runtime errors during server-side rendering, the library lazy-loads heavyweight peers and guards browser globals where possible. However, some advanced features (Monaco editor, Mermaid progressive rendering, Web Workers) are inherently client-side and must be used inside client-only blocks in SSR environments. + +Recommended consumption patterns: + +- For Nuxt 3 (or other SSR frameworks): render the component client-side only. Example using Nuxt's wrapper: + +```vue + +``` + +For a fuller Nuxt 3 recipe and extra notes, see the docs: `docs/nuxt-ssr.md`. + +- For Vite + plain SSR (or when you control hydration): conditionally render on the client with a small wrapper component: + +```vue + + + +``` + +Notes and caveats: + +- If you depend on Monaco, Mermaid, KaTeX or the optional icon pack at runtime, install those peers in your app and ensure they are available on the client. The library will attempt to lazy-load them only in the browser. +- The library aims to avoid throwing on import during SSR. If you still see ReferenceErrors (e.g. `window is not defined`), please open an issue and include the stack trace — I'll prioritize a fix. +- For server-rendered markup that needs diagrams or highlighted code server-side, consider generating static HTML on the server (e.g., pre-render Mermaid/KaTeX output) and pass it into the renderer as raw HTML or a safe, server-side AST. + +If you'd like, I can add an explicit "SSR" recipe and a Nuxt module example to the repo — say the word and I'll add it. + +### SSR recipe (Nuxt 3 and Vite SSR) + +Below are concrete recipes to run this renderer safely in SSR environments. These cover common setups and show how to avoid importing client-only peers during server rendering. + +- Nuxt 3 (recommended for full-app SSR) + + 1. Install peers you need on the client (for example `mermaid`, `vue-use-monaco`) as normal dependencies in your Nuxt app. + 2. Use Nuxt's `` wrapper for pages or components that rely on client-only features like Monaco or progressive Mermaid: + + ```vue + + ``` + + 3. If you need server-rendered HTML for specific diagrams or math, pre-render those outputs on the server (for example using a small service or build step that runs KaTeX or Mermaid CLI) and pass the resulting HTML into your page as pre-rendered fragments. + +- Vite + custom SSR (manual hydrate) + + If you run your own Vite SSR pipeline, prefer a client-only wrapper to delay browser-only initialization until hydration: + + ```vue + + + + ``` + +Notes: + +- The package aims to be import-safe during SSR: heavy peers are lazy-loaded in the browser and many DOM/Worker usages are guarded. However, some features (Monaco editor, Web Workers, progressive Mermaid rendering) are client-only by nature — they must be used inside client-only wrappers or deferred with lifecycle hooks. +- To guard against regressions, this repo includes a small SSR smoke test you can run locally: + + ```bash + pnpm run check:ssr + ``` + + This uses Vitest to import the library entry (`src/exports`) in a Node environment and will fail if the import throws during SSR. + +- CI: a small GitHub Actions workflow (`.github/workflows/ci.yml`) has been added to run typecheck and tests (including the SSR smoke test) on push and PR to `main`. + +If you'd like, I can add a short Nuxt module wrapper or a dedicated example page for Nuxt to the `playground/` directory — say the word and I'll scaffold it. ## Quick Start ### Choose Your Code Block Rendering Style @@ -636,11 +745,22 @@ If your project uses Webpack instead of Vite, you can use the official `monaco-e Install: ```bash +# pnpm (dev) pnpm add -D monaco-editor monaco-editor-webpack-plugin -# or +``` + +```bash +# npm (dev) npm install --save-dev monaco-editor monaco-editor-webpack-plugin ``` +```bash +# yarn (dev) +yarn add -D monaco-editor monaco-editor-webpack-plugin +``` + +Note: `pnpm add -D` and `yarn add -D` are equivalent to `npm install --save-dev` and install the packages as development dependencies. + Example `webpack.config.js`: ```js diff --git a/components.d.ts b/components.d.ts index 23934ba..67a5544 100644 --- a/components.d.ts +++ b/components.d.ts @@ -29,11 +29,14 @@ declare module 'vue' { LinkNode: typeof import('./src/components/LinkNode/LinkNode.vue')['default'] ListItemNode: typeof import('./src/components/ListItemNode/ListItemNode.vue')['default'] ListNode: typeof import('./src/components/ListNode/ListNode.vue')['default'] + MarkdownCodeBlockNode: typeof import('./src/components/MarkdownCodeBlockNode/MarkdownCodeBlockNode.vue')['default'] MathBlockNode: typeof import('./src/components/MathBlockNode/MathBlockNode.vue')['default'] MathInlineNode: typeof import('./src/components/MathInlineNode/MathInlineNode.vue')['default'] MermaidBlockNode: typeof import('./src/components/MermaidBlockNode/MermaidBlockNode.vue')['default'] NodeRenderer: typeof import('./src/components/NodeRenderer/NodeRenderer.vue')['default'] ParagraphNode: typeof import('./src/components/ParagraphNode/ParagraphNode.vue')['default'] + PreCodeNode: typeof import('./src/components/PreCodeNode/PreCodeNode.vue')['default'] + ReferenceNode: typeof import('./src/components/ReferenceNode/ReferenceNode.vue')['default'] RouterLink: typeof import('vue-router')['RouterLink'] RouterView: typeof import('vue-router')['RouterView'] StrikethroughNode: typeof import('./src/components/StrikethroughNode/StrikethroughNode.vue')['default'] diff --git a/docs/nuxt-ssr.md b/docs/nuxt-ssr.md new file mode 100644 index 0000000..e54e0f1 --- /dev/null +++ b/docs/nuxt-ssr.md @@ -0,0 +1,56 @@ +# Nuxt 3 SSR usage (example) + +This short recipe shows a minimal, safe way to use `vue-renderer-markdown` in Nuxt 3 so that client-only features (Monaco, Mermaid, Web Workers) are only initialized in the browser. + +## Install peers (client-side) + +Install the peers you need in your Nuxt app. For example, to enable Mermaid and Monaco editor previews (optional): + +```bash +# pnpm (recommended) +pnpm add mermaid vue-use-monaco + +# npm +npm install mermaid vue-use-monaco +``` + +Do NOT import these peers from server-only code paths during SSR. + +## Example page (client-only wrapper) + +Create a page or component that defers mounting the renderer to the client. Nuxt provides a `` built-in wrapper which is perfect: + +```vue + + + +``` + +This ensures the `MarkdownRender` component (and any optional peers it lazy-loads) are only imported/initialized in the browser. + +## Server-rendered diagrams or math + +If you need server-rendered HTML for diagrams or math (so crawlers or first paint include them), pre-render those outputs during your build step or via a small server-side task. Example approaches: + +- Use a build script that runs `mermaid-cli` / `katex` to convert certain diagrams/formulas to HTML/SVG during content generation and embed the resulting HTML into the page. +- Use a server endpoint that returns pre-rendered diagram SVG/HTML and fetch it on the client as needed. + +Then pass pre-rendered fragments into the renderer as trusted HTML or a server-side AST so the client avoids heavy initialization for those artifacts. + +## Notes + +- Prefer `client-only` when using Monaco Editor, Web Workers, or progressive Mermaid. +- The library is designed to be import-safe during SSR: heavy peers are lazy-loaded in the browser and many DOM/Worker usages are guarded. If you see an import-time ReferenceError, please provide the stack trace. + +--- + +If you'd like, I can also add a ready-to-run Nuxt 3 playground example under `playground/` (small project + page) that shows this integration end-to-end. diff --git a/package.json b/package.json index 4b82571..72c912b 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "test:ui": "vitest --ui", "test:update": "vitest -u", "typecheck": "vue-tsc --noEmit", + "check:ssr": "vitest --run -t \"SSR import safety\"", "prepublishOnly": "npm run build", "play": "pnpm run -C playground dev", "play:build": "pnpm run -C playground build", diff --git a/src/components/CodeBlockNode/CodeBlockNode.vue b/src/components/CodeBlockNode/CodeBlockNode.vue index a905c12..4f7d52c 100644 --- a/src/components/CodeBlockNode/CodeBlockNode.vue +++ b/src/components/CodeBlockNode/CodeBlockNode.vue @@ -6,6 +6,7 @@ import { useSafeI18n } from '../../composables/useSafeI18n' // Tooltip is provided as a singleton via composable to avoid many DOM nodes import { hideTooltip, showTooltipForAnchor } from '../../composables/useSingletonTooltip' import { getLanguageIcon, languageMap } from '../../utils' +import { safeCancelRaf, safeRaf } from '../../utils/safeRaf' import PreCodeNode from '../PreCodeNode' import { getIconify } from './iconify' import { getUseMonaco } from './monaco' @@ -99,54 +100,57 @@ let detectLanguage: (code: string) => string = () => props.node.language || 'pla let setTheme: (theme: MonacoTheme) => Promise = async () => {} const isDiff = computed(() => props.node.diff) const usePreCodeRender = ref(false) -;(async () => { - try { - const mod = await getUseMonaco() - // `useMonaco` and `detectLanguage` should be available - const useMonaco = (mod as any).useMonaco - const det = (mod as any).detectLanguage - if (typeof det === 'function') - detectLanguage = det - if (typeof useMonaco === 'function') { - const theme = getPreferredColorScheme() - if (theme && props.themes && Array.isArray(props.themes) && !props.themes.includes(theme)) { - throw new Error('Preferred theme not in provided themes array') - } - const helpers = useMonaco({ - wordWrap: 'on', - wrappingIndent: 'same', - themes: props.themes, - theme, - ...(props.monacoOptions || {}), - onThemeChange() { - syncEditorCssVars() - }, - }) - createEditor = helpers.createEditor || createEditor - createDiffEditor = helpers.createDiffEditor || createDiffEditor - updateCode = helpers.updateCode || updateCode - updateDiffCode = helpers.updateDiff || updateDiffCode - getEditor = helpers.getEditor || getEditor - getEditorView = helpers.getEditorView || getEditorView - getDiffEditorView = helpers.getDiffEditorView || getDiffEditorView - cleanupEditor = helpers.cleanupEditor || cleanupEditor - safeClean = helpers.cleanupEditor || safeClean - setTheme = helpers.setTheme || setTheme - - if (!editorCreated.value && codeEditor.value) { - editorCreated.value = true - isDiff.value - ? createDiffEditor(codeEditor.value as HTMLElement, props.node.originalCode || '', props.node.updatedCode || '', codeLanguage.value) - : createEditor(codeEditor.value as HTMLElement, props.node.code, codeLanguage.value) +// Defer client-only editor initialization to the browser to avoid SSR errors +if (typeof window !== 'undefined') { + ;(async () => { + try { + const mod = await getUseMonaco() + // `useMonaco` and `detectLanguage` should be available + const useMonaco = (mod as any).useMonaco + const det = (mod as any).detectLanguage + if (typeof det === 'function') + detectLanguage = det + if (typeof useMonaco === 'function') { + const theme = getPreferredColorScheme() + if (theme && props.themes && Array.isArray(props.themes) && !props.themes.includes(theme)) { + throw new Error('Preferred theme not in provided themes array') + } + const helpers = useMonaco({ + wordWrap: 'on', + wrappingIndent: 'same', + themes: props.themes, + theme, + ...(props.monacoOptions || {}), + onThemeChange() { + syncEditorCssVars() + }, + }) + createEditor = helpers.createEditor || createEditor + createDiffEditor = helpers.createDiffEditor || createDiffEditor + updateCode = helpers.updateCode || updateCode + updateDiffCode = helpers.updateDiff || updateDiffCode + getEditor = helpers.getEditor || getEditor + getEditorView = helpers.getEditorView || getEditorView + getDiffEditorView = helpers.getDiffEditorView || getDiffEditorView + cleanupEditor = helpers.cleanupEditor || cleanupEditor + safeClean = helpers.cleanupEditor || safeClean + setTheme = helpers.setTheme || setTheme + + if (!editorCreated.value && codeEditor.value) { + editorCreated.value = true + isDiff.value + ? createDiffEditor(codeEditor.value as HTMLElement, props.node.originalCode || '', props.node.updatedCode || '', codeLanguage.value) + : createEditor(codeEditor.value as HTMLElement, props.node.code, codeLanguage.value) + } } } - } - catch { - console.warn('vue-use-monaco not available; code blocks will not be rendered with Monaco editor') - // 使用 PreCodeNode 渲染 - usePreCodeRender.value = true - } -})() + catch { + console.warn('vue-use-monaco not available; code blocks will not be rendered with Monaco editor') + // 使用 PreCodeNode 渲染 + usePreCodeRender.value = true + } + })() +} const codeFontMin = 10 const codeFontMax = 36 @@ -166,6 +170,8 @@ const CONTENT_PADDING = 0 const LINE_EXTRA_PER_LINE = 1.5 const PIXEL_EPSILON = 1 +// Use shared safeRaf / safeCancelRaf from utils to avoid duplication + function measureLineHeightFromDom(): number | null { try { const root = codeEditor.value as HTMLElement | null @@ -200,10 +206,15 @@ function readActualFontSizeFromEditor(): number | null { if (root) { const lineEl = root.querySelector('.view-lines .view-line') as HTMLElement | null if (lineEl) { - const fs = window.getComputedStyle(lineEl).fontSize - const m = fs && fs.match(/^(\d+(?:\.\d+)?)/) - if (m) - return Number.parseFloat(m[1]) + try { + if (typeof window !== 'undefined' && typeof window.getComputedStyle === 'function') { + const fs = window.getComputedStyle(lineEl).fontSize + const m = fs && fs.match(/^(\d+(?:\.\d+)?)/) + if (m) + return Number.parseFloat(m[1]) + } + } + catch {} } } } @@ -317,10 +328,18 @@ function syncEditorCssVars() { // Monaco usually applies theme variables on an element with class // 'monaco-editor' or on the editor root; try to read from either. const src = editorEl.querySelector('.monaco-editor') || editorEl - const styles = window.getComputedStyle(src as Element) - const fg = styles.getPropertyValue('--vscode-editor-foreground') || '' - const bg = styles.getPropertyValue('--vscode-editor-background') || '' - const hoverBg = styles.getPropertyValue('--vscode-editor-hoverHighlightBackground') || '' + let styles: CSSStyleDeclaration | null = null + try { + if (typeof window !== 'undefined' && typeof window.getComputedStyle === 'function') { + styles = window.getComputedStyle(src as Element) + } + } + catch { + styles = null + } + const fg = (styles && styles.getPropertyValue('--vscode-editor-foreground')) || '' + const bg = (styles && styles.getPropertyValue('--vscode-editor-background')) || '' + const hoverBg = (styles && styles.getPropertyValue('--vscode-editor-hoverHighlightBackground')) || '' if (fg && bg) { rootEl.style.setProperty('--vscode-editor-foreground', fg.trim()) rootEl.style.setProperty('--vscode-editor-background', bg.trim()) @@ -441,7 +460,7 @@ watch( ? updateDiffCode(props.node.originalCode || '', props.node.updatedCode || '', codeLanguage.value) : updateCode(newCode, codeLanguage.value) if (isExpanded.value) { - requestAnimationFrame(() => updateExpandedHeight()) + safeRaf(() => updateExpandedHeight()) } }, ) @@ -478,7 +497,9 @@ const containerStyle = computed(() => { // 复制代码 async function copy() { try { - await navigator.clipboard.writeText(props.node.code) + if (typeof navigator !== 'undefined' && navigator.clipboard && typeof navigator.clipboard.writeText === 'function') { + await navigator.clipboard.writeText(props.node.code) + } copyText.value = true emits('copy', props.node.code) setTimeout(() => { @@ -536,7 +557,6 @@ function toggleExpand() { } else { stopExpandAutoResize() - // Collapsed: cap height via maxHeight and let internal scroll setAutomaticLayout(false) container.style.overflow = 'auto' updateCollapsedHeight() @@ -566,7 +586,7 @@ function toggleHeaderCollapse() { } catch {} resumeGuardFrames = 2 - requestAnimationFrame(() => { + safeRaf(() => { if (isExpanded.value) updateExpandedHeight() else @@ -671,7 +691,7 @@ const stopCreateEditorWatch = watch( if (props.loading === false) { await nextTick() - requestAnimationFrame(() => { + safeRaf(() => { if (isExpanded.value && !isCollapsed.value) updateExpandedHeight() else if (!isCollapsed.value) @@ -746,7 +766,7 @@ const stopLoadingWatch = watch( if (loaded) return await nextTick() - requestAnimationFrame(() => { + safeRaf(() => { if (!isCollapsed.value) { if (isExpanded.value) updateExpandedHeight() @@ -762,7 +782,7 @@ const stopLoadingWatch = watch( function stopExpandAutoResize() { if (expandRafId != null) { - cancelAnimationFrame(expandRafId) + safeCancelRaf(expandRafId) expandRafId = null } } @@ -774,7 +794,8 @@ onUnmounted(() => { if (resizeSyncHandler) { try { - window.removeEventListener('resize', resizeSyncHandler) + if (typeof window !== 'undefined') + window.removeEventListener('resize', resizeSyncHandler) } catch {} resizeSyncHandler = null diff --git a/src/components/FootnoteReferenceNode/FootnoteReferenceNode.vue b/src/components/FootnoteReferenceNode/FootnoteReferenceNode.vue index d191e78..89126d9 100644 --- a/src/components/FootnoteReferenceNode/FootnoteReferenceNode.vue +++ b/src/components/FootnoteReferenceNode/FootnoteReferenceNode.vue @@ -12,6 +12,10 @@ const props = defineProps<{ }>() const href = `#footnote-${props.node.id}` function handleScroll() { + if (typeof document === 'undefined') { + // SSR: nothing to do + return + } const element = document.querySelector(href) if (element) { element.scrollIntoView({ behavior: 'smooth' }) diff --git a/src/components/MarkdownCodeBlockNode/MarkdownCodeBlockNode.vue b/src/components/MarkdownCodeBlockNode/MarkdownCodeBlockNode.vue index 9efac7f..adb78d6 100644 --- a/src/components/MarkdownCodeBlockNode/MarkdownCodeBlockNode.vue +++ b/src/components/MarkdownCodeBlockNode/MarkdownCodeBlockNode.vue @@ -130,17 +130,19 @@ const contentStyle = computed(() => { const highlighter = ref(null) const highlightedCode = ref('') -watch(() => props.themes, async (newThemes) => { - disposeHighlighter() - highlighter.value = await registerHighlight({ - themes: newThemes as any, - }) - if (!props.loading) { - const theme = props.themes && props.themes.length > 0 ? (props.isDark ? props.themes[0] : props.themes[1] || props.themes[0]) : (props.isDark ? props.darkTheme || 'vitesse-dark' : props.lightTheme || 'vitesse-light') - const lang = props.node.language.split(':')[0] // 支持 language:variant 形式 - highlightedCode.value = await highlighter.value.codeToHtml(props.node.code, { lang, theme }) - } -}, { immediate: true }) +if (typeof window !== 'undefined') { + watch(() => props.themes, async (newThemes) => { + disposeHighlighter() + highlighter.value = await registerHighlight({ + themes: newThemes as any, + }) + if (!props.loading) { + const theme = props.themes && props.themes.length > 0 ? (props.isDark ? props.themes[0] : props.themes[1] || props.themes[0]) : (props.isDark ? props.darkTheme || 'vitesse-dark' : props.lightTheme || 'vitesse-light') + const lang = props.node.language.split(':')[0] // 支持 language:variant 形式 + highlightedCode.value = await highlighter.value.codeToHtml(props.node.code, { lang, theme }) + } + }, { immediate: true }) +} watch(() => [props.node.code, props.node.language], async ([code, lang]) => { if (lang !== codeLanguage.value) @@ -202,7 +204,9 @@ function handleScroll() { // Copy code functionality async function copy() { try { - await navigator.clipboard.writeText(props.node.code) + if (typeof navigator !== 'undefined' && navigator.clipboard && typeof navigator.clipboard.writeText === 'function') { + await navigator.clipboard.writeText(props.node.code) + } copyText.value = true emits('copy', props.node.code) setTimeout(() => { diff --git a/src/components/MermaidBlockNode/MermaidBlockNode.vue b/src/components/MermaidBlockNode/MermaidBlockNode.vue index a0e22ca..a29043a 100644 --- a/src/components/MermaidBlockNode/MermaidBlockNode.vue +++ b/src/components/MermaidBlockNode/MermaidBlockNode.vue @@ -3,6 +3,7 @@ import type { CodeBlockNode } from '../../types' import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue' import { hideTooltip, showTooltipForAnchor } from '../../composables/useSingletonTooltip' import mermaidIconUrl from '../../icon/mermaid.svg?url' +import { safeRaf } from '../../utils/safeRaf' import { canParseOffthread as canParseOffthreadClient, findPrefixOffthread as findPrefixOffthreadClient, terminateWorker as terminateMermaidWorker } from '../../workers/mermaidWorkerClient' import { getIconify } from '../CodeBlockNode/iconify' import { getMermaid } from './mermaid' @@ -25,10 +26,13 @@ const emits = defineEmits(['copy']) const Icon: any = getIconify() let mermaid: any = null -;(async () => { - mermaid = await getMermaid() - mermaid.initialize?.({ startOnLoad: false, securityLevel: 'loose' }) -})() +// Only initialize mermaid on the client to avoid SSR errors +if (typeof window !== 'undefined') { + ;(async () => { + mermaid = await getMermaid() + mermaid.initialize?.({ startOnLoad: false, securityLevel: 'loose' }) + })() +} const copyText = ref(false) const isCollapsed = ref(false) @@ -138,7 +142,8 @@ function withTimeoutSignal( } if (timeoutMs && timeoutMs > 0) { - timer = window.setTimeout(() => { + // use globalThis so this code doesn't assume `window` exists (SSR) + timer = (globalThis as any).setTimeout(() => { if (settled) return settled = true @@ -178,6 +183,8 @@ function withTimeoutSignal( // Unified error renderer (only used when props.loading === false) function renderErrorToContainer(error: unknown) { + if (typeof document === 'undefined') + return if (!mermaidContent.value) return const errorDiv = document.createElement('div') @@ -439,8 +446,18 @@ function handleKeydown(e: KeyboardEvent) { function openModal() { isModalOpen.value = true - document.body.style.overflow = 'hidden' - window.addEventListener('keydown', handleKeydown) + if (typeof document !== 'undefined') { + try { + document.body.style.overflow = 'hidden' + } + catch {} + } + if (typeof window !== 'undefined') { + try { + window.addEventListener('keydown', handleKeydown) + } + catch {} + } nextTick(() => { if (mermaidContainer.value && modalContent.value) { @@ -472,8 +489,18 @@ function closeModal() { modalContent.value.innerHTML = '' } modalCloneWrapper.value = null - document.body.style.overflow = '' - window.removeEventListener('keydown', handleKeydown) + if (typeof document !== 'undefined') { + try { + document.body.style.overflow = '' + } + catch {} + } + if (typeof window !== 'undefined') { + try { + window.removeEventListener('keydown', handleKeydown) + } + catch {} + } } function debounce any>( @@ -622,7 +649,9 @@ function handleWheel(event: WheelEvent) { // Copy functionality async function copy() { try { - await navigator.clipboard.writeText(baseFixedCode.value) + if (typeof navigator !== 'undefined' && navigator.clipboard && typeof navigator.clipboard.writeText === 'function') { + await navigator.clipboard.writeText(baseFixedCode.value) + } copyText.value = true emits('copy', baseFixedCode.value) setTimeout(() => { @@ -646,13 +675,18 @@ async function exportSvg() { const svgData = new XMLSerializer().serializeToString(svgElement) const blob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' }) const url = URL.createObjectURL(blob) - const link = document.createElement('a') - link.href = url - link.download = `mermaid-diagram-${Date.now()}.svg` - document.body.appendChild(link) - link.click() - document.body.removeChild(link) - URL.revokeObjectURL(url) + if (typeof document !== 'undefined') { + const link = document.createElement('a') + link.href = url + link.download = `mermaid-diagram-${Date.now()}.svg` + try { + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + } + catch {} + URL.revokeObjectURL(url) + } } catch (error) { console.error('Failed to export SVG:', error) @@ -922,7 +956,7 @@ function stopPreviewPolling() { previewPollController = null } if (previewPollTimeoutId) { - clearTimeout(previewPollTimeoutId) + ;(globalThis as any).clearTimeout(previewPollTimeoutId) previewPollTimeoutId = null } if (previewPollIdleId) { @@ -964,8 +998,8 @@ function scheduleNextPreviewPoll(delay = 800) { if (!isPreviewPolling) return if (previewPollTimeoutId) - clearTimeout(previewPollTimeoutId) - previewPollTimeoutId = window.setTimeout(() => { + (globalThis as any).clearTimeout(previewPollTimeoutId) + previewPollTimeoutId = (globalThis as any).setTimeout(() => { previewPollIdleId = requestIdle(async () => { if (!isPreviewPolling) return @@ -1167,9 +1201,8 @@ watch( resizeObserver = new ResizeObserver((entries) => { if (entries && entries.length > 0 && !hasRenderedOnce.value && !isThemeRendering.value) { - // 使用 requestAnimationFrame 确保在下一次重绘前执行更新 - // 这给了DOM充足的时间来完成SVG的内部布局更新 - requestAnimationFrame(() => { + // 使用 safeRaf 确保在 SSR 环境下不会抛错,同时在浏览器中使用 RAF + safeRaf(() => { const newWidth = entries[0].contentRect.width updateContainerHeight(newWidth) }) diff --git a/src/utils/safeRaf.ts b/src/utils/safeRaf.ts new file mode 100644 index 0000000..7daffd3 --- /dev/null +++ b/src/utils/safeRaf.ts @@ -0,0 +1,26 @@ +// Safe requestAnimationFrame / cancel wrapper to avoid ReferenceError in SSR +export function safeRaf(cb: FrameRequestCallback) { + try { + if (typeof globalThis !== 'undefined' && typeof (globalThis as any).requestAnimationFrame === 'function') + return (globalThis as any).requestAnimationFrame(cb) + } + catch {} + // Fallback to setTimeout when RAF isn't available (SSR or older envs) + return (globalThis as any).setTimeout(cb as any, 0) as unknown as number +} + +export function safeCancelRaf(id: number | null) { + try { + if (id == null) + return + if (typeof globalThis !== 'undefined' && typeof (globalThis as any).cancelAnimationFrame === 'function') { + (globalThis as any).cancelAnimationFrame(id) + return + } + } + catch {} + try { + ;(globalThis as any).clearTimeout(id) + } + catch {} +} diff --git a/src/workers/katexWorkerClient.ts b/src/workers/katexWorkerClient.ts index e4b571c..b1df52a 100644 --- a/src/workers/katexWorkerClient.ts +++ b/src/workers/katexWorkerClient.ts @@ -14,8 +14,14 @@ function ensureWorker() { if (worker) return worker try { - // Vite-friendly worker instantiation. Bundlers will inline the worker when configured. - worker = new Worker(new URL('./katexRenderer.worker.ts', import.meta.url), { type: 'module' }) + // Only create a Worker in a browser environment + if (typeof window === 'undefined') { + worker = null + } + else { + // Vite-friendly worker instantiation. Bundlers will inline the worker when configured. + worker = new Worker(new URL('./katexRenderer.worker.ts', import.meta.url), { type: 'module' }) + } } catch { worker = null @@ -25,9 +31,10 @@ function ensureWorker() { worker.addEventListener('message', (ev: MessageEvent) => { const { id, html, error, content, displayMode } = ev.data as any const p = pending.get(id) - if (!p) + if (!p) { return - window.clearTimeout(p.timeoutId) + } + (globalThis as any).clearTimeout(p.timeoutId) pending.delete(id) if (error) { p.reject(new Error(error)) @@ -75,14 +82,14 @@ export async function renderKaTeXInWorker(content: string, displayMode = true, t return } const id = Math.random().toString(36).slice(2) - const timeoutId = window.setTimeout(() => { + const timeoutId = (globalThis as any).setTimeout(() => { pending.delete(id) reject(new Error('Worker render timed out')) }, timeout) // Listen for abort to cancel this pending request const onAbort = () => { - window.clearTimeout(timeoutId) + (globalThis as any).clearTimeout(timeoutId) if (pending.has(id)) pending.delete(id) const err = new Error('Aborted') diff --git a/src/workers/mermaidWorkerClient.ts b/src/workers/mermaidWorkerClient.ts index fa3faf3..730b7f0 100644 --- a/src/workers/mermaidWorkerClient.ts +++ b/src/workers/mermaidWorkerClient.ts @@ -8,7 +8,13 @@ function ensureWorker() { return worker try { // Vite-style worker URL import - worker = new Worker(new URL('./mermaidParser.worker.ts', import.meta.url), { type: 'module' }) + // Only create a Worker if running in a browser environment + if (typeof window === 'undefined') { + worker = null + } + else { + worker = new Worker(new URL('./mermaidParser.worker.ts', import.meta.url), { type: 'module' }) + } } catch { worker = null @@ -40,7 +46,7 @@ function callWorker(action: 'canParse' | 'findPrefix', payload: any, timeout rpcMap.set(id, { resolve, reject }) wk.postMessage({ id, action, payload }) - const timeoutId = window.setTimeout(() => { + const timeoutId = (globalThis as any).setTimeout(() => { if (rpcMap.has(id)) rpcMap.delete(id) reject(new Error('Worker call timed out')) @@ -48,11 +54,11 @@ function callWorker(action: 'canParse' | 'findPrefix', payload: any, timeout // clear timeout on resolution const wrapResolve = (v: any) => { - clearTimeout(timeoutId) + (globalThis as any).clearTimeout(timeoutId) resolve(v) } const wrapReject = (e: any) => { - clearTimeout(timeoutId) + (globalThis as any).clearTimeout(timeoutId) reject(e) } rpcMap.set(id, { resolve: wrapResolve, reject: wrapReject }) diff --git a/test/ssr-import.test.ts b/test/ssr-import.test.ts new file mode 100644 index 0000000..920300b --- /dev/null +++ b/test/ssr-import.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' + +// SSR import smoke test: importing the library entry should not throw in Node +describe('sSR import safety', () => { + it('can import library entry without throwing', async () => { + let mod: any = null + let threw = false + try { + // import the compiled entry points are under src during dev; import the source exports + mod = await import('../src/exports') + } + catch (e) { + threw = true + console.error('Import threw:', e) + } + expect(threw).toBe(false) + // Basic sanity: exports should contain known symbols + expect(mod).toBeTruthy() + expect(typeof mod.default === 'object' || typeof mod.VueRendererMarkdown !== 'undefined' || Object.keys(mod).length > 0).toBe(true) + }) +})