Streaming Markdown
FreeSafe Markdown renderer built for token-by-token output. Renders a parsed token model through Angular bindings — no innerHTML and no bypassSecurityTrust, so untrusted content can never become markup. Headings, lists (incl. tasks), tables with alignment, blockquotes and inline emphasis; fenced code delegates to sv-code-block.
Complexity tier: Interactive The primitive bar, plus declared async states, a keyboard model, focus restoration and undo for destructive actions.
import { SvStreamingMarkdownComponent } from '@styloviz/streaming-markdown';Demo & usage
Basic usage
Usage example — open the gallery for the full interactive demo.
An interactive demo for this component lives in the gallery.
Open live demoMore examples
Streaming buffer
The parser is pure, so re-running it on a growing buffer is correct and cheap.
<sv-streaming-markdown [content]="buffer()" [streaming]="!done()" />
Fenced code with line numbers
Fenced blocks render through sv-code-block — highlighting, filename header and copy come for free.
<sv-streaming-markdown [content]="md" [codeLineNumbers]="true" codeMaxHeight="20rem" />
Intercepting links
linkClick fires with the resolved href — useful for routing citations in-app.
<sv-streaming-markdown [content]="md" (linkClick)="router.navigateByUrl($event.href)" />
When to use
- Rendering an LLM response as it streams in, token by token.
- Displaying user- or model-authored Markdown where the source is not trusted.
- Any place a chat message, changelog entry or docs excerpt is stored as Markdown.
Async states
None — this component has nothing to load, so it has no loading, empty or error state to drive.
Inputs
| Name | Type | Default | Description |
|---|---|---|---|
| content | string | '' | Markdown source. Safe to update on every streamed token. |
| streaming | boolean | false | Render a blinking caret after the last block, for in-flight responses. |
| density | MarkdownDensity | 'md' | Text density. |
| headingOffset | number | 0 | Added to every heading level, so `#` becomes `<h3>` at an offset of 2. Use it to keep document headings from competing with the surrounding page. |
| codeLineNumbers | boolean | false | Show line numbers in fenced code blocks. |
| codeMaxHeight | string | '20rem' | Max height of a fenced code block, as a CSS length. |
| ariaLabel | string | '' | Accessible label for the rendered region. |
| completionMessage | string | 'Response complete' | What a screen reader is told when a streamed response finishes. A LIVE REGION UPDATED PER TOKEN IS UNUSABLE, which is why this component has never had one — but the silence had its own cost: a screen-reader user got no signal at all that generation had finished, and had to poll the region by hand to find out. The answer is not a quieter live region, it is a DIFFERENT event: `aria-busy` while the text is arriving, and one announcement at the moment it stops. Set to `''` to announce nothing at all. |
Outputs
| Event | Payload type | Description |
|---|---|---|
| linkClick | MarkdownLinkClick | Emits when a rendered link is activated. Call `preventDefault` upstream to intercept. |
| codeCopied | string | Emits the copied text when a fenced code block is copied. |
Customization & theming
- Zero third-party dependencies: a small rule-based parser emits a token model that the template renders through Angular bindings — no `innerHTML`, no `bypassSecurityTrust*`.
- Streaming-safe: `parseMarkdown()` is pure, so it can re-run on every token. Unmatched delimiters degrade to literal text and an unterminated fence yields `complete: false` rather than swallowing the document.
- Supports ATX headings, paragraphs, fenced code, blockquotes, ordered/unordered lists with `- [x]` tasks and indent depth, pipe tables with alignment, and thematic breaks.
- Inline `**strong**`, `*em*`, `` `code` ``, `~~strike~~` and `[text](url)` nest arbitrarily — the template renders the span tree recursively.
- Link URLs are scheme-checked at parse time; `javascript:`, `data:` and `vbscript:` are rejected and render as plain text. Accepted links get `rel="noopener noreferrer"`.
- Fenced code delegates to `sv-code-block`, so highlighting, the filename header and copy-to-clipboard are inherited; tune it with `codeLineNumbers` and `codeMaxHeight`.
- `headingOffset` demotes every heading; `density` switches between two prose scales.
- The exported `parseMarkdown()`, `parseInline()` and `safeHref()` helpers are public, so the parser can be reused outside the component.
Best practices
- Do: Set `headingOffset` inside a chat bubble or card so document headings do not compete with the page hierarchy.
- Do: Bind `streaming` while a response is in flight — it renders the caret and keeps an unterminated code fence visible.
- Don't: Do not pre-sanitise or pre-escape the source. The renderer never treats it as markup, and escaping first would show the escapes.
- Don't: Do not reach for `innerHTML` to add an unsupported construct — extend the parser so the token model stays the only path to the DOM.
- Tip: Handle `linkClick` to route citations internally instead of opening a new tab.
Accessibility
- Renders real semantic elements — `<h1>`–`<h6>`, `<ul>`, `<blockquote>`, `<table>` with `<th scope="col">` — so screen readers get the document structure, not a wall of text.
- Task-list markers are `role="img"` with a Done / Not done label; decorative bullets and the streaming caret are `aria-hidden`.
- Links carry `rel="noopener noreferrer"` and a visible `focus-visible` ring.
- Set `ariaLabel` to expose the rendered output as a named `role="region"` when it stands alone.
Feedback on Streaming Markdown
Spotted a gap in this page, or something that didn't work as described? Comments are GitHub Discussions in the public styloviz/community repo, so posting needs a GitHub account.