Skip to content

Commit 572da38

Browse files
committed
feat: allow passing MarkdownTree to <Markdown> component
1 parent 72cd561 commit 572da38

15 files changed

Lines changed: 374 additions & 31 deletions

File tree

packages/comark-angular/src/components/markdown.component.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
} from '@angular/core'
1010
import { createSerializedParse } from 'comark'
1111
import type { ParseOptions, MarkdownTree } from 'comark'
12+
import { isMarkdownTree } from 'comark/utils'
1213
import { MarkdownParsed } from './markdown-parsed.component.ts'
1314
import { warnDeprecated } from '../internal/deprecation.ts'
1415

@@ -39,8 +40,8 @@ import { warnDeprecated } from '../internal/deprecation.ts'
3940
`,
4041
})
4142
export class Markdown implements OnChanges {
42-
/** The markdown content to parse and render */
43-
@Input() value?: string
43+
/** The markdown content to parse and render, or a pre-parsed MarkdownTree */
44+
@Input() value?: string | MarkdownTree
4445

4546
/**
4647
* The markdown content to parse and render
@@ -107,7 +108,14 @@ export class Markdown implements OnChanges {
107108
}
108109

109110
private parseMarkdown(): void {
110-
let source = this.value ?? this.markdown ?? ''
111+
// Pre-parsed tree — skip parse and render directly
112+
if (isMarkdownTree(this.value)) {
113+
this.tree = this.value
114+
this.cdr.markForCheck()
115+
return
116+
}
117+
118+
let source = (this.value as string | undefined) ?? this.markdown ?? ''
111119
if (this.summary) {
112120
source = source.split('<!-- more -->')[0] || ''
113121
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { describe, expect, it, vi } from 'vitest'
2+
import { parse } from 'comark'
3+
import { isMarkdownTree } from 'comark/utils'
4+
import type { MarkdownTree } from 'comark'
5+
import { Markdown } from '../src/components/markdown.component.ts'
6+
7+
/**
8+
* Angular's high-level Markdown component accepts a string or a pre-parsed
9+
* MarkdownTree on `value`. When a tree is passed, parse is skipped and the
10+
* tree is assigned for MarkdownParsed to render.
11+
*/
12+
function createMarkdown(): Markdown {
13+
const cdr = { markForCheck: vi.fn() }
14+
return new Markdown(cdr as any)
15+
}
16+
17+
describe('Markdown value as MarkdownTree', () => {
18+
it('assigns a pre-parsed tree without calling parse', async () => {
19+
const tree = await parse('# Hello **World**')
20+
const component = createMarkdown()
21+
22+
component.value = tree
23+
component.ngOnChanges({
24+
value: {
25+
currentValue: tree,
26+
previousValue: undefined,
27+
firstChange: true,
28+
isFirstChange: () => true,
29+
},
30+
})
31+
32+
expect(isMarkdownTree(component.value)).toBe(true)
33+
expect(component.tree).toBe(tree)
34+
expect(component.tree!.nodes[0]?.[0]).toBe('h1')
35+
})
36+
37+
it('still parses markdown strings', async () => {
38+
const component = createMarkdown()
39+
component.value = 'Hello **world**'
40+
component.ngOnChanges({
41+
value: {
42+
currentValue: 'Hello **world**',
43+
previousValue: undefined,
44+
firstChange: true,
45+
isFirstChange: () => true,
46+
},
47+
})
48+
49+
// Wait for async parse
50+
await vi.waitFor(() => {
51+
expect(component.tree).not.toBeNull()
52+
})
53+
54+
expect(component.tree!.nodes[0]?.[0]).toBe('p')
55+
})
56+
57+
it('accepts an empty tree', () => {
58+
const empty: MarkdownTree = { nodes: [], frontmatter: {}, meta: {} }
59+
const component = createMarkdown()
60+
component.value = empty
61+
component.ngOnChanges({
62+
value: {
63+
currentValue: empty,
64+
previousValue: undefined,
65+
firstChange: true,
66+
isFirstChange: () => true,
67+
},
68+
})
69+
70+
expect(component.tree).toBe(empty)
71+
expect(component.tree!.nodes).toEqual([])
72+
})
73+
})

packages/comark-react/src/components/Markdown.tsx

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import React from 'react'
22
import { parse } from 'comark'
3-
import type { ParseOptions } from 'comark'
3+
import type { MarkdownTree, ParseOptions } from 'comark'
4+
import { isMarkdownTree } from 'comark/utils'
45
import { MarkdownParsed } from './MarkdownParsed.tsx'
56
import { MarkdownClient } from './MarkdownClient.tsx'
67
import { warnDeprecated } from '../internal/deprecation.ts'
@@ -12,9 +13,9 @@ export interface MarkdownProps {
1213
children?: React.ReactNode
1314

1415
/**
15-
* The markdown content to parse and render
16+
* The markdown content to parse and render, or a pre-parsed MarkdownTree
1617
*/
17-
value?: string
18+
value?: string | MarkdownTree
1819

1920
/**
2021
* The markdown content to parse and render
@@ -125,7 +126,23 @@ export async function Markdown({
125126
if (markdown !== undefined && value === undefined) {
126127
warnDeprecated('markdown (prop)', 'value')
127128
}
128-
const source = children ? String(children) : (value ?? markdown ?? '')
129+
130+
// Pre-parsed tree — skip parse and render directly
131+
if (isMarkdownTree(value)) {
132+
return (
133+
<MarkdownParsed
134+
value={value}
135+
components={customComponents}
136+
componentsManifest={componentsManifest}
137+
streaming={streaming}
138+
className={className}
139+
caret={caret}
140+
data={data}
141+
/>
142+
)
143+
}
144+
145+
const source = children ? String(children) : ((value as string | undefined) ?? markdown ?? '')
129146
// `unwrap` prop is a shorthand for the `unwrap` parse option; an explicit
130147
// `options.unwrap` still wins when the prop is left at its default.
131148
const parseOptions = unwrap ? { ...options, unwrap } : options

packages/comark-react/src/components/MarkdownClient.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { use, useDeferredValue, useMemo, Suspense } from 'react'
44
import { parse } from 'comark'
55
import type { MarkdownTree } from 'comark'
6+
import { isMarkdownTree } from 'comark/utils'
67
import { MarkdownLive } from './MarkdownLive.tsx'
78
import type { MarkdownProps } from './Markdown'
89

@@ -35,11 +36,19 @@ function MarkdownContent({
3536
}
3637

3738
export function MarkdownClient({ children, value, markdown, options = {}, plugins = [], ...rest }: MarkdownProps) {
38-
const content = children ? String(children) : (value ?? markdown ?? '')
39+
const content = isMarkdownTree(value)
40+
? value
41+
: children
42+
? String(children)
43+
: ((value as string | undefined) ?? markdown ?? '')
3944

4045
// Re-creates the promise only when content changes.
4146
// Note: options/plugins should be stable references (defined outside render or memoized).
42-
const parsePromise = useMemo(() => parse(content, { ...options, plugins }), [content])
47+
// Pre-parsed trees resolve immediately without calling parse().
48+
const parsePromise = useMemo(
49+
() => (isMarkdownTree(content) ? Promise.resolve(content) : parse(content, { ...options, plugins })),
50+
[content]
51+
)
4352

4453
// Keep showing the previous parsed result while a new parse is pending —
4554
// prevents blank flashes during rapid streaming updates.
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { renderToString } from 'react-dom/server'
3+
import { parse } from 'comark'
4+
import { Markdown } from '../src/components/Markdown'
5+
import { MarkdownParsed } from '../src/components/MarkdownParsed'
6+
7+
/**
8+
* Markdown accepts either a markdown string or a pre-parsed MarkdownTree on
9+
* `value`. When a tree is passed it should render via MarkdownParsed without
10+
* re-parsing.
11+
*/
12+
async function renderMarkdownComponent(props: Record<string, unknown>) {
13+
const element = await Markdown(props as any)
14+
return renderToString(element as React.ReactElement)
15+
}
16+
17+
function renderMarkdownParsedComponent(props: Record<string, unknown>) {
18+
return renderToString(<MarkdownParsed {...(props as any)} />)
19+
}
20+
21+
describe('Markdown value as MarkdownTree', () => {
22+
it('renders a pre-parsed tree the same as MarkdownParsed', async () => {
23+
const tree = await parse('# Hello **World**')
24+
const fromMarkdown = await renderMarkdownComponent({ value: tree })
25+
const fromParsed = renderMarkdownParsedComponent({ value: tree })
26+
27+
expect(fromMarkdown).toContain('<h1')
28+
expect(fromMarkdown).toContain('Hello <strong>World</strong>')
29+
expect(fromMarkdown).toBe(fromParsed)
30+
})
31+
32+
it('still renders markdown strings', async () => {
33+
const html = await renderMarkdownComponent({ value: 'Hello **world**' })
34+
expect(html).toContain('<p>')
35+
expect(html).toContain('<strong>world</strong>')
36+
})
37+
38+
it('renders an empty tree without crashing', async () => {
39+
const html = await renderMarkdownComponent({
40+
value: { nodes: [], frontmatter: {}, meta: {} },
41+
})
42+
expect(html).toContain('comark-content')
43+
})
44+
})

packages/comark-svelte/src/async/MarkdownAsync.svelte

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,9 @@ and wrap this component in a `<svelte:boundary>` for pending/error states.
2828
```
2929
-->
3030
<script lang="ts">
31-
import type { ComarkPlugin, ComponentManifest } from 'comark'
31+
import type { MarkdownTree, ComarkPlugin, ComponentManifest } from 'comark'
3232
import { parse } from 'comark'
33+
import { isMarkdownTree } from 'comark/utils'
3334
import MarkdownParsed from '../components/MarkdownParsed.svelte'
3435
import ResolveAsync from './ResolveAsync.svelte'
3536
import { warnDeprecated } from '../internal/deprecation.js'
@@ -47,7 +48,7 @@ and wrap this component in a `<svelte:boundary>` for pending/error states.
4748
data,
4849
class: className = '',
4950
}: {
50-
value?: string
51+
value?: string | MarkdownTree
5152
/** @deprecated Use `value` instead */
5253
markdown?: string
5354
options?: Record<string, any>
@@ -66,12 +67,14 @@ and wrap this component in a `<svelte:boundary>` for pending/error states.
6667
warnDeprecated('markdown (prop)', 'value')
6768
}
6869
69-
let content = $derived((value ?? markdown ?? '').trim())
70+
let content = $derived(typeof value === 'string' ? value.trim() : (markdown ?? '').trim())
7071
let parsed = $derived(
71-
// `parse` directly mutates `plugins` which creates an infinite effect loop
72-
// so we copy it before passing it in so it gets a regular JS array and we get to still
73-
// track dependencies from an external perspective
74-
await parse(content, { ...options, ...(unwrap ? { unwrap } : {}), plugins: [...plugins] }),
72+
isMarkdownTree(value)
73+
? value
74+
: // `parse` directly mutates `plugins` which creates an infinite effect loop
75+
// so we copy it before passing it in so it gets a regular JS array and we get to still
76+
// track dependencies from an external perspective
77+
await parse(content, { ...options, ...(unwrap ? { unwrap } : {}), plugins: [...plugins] }),
7578
)
7679
</script>
7780

