GeezerVisonThe World Wide Web, restored to 800 pixels of glory. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Home|About|Gaming cabinet|Archives|How it works|Starter sites | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
GeezerVison is showing comark.dev — Angular Markdown Component with Streaming (v17+) Rendering Angular Markdown Component with Streaming (v17+)Render Markdown in Angular 17+ with standalone components, custom components, plugins, and streaming for AI output. The Requirements
@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
|
| 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 |
optionsSee ParserOptions for available options.
@Component({
template: `<comark-markdown [value]="content" [options]="{ autoUnwrap: true, autoClose: true }" />`,
})pluginsSee 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 }
}componentsUse this input to map custom Angular components to Comark elements and use them in your markdown.
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'
}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 }
}::alert{type="warning"}
This is a warning message!
::dataExpose 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"}!`
}transformNodeChange 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.
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)
}
}| 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 |
Comark automatically bridges the gap between Comark syntax and your Angular component's interface.
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) |
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.
::Use the Prose prefix to override how native HTML elements render:
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
}components input@Component({
template: `<comark-markdown [value]="content" [components]="components" />`,
})
export class AppComponent {
components = { ProseH1: ProseH1Component }
}Components are resolved in this order:
Prose{PascalTag}: for example, ProseH1 for h1{PascalTag}: for example, Alert for alert{tag}: for example, alertIf no custom component matches, the tag renders as a native HTML element.
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 }".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; }
}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
}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.
defineMarkdownComponentimport { 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.
defineMarkdownDocumentComponentSame 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',
})