Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/content/3.rendering/5.react.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ Passing a document to `<Markdown>` skips parsing at runtime, but the **parser is
| [`caret`](#streaming-caret) | `boolean \| { class: string }` | `false` | Append caret to last text node |
| [`data`](#code-markdown-props-code-data) | `Record<string, unknown>` | `undefined` | Runtime values referenced from markdown via `:prop="data.path"` |
| `className` | `string` | `undefined` | CSS class for wrapper element |
| `...attributes` | `HTMLAttributes<HTMLDivElement>` | — | Any other HTML attribute (`id`, `style`, `role`, `data-*`, `aria-*`, ...) is set on the wrapper `<div>` |

#### `options`

Expand Down Expand Up @@ -418,6 +419,7 @@ export default async function DocsPage({ params }: { params: { slug: string } })
| [`caret`](#streaming-caret) | `boolean \| { class: string }` | `false` | Append a blinking caret to the last text node |
| [`data`](#code-markdown-props-code-data) | `Record<string, unknown>` | `undefined` | Runtime values referenced from markdown via `:prop="data.path"` |
| `className` | `string` | `undefined` | CSS class for the wrapper `<div>` |
| `...attributes` | `HTMLAttributes<HTMLDivElement>` | — | Any other HTML attribute (`id`, `style`, `role`, `data-*`, `aria-*`, ...) is set on the wrapper `<div>` |

### `defineMarkdownDocumentComponent`

Expand Down
2 changes: 2 additions & 0 deletions docs/content/3.rendering/6.svelte.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ Passing a document to `<Markdown>` skips parsing at runtime, but the **parser is
| [`caret`](#streaming-caret) | `boolean \| { class: string }` | `false` | Append caret to last text node |
| [`data`](#code-markdown-props-code-data) | `Record<string, unknown>` | `undefined` | Runtime values referenced from markdown via `:prop="data.path"` |
| `class` | `string` | `''` | CSS class for wrapper element |
| `...attributes` | `HTMLAttributes<HTMLDivElement>` | — | Any other HTML attribute (`id`, `style`, `role`, `data-*`, `aria-*`, ...) is set on the wrapper `<div>` |

#### `options`

Expand Down Expand Up @@ -386,6 +387,7 @@ export const load: PageLoad = async ({ params, fetch }) => {
| [`caret`](#streaming-caret) | `boolean \| { class: string }` | `false` | Append a blinking caret to the last text node |
| [`data`](#code-markdown-props-code-data) | `Record<string, unknown>` | `undefined` | Runtime values referenced from markdown via `:prop="data.path"` |
| `class` | `string` | `''` | CSS class for wrapper element |
| `...attributes` | `HTMLAttributes<HTMLDivElement>` | — | Any other HTML attribute (`id`, `style`, `role`, `data-*`, `aria-*`, ...) is set on the wrapper `<div>` |

### `componentsManifest`

Expand Down
73 changes: 73 additions & 0 deletions packages/comark-angular/test/root-attributes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import '@angular/compiler'
import { describe, expect, it } from 'vitest'
import { Component, provideZonelessChangeDetection } from '@angular/core'
import { bootstrapApplication } from '@angular/platform-browser'
import { renderApplication } from '@angular/platform-server'
import { parseMarkdown, type MarkdownDocument as MarkdownDocumentType } from 'comark'
import { Markdown } from '../src/components/markdown.component.ts'
import { MarkdownDocument } from '../src/components/markdown-document.component.ts'

/**
* Angular renders the component host element (`<comark-markdown>`,
* `<comark-markdown-document>`) into the DOM, so plain HTML attributes
* (`data-*`, `aria-*`, `id`, `role`, ...) set on it stay on the root.
*/
const attrTemplate =
'id="answer" role="region" data-testid="answer" data-message-id="42" aria-label="Assistant reply" class="custom"'

async function renderTemplate(template: string, document?: MarkdownDocumentType): Promise<string> {
class App {
document = document
}
Component({
selector: 'app-root',
standalone: true,
imports: [Markdown, MarkdownDocument],
template,
})(App)

return renderApplication(
(context) => bootstrapApplication(App, { providers: [provideZonelessChangeDetection()] }, context),
{ document: '<!DOCTYPE html><html><head></head><body><app-root></app-root></body></html>' }
)
}

function hostTag(html: string, selector: string): string {
return html.match(new RegExp(`<${selector}\\b[^>]*>`))?.[0] ?? ''
}

function expectHostAttributes(html: string, selector: string) {
const host = hostTag(html, selector)
expect(host).toContain('id="answer"')
expect(host).toContain('role="region"')
expect(host).toContain('data-testid="answer"')
expect(host).toContain('data-message-id="42"')
expect(host).toContain('aria-label="Assistant reply"')
expect(host).toContain('class="custom"')
}

describe('root element attributes', () => {
it('comark-markdown keeps static attributes on its host element', async () => {
const document = await parseMarkdown('Hello **world**')
const html = await renderTemplate(`<comark-markdown [value]="document" ${attrTemplate} />`, document)
expectHostAttributes(html, 'comark-markdown')
expect(html).toContain('<strong>world</strong>')
})

it('comark-markdown-document keeps static attributes on its host element', async () => {
const document = await parseMarkdown('Hello')
const html = await renderTemplate(`<comark-markdown-document [value]="document" ${attrTemplate} />`, document)
expectHostAttributes(html, 'comark-markdown-document')
})

it('comark-markdown-document keeps bound attributes on its host element', async () => {
const document = await parseMarkdown('Hello')
const html = await renderTemplate(
'<comark-markdown-document [value]="document" [attr.aria-busy]="true" [attr.data-message-id]="42" />',
document
)
const host = hostTag(html, 'comark-markdown-document')
expect(host).toContain('aria-busy="true"')
expect(host).toContain('data-message-id="42"')
})
})
10 changes: 9 additions & 1 deletion packages/comark-react/src/components/Markdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@ import { isMarkdownDocument } from 'comark/utils'
import { MarkdownDocument } from './MarkdownDocument.tsx'
import { MarkdownClient } from './MarkdownClient.tsx'

export interface MarkdownProps {
/**
* Every other HTML attribute (`id`, `style`, `role`, `data-*`, `aria-*`, ...) is
* set on the wrapper div.
*/
export interface MarkdownProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'children'> {
/**
* The children content to parse and render
*/
Expand Down Expand Up @@ -114,11 +118,13 @@ export async function Markdown({
caret = false,
data,
className,
...rest
}: MarkdownProps) {
// Pre-parsed document — skip parsing and render directly
if (isMarkdownDocument(value)) {
return (
<MarkdownDocument
{...rest}
value={value}
components={customComponents}
componentsManifest={componentsManifest}
Expand All @@ -138,6 +144,7 @@ export async function Markdown({
if (streaming) {
return (
<MarkdownClient
{...rest}
value={source}
options={parseOptions}
plugins={plugins}
Expand All @@ -155,6 +162,7 @@ export async function Markdown({

return (
<MarkdownDocument
{...rest}
value={parsed}
components={customComponents}
componentsManifest={componentsManifest}
Expand Down
16 changes: 14 additions & 2 deletions packages/comark-react/src/components/MarkdownClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,13 @@ function MarkdownContent({
caret = false,
data,
className,
...rest
}: MarkdownContentProps) {
const parsed = use(parsePromise)

return (
<MarkdownLive
{...rest}
value={parsed}
components={customComponents}
componentsManifest={componentsManifest}
Expand All @@ -35,7 +37,14 @@ function MarkdownContent({
)
}

export function MarkdownClient({ children, value, options = {}, plugins = [], ...rest }: MarkdownProps) {
export function MarkdownClient({
children,
value,
options = {},
plugins = [],
unwrap = false,
...rest
}: MarkdownProps) {
const content = isMarkdownDocument(value)
? value
: children
Expand All @@ -46,7 +55,10 @@ export function MarkdownClient({ children, value, options = {}, plugins = [], ..
// Note: options/plugins should be stable references (defined outside render or memoized).
// Pre-parsed documents resolve immediately without calling parseMarkdown().
const parsePromise = useMemo(
() => (isMarkdownDocument(content) ? Promise.resolve(content) : parseMarkdown(content, { ...options, plugins })),
() =>
isMarkdownDocument(content)
? Promise.resolve(content)
: parseMarkdown(content, { ...options, ...(unwrap ? { unwrap } : {}), plugins }),
[content]
)

Expand Down
16 changes: 14 additions & 2 deletions packages/comark-react/src/components/MarkdownDocument.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,11 @@ function renderNode(
return null
}

export interface MarkdownDocumentProps {
/**
* Every other HTML attribute (`id`, `style`, `role`, `data-*`, `aria-*`, ...) is
* set on the wrapper div.
*/
export interface MarkdownDocumentProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'children'> {
/**
* The parsed Comark document to render — either a full `MarkdownDocument` or a bare
* `Node[]`. When a node array is passed, frontmatter and meta
Expand Down Expand Up @@ -341,6 +345,7 @@ export const MarkdownDocument: React.FC<MarkdownDocumentProps> = ({
caret: caretProp = false,
data,
className,
...rest
}) => {
const document = value ?? { nodes: [] }

Expand Down Expand Up @@ -373,5 +378,12 @@ export const MarkdownDocument: React.FC<MarkdownDocumentProps> = ({
}, [document, customComponents, componentsManifest, streaming, caret, data])

// Wrap in a fragment
return <div className={`comark-content ${className || ''}`}>{renderedNodes}</div>
return (
<div
{...rest}
className={`comark-content ${className || ''}`}
>
{renderedNodes}
</div>
)
}
137 changes: 137 additions & 0 deletions packages/comark-react/test/root-attributes.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { describe, expect, it } from 'vitest'
import React from 'react'
import { renderToReadableStream, renderToString } from 'react-dom/server'
import { parseMarkdown } from 'comark'
import { Markdown } from '../src/components/Markdown'
import { MarkdownDocument } from '../src/components/MarkdownDocument'
import { MarkdownLive } from '../src/components/MarkdownLive'
import { defineMarkdownComponent, defineMarkdownDocumentComponent } from '../src/index'

/**
* HTML attributes that are not renderer options (`data-*`, `aria-*`, `id`,
* `role`, `style`, ...) land on the `comark-content` root element.
*/
const attrs = {
id: 'answer',
role: 'region',
'data-testid': 'answer',
'data-message-id': '42',
'aria-label': 'Assistant reply',
'aria-busy': true,
style: { color: 'red' },
}

function rootTag(html: string): string {
return html.match(/<div[^>]*class="comark-content[^>]*>/)?.[0] ?? ''
}

function expectRootAttributes(html: string) {
const root = rootTag(html)
expect(root).toContain('id="answer"')
expect(root).toContain('role="region"')
expect(root).toContain('data-testid="answer"')
expect(root).toContain('data-message-id="42"')
expect(root).toContain('aria-label="Assistant reply"')
expect(root).toContain('aria-busy="true"')
expect(root).toContain('style="color:red"')
expect(root).toContain('class="comark-content custom"')
}

async function renderAsync(element: React.ReactElement): Promise<string> {
const stream = await renderToReadableStream(element)
await stream.allReady
return new Response(stream).text()
}

async function renderMarkdown(props: Record<string, unknown>) {
const element = await Markdown(props as any)
return renderToString(element as React.ReactElement)
}

describe('root element attributes', () => {
it('Markdown sets them on the root element', async () => {
const html = await renderMarkdown({ value: 'Hello **world**', className: 'custom', ...attrs })
expectRootAttributes(html)
expect(html).toContain('<strong>world</strong>')
})

it('Markdown sets them when given a pre-parsed document', async () => {
const value = await parseMarkdown('Hello **world**')
expectRootAttributes(await renderMarkdown({ value, className: 'custom', ...attrs }))
})

it('Markdown keeps renderer options off the DOM', async () => {
const html = await renderMarkdown({
value: 'Hello',
unwrap: true,
data: { a: 1 },
options: {},
plugins: [],
'data-testid': 'answer',
})
const root = rootTag(html)
expect(root).toContain('data-testid="answer"')
expect(root).not.toMatch(/unwrap|options|plugins|caret|streaming|components/)
expect(html).not.toContain('<p>')
})

it('Markdown sets them in streaming mode (MarkdownClient)', async () => {
const element = await Markdown({ value: 'Hello **world**', streaming: true, className: 'custom', ...attrs } as any)
const html = await renderAsync(element as React.ReactElement)
expectRootAttributes(html)
expect(html).toContain('<strong>world</strong>')
})

it('MarkdownDocument sets them on the root element', async () => {
const value = await parseMarkdown('Hello')
expectRootAttributes(
renderToString(
<MarkdownDocument
value={value}
className="custom"
{...attrs}
/>
)
)
})

it('MarkdownLive sets them on the root element', async () => {
const value = await parseMarkdown('Hello')
expectRootAttributes(
renderToString(
<MarkdownLive
value={value}
documentKey="doc"
className="custom"
{...attrs}
/>
)
)
})

it('defineMarkdownComponent forwards them', async () => {
const value = await parseMarkdown('Hello')
const Defined = defineMarkdownComponent({ className: 'custom' })
expectRootAttributes(
await renderAsync(
<Defined
value={value}
{...attrs}
/>
)
)
})

it('defineMarkdownDocumentComponent forwards them', async () => {
const value = await parseMarkdown('Hello')
const Defined = defineMarkdownDocumentComponent({ className: 'custom' })
expectRootAttributes(
renderToString(
<Defined
value={value}
{...attrs}
/>
)
)
})
})
5 changes: 4 additions & 1 deletion packages/comark-svelte/src/async/MarkdownAsync.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ and wrap this component in a `<svelte:boundary>` for pending/error states.
import { isMarkdownDocument } from 'comark/utils'
import MarkdownDocument from '../components/MarkdownDocument.svelte'
import ResolveAsync from './ResolveAsync.svelte'
import type { RootAttributes } from '../types.js'

let {
value,
Expand All @@ -45,7 +46,8 @@ and wrap this component in a `<svelte:boundary>` for pending/error states.
caret = false,
data,
class: className = '',
}: {
...rest
}: RootAttributes & {
value?: string | MarkdownDocumentType
options?: Record<string, any>
plugins?: ComarkPlugin[]
Expand Down Expand Up @@ -77,6 +79,7 @@ and wrap this component in a `<svelte:boundary>` for pending/error states.

{#if parsed}
<MarkdownDocument
{...rest}
value={parsed}
{components}
{componentsManifest}
Expand Down
Loading
Loading