diff --git a/docs/content/4.plugins/1.built-in/math.md b/docs/content/4.plugins/1.built-in/math.md index b309e3e1a..59ff5b88f 100644 --- a/docs/content/4.plugins/1.built-in/math.md +++ b/docs/content/4.plugins/1.built-in/math.md @@ -156,7 +156,7 @@ Returns a `ComarkPlugin` that tokenizes `$...$` and `$$...$$` expressions. **Returns:** `ComarkPlugin` -The plugin stores LaTeX source as plain text in the AST. Rendering requires passing `Math` to the `components` prop of `` (see [Usage](#usage)). KaTeX only runs when the component mounts. +The plugin stores LaTeX source as plain text in the AST. Rendering requires passing `Math` to the `components` prop of `` (see [Usage](#usage)). The `Math` components load KaTeX on first use, so pages without math don't load it. A formula KaTeX can't typeset, such as one still streaming in, shows as its TeX source. ### Rendering math manually diff --git a/packages/comark-angular/src/components/math.component.ts b/packages/comark-angular/src/components/math.component.ts index 47410e3a6..81d640e82 100644 --- a/packages/comark-angular/src/components/math.component.ts +++ b/packages/comark-angular/src/components/math.component.ts @@ -1,5 +1,5 @@ import { Component, Input, OnChanges, SimpleChanges, ChangeDetectionStrategy, ElementRef, inject } from '@angular/core' -import katex from 'katex' +import { loadKatex, typesetMath } from 'comark/plugins/math' /** * Math rendering component for Angular. @@ -44,13 +44,19 @@ export class Math implements OnChanges { const wrapper = document.createElement(isInline ? 'span' : 'div') wrapper.className = isInline ? 'math inline' : 'math block' - try { - wrapper.innerHTML = katex.renderToString(this.content, { - throwOnError: true, - displayMode: !isInline, - }) - } catch { - wrapper.textContent = '...' + const html = typesetMath(this.content, !isInline) + if (html) { + wrapper.innerHTML = html + } else { + // Unfinished or invalid TeX, and TeX before KaTeX loads, stays readable as its source. + wrapper.textContent = this.content + if (html === undefined) { + const content = this.content + loadKatex().then( + () => content === this.content && this.renderMath(), + () => {} + ) + } } hostEl.appendChild(wrapper) diff --git a/packages/comark-react/package.json b/packages/comark-react/package.json index 5a05b21bc..d4e623118 100644 --- a/packages/comark-react/package.json +++ b/packages/comark-react/package.json @@ -61,6 +61,7 @@ "devDependencies": { "@types/react": "catalog:", "@types/react-dom": "catalog:", + "happy-dom": "catalog:", "react": "catalog:", "react-dom": "catalog:", "vitest": "catalog:" diff --git a/packages/comark-react/src/components/Math.tsx b/packages/comark-react/src/components/Math.tsx index 5f0de7daa..5dec0935f 100644 --- a/packages/comark-react/src/components/Math.tsx +++ b/packages/comark-react/src/components/Math.tsx @@ -1,32 +1,53 @@ -import { useState, useEffect } from 'react' -import katex from 'katex' +import { Suspense, memo, use } from 'react' +import { loadKatex, typesetMath } from 'comark/plugins/math' export interface MathProps { content: string className?: string } -export function Math({ content, className = '' }: MathProps) { - const isInline = className.includes('inline') - const [mathml, setMathml] = useState('...') - - useEffect(() => { - try { - const rendered = katex.renderToString(content, { - throwOnError: true, - displayMode: !isInline, - }) - setMathml(rendered) - } catch { - // Keep loading state on error - } - }, [content, isInline]) +// After a failed load, formulas show their source, and a render this long +// after the failure loads KaTeX again. +const RETRY_DELAY = 5000 + +// One promise for every formula, since `use` needs the same one on each render. +let katexReady: Promise | undefined +let failedAt: number | undefined + +function waitForKatex(): Promise { + if (!katexReady || (failedAt !== undefined && Date.now() - failedAt >= RETRY_DELAY)) { + failedAt = undefined + katexReady = loadKatex().then( + () => true, + () => { + failedAt = Date.now() + return false + } + ) + } + return katexReady +} + +// Memoized because streaming re-renders every formula on each chunk. +const Typeset = memo(function Typeset({ content, isInline }: { content: string; isInline: boolean }) { + let html = typesetMath(content, !isInline) + if (html === undefined) { + use(waitForKatex()) + html = typesetMath(content, !isInline) + } + if (!html) + return ( + + ) if (isInline) { return ( ) } @@ -34,7 +55,34 @@ export function Math({ content, className = '' }: MathProps) { return (
) +}) + +// Unfinished or invalid TeX, and TeX without KaTeX installed, stays readable as its source. +function Source({ content, isInline }: { content: string; isInline: boolean }) { + return isInline ? {content} :
{content}
+} + +export function Math({ content, className = '' }: MathProps) { + const isInline = className.includes('inline') + + // KaTeX loads on first use. Streaming server rendering and hydration wait + // for it; otherwise the source shows until it loads. + return ( + + } + > + + + ) } diff --git a/packages/comark-react/test/plugin-math.client.test.tsx b/packages/comark-react/test/plugin-math.client.test.tsx new file mode 100644 index 000000000..2fb20c823 --- /dev/null +++ b/packages/comark-react/test/plugin-math.client.test.tsx @@ -0,0 +1,35 @@ +// @vitest-environment happy-dom +import { StrictMode, act } from 'react' +import { createRoot } from 'react-dom/client' +import { expect, it, vi } from 'vitest' + +;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +it('shows the TeX source and stops loading KaTeX after a failed load in StrictMode', async () => { + let attempts = 0 + vi.doMock('katex', () => { + attempts++ + throw new Error('Failed to fetch dynamically imported module') + }) + const { Math } = await import('../src/plugins/math') + const container = document.createElement('div') + const root = createRoot(container) + + await act(async () => { + root.render( + + + + ) + }) + await act(() => new Promise((resolve) => setTimeout(resolve, 100))) + + expect(container.innerHTML).toBe('x^2') + expect(attempts).toBe(1) + + act(() => root.unmount()) + vi.doUnmock('katex') +}) diff --git a/packages/comark-react/test/plugin-math.test.tsx b/packages/comark-react/test/plugin-math.test.tsx index 5569df4c7..8c85620f4 100644 --- a/packages/comark-react/test/plugin-math.test.tsx +++ b/packages/comark-react/test/plugin-math.test.tsx @@ -1,12 +1,11 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { renderToString } from 'react-dom/server' import { parseMarkdown } from 'comark' import { MarkdownDocument } from '../src/components/MarkdownDocument' import math, { Math } from '../src/plugins/math' // `Math` derives its wrapper markup from the incoming class string, so the -// `class` -> `className` renderer remap is observable under SSR alone — -// `useEffect` (katex) never runs here. +// `class` -> `className` renderer remap is observable under SSR alone. describe('@comark/react plugins/math — Math component', () => { it('renders inline math as an inline ', async () => { @@ -31,4 +30,77 @@ describe('@comark/react plugins/math — Math component', () => { ) expect(html).toContain('
{ + const tree = await parseMarkdown('$\\frac{}{$', { plugins: [math()] }) + const html = renderToString( + + ) + expect(html).toContain('\\frac{<b>}{') + expect(html).not.toContain('...') + }) + + it('waits for KaTeX in streaming server rendering', async () => { + vi.resetModules() + const { Math } = await import('../src/plugins/math') + const { renderToReadableStream, renderToString } = await import('react-dom/server') + + // Before KaTeX loads, the source stands in for the formula. + expect( + renderToString( + + ) + ).toContain('x^2') + + const stream = await renderToReadableStream( + + ) + await stream.allReady + const html = await new Response(stream).text() + + expect(html).toContain('') + }) + + it('loads KaTeX again after a failed load', async () => { + vi.resetModules() + let fail = true + vi.doMock('katex', async (importOriginal) => { + if (fail) throw new Error('Failed to fetch dynamically imported module') + return importOriginal() + }) + const { Math } = await import('../src/plugins/math') + const { renderToReadableStream } = await import('react-dom/server') + const render = async () => { + const stream = await renderToReadableStream( + + ) + await stream.allReady + return new Response(stream).text() + } + + vi.useFakeTimers({ toFake: ['Date'] }) + try { + expect(await render()).toContain('x^2') + fail = false + // Right after the failure, the source still shows. + expect(await render()).toContain('x^2') + vi.advanceTimersByTime(5000) + expect(await render()).toContain('') + } finally { + vi.useRealTimers() + vi.doUnmock('katex') + } + }) }) diff --git a/packages/comark-svelte/src/components/Math.svelte b/packages/comark-svelte/src/components/Math.svelte index 7ff4088f5..440a1e26a 100644 --- a/packages/comark-svelte/src/components/Math.svelte +++ b/packages/comark-svelte/src/components/Math.svelte @@ -16,7 +16,7 @@ determines inline vs display mode (display when class does NOT contain "inline") ``` --> {#if isInline} - {@html mathml} + {#if html}{@html html}{:else}{content}{/if} {:else} -
{@html mathml}
+
{#if html}{@html html}{:else}{content}{/if}
{/if} diff --git a/packages/comark-svelte/test/math/index.svelte.test.ts b/packages/comark-svelte/test/math/index.svelte.test.ts index 6ea3c00fc..114f586ac 100644 --- a/packages/comark-svelte/test/math/index.svelte.test.ts +++ b/packages/comark-svelte/test/math/index.svelte.test.ts @@ -10,20 +10,21 @@ describe('Math component', () => { const screen = await render(Math, { content: 'x^2', class: 'inline' }) const wrapper = screen.container.querySelector('.math.inline')! expect(wrapper).not.toBeNull() - expect(wrapper.querySelector('.katex')).not.toBeNull() + await expect.poll(() => wrapper.querySelector('.katex')).not.toBeNull() }) it('renders display math with KaTeX', async () => { const screen = await render(Math, { content: 'E = mc^2', class: '' }) const wrapper = screen.container.querySelector('.math.block')! expect(wrapper).not.toBeNull() - expect(wrapper.querySelector('.katex')).not.toBeNull() + await expect.poll(() => wrapper.querySelector('.katex')).not.toBeNull() }) - it('handles invalid LaTeX gracefully', async () => { + it('shows the TeX source for invalid LaTeX', async () => { const screen = await render(Math, { content: '\\invalid{', class: '' }) const wrapper = screen.container.querySelector('.math.block')! expect(wrapper).not.toBeNull() + expect(wrapper.textContent).toBe('\\invalid{') }) }) diff --git a/packages/comark-svelte/test/math/index.test.ts b/packages/comark-svelte/test/math/index.test.ts index 3c1cf2d79..569f50297 100644 --- a/packages/comark-svelte/test/math/index.test.ts +++ b/packages/comark-svelte/test/math/index.test.ts @@ -1,6 +1,7 @@ -import { describe, expect, it } from 'vitest' +import { beforeAll, describe, expect, it } from 'vitest' import { render } from 'svelte/server' import { parseMarkdown } from 'comark' +import { loadKatex } from 'comark/plugins/math' import math, { Math } from '../../src/plugins/math' import MarkdownDocument from '../../src/components/MarkdownDocument.svelte' @@ -10,6 +11,8 @@ function html(body: string): string { } describe('Math component (server)', () => { + beforeAll(() => loadKatex()) + it('renders inline math', () => { const { body } = render(Math, { props: { content: 'x^2', class: 'inline' }, @@ -33,8 +36,7 @@ describe('Math component (server)', () => { props: { content: '\\invalid{', class: '' }, }) const output = html(body) - // Should render fallback rather than throwing - expect(output).toContain('
') + expect(output).toContain('
\\invalid{
') }) }) diff --git a/packages/comark-vue/src/components/Math.ts b/packages/comark-vue/src/components/Math.ts index 34eada06f..6f8b661d2 100644 --- a/packages/comark-vue/src/components/Math.ts +++ b/packages/comark-vue/src/components/Math.ts @@ -1,7 +1,7 @@ -import { defineComponent, h, computed, watch, ref } from 'vue' -import katex from 'katex' +import { defineAsyncComponent, defineComponent, h, onMounted, onServerPrefetch, ref } from 'vue' +import { loadKatex, typesetMath } from 'comark/plugins/math' -export const Math = defineComponent({ +const Typeset = defineComponent({ name: 'Math', props: { content: { @@ -18,38 +18,41 @@ export const Math = defineComponent({ }, }, setup(props) { - const isInline = computed(() => { - return props.class?.includes('inline') - }) - const mathml = ref('...') - - watch( - () => props.content, - () => { - try { - mathml.value = katex.renderToString(props.content, { - throwOnError: true, - displayMode: !isInline.value, - }) - } catch { - // Keep loading state on error - } - }, - { immediate: true } - ) - - if (isInline.value) { - return () => - h('span', { - class: 'math inline', - innerHTML: mathml.value, - }) + // KaTeX failed to load before this formula rendered: try again, and + // typeset once it loads. + const loaded = ref(typesetMath('', true) !== undefined) + if (!loaded.value) { + const retry = () => + loadKatex().then( + () => (loaded.value = true), + () => {} + ) + onServerPrefetch(retry) + onMounted(retry) } - return () => - h('div', { - class: 'math block', - innerHTML: mathml.value, - }) + return () => { + void loaded.value + const isInline = props.class?.includes('inline') + const tag = isInline ? 'span' : 'div' + const className = isInline ? 'math inline' : 'math block' + const html = typesetMath(props.content, !isInline) + // Unfinished or invalid TeX, and TeX before KaTeX loads, stays readable as its source. + return html ? h(tag, { class: className, innerHTML: html }) : h(tag, { class: className }, props.content) + } }, }) + +// An async component keeps KaTeX out of the initial bundle, and server +// rendering and hydration wait for it. +export const Math = defineAsyncComponent({ + loader: () => + loadKatex().then( + () => Typeset, + () => Typeset + ), + // A copy, since Vue doesn't render the same component again with the same props. + loadingComponent: { ...Typeset }, + delay: 0, + suspensible: false, +}) diff --git a/packages/comark-vue/test/plugin-math.test.ts b/packages/comark-vue/test/plugin-math.test.ts new file mode 100644 index 000000000..cbec6533b --- /dev/null +++ b/packages/comark-vue/test/plugin-math.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it, vi } from 'vitest' +import { createSSRApp, h } from 'vue' +import { renderToString } from '@vue/server-renderer' +import { parseMarkdown } from 'comark' +import { MarkdownDocument } from '../src/components/MarkdownDocument' +import math, { Math } from '../src/plugins/math' + +describe('@comark/vue plugins/math — Math component', () => { + it('waits for KaTeX in server rendering', async () => { + const html = await renderToString(createSSRApp({ render: () => h(Math, { content: 'x^2', class: 'math inline' }) })) + + expect(html).toContain('') + }) + + it('shows the escaped TeX source when KaTeX cannot parse it', async () => { + const tree = await parseMarkdown('$\\frac{}{$', { plugins: [math()] }) + const app = createSSRApp({ + setup: () => () => h(MarkdownDocument, { value: tree, components: { math: Math } }), + }) + + expect(await renderToString(app)).toContain('\\frac{<b>}{') + }) + + it('loads KaTeX again after a failed load', async () => { + vi.resetModules() + let fail = true + vi.doMock('katex', async (importOriginal) => { + if (fail) throw new Error('Failed to fetch dynamically imported module') + return importOriginal() + }) + const { Math } = await import('../src/plugins/math') + const { createSSRApp, h } = await import('vue') + const { renderToString } = await import('@vue/server-renderer') + const render = () => + renderToString(createSSRApp({ render: () => h(Math, { content: 'x^2', class: 'math inline' }) })) + + try { + expect(await render()).toContain('x^2') + fail = false + expect(await render()).toContain('') + } finally { + vi.doUnmock('katex') + } + }) +}) diff --git a/packages/comark/src/plugins/math.ts b/packages/comark/src/plugins/math.ts index c45337f51..74578c4e5 100644 --- a/packages/comark/src/plugins/math.ts +++ b/packages/comark/src/plugins/math.ts @@ -1,4 +1,4 @@ -import type { MarkdownExit, StateInline, StateBlock } from 'markdown-exit' +import type { MarkdownExit, StateInline, StateBlock, Token } from 'markdown-exit' import type { MarkdownItPlugin } from 'comark' import { defineComarkPlugin } from 'comark' import { findBracketClose, isBracketMath } from '../internal/bracket-math.ts' @@ -24,6 +24,44 @@ export interface MathConfig { brackets?: boolean } +interface Katex { + renderToString(tex: string, options?: Record): string +} + +let katex: Katex | undefined +let loading: Promise | undefined + +/** + * Load KaTeX once, on first use. Renderers call this instead of importing + * KaTeX, so apps that never render math don't load it. + */ +export function loadKatex(): Promise { + loading ??= import('katex').then( + (module) => (katex = module.default), + (error) => { + loading = undefined + throw error + } + ) + return loading +} + +/** + * Typeset a formula to HTML with the KaTeX that {@link loadKatex} loaded. + * + * @param tex LaTeX math expression + * @param displayMode Whether to render in display mode + * @returns HTML, `''` when KaTeX can't typeset the formula, or `undefined` when KaTeX isn't loaded yet + */ +export function typesetMath(tex: string, displayMode: boolean): string | undefined { + if (!katex) return undefined + try { + return katex.renderToString(tex, { throwOnError: true, displayMode }) + } catch { + return '' + } +} + /** * Render LaTeX math expression to HTML using KaTeX * @@ -417,4 +455,15 @@ export default defineComarkPlugin((config: MathConfig = {}) => ({ name: 'math', markdownItPlugins: [((md: MarkdownExit) => markdownItMath(md, config)) as unknown as MarkdownItPlugin], autoClose: config.brackets ? { bracketMath: true } : undefined, + // Loading KaTeX while parsing a document with math lets the renderers + // typeset synchronously, including in server rendering. + async post(state) { + if (hasMath(state.tokens)) await loadKatex().catch(() => {}) + }, })) + +function hasMath(tokens: Token[]): boolean { + return tokens.some( + (token) => token.type === 'math_block' || token.children?.some((child) => child.type === 'math_inline') + ) +} diff --git a/packages/comark/test/plugins/math.test.ts b/packages/comark/test/plugins/math.test.ts index 704b195cd..d977c4774 100644 --- a/packages/comark/test/plugins/math.test.ts +++ b/packages/comark/test/plugins/math.test.ts @@ -1,14 +1,18 @@ import { describe, expect, it } from 'vitest' import { parseMarkdown } from '../../src/parse' -import math from '../../src/plugins/math' +import math, { typesetMath } from '../../src/plugins/math' describe('math plugin', () => { - it('parses inline and display math without loading katex', async () => { + it('parses inline and display math and loads katex for the renderers', async () => { + expect(typesetMath('x^2', false)).toBeUndefined() + const { nodes } = await parseMarkdown('Inline $x^2$ and display $$E = mc^2$$', { plugins: [math()], }) expect(JSON.stringify(nodes)).toContain('math') + expect(typesetMath('x^2', false)).toContain('katex') + expect(typesetMath('\\frac{', false)).toBe('') }) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fed2c202f..888c11b4a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -323,6 +323,9 @@ catalogs: github-slugger: specifier: ^2.0.0 version: 2.0.0 + happy-dom: + specifier: ^21.0.4 + version: 21.0.4 hast-util-to-string: specifier: ^3.0.1 version: 3.0.1 @@ -543,7 +546,7 @@ importers: version: 11.0.5 vitest: specifier: 'catalog:' - version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.6.5)(@vitest/browser-playwright@4.1.11)(vite@8.3.5(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.104.1)(sass@1.104.1)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1)) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.6.5)(@vitest/browser-playwright@4.1.11)(happy-dom@21.0.4)(vite@8.3.5(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.104.1)(sass@1.104.1)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1)) docs: dependencies: @@ -1467,7 +1470,7 @@ importers: version: 8.3.5(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.104.1)(sass@1.104.1)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1) vitest: specifier: 'catalog:' - version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.6.5)(@vitest/browser-playwright@4.1.11)(vite@8.3.5(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.104.1)(sass@1.104.1)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1)) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.6.5)(@vitest/browser-playwright@4.1.11)(happy-dom@21.0.4)(vite@8.3.5(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.104.1)(sass@1.104.1)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1)) packages/comark-angular: dependencies: @@ -1507,7 +1510,7 @@ importers: version: 6.0.3 vitest: specifier: 'catalog:' - version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.6.5)(@vitest/browser-playwright@4.1.11)(vite@8.3.5(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.104.1)(sass@1.104.1)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1)) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.6.5)(@vitest/browser-playwright@4.1.11)(happy-dom@21.0.4)(vite@8.3.5(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.104.1)(sass@1.104.1)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1)) packages/comark-ansi: dependencies: @@ -1526,7 +1529,7 @@ importers: devDependencies: vitest: specifier: 'catalog:' - version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.6.5)(@vitest/browser-playwright@4.1.11)(vite@8.3.5(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.104.1)(sass@1.104.1)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1)) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.6.5)(@vitest/browser-playwright@4.1.11)(happy-dom@21.0.4)(vite@8.3.5(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.104.1)(sass@1.104.1)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1)) packages/comark-html: dependencies: @@ -1545,7 +1548,7 @@ importers: version: 0.19.0 vitest: specifier: 'catalog:' - version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.6.5)(@vitest/browser-playwright@4.1.11)(vite@8.3.5(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.104.1)(sass@1.104.1)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1)) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.6.5)(@vitest/browser-playwright@4.1.11)(happy-dom@21.0.4)(vite@8.3.5(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.104.1)(sass@1.104.1)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1)) packages/comark-nuxt: dependencies: @@ -1567,7 +1570,7 @@ importers: version: 3.5.44 vitest: specifier: 'catalog:' - version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.6.5)(@vitest/browser-playwright@4.1.11)(vite@8.3.5(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.104.1)(sass@1.104.1)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1)) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.6.5)(@vitest/browser-playwright@4.1.11)(happy-dom@21.0.4)(vite@8.3.5(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.104.1)(sass@1.104.1)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1)) vue: specifier: 'catalog:' version: 3.5.44(typescript@6.0.3) @@ -1593,6 +1596,9 @@ importers: '@types/react-dom': specifier: 'catalog:' version: 19.3.0(@types/react@19.3.0) + happy-dom: + specifier: 'catalog:' + version: 21.0.4 react: specifier: 'catalog:' version: 19.3.0 @@ -1601,7 +1607,7 @@ importers: version: 19.3.0(react@19.3.0) vitest: specifier: 'catalog:' - version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.6.5)(@vitest/browser-playwright@4.1.11)(vite@8.3.5(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.104.1)(sass@1.104.1)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1)) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.6.5)(@vitest/browser-playwright@4.1.11)(happy-dom@21.0.4)(vite@8.3.5(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.104.1)(sass@1.104.1)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1)) packages/comark-svelte: dependencies: @@ -1641,7 +1647,7 @@ importers: version: 4.7.6(picomatch@4.0.7)(svelte@5.57.2)(typescript@6.0.3) vitest: specifier: 'catalog:' - version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.6.5)(@vitest/browser-playwright@4.1.11)(vite@8.3.5(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.104.1)(sass@1.104.1)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1)) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.6.5)(@vitest/browser-playwright@4.1.11)(happy-dom@21.0.4)(vite@8.3.5(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.104.1)(sass@1.104.1)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1)) vitest-browser-svelte: specifier: 'catalog:' version: 3.1.0(svelte@5.57.2)(vitest@4.1.11) @@ -1672,7 +1678,7 @@ importers: version: 8.3.5(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.104.1)(sass@1.104.1)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1) vitest: specifier: 'catalog:' - version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.6.5)(@vitest/browser-playwright@4.1.11)(vite@8.3.5(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.104.1)(sass@1.104.1)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1)) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.6.5)(@vitest/browser-playwright@4.1.11)(happy-dom@21.0.4)(vite@8.3.5(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.104.1)(sass@1.104.1)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1)) vue: specifier: 'catalog:' version: 3.5.44(typescript@6.0.3) @@ -5890,6 +5896,12 @@ packages: '@types/web-bluetooth@0.0.21': resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==} + '@types/whatwg-mimetype@3.0.2': + resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==} + + '@types/ws@8.18.2': + resolution: {integrity: sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==} + '@typescript/vfs@1.6.4': resolution: {integrity: sha512-PJFXFS4ZJKiJ9Qiuix6Dz/OwEIqHD7Dme1UwZhTK11vR+5dqW2ACbdndWQexBzCx+CPuMe5WBYQWCsFyGlQLlQ==} peerDependencies: @@ -6678,6 +6690,10 @@ packages: buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + buffer-image-size@0.6.4: + resolution: {integrity: sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==} + engines: {node: '>=4.0'} + buffer@6.0.3: resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} @@ -7745,6 +7761,10 @@ packages: h3@1.15.11: resolution: {integrity: sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg==} + happy-dom@21.0.4: + resolution: {integrity: sha512-tFm+GaLBgo6o0awHnvHaNoraVo27qJ+2tZEtZ+cIa08hXbeitrxAJajLI05hsUy/2/YTKcUjQYzHe5mCcraLBw==} + engines: {node: '>=20.0.0'} + harfbuzzjs@0.10.0: resolution: {integrity: sha512-SN8LVwCOzvTq3OPNd0+EAghgXugItz56wst567D1vs7LnnKGWprhi3EG58aVOBwhN8HEbQxL4I9NDWkcXUutRw==} @@ -10845,6 +10865,10 @@ packages: webpack-virtual-modules@0.6.2: resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} + whatwg-mimetype@3.0.0: + resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} + engines: {node: '>=12'} + whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} @@ -11226,7 +11250,7 @@ snapshots: postcss: 8.5.32 rollup: 4.65.1 tailwindcss: 4.3.3 - vitest: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.6.5)(@vitest/browser-playwright@4.1.11)(vite@8.3.5(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.104.1)(sass@1.104.1)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1)) + vitest: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.6.5)(@vitest/browser-playwright@4.1.11)(happy-dom@21.0.4)(vite@8.3.5(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.104.1)(sass@1.104.1)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1)) transitivePeerDependencies: - '@types/node' - '@vitejs/devtools' @@ -11766,7 +11790,7 @@ snapshots: '@codspeed/core': 6.0.1(debug@4.4.3(supports-color@10.2.2))(supports-color@10.2.2) tinybench: 6.2.2 vite: 8.3.5(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.104.1)(sass@1.104.1)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1) - vitest: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.6.5)(@vitest/browser-playwright@4.1.11)(vite@8.3.5(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.104.1)(sass@1.104.1)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1)) + vitest: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.6.5)(@vitest/browser-playwright@4.1.11)(happy-dom@21.0.4)(vite@8.3.5(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.104.1)(sass@1.104.1)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1)) transitivePeerDependencies: - debug - supports-color @@ -15135,6 +15159,12 @@ snapshots: '@types/web-bluetooth@0.0.21': {} + '@types/whatwg-mimetype@3.0.2': {} + + '@types/ws@8.18.2': + dependencies: + '@types/node': 26.6.5 + '@typescript/vfs@1.6.4(supports-color@10.2.2)(typescript@6.0.3)': dependencies: debug: 4.4.3(supports-color@10.2.2) @@ -15363,7 +15393,7 @@ snapshots: '@vitest/mocker': 4.1.11(vite@8.3.5(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.104.1)(sass@1.104.1)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1)) playwright: 1.64.0 tinyrainbow: 3.1.1 - vitest: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.6.5)(@vitest/browser-playwright@4.1.11)(vite@8.3.5(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.104.1)(sass@1.104.1)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1)) + vitest: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.6.5)(@vitest/browser-playwright@4.1.11)(happy-dom@21.0.4)(vite@8.3.5(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.104.1)(sass@1.104.1)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1)) transitivePeerDependencies: - bufferutil - msw @@ -15379,7 +15409,7 @@ snapshots: pngjs: 7.0.0 sirv: 3.0.2 tinyrainbow: 3.1.1 - vitest: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.6.5)(@vitest/browser-playwright@4.1.11)(vite@8.3.5(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.104.1)(sass@1.104.1)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1)) + vitest: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.6.5)(@vitest/browser-playwright@4.1.11)(happy-dom@21.0.4)(vite@8.3.5(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.104.1)(sass@1.104.1)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1)) ws: 8.22.0 transitivePeerDependencies: - bufferutil @@ -16070,6 +16100,10 @@ snapshots: buffer-from@1.1.2: {} + buffer-image-size@0.6.4: + dependencies: + '@types/node': 26.6.5 + buffer@6.0.3: dependencies: base64-js: 1.5.1 @@ -17341,6 +17375,19 @@ snapshots: ufo: 1.6.4 uncrypto: 0.1.3 + happy-dom@21.0.4: + dependencies: + '@types/node': 26.6.5 + '@types/whatwg-mimetype': 3.0.2 + '@types/ws': 8.18.2 + buffer-image-size: 0.6.4 + entities: 7.0.1 + whatwg-mimetype: 3.0.0 + ws: 8.22.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + harfbuzzjs@0.10.0: {} has-flag@4.0.0: {} @@ -21086,9 +21133,9 @@ snapshots: dependencies: '@testing-library/svelte-core': 1.1.3(svelte@5.57.2) svelte: 5.57.2 - vitest: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.6.5)(@vitest/browser-playwright@4.1.11)(vite@8.3.5(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.104.1)(sass@1.104.1)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1)) + vitest: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.6.5)(@vitest/browser-playwright@4.1.11)(happy-dom@21.0.4)(vite@8.3.5(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.104.1)(sass@1.104.1)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1)) - vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.6.5)(@vitest/browser-playwright@4.1.11)(vite@8.3.5(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.104.1)(sass@1.104.1)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1)): + vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.6.5)(@vitest/browser-playwright@4.1.11)(happy-dom@21.0.4)(vite@8.3.5(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.104.1)(sass@1.104.1)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1)): dependencies: '@vitest/expect': 4.1.11 '@vitest/mocker': 4.1.11(vite@8.3.5(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.104.1)(sass@1.104.1)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1)) @@ -21114,6 +21161,7 @@ snapshots: '@opentelemetry/api': 1.9.1 '@types/node': 26.6.5 '@vitest/browser-playwright': 4.1.11(playwright@1.64.0)(vite@8.3.5(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.104.1)(sass@1.104.1)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1))(vitest@4.1.11) + happy-dom: 21.0.4 transitivePeerDependencies: - msw @@ -21200,6 +21248,8 @@ snapshots: webpack-virtual-modules@0.6.2: {} + whatwg-mimetype@3.0.0: {} + whatwg-url@5.0.0: dependencies: tr46: 0.0.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index fa037d44f..2d76df17c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -162,6 +162,7 @@ catalog: # Testing "@codspeed/vitest-plugin": ^6.0.0 + happy-dom: ^21.0.4 tinybench: ^6.2.2 vitest: ^4.1.11 vitest-browser-svelte: ^3.1.0 diff --git a/test/bundle.test.ts b/test/bundle.test.ts index be4903440..158976145 100644 --- a/test/bundle.test.ts +++ b/test/bundle.test.ts @@ -60,14 +60,14 @@ describe('package bundle size', { timeout: 60_000 }, () => { expect(report).toMatchInlineSnapshot(` { - "@comark/angular": "56.5k (72 files)", + "@comark/angular": "56.7k (72 files)", "@comark/ansi": "37.3k (98 files)", "@comark/html": "15.6k (58 files)", "@comark/nuxt": "11.9k (58 files)", - "@comark/react": "38.6k (76 files)", - "@comark/svelte": "46.8k (84 files)", - "@comark/vue": "56.4k (80 files)", - "comark": "398k (158 files)", + "@comark/react": "39.4k (76 files)", + "@comark/svelte": "47.1k (84 files)", + "@comark/vue": "56.6k (80 files)", + "comark": "399k (158 files)", } `) })