packages/comark-svelte/src/components/Markdown.svelte

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@ This is an alert component
2525
-->
2626
<script lang="ts">
2727
import type { MarkdownTree, ComarkPlugin, ComponentManifest } from 'comark'
28-
import { parse } from 'comark'
28+
import { parse } from 'comark'
29+
import { isMarkdownTree } from 'comark/utils'
2930
import MarkdownParsed from './MarkdownParsed.svelte'
3031
import { warnDeprecated } from '../internal/deprecation.js'
3132
@@ -42,7 +43,7 @@ This is an alert component
4243
data,
4344
class: className = '',
4445
}: {
45-
value?: string
46+
value?: string | MarkdownTree
4647
/** @deprecated Use `value` instead */
4748
markdown?: string
4849
options?: Record<string, any>
@@ -63,11 +64,12 @@ This is an alert component
6364
6465
let parsed: MarkdownTree | null = $state(null)
6566
66-
let content = $derived((value ?? markdown ?? '').trim())
67+
let content = $derived(typeof value === 'string' ? value.trim() : (markdown ?? '').trim())
6768
6869
let requestVersion = 0
6970
let appliedVersion = 0
7071
$effect(() => {
72+
if (isMarkdownTree(value)) return
7173
const currentVersion = ++requestVersion
7274
// `parse` directly mutates `plugins` which creates an infinite effect loop
7375
// so we copy it before passing it in so it gets a regular JS array and we get to still
@@ -81,7 +83,17 @@ This is an alert component
8183
})
8284
</script>
8385

84-
{#if parsed}
86+
{#if isMarkdownTree(value)}
87+
<MarkdownParsed
88+
{value}
89+
{components}
90+
{componentsManifest}
91+
{streaming}
92+
{caret}
93+
{data}
94+
class={className}
95+
/>
96+
{:else if parsed}
8597
<MarkdownParsed
8698
value={parsed}
8799
{components}

packages/comark-svelte/src/types.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,8 @@ export interface MarkdownParsedProps {
3232
}
3333

3434
export interface MarkdownProps {
35-
/** The markdown content to parse and render */
36-
value?: string
35+
/** The markdown content to parse and render, or a pre-parsed MarkdownTree */
36+
value?: string | MarkdownTree
3737
/** @deprecated Use `value` instead */
3838
markdown?: string
3939
options?: Exclude<ParseOptions, 'plugins'>
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { render } from 'svelte/server'
3+
import { parse } from 'comark'
4+
import Markdown from '../src/components/Markdown.svelte'
5+
import MarkdownParsed from '../src/components/MarkdownParsed.svelte'
6+
import MarkdownAsync from '../src/async/MarkdownAsync.svelte'
7+
8+
/** Strip Svelte SSR hydration comments from rendered HTML */
9+
function html(body: string): string {
10+
return body.replace(/<!--[[\]\-\d!]*-->/g, '').replace(/<!---->/g, '')
11+
}
12+
13+
describe('Markdown value as MarkdownTree', () => {
14+
it('renders a pre-parsed tree the same as MarkdownParsed', async () => {
15+
const tree = await parse('# Hello **World**')
16+
const fromMarkdown = html(render(Markdown, { props: { value: tree } }).body)
17+
const fromParsed = html(render(MarkdownParsed, { props: { value: tree } }).body)
18+
19+
expect(fromMarkdown).toContain('<h1')
20+
expect(fromMarkdown).toContain('Hello <strong>World</strong>')
21+
expect(fromMarkdown).toBe(fromParsed)
22+
})
23+
24+
it('still renders markdown strings after parse settles', async () => {
25+
// Server render of Markdown only emits once parse has completed in $effect —
26+
// string path is empty on first SSR tick. MarkdownAsync covers the string path.
27+
const { body } = await render(MarkdownAsync, {
28+
props: { value: 'Hello **world**' },
29+
})
30+
const output = html(body)
31+
expect(output).toContain('<p>')
32+
expect(output).toContain('<strong>world</strong>')
33+
})
34+
35+
it('renders an empty tree without crashing', () => {
36+
const { body } = render(Markdown, {
37+
props: { value: { nodes: [], frontmatter: {}, meta: {} } },
38+
})
39+
expect(html(body)).toBe('<div class="comark-content "></div>')
40+
})
41+
})
42+
43+
describe('MarkdownAsync value as MarkdownTree', () => {
44+
it('renders a pre-parsed tree without parsing', async () => {
45+
const tree = await parse('# Hello **World**')
46+
const { body } = await render(MarkdownAsync, { props: { value: tree } })
47+
const output = html(body)
48+
expect(output).toContain('<h1')
49+
expect(output).toContain('Hello <strong>World</strong>')
50+
})
51+
52+
it('renders a tree the same as MarkdownParsed', async () => {
53+
const tree = await parse('A paragraph with **bold**')
54+
const fromAsync = html((await render(MarkdownAsync, { props: { value: tree } })).body)
55+
const fromParsed = html(render(MarkdownParsed, { props: { value: tree } }).body)
56+
expect(fromAsync).toBe(fromParsed)
57+
})
58+
})

0 commit comments

Comments
 (0)