e0a5f57793
Vendor the rebranded BoosAPI Desktop client (Tauri + Vue) into metapi as a monorepo subdirectory under desktop/. Source only — node_modules, dist, Cargo target and the upstream .git history are excluded. desktop/ keeps its own independent Cargo workspace (desktop/src-tauri with crates/*); it is not part of the backend boosapi crate, so a `cargo check` at the metapi root still compiles only the backend. Build the desktop app from desktop/src-tauri per desktop/README.md. Note: app icons are still placeholder assets pending the final BoosAPI logo. The updater signing keys live outside the repo and are not included.
634 lines
19 KiB
TypeScript
634 lines
19 KiB
TypeScript
/**
|
|
* @vitest-environment node
|
|
*/
|
|
|
|
import katex from 'katex'
|
|
import { afterEach, describe, expect, it } from 'vitest'
|
|
import { createSSRApp, defineComponent, h } from 'vue'
|
|
import { renderToString } from 'vue/server-renderer'
|
|
import { disableD2, enableD2 } from '../src/components/D2BlockNode/d2'
|
|
import HtmlBlockNode from '../src/components/HtmlBlockNode/HtmlBlockNode.vue'
|
|
import { setInfographicLoader } from '../src/components/InfographicBlockNode/infographic'
|
|
import MarkdownCodeBlockNode from '../src/components/MarkdownCodeBlockNode'
|
|
import MathBlockNode from '../src/components/MathBlockNode'
|
|
import MathInlineNode from '../src/components/MathInlineNode'
|
|
import { disableKatex, enableKatex, setKatexLoader } from '../src/components/MathInlineNode/katex'
|
|
import { disableMermaid, enableMermaid } from '../src/components/MermaidBlockNode/mermaid'
|
|
import MarkdownRender from '../src/components/NodeRenderer'
|
|
import { clearGlobalCustomComponents, setCustomComponents } from '../src/utils/nodeComponents'
|
|
|
|
const BASIC_SCOPE = 'ssr-render-basic'
|
|
const defaultInfographicLoader = () => import('@antv/infographic')
|
|
|
|
async function renderComponent(component: any, props: Record<string, any>) {
|
|
const app = createSSRApp({
|
|
render: () => h(component, props),
|
|
})
|
|
return renderToString(app)
|
|
}
|
|
|
|
async function renderMarkdown(content: string, props: Record<string, any> = {}) {
|
|
return renderComponent(MarkdownRender, {
|
|
content,
|
|
final: true,
|
|
...props,
|
|
})
|
|
}
|
|
|
|
function textNode(content: string) {
|
|
return {
|
|
type: 'text',
|
|
content,
|
|
raw: content,
|
|
}
|
|
}
|
|
|
|
function paragraphNode(children: any[], raw?: string) {
|
|
return {
|
|
type: 'paragraph',
|
|
children,
|
|
raw: raw ?? children.map(child => String(child?.raw ?? child?.content ?? '')).join(''),
|
|
}
|
|
}
|
|
|
|
function inlineWrapper(type: string, content: string, raw: string) {
|
|
return {
|
|
type,
|
|
children: [textNode(content)],
|
|
raw,
|
|
}
|
|
}
|
|
|
|
function linkNode(content: string, href: string) {
|
|
return {
|
|
type: 'link',
|
|
href,
|
|
title: href,
|
|
text: content,
|
|
raw: `[${content}](${href})`,
|
|
children: [textNode(content)],
|
|
}
|
|
}
|
|
|
|
describe('ssr renderToString coverage', () => {
|
|
afterEach(() => {
|
|
clearGlobalCustomComponents()
|
|
enableKatex()
|
|
enableMermaid()
|
|
enableD2()
|
|
setInfographicLoader(defaultInfographicLoader)
|
|
})
|
|
|
|
it('renders basic markdown, HTML, and custom HTML tags on the server', async () => {
|
|
setCustomComponents(BASIC_SCOPE, {
|
|
'ssr-badge': defineComponent({
|
|
name: 'SsrBadgeNode',
|
|
setup(_, { slots }) {
|
|
return () => h('span', { 'data-ssr-custom': 'badge' }, slots.default?.() ?? [])
|
|
},
|
|
}),
|
|
})
|
|
|
|
const html = await renderMarkdown(`
|
|
# SSR Basics
|
|
|
|
Inline HTML: <mark data-ssr-inline-html="1">inline html</mark>
|
|
|
|
Trusted custom tag: <ssr-badge>server custom tag</ssr-badge>
|
|
|
|
- [x] done
|
|
- [ ] pending
|
|
|
|
| Name | Role |
|
|
| --- | --- |
|
|
| Markstream | Renderer |
|
|
|
|
Visit [Vue](https://vuejs.org).
|
|
|
|

|
|
|
|
<div data-ssr-html-block="1"><strong>HTML block</strong> is present.</div>
|
|
|
|
Footnotes are server-rendered.[^1]
|
|
|
|
[^1]: Footnote body
|
|
`.trim(), {
|
|
customHtmlTags: ['ssr-badge'],
|
|
customId: BASIC_SCOPE,
|
|
})
|
|
|
|
expect(html).toContain('<h1')
|
|
expect(html).toContain('data-ssr-inline-html="1"')
|
|
expect(html).toContain('data-ssr-html-block="1"')
|
|
expect(html).toContain('data-ssr-custom="badge"')
|
|
expect(html).toContain('<table')
|
|
expect(html).toContain('href="https://vuejs.org"')
|
|
expect(html).toContain('src="/vue-markdown-icon.svg"')
|
|
expect(html).not.toContain('<figure')
|
|
expect(html).toContain('Footnote body')
|
|
})
|
|
|
|
it('renders structured html wrappers on the server without duplicating nested markdown', async () => {
|
|
const html = await renderMarkdown(`<span style="font-size: 12px;">
|
|
|
|
- alpha
|
|
- beta
|
|
|
|
</span>`)
|
|
|
|
expect(html).toContain('<span')
|
|
expect(html).toContain('font-size:12px;')
|
|
expect(html).toContain('<ul')
|
|
expect(html.match(/alpha/g)?.length ?? 0).toBe(1)
|
|
expect(html.match(/beta/g)?.length ?? 0).toBe(1)
|
|
})
|
|
|
|
it('renders nested structured html wrappers on the server', async () => {
|
|
const html = await renderMarkdown(`<div>
|
|
<div>
|
|
|
|
- alpha
|
|
|
|
</div>
|
|
</div>`)
|
|
|
|
expect(html.match(/<ul/g)?.length ?? 0).toBe(1)
|
|
expect(html.match(/alpha/g)?.length ?? 0).toBe(1)
|
|
})
|
|
|
|
it('renders structured details html blocks without wrapper nodes before summary', async () => {
|
|
const html = await renderComponent(HtmlBlockNode, {
|
|
node: {
|
|
type: 'html_block',
|
|
tag: 'details',
|
|
content: '<details open><summary>展开看一段 HTML</summary><p>body</p></details>',
|
|
attrs: [['open', '']],
|
|
children: [
|
|
{
|
|
type: 'html_block',
|
|
tag: 'summary',
|
|
content: '<summary>展开看一段 HTML</summary>',
|
|
children: [
|
|
{
|
|
type: 'paragraph',
|
|
raw: '展开看一段 HTML',
|
|
children: [{ type: 'text', content: '展开看一段 HTML', raw: '展开看一段 HTML' }],
|
|
},
|
|
],
|
|
},
|
|
{
|
|
type: 'paragraph',
|
|
raw: 'body',
|
|
children: [{ type: 'text', content: 'body', raw: 'body' }],
|
|
},
|
|
],
|
|
},
|
|
})
|
|
|
|
expect(html).toMatch(/<details[^>]*class="html-block-node"[^>]*>(?:<!--\[-->|<!--\]-->)*<summary[^>]*>/)
|
|
expect(html).not.toContain('class="markstream-vue markdown-renderer"')
|
|
expect(html).not.toContain('node-slot')
|
|
expect(html).not.toContain('data-node-index=')
|
|
expect(html).not.toContain('<template>')
|
|
expect(html).toMatch(/<p dir="auto" class="paragraph-node"[^>]*>.*?<span[^>]*><span[^>]*>body<\/span>/)
|
|
})
|
|
|
|
it('renders an explicit SSR matrix for the lighter built-in node components', async () => {
|
|
const matrixNodes = [
|
|
{
|
|
type: 'heading',
|
|
level: 2,
|
|
text: 'SSR Matrix Heading',
|
|
raw: '## SSR Matrix Heading',
|
|
attrs: { id: 'ssr-matrix-heading' },
|
|
children: [
|
|
textNode('SSR Matrix '),
|
|
{ type: 'emoji', name: '😄', markup: ':smile:', raw: ':smile:' },
|
|
textNode(' Heading'),
|
|
],
|
|
},
|
|
paragraphNode([
|
|
textNode('Inline matrix: '),
|
|
inlineWrapper('strong', 'bold', '**bold**'),
|
|
textNode(' '),
|
|
inlineWrapper('emphasis', 'italic', '*italic*'),
|
|
textNode(' '),
|
|
inlineWrapper('strikethrough', 'strike', '~~strike~~'),
|
|
textNode(' '),
|
|
inlineWrapper('highlight', 'mark', '==mark=='),
|
|
textNode(' '),
|
|
inlineWrapper('insert', 'inserted', '++inserted++'),
|
|
textNode(' H'),
|
|
inlineWrapper('subscript', '2', '~2~'),
|
|
textNode('O x'),
|
|
inlineWrapper('superscript', '2', '^2^'),
|
|
textNode(' '),
|
|
{ type: 'inline_code', code: 'const x = 1', raw: '`const x = 1`' },
|
|
textNode(' '),
|
|
linkNode('Example link', 'https://example.com'),
|
|
textNode(' '),
|
|
{
|
|
type: 'html_inline',
|
|
content: '<mark data-ssr-inline-node="1">inline html</mark>',
|
|
raw: '<mark data-ssr-inline-node="1">inline html</mark>',
|
|
loading: false,
|
|
},
|
|
textNode(' '),
|
|
{ type: 'reference', id: '7', raw: '[7]' },
|
|
textNode(' '),
|
|
{ type: 'footnote_reference', id: 'matrix-1', raw: '[^matrix-1]' },
|
|
{ type: 'hardbreak', raw: ' \n' },
|
|
textNode('After hard break'),
|
|
]),
|
|
{
|
|
type: 'blockquote',
|
|
cite: 'https://example.com/source',
|
|
raw: '> Quoted block content',
|
|
children: [
|
|
paragraphNode([textNode('Quoted block content')]),
|
|
],
|
|
},
|
|
{
|
|
type: 'list',
|
|
ordered: true,
|
|
start: 3,
|
|
raw: '3. Checked task item\n4. Ordered second item',
|
|
items: [
|
|
{
|
|
type: 'list_item',
|
|
raw: '[x] Checked task item',
|
|
children: [
|
|
paragraphNode([
|
|
{ type: 'checkbox', checked: true, raw: '[x]' },
|
|
textNode(' Checked task item'),
|
|
]),
|
|
],
|
|
},
|
|
{
|
|
type: 'list_item',
|
|
raw: 'Ordered second item',
|
|
children: [
|
|
paragraphNode([textNode('Ordered second item')]),
|
|
],
|
|
},
|
|
],
|
|
},
|
|
{
|
|
type: 'definition_list',
|
|
raw: 'SSR term: SSR definition',
|
|
items: [
|
|
{
|
|
type: 'definition_item',
|
|
raw: 'SSR term: SSR definition',
|
|
term: [textNode('SSR term')],
|
|
definition: [paragraphNode([textNode('SSR definition')])],
|
|
},
|
|
],
|
|
},
|
|
{
|
|
type: 'table',
|
|
raw: '| Column | Value |\n| --- | --- |\n| SSR | Stable |',
|
|
loading: false,
|
|
header: {
|
|
type: 'table_row',
|
|
raw: '| Column | Value |',
|
|
cells: [
|
|
{ type: 'table_cell', header: true, raw: 'Column', children: [textNode('Column')] },
|
|
{ type: 'table_cell', header: true, raw: 'Value', children: [textNode('Value')] },
|
|
],
|
|
},
|
|
rows: [
|
|
{
|
|
type: 'table_row',
|
|
raw: '| SSR | Stable |',
|
|
cells: [
|
|
{ type: 'table_cell', header: false, raw: 'SSR', children: [textNode('SSR')] },
|
|
{ type: 'table_cell', header: false, raw: 'Stable', children: [textNode('Stable')] },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
{
|
|
type: 'footnote',
|
|
id: 'matrix-1',
|
|
raw: '[^matrix-1]: Footnote body',
|
|
children: [
|
|
paragraphNode([
|
|
textNode('Footnote body'),
|
|
{ type: 'footnote_anchor', id: 'matrix-1', raw: '↩︎' },
|
|
]),
|
|
],
|
|
},
|
|
{
|
|
type: 'admonition',
|
|
kind: 'tip',
|
|
title: 'SSR Tip',
|
|
raw: '::: tip SSR Tip\nAdmonition body\n:::',
|
|
children: [
|
|
paragraphNode([textNode('Admonition body')]),
|
|
],
|
|
},
|
|
{
|
|
type: 'vmr_container',
|
|
name: 'callout',
|
|
raw: '::: callout\nContainer body\n:::',
|
|
attrs: { 'data-ssr-container': '1' },
|
|
children: [
|
|
paragraphNode([textNode('Container body')]),
|
|
],
|
|
},
|
|
{
|
|
type: 'thematic_break',
|
|
raw: '---',
|
|
},
|
|
]
|
|
|
|
const html = await renderMarkdown('', {
|
|
nodes: matrixNodes,
|
|
})
|
|
|
|
expect(html).toContain('id="ssr-matrix-heading"')
|
|
expect(html).toContain('class="paragraph-node"')
|
|
expect(html).toContain('class="strong-node"')
|
|
expect(html).toContain('class="emphasis-node"')
|
|
expect(html).toContain('class="strikethrough-node"')
|
|
expect(html).toContain('class="highlight-node"')
|
|
expect(html).toContain('class="insert-node"')
|
|
expect(html).toContain('class="subscript-node"')
|
|
expect(html).toContain('class="superscript-node"')
|
|
expect(html).toContain('class="emoji-node"')
|
|
expect(html).toContain('data-ssr-inline-node="1"')
|
|
expect(html).toContain('href="https://example.com"')
|
|
expect(html).toContain('class="reference-node')
|
|
expect(html).toContain('class="footnote-reference"')
|
|
expect(html).toContain('class="hard-break"')
|
|
expect(html).toContain('<blockquote')
|
|
expect(html).toContain('class="checkbox-node"')
|
|
expect(html).toContain('<ol')
|
|
expect(html).toContain('<dl')
|
|
expect(html).toContain('<table')
|
|
expect(html).toContain('class="footnote-anchor')
|
|
expect(html).toContain('class="admonition admonition-tip')
|
|
expect(html).toContain('data-ssr-container="1"')
|
|
expect(html).toContain('class="vmr-container vmr-container-callout"')
|
|
expect(html).toContain('<hr class="hr-node"')
|
|
expect(html).toContain('After hard break')
|
|
})
|
|
|
|
it('renders code blocks with an SSR pre fallback inside the code shell', async () => {
|
|
const html = await renderMarkdown(`
|
|
\`\`\`diff
|
|
--- a/file.txt
|
|
+++ b/file.txt
|
|
@@
|
|
- old line
|
|
+ new line
|
|
\`\`\`
|
|
|
|
\`\`\`ts
|
|
export const greet = (name: string) => \`hello \${name}\`
|
|
\`\`\`
|
|
`.trim())
|
|
|
|
expect(html).toContain('data-markstream-code-block="1"')
|
|
expect(html).toContain('data-markstream-enhanced="false"')
|
|
expect(html).toContain('data-markstream-pre="1"')
|
|
expect(html).toContain('@@')
|
|
expect(html).toContain('new line')
|
|
expect(html).toContain('hello ' + '$' + '{name}')
|
|
})
|
|
|
|
it('renders the standalone MarkdownCodeBlockNode shell during SSR', async () => {
|
|
const html = await renderComponent(MarkdownCodeBlockNode, {
|
|
node: {
|
|
type: 'code_block',
|
|
language: 'ts',
|
|
code: 'export const value = 1',
|
|
raw: '```ts\nexport const value = 1\n```',
|
|
},
|
|
loading: false,
|
|
stream: false,
|
|
})
|
|
|
|
expect(html).toContain('code-block-container')
|
|
expect(html).toContain('code-block-header')
|
|
expect(html).toContain('code-block-content')
|
|
expect(html).toContain('Typescript')
|
|
})
|
|
|
|
it('renders KaTeX HTML during SSR when a sync loader is configured', async () => {
|
|
enableKatex(() => katex)
|
|
|
|
const html = await renderMarkdown(`
|
|
Inline math: $E = mc^2$.
|
|
|
|
$$
|
|
\\int_0^1 x^2 \\, dx = \\frac{1}{3}
|
|
$$
|
|
`.trim())
|
|
|
|
expect(html).toContain('data-markstream-math="inline"')
|
|
expect(html).toContain('data-markstream-math="block"')
|
|
expect(html).toContain('data-markstream-mode="katex"')
|
|
expect(html).toContain('class="katex')
|
|
})
|
|
|
|
it('renders HTML and diagram fallbacks in the raw SSR response', async () => {
|
|
const html = await renderMarkdown(`
|
|
Inline HTML stays visible: <mark data-ssr-inline-html="1">inline html</mark>.
|
|
|
|
<div data-ssr-html-block="1"><strong>HTML block</strong> is present.</div>
|
|
|
|
\`\`\`mermaid
|
|
graph TD
|
|
A --> B
|
|
\`\`\`
|
|
|
|
\`\`\`d2
|
|
client -> server: request
|
|
\`\`\`
|
|
|
|
\`\`\`infographic
|
|
infographic list-row-simple-horizontal-arrow
|
|
data
|
|
items
|
|
- label SSR
|
|
desc First paint
|
|
\`\`\`
|
|
`.trim())
|
|
|
|
expect(html).toContain('data-ssr-inline-html="1"')
|
|
expect(html).toContain('data-ssr-html-block="1"')
|
|
expect(html).toContain('data-markstream-mermaid="1"')
|
|
expect(html).toContain('data-markstream-d2="1"')
|
|
expect(html).toContain('data-markstream-infographic="1"')
|
|
expect(html).toContain('data-markstream-mode="fallback"')
|
|
expect(html).toContain('graph TD')
|
|
expect(html).toContain('client -> server: request')
|
|
expect(html).toContain('label SSR')
|
|
})
|
|
|
|
it('keeps diagram SSR fallbacks stable when optional loaders are unavailable', async () => {
|
|
disableMermaid()
|
|
disableD2()
|
|
setInfographicLoader(null)
|
|
|
|
const html = await renderMarkdown(`
|
|
\`\`\`mermaid
|
|
graph LR
|
|
Raw --> Stable
|
|
\`\`\`
|
|
|
|
\`\`\`d2
|
|
stable -> fallback
|
|
\`\`\`
|
|
|
|
\`\`\`infographic
|
|
infographic list-row-simple-horizontal-arrow
|
|
data
|
|
items
|
|
- label Disabled
|
|
desc Static fallback
|
|
\`\`\`
|
|
`.trim())
|
|
|
|
expect(html).toContain('data-markstream-mermaid="1"')
|
|
expect(html).toContain('data-markstream-d2="1"')
|
|
expect(html).toContain('data-markstream-infographic="1"')
|
|
expect(html).toContain('data-markstream-mode="fallback"')
|
|
expect(html).toContain('Raw --> Stable')
|
|
expect(html).toContain('stable -> fallback')
|
|
expect(html).toContain('label Disabled')
|
|
})
|
|
|
|
it('renders scoped custom overrides, custom node types, and trusted custom tags during SSR', async () => {
|
|
const scopeId = 'ssr-render-custom-components'
|
|
|
|
const CustomListItem = defineComponent({
|
|
name: 'SsrCustomListItem',
|
|
props: {
|
|
node: { type: Object, required: true },
|
|
indexKey: [String, Number],
|
|
customId: String,
|
|
},
|
|
setup(props) {
|
|
return () => h('li', { 'data-ssr-custom-list-item': '1' }, [
|
|
h(MarkdownRender, {
|
|
nodes: (props.node as any).children || [],
|
|
customId: props.customId,
|
|
indexKey: `ssr-custom-list-item-${String(props.indexKey ?? '')}`,
|
|
final: true,
|
|
typewriter: false,
|
|
batchRendering: false,
|
|
}),
|
|
])
|
|
},
|
|
})
|
|
|
|
const CustomNodeCard = defineComponent({
|
|
name: 'SsrCustomNodeCard',
|
|
props: {
|
|
node: { type: Object, required: true },
|
|
},
|
|
setup(props) {
|
|
return () => h('section', { 'data-ssr-custom-node': '1' }, String((props.node as any).content ?? ''))
|
|
},
|
|
})
|
|
|
|
const ThinkingNode = defineComponent({
|
|
name: 'SsrThinkingNode',
|
|
props: {
|
|
node: { type: Object, required: true },
|
|
},
|
|
setup(props) {
|
|
return () => h('aside', { 'data-ssr-custom-tag': '1' }, String((props.node as any).content ?? ''))
|
|
},
|
|
})
|
|
|
|
setCustomComponents(scopeId, {
|
|
list_item: CustomListItem,
|
|
ssr_card: CustomNodeCard,
|
|
thinking: ThinkingNode,
|
|
})
|
|
|
|
const html = await renderMarkdown('', {
|
|
customId: scopeId,
|
|
customHtmlTags: ['thinking'],
|
|
nodes: [
|
|
{
|
|
type: 'list',
|
|
ordered: false,
|
|
raw: '- Override one\n- Override two',
|
|
items: [
|
|
{
|
|
type: 'list_item',
|
|
raw: 'Override one',
|
|
children: [paragraphNode([textNode('Override one')])],
|
|
},
|
|
{
|
|
type: 'list_item',
|
|
raw: 'Override two',
|
|
children: [paragraphNode([textNode('Override two')])],
|
|
},
|
|
],
|
|
},
|
|
{
|
|
type: 'ssr_card',
|
|
raw: 'Scoped custom node content',
|
|
content: 'Scoped custom node content',
|
|
},
|
|
{
|
|
type: 'html_block',
|
|
tag: 'thinking',
|
|
content: '<thinking>Trusted custom tag content</thinking>',
|
|
raw: '<thinking>Trusted custom tag content</thinking>',
|
|
loading: false,
|
|
},
|
|
],
|
|
})
|
|
|
|
expect(html).toContain('data-ssr-custom-list-item="1"')
|
|
expect(html).toContain('Override one')
|
|
expect(html).toContain('Override two')
|
|
expect(html).toContain('data-ssr-custom-node="1"')
|
|
expect(html).toContain('Scoped custom node content')
|
|
expect(html).toContain('data-ssr-custom-tag="1"')
|
|
expect(html).toContain('Trusted custom tag content')
|
|
})
|
|
|
|
it('renders stable raw math fallback when KaTeX is disabled or unavailable', async () => {
|
|
disableKatex()
|
|
|
|
const inlineHtml = await renderComponent(MathInlineNode, {
|
|
node: {
|
|
type: 'math_inline',
|
|
content: 'a^2 + b^2 = c^2',
|
|
raw: '$a^2 + b^2 = c^2$',
|
|
markup: '$',
|
|
loading: false,
|
|
},
|
|
})
|
|
|
|
setKatexLoader(() => {
|
|
throw new Error('KaTeX unavailable')
|
|
})
|
|
|
|
const blockHtml = await renderComponent(MathBlockNode, {
|
|
node: {
|
|
type: 'math_block',
|
|
content: '\\sum_{n=1}^{3} n = 6',
|
|
raw: '$$\\sum_{n=1}^{3} n = 6$$',
|
|
markup: '$$',
|
|
loading: false,
|
|
},
|
|
})
|
|
|
|
expect(inlineHtml).toContain('data-markstream-mode="fallback"')
|
|
expect(inlineHtml).toContain('$a^2 + b^2 = c^2$')
|
|
expect(inlineHtml).not.toContain('class="katex')
|
|
expect(blockHtml).toContain('data-markstream-mode="fallback"')
|
|
expect(blockHtml).toContain('\\sum_{n=1}^{3} n = 6')
|
|
expect(blockHtml).not.toContain('class="katex')
|
|
})
|
|
})
|