Angular Markdown Component with Streaming (v17+)
The @comark/angular package provides standalone components for rendering Comark content in Angular with full support for custom components, plugins, and streaming.
Requirements
| Dependency | Version |
|---|---|
| Angular | >=17.0.0 <22.0.0 |
| TypeScript | >=5.5.0 |
@comark/angular is bundler-agnostic: it works with the Angular CLI, Vite (via @analogjs/vite-plugin-angular), Webpack, esbuild, or any other build tool that supports Angular.Installation
pnpm add @comark/angularnpm install @comark/angularyarn add @comark/angularbun add @comark/angular<comark-markdown>
The <comark-markdown> component is the quickest way to render markdown in Angular. It handles parsing and rendering automatically. The value input accepts a markdown string or a pre-parsed MarkdownDocument.
import { Component } from '@angular/core'
import { Markdown } from '@comark/angular'
@Component({
selector: 'app-root',
standalone: true,
imports: [Markdown],
template: `<comark-markdown [value]="content" />`,
})
export class AppComponent {
content = `# Hello World
This is **markdown** with Comark components.
`
}<comark-markdown> skips parsing at runtime, but the parser is still bundled because Markdown imports it. To keep the client bundle free of the parser, use <comark-markdown-document> instead.Props (inputs)
| Input | Type | Default | Description |
|---|---|---|---|
value | string | MarkdownDocument | '' | Markdown string or pre-parsed document |
options | ParserOptions | {} | Parser options (autoUnwrap, autoClose, etc.) |
plugins | ComarkPlugin[] | [] | Array of plugins |
unwrap | boolean | string | string[] | false | Strip wrapper tags (MDC unwrap) — true unwraps <p>; a comma/space-separated string or array peels tags sequentially, for example "ul li" |
components | Record<string, Type<any>> | {} | Custom Angular component mappings |
streaming | boolean | false | Enable streaming mode |
summary | boolean | false | Only render content before <!-- more --> |
caret | boolean | { class: string } | false | Append caret to last text node |
data | Record<string, unknown> | {} | Runtime values referenced from markdown via :prop="data.path" |
transformNode | NodeTransform | NodeTransform[] | undefined | Change the tag or props of element nodes right before they render |
options
See ParserOptions for available options.
@Component({
template: `<comark-markdown [value]="content" [options]="{ autoUnwrap: true, autoClose: true }" />`,
})plugins
See ComarkPlugin for available plugins.
import { Component } from '@angular/core'
import { Markdown } from '@comark/angular'
import shiki from '@comark/angular/plugins/shiki'
import githubLight from '@shikijs/themes/github-light'
import githubDark from '@shikijs/themes/github-dark'
@Component({
selector: 'app-root',
standalone: true,
imports: [Markdown],
template: `<comark-markdown [value]="content" [plugins]="plugins" />`,
})
export class AppComponent {
content = '```js\nconsole.log("hello")\n```'
plugins = [
shiki({
themes: { light: githubLight, dark: githubDark },
}),
]
}For math and mermaid plugins, also pass the companion components:
import { Component } from '@angular/core'
import { Markdown } from '@comark/angular'
import math, { Math } from '@comark/angular/plugins/math'
import mermaid, { Mermaid } from '@comark/angular/plugins/mermaid'
@Component({
selector: 'app-root',
standalone: true,
imports: [Markdown],
template: `
<comark-markdown
[value]="content"
[plugins]="plugins"
[components]="components"
/>
`,
})
export class AppComponent {
content = '$E = mc^2$'
plugins = [math(), mermaid()]
components = { Math, Mermaid }
}components
Use this input to map custom Angular components to Comark elements and use them in your markdown.
Create a component
import { Component, Input } from '@angular/core'
@Component({
selector: 'app-alert',
standalone: true,
template: `
<div class="alert" [class]="'alert-' + type" role="alert">
<ng-content />
</div>
`,
})
export class AlertComponent {
@Input() type: 'info' | 'warning' | 'error' | 'success' = 'info'
}Map the tag to your component
import { Component } from '@angular/core'
import { Markdown } from '@comark/angular'
import { AlertComponent } from './components/alert.component'
import { CardComponent } from './components/card.component'
@Component({
selector: 'app-root',
standalone: true,
imports: [Markdown],
template: `<comark-markdown [value]="content" [components]="components" />`,
})
export class AppComponent {
content = '...'
components = { alert: AlertComponent, card: CardComponent }
}Use it in your Markdown
::alert{type="warning"}
This is a warning message!
::data
Expose runtime values to markdown authors. Any prop written with a : prefix is resolved against the render context { frontmatter, meta, data, props } when its value isn't valid JSON. See Data Binding for the full scope.
import { Component } from '@angular/core'
import { Markdown } from '@comark/angular'
@Component({
selector: 'app-root',
standalone: true,
imports: [Markdown],
template: `<comark-markdown [value]="content" [data]="data" />`,
})
export class AppComponent {
data = { user: { name: 'Ada', role: 'admin' } }
content = `Hello, :badge{:label="data.user.name"}!`
}transformNode
Change an element's tag or props right before it renders, using values that are only known at render time. The function receives the node's tag, its resolved props, and the render context. It returns a new tag, new props, or nothing to keep the node as it is. See Transforming nodes before render for the full rules.
import { Component } from '@angular/core'
import { Markdown } from '@comark/angular'
import type { NodeTransform } from '@comark/angular'
const user = { id: 42 }
// Render every `::cta` as a link to the user's dashboard
const transformNode: NodeTransform = ({ tag, props }) => {
if (tag !== 'cta') return
return { tag: 'a', props: { ...props, href: `/users/${user.id}/dashboard` } }
}
@Component({
selector: 'app-root',
standalone: true,
imports: [Markdown],
template: `<comark-markdown [value]="content" [transformNode]="transformNode" />`,
})
export class AppComponent {
transformNode = transformNode
content = `::cta\nOpen your dashboard\n::`
}Comark renders what the function returns as-is, without sanitizing it, and the security plugin doesn't apply to it. Validate anything you build from untrusted data, such as URLs from data.
Keep the function on a field instead of creating it in the template, so change detection sees the same input. Angular can call it twice for a node that renders as a custom component, so keep it free of side effects.
<comark-markdown-document>
Renders a pre-parsed MarkdownDocument without any parsing. Use it when you parse on the server, in a build step, or via an API, so no parser or plugin code is shipped to the browser.
Parsing
Parse your markdown content and pass the document directly:
import { Component, OnInit } from '@angular/core'
import { MarkdownDocument } from '@comark/angular'
import { parseMarkdown } from 'comark'
import type { MarkdownDocument as MarkdownDocumentType } from 'comark'
@Component({
selector: 'app-docs',
standalone: true,
imports: [MarkdownDocument],
template: `
@if (document) {
<comark-markdown-document [value]="document" [components]="components" />
}
`,
})
export class DocsComponent implements OnInit {
document: MarkdownDocumentType | null = null
components = {}
async ngOnInit() {
const markdown = await fetch('/api/content').then((r) => r.text())
this.document = await parseMarkdown(markdown)
}
}Renderer props (inputs)
| Input | Type | Default | Description |
|---|---|---|---|
value | MarkdownDocument | — | Required. The parsed document returned by parseMarkdown() |
components | Record<string, Type<any>> | {} | Custom Angular component mappings |
streaming | boolean | false | Enable streaming mode |
caret | boolean | { class: string } | false | Append a blinking caret to the last text node |
data | Record<string, unknown> | {} | Runtime values referenced from markdown via :prop="data.path" |
transformNode | NodeTransform | NodeTransform[] | undefined | Change the tag or props of element nodes right before they render |
Component bindings
Comark automatically bridges the gap between Comark syntax and your Angular component's interface.
Prop binding
Attributes in Comark syntax are passed as @Input() values to your component. Use the : prefix to pass typed values:
| Markdown | Angular Input value |
|---|---|
{type="warning"} | "warning" (string) |
{:count="5"} | 5 (number) |
{:active="true"} | true (boolean) |
{:config='{"key":"val"}'} | { key: 'val' } (object) |
Content projection
Default content in Comark maps to Angular's content projection (<ng-content />):
@Component({
selector: 'app-card',
standalone: true,
template: `
<div class="card">
<h3>{{ title }}</h3>
<div class="card-body">
<ng-content />
</div>
</div>
`,
})
export class CardComponent {
@Input() title = ''
}::card{title="My Card"}
Default content goes here.
::Overriding HTML elements
Use the Prose prefix to override how native HTML elements render:
Create an override component
import { Component, Input } from '@angular/core'
@Component({
selector: 'prose-h1',
standalone: true,
template: `
<h1 [id]="id" class="custom-heading">
<ng-content />
</h1>
`,
})
export class ProseH1Component {
@Input() id?: string
}Map it via the components input
@Component({
template: `<comark-markdown [value]="content" [components]="components" />`,
})
export class AppComponent {
components = { ProseH1: ProseH1Component }
}Resolution order
Components are resolved in this order:
Prose{PascalTag}: for example,ProseH1forh1{PascalTag}: for example,Alertforalert{tag}: for example,alert
If no custom component matches, the tag renders as a native HTML element.
Streaming
Enable real-time rendering as content arrives, ideal for AI chat interfaces and live previews.
Set streaming to true while content is being received, then false when done:
import { Component } from '@angular/core'
import { Markdown } from '@comark/angular'
@Component({
selector: 'app-ai-chat',
standalone: true,
imports: [Markdown],
template: `
<comark-markdown
[value]="content"
[streaming]="isStreaming"
[caret]="true"
/>
`,
})
export class AiChatComponent {
content = ''
isStreaming = false
async askAI(prompt: string) {
this.content = ''
this.isStreaming = true
const response = await fetch('/api/chat', {
method: 'POST',
body: JSON.stringify({ prompt }),
})
const reader = response.body!.getReader()
const decoder = new TextDecoder()
while (true) {
const { done, value } = await reader.read()
if (done) break
this.content += decoder.decode(value, { stream: true })
}
this.isStreaming = false
}
}autoClose is enabled by default: incomplete syntax like **bold text is automatically closed on every parse. Disable with [options]="{ autoClose: false }".Caret
The caret input appends a blinking cursor to the last text node while streaming is true:
<!-- Default caret -->
<comark-markdown [value]="content" [streaming]="isStreaming" [caret]="true" />
<!-- Custom caret class -->
<comark-markdown [value]="content" [streaming]="isStreaming" [caret]="{ class: 'my-caret' }" />.my-caret {
display: inline-block;
width: 2px;
height: 1em;
background: currentColor;
animation: blink 1s step-end infinite;
vertical-align: text-bottom;
}
@keyframes blink {
50% { opacity: 0; }
}TypeScript support
Use ComarkPlugin from comark to type plugin arrays, and ElementNode to type the __node input in components that override HTML elements:
import { Component, Input, Type } from '@angular/core'
import { Markdown } from '@comark/angular'
import type { ComarkPlugin } from 'comark'
@Component({
selector: 'app-comark-wrapper',
standalone: true,
imports: [Markdown],
template: `<comark-markdown [value]="content" [components]="components" [plugins]="plugins" />`,
})
export class ComarkWrapperComponent {
@Input() content = ''
@Input() components: Record<string, Type<any>> = {}
@Input() plugins: ComarkPlugin[] = []
}import { Component, Input } from '@angular/core'
import type { ElementNode } from 'comark'
@Component({
selector: 'app-heading',
standalone: true,
template: `
<h2 [id]="id" class="heading">
<ng-content />
</h2>
`,
})
export class HeadingComponent {
@Input() __node?: ElementNode
@Input() id?: string
}Pre-configured components
Use defineMarkdownComponent and defineMarkdownDocumentComponent to create pre-configured wrappers with default plugins, components, and styling baked in, so there is no need to repeat the same config on every instance.
defineMarkdownComponent
import { defineMarkdownComponent } from '@comark/angular'
import shiki from '@comark/angular/plugins/shiki'
import math, { Math } from '@comark/angular/plugins/math'
import mermaid, { Mermaid } from '@comark/angular/plugins/mermaid'
export const DocsMarkdown = defineMarkdownComponent({
plugins: [shiki(), math(), mermaid()],
components: { Math, Mermaid },
class: 'prose dark:prose-invert',
})Then use it like any other component. The returned component always uses the comark-markdown-defined selector:
import { Component } from '@angular/core'
import { DocsMarkdown } from './components/docs-markdown'
@Component({
selector: 'app-page',
standalone: true,
imports: [DocsMarkdown],
template: `<comark-markdown-defined [value]="content" />`,
})
export class PageComponent {
content = '# Hello\n\n$E = mc^2$'
}Instance-level [plugins] and [components] inputs are merged with (and override) the config-level defaults. Both defineMarkdownComponent and defineMarkdownDocumentComponent also accept transformNode, which runs before the instance's [transformNode] input.
defineMarkdownDocumentComponent
Same idea, but for the low-level renderer. The returned component always uses the comark-markdown-document-defined selector:
import { defineMarkdownDocumentComponent } from '@comark/angular'
import { Math } from '@comark/angular/plugins/math'
export const DocsMarkdownDocument = defineMarkdownDocumentComponent({
components: { Math },
class: 'prose',
})