/** * @vitest-environment node */ import { createRequire } from 'node:module' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const packageRequire = createRequire(new URL('../packages/markstream-react/package.json', import.meta.url)) const React = packageRequire('react') as typeof import('react') const { renderToStaticMarkup } = packageRequire('react-dom/server') as typeof import('react-dom/server') function textNode(content: string) { return { type: 'text', content, raw: content, } } function paragraphNode(content: string) { return { type: 'paragraph', raw: content, children: [textNode(content)], } } function listItemNode(content: string) { return { type: 'list_item', raw: content, children: [paragraphNode(content)], } } function definitionListNode() { return { type: 'definition_list', items: [ { type: 'definition_item', term: [textNode('Term')], definition: [paragraphNode('Definition')], }, ], } } function tableNode() { return { type: 'table', header: { cells: [ { children: [textNode('Name')] }, { children: [textNode('Value')] }, ], }, rows: [ { cells: [ { children: [textNode('Alpha')] }, { children: [textNode('One')] }, ], }, ], } } function vmrContainerNode() { return { type: 'vmr_container', name: 'tip', attrs: [['data-kind', 'tip']], children: [paragraphNode('Container body')], } } function insightNode(label: string, body: string) { return { type: 'insight', raw: label, label, children: [paragraphNode(body)], } } function customThinkingNode(scope: string, body: string) { return { type: 'html_block', tag: 'thinking', attrs: [ ['data-tone', 'calm'], ['data-scope', scope], ], content: `${body}`, raw: `${body}`, } } function createRenderCtx(serverEntry: any) { return { customComponents: {}, events: {}, typewriter: false, codeBlockStream: true, showTooltips: false, renderCodeBlocksAsPre: false, codeBlockThemes: {}, customId: 'matrix', renderNode: serverEntry.renderNode, } as any } function createMatrixCases(entry: any, serverEntry: any) { const ctx = createRenderCtx(serverEntry) const withCtx = (props: Record) => ({ ...props, ctx, renderNode: serverEntry.renderNode, indexKey: 'matrix', customId: 'matrix', }) return [ { name: 'NodeRenderer', expected: 'Matrix Heading', element: React.createElement(entry.NodeRenderer, { content: '# Matrix Heading\n\nParagraph text', final: true, }), }, { name: 'AdmonitionNode', expected: 'Alert body', element: React.createElement(entry.AdmonitionNode, withCtx({ node: { type: 'admonition', kind: 'note', children: [paragraphNode('Alert body')], }, })), }, { name: 'BlockquoteNode', expected: 'Quoted body', element: React.createElement(entry.BlockquoteNode, withCtx({ node: { type: 'blockquote', children: [paragraphNode('Quoted body')], }, })), }, { name: 'CheckboxNode', expected: 'checkbox', element: React.createElement(entry.CheckboxNode, { node: { type: 'checkbox', checked: true }, }), }, { name: 'CodeBlockNode', expected: 'data-ssr-fallback="code-block"', element: React.createElement(entry.CodeBlockNode, { node: { type: 'code_block', language: 'ts', code: 'const answer = 42', raw: 'const answer = 42', }, }), }, { name: 'MarkdownCodeBlockNode', expected: 'data-ssr-fallback="markdown-code-block"', element: React.createElement(entry.MarkdownCodeBlockNode, { node: { type: 'code_block', language: 'md', code: '# nested markdown', raw: '# nested markdown', }, }), }, { name: 'PreCodeNode', expected: 'const fallback = true', element: React.createElement(entry.PreCodeNode, { node: { type: 'code_block', language: 'js', code: 'const fallback = true', raw: 'const fallback = true', }, }), }, { name: 'D2BlockNode', expected: 'data-ssr-fallback="d2"', element: React.createElement(entry.D2BlockNode, { node: { type: 'code_block', language: 'd2', code: 'x -> y', raw: 'x -> y', }, }), }, { name: 'MermaidBlockNode', expected: 'data-ssr-fallback="mermaid"', element: React.createElement(entry.MermaidBlockNode, { node: { type: 'code_block', language: 'mermaid', code: 'graph TD;A-->B;', raw: 'graph TD;A-->B;', }, }), }, { name: 'InfographicBlockNode', expected: 'data-ssr-fallback="infographic"', element: React.createElement(entry.InfographicBlockNode, { node: { type: 'code_block', language: 'infographic', code: '{"title":"Demo"}', raw: '{"title":"Demo"}', }, }), }, { name: 'DefinitionListNode', expected: 'Definition', element: React.createElement(entry.DefinitionListNode, withCtx({ node: definitionListNode(), })), }, { name: 'EmojiNode', expected: 'sparkles', element: React.createElement(entry.EmojiNode, { node: { type: 'emoji', name: 'sparkles' }, }), }, { name: 'EmphasisNode', expected: 'Emphasis', element: React.createElement(entry.EmphasisNode, { node: { type: 'emphasis' }, children: 'Emphasis', }), }, { name: 'StrongNode', expected: 'Strong', element: React.createElement(entry.StrongNode, { node: { type: 'strong' }, children: 'Strong', }), }, { name: 'StrikethroughNode', expected: 'Strike', element: React.createElement(entry.StrikethroughNode, { node: { type: 'strikethrough' }, children: 'Strike', }), }, { name: 'HighlightNode', expected: 'Highlight', element: React.createElement(entry.HighlightNode, { node: { type: 'highlight' }, children: 'Highlight', }), }, { name: 'InsertNode', expected: 'Insert', element: React.createElement(entry.InsertNode, { node: { type: 'insert' }, children: 'Insert', }), }, { name: 'SubscriptNode', expected: 'Sub', element: React.createElement(entry.SubscriptNode, { node: { type: 'subscript' }, children: 'Sub', }), }, { name: 'SuperscriptNode', expected: 'Sup', element: React.createElement(entry.SuperscriptNode, { node: { type: 'superscript' }, children: 'Sup', }), }, { name: 'FootnoteNode', expected: 'Footnote body', element: React.createElement(entry.FootnoteNode, withCtx({ node: { type: 'footnote', id: '1', children: [paragraphNode('Footnote body')], }, })), }, { name: 'FootnoteReferenceNode', expected: 'fnref-1', element: React.createElement(entry.FootnoteReferenceNode, { node: { type: 'footnote_reference', id: '1' }, }), }, { name: 'FootnoteAnchorNode', expected: 'fnref-1', element: React.createElement(entry.FootnoteAnchorNode, { node: { type: 'footnote_anchor', id: '1' }, }), }, { name: 'HardBreakNode', expected: '
', element: React.createElement(entry.HardBreakNode, { node: { type: 'hardbreak' }, }), }, { name: 'HeadingNode', expected: 'Heading', element: React.createElement(entry.HeadingNode, { node: { type: 'heading', level: 2 }, children: 'Heading', }), }, { name: 'ParagraphNode', expected: 'Paragraph', element: React.createElement(entry.ParagraphNode, { node: { type: 'paragraph' }, children: 'Paragraph', }), }, { name: 'HtmlBlockNode', expected: 'Block HTML', element: React.createElement(entry.HtmlBlockNode, { node: { type: 'html_block', content: '
Block HTML
' }, }), }, { name: 'HtmlInlineNode', expected: 'Inline HTML', element: React.createElement(entry.HtmlInlineNode, { node: { type: 'html_inline', content: 'Inline HTML' }, }), }, { name: 'ImageNode', expected: 'https://example.com/demo.png', element: React.createElement(entry.ImageNode, { node: { type: 'image', src: 'https://example.com/demo.png', alt: 'Demo image', title: 'Demo image', raw: '![Demo image](https://example.com/demo.png)', }, }), }, { name: 'InlineCodeNode', expected: 'inline-demo', element: React.createElement(entry.InlineCodeNode, { node: { type: 'inline_code', code: 'inline-demo' }, }), }, { name: 'LinkNode', expected: 'https://example.com', element: React.createElement(entry.LinkNode, { node: { type: 'link', href: 'https://example.com', title: 'Example', text: 'Example', raw: '[Example](https://example.com)', children: [textNode('Example')], }, children: 'Example', }), }, { name: 'ListItemNode', expected: 'List item', element: React.createElement(entry.ListItemNode, { node: { type: 'list_item' }, children: 'List item', }), }, { name: 'ListNode', expected: 'List body', element: React.createElement(entry.ListNode, withCtx({ node: { type: 'list', ordered: false, items: [listItemNode('List body')], }, })), }, { name: 'MathBlockNode', expected: 'math-block', element: React.createElement(entry.MathBlockNode, { node: { type: 'math_block', content: 'x^2', raw: '$$x^2$$', }, }), }, { name: 'MathInlineNode', expected: 'math-inline-wrapper', element: React.createElement(entry.MathInlineNode, { node: { type: 'math_inline', content: 'a+b', raw: '$a+b$', markup: '$', }, }), }, { name: 'ReferenceNode', expected: 'ref-1', element: React.createElement(entry.ReferenceNode, { node: { type: 'reference', id: 'ref-1' }, }), }, { name: 'TableNode', expected: 'Alpha', element: React.createElement(entry.TableNode, withCtx({ node: tableNode(), })), }, { name: 'TextNode', expected: 'Text body', element: React.createElement(entry.TextNode, { node: { type: 'text', content: 'Text body' }, }), }, { name: 'ThematicBreakNode', expected: '', element: React.createElement(entry.Tooltip, { visible: true, anchorEl: null, content: 'Tooltip body', }), }, { name: 'HtmlPreviewFrame', expected: '
', element: React.createElement(entry.HtmlPreviewFrame, { code: '

Preview

', }), }, { name: 'VmrContainerNode', expected: 'Container body', element: React.createElement(entry.VmrContainerNode, withCtx({ node: vmrContainerNode(), })), }, { name: 'FallbackComponent', expected: 'Unsupported node type', element: React.createElement(entry.FallbackComponent, { node: { type: 'mystery' }, }), }, ] } function renderExport(name: string, element: React.ReactElement) { return renderToStaticMarkup( React.createElement('section', { 'data-ssr-export': name }, element), ) } describe('markstream-react next/server SSR', () => { beforeEach(() => { vi.useRealTimers() vi.unstubAllGlobals() vi.resetModules() }) afterEach(async () => { const serverEntry = await import('../packages/markstream-react/src/server') serverEntry.clearGlobalCustomComponents() serverEntry.removeCustomComponents?.('next-ssr-lab') serverEntry.removeCustomComponents?.('next-ssr-lab-alt') serverEntry.removeCustomComponents?.('server-ssr-lab') serverEntry.removeCustomComponents?.('server-ssr-lab-alt') vi.useRealTimers() vi.unstubAllGlobals() vi.resetModules() }) it('keeps root, next, and server entry imports safe', async () => { const entries = [ await import('../packages/markstream-react/src/index'), await import('../packages/markstream-react/src/next'), await import('../packages/markstream-react/src/server'), ] expect(typeof entries[0].default).toBe('function') expect(typeof entries[1].default).toBe('function') expect(typeof entries[2].default).toBe('function') }, 15000) it('renders the next entry export matrix as SSR-safe HTML', async () => { const nextEntry = await import('../packages/markstream-react/src/next') const serverEntry = await import('../packages/markstream-react/src/server') for (const item of createMatrixCases(nextEntry, serverEntry)) { const html = renderExport(item.name, item.element) expect(html).toContain(`data-ssr-export="${item.name}"`) expect(html).toContain(item.expected) } }) it('renders the server entry export matrix as SSR-safe HTML', async () => { const serverEntry = await import('../packages/markstream-react/src/server') for (const item of createMatrixCases(serverEntry, serverEntry)) { const html = renderExport(item.name, item.element) expect(html).toContain(`data-ssr-export="${item.name}"`) expect(html).toContain(item.expected) } }) it('keeps closed unknown tags as raw HTML and escapes malformed ones in the server entry', async () => { const serverEntry = await import('../packages/markstream-react/src/server') const closedHtml = renderToStaticMarkup( React.createElement(serverEntry.NodeRenderer, { content: 'ok', final: true, }), ) expect(closedHtml).toContain('ok') const malformedHtml = renderToStaticMarkup( React.createElement(serverEntry.NodeRenderer, { content: 'ok', final: true, }), ) expect(malformedHtml).toContain('<question>ok') expect(malformedHtml).not.toContain('&lt;') }) it('renders structured html wrappers on the server without structuring blocked tags', async () => { const serverEntry = await import('../packages/markstream-react/src/server') const structuredHtml = renderToStaticMarkup( React.createElement(serverEntry.NodeRenderer, { nodes: [ { type: 'html_block', tag: 'span', attrs: [['style', 'font-size: 12px;']], content: '', children: [ { type: 'list', ordered: false, items: [listItemNode('alpha'), listItemNode('beta')], }, ], } as any, ], }), ) expect(structuredHtml).toContain('\n\n- alpha\n\n', children: [ { type: 'list', ordered: false, items: [listItemNode('alpha')], }, ], } as any, ], }), ) expect(blockedHtml).not.toContain('
    ') expect(blockedHtml).not.toContain('
  • ') }) it('renders custom overrides, custom tags, and heavy-node fallbacks through next and server entries', async () => { const nextEntry = await import('../packages/markstream-react/src/next') const serverEntry = await import('../packages/markstream-react/src/server') const renderCustomChildren = (props: any) => ( Array.isArray(props.node?.children) && props.renderNode && props.ctx ? props.node.children.map((child: any, idx: number) => ( React.createElement(React.Fragment, { key: `${String(props.indexKey ?? 'custom')}-${idx}` }, props.renderNode(child, `${String(props.indexKey ?? 'custom')}-${idx}`, props.ctx)) )) : null ) const readAttr = (node: any, name: string) => { if (!node?.attrs) return undefined if (Array.isArray(node.attrs)) return node.attrs.find((item: [string, string | null]) => item[0] === name)?.[1] ?? undefined return node.attrs[name] } serverEntry.setCustomComponents('server-ssr-lab', { paragraph: ({ node }: any) => React.createElement('div', { 'data-ssr-status': 'paragraph-override' }, node.raw), insight: (props: any) => React.createElement( 'section', { 'data-ssr-status': 'server-custom-node' }, React.createElement('div', { 'data-ssr-status': 'server-custom-node-children' }, renderCustomChildren(props)), ), thinking: ({ node }: any) => React.createElement('aside', { 'data-ssr-status': 'thinking-tag', 'data-ssr-tone': readAttr(node, 'data-tone'), 'data-ssr-scope': readAttr(node, 'data-scope'), }, node.content), }) serverEntry.setCustomComponents('server-ssr-lab-alt', { paragraph: ({ node }: any) => React.createElement('div', { 'data-ssr-status': 'server-paragraph-alt' }, node.raw), }) const serverHtml = renderToStaticMarkup( React.createElement(React.Fragment, null, React.createElement(serverEntry.NodeRenderer, { customId: 'server-ssr-lab', customHtmlTags: ['thinking'], nodes: [ paragraphNode('Paragraph override') as any, insightNode('Server custom node', 'Server child body') as any, customThinkingNode('server-primary', 'Server tag body') as any, { type: 'code_block', language: 'mermaid', code: 'graph TD;A-->B;', raw: 'graph TD;A-->B;', } as any, { type: 'code_block', language: 'd2', code: 'x -> y', raw: 'x -> y', } as any, { type: 'code_block', language: 'infographic', code: '{"title":"SSR"}', raw: '{"title":"SSR"}', } as any, ], }), React.createElement(serverEntry.NodeRenderer, { customId: 'server-ssr-lab-alt', customHtmlTags: ['thinking'], nodes: [paragraphNode('Paragraph alt') as any], })), ) expect(serverHtml).toContain('data-ssr-status="paragraph-override"') expect(serverHtml).toContain('data-ssr-status="server-paragraph-alt"') expect(serverHtml).toContain('data-ssr-status="server-custom-node-children"') expect(serverHtml).toContain('Server child body') expect(serverHtml).toContain('data-ssr-status="thinking-tag"') expect(serverHtml).toContain('data-ssr-tone="calm"') expect(serverHtml).toContain('data-ssr-scope="server-primary"') expect(serverHtml).toContain('data-ssr-fallback="mermaid"') expect(serverHtml).toContain('data-ssr-fallback="d2"') expect(serverHtml).toContain('data-ssr-fallback="infographic"') nextEntry.setCustomComponents('next-ssr-lab', { paragraph: ({ node }: any) => React.createElement('div', { 'data-ssr-status': 'next-paragraph-override' }, node.raw), insight: (props: any) => React.createElement( 'section', { 'data-ssr-status': 'next-custom-node' }, React.createElement('div', { 'data-ssr-status': 'next-custom-node-children' }, renderCustomChildren(props)), ), thinking: ({ node }: any) => React.createElement('aside', { 'data-ssr-status': 'next-thinking-tag', 'data-ssr-tone': readAttr(node, 'data-tone'), 'data-ssr-scope': readAttr(node, 'data-scope'), }, node.content), }) nextEntry.setCustomComponents('next-ssr-lab-alt', { paragraph: ({ node }: any) => React.createElement('div', { 'data-ssr-status': 'next-paragraph-alt' }, node.raw), }) const nextHtml = renderToStaticMarkup( React.createElement(React.Fragment, null, React.createElement(nextEntry.NodeRenderer, { customId: 'next-ssr-lab', customHtmlTags: ['thinking'], nodes: [ paragraphNode('Paragraph override') as any, insightNode('Next custom node', 'Next child body') as any, customThinkingNode('next-primary', 'Next tag body') as any, { type: 'code_block', language: 'mermaid', code: 'graph TD;A-->B;', raw: 'graph TD;A-->B;', } as any, ], }), React.createElement(nextEntry.NodeRenderer, { customId: 'next-ssr-lab-alt', customHtmlTags: ['thinking'], nodes: [paragraphNode('Paragraph alt') as any], })), ) expect(nextHtml).toContain('data-ssr-status="next-paragraph-override"') expect(nextHtml).toContain('data-ssr-status="next-paragraph-alt"') expect(nextHtml).toContain('data-ssr-status="next-custom-node-children"') expect(nextHtml).toContain('Next child body') expect(nextHtml).toContain('data-ssr-status="next-thinking-tag"') expect(nextHtml).toContain('data-ssr-tone="calm"') expect(nextHtml).toContain('data-ssr-scope="next-primary"') expect(nextHtml).toContain('data-ssr-fallback="mermaid"') }) })