/**
* @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: '',
},
}),
},
{
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('<')
})
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"')
})
})