ComponentStandaloneSSR-safe

Markdown

Renders markdown as DOM, not as HTML. Built for streamed model output, and useful for any markdown an app does not write itself — comments, release notes, a README. Raw HTML in the source is escaped rather than rendered, and nothing in the pipeline is ever parsed as HTML.

Installation

import { WrMarkdown } from 'ngwr/markdown';

@Component({ imports: [WrMarkdown] })
export class MyComponent {
  protected readonly doc = signal('# hello');
}
// Global styles — the component is ViewEncapsulation.None.
@use 'ngwr/markdown';

Security

The input is untrusted by construction, so the component takes the one decision that makes it safe by default: there is no HTML anywhere in the pipeline. The parser produces a node tree, the template renders it through ordinary bindings, every text node is a text node, and every href passes a scheme check before Angular's own URL sanitizer sees it. No [innerHTML], no bypassSecurityTrustHtml, nothing for a <img onerror> to ride in on. Raw HTML in the source is therefore escaped, never rendered — <div> in, <div> on screen, as text. Everything in the document below is hostile; all of it renders as prose.

Inline HTML is text: <img src=x onerror="alert('xss')">.

<div style="color: red">So is a block-level tag.</div>

<script>alert('xss')</script>

A javascript: link keeps its label and loses its href. An ordinary link is still a link.

<wr-markdown [value]="hostile" />

Basic usage

One input. [value] is the markdown source; the component re-parses it whenever it changes and renders the result into its own host — ViewEncapsulation.None, so the .wr-markdown__* classes are yours to style.

Release notes

v11 ships <wr-markdown>. Markdown in, DOM out — with innerHTML nowhere in the pipeline.

  • headings, paragraphs, fenced code, quotes, rules
  • bullet / ordered / task lists, GFM tables
  • inline code, strong, emphasis, strikethrough, links, images

A renderer whose input is model output has no business parsing HTML.

Autolinks work bare, too: https://ngwr.dev

<wr-markdown [value]="doc" />

The supported subset

Stated rather than discovered. Blocks: ATX headings, paragraphs, fenced code, blockquotes, bullet / ordered / task lists (nested), GFM tables, thematic breaks. Inline: code, strong, emphasis, strikethrough, links, images, autolinks (bracketed and bare), hard breaks, backslash escapes.

Four things are deliberately absent, each for a reason, and the demo below shows all four behaving as documented:

  • Raw HTML — escaped, never rendered. A renderer fed model output or user comments cannot pass <img onerror> through and call it a feature.
  • Indented (four-space) code blocks — in a document that also has nested lists, indentation is structural, and the ambiguity resolves against the author almost every time: a wrapped list continuation becomes a code block. Fences are unambiguous, and fences are what generators emit.
  • Setext headings (=== / --- under a line) — a line of dashes is a thematic break here, always. The dual meaning is a well-known trap and the underline form is essentially unused in generated markdown.
  • Reference links, footnotes, definition lists — they need a second pass over a document that, mid-stream, is not all there yet.

Four spaces is not a code block:

this is still a paragraph

A line of dashes under text is a rule, not a heading


A [reference link][1] needs a second pass over the whole document, so it stays text.

[1]: https://ngwr.dev

And snake_case_name in prose survives: snake_case_name.

Four spaces is not a code block:

    this is still a paragraph

A line of dashes under text is a rule, not a heading
---

A [reference link][1] needs a second pass over the whole document, so it stays text.

[1]: https://ngwr.dev

And `snake_case_name` in prose survives: snake_case_name.

Streaming

[streaming] says the source is a PREFIX of a longer document, and that changes what an unmatched marker means. An open fence renders as code instead of waiting for its closer; a half-typed **bold renders bold instead of flashing asterisks that vanish one chunk later; a trailing [label](htt is withheld until it resolves. The host also gains .wr-markdown--streaming, which paints a caret after the last block — CSS, so it costs no node. The demo types the document below in uneven chunks; watch the fence, the emphasis and the caret. prefers-reduced-motion is honoured: with it set, the document arrives whole and the caret never appears.

Replay
<wr-markdown [value]="streamed()" [streaming]="streaming()" [copyable]="true" />

Dropping [streaming] at the end is not cosmetic: the same text is re-parsed as a finished document, which is what closes the fence, retires the caret and lets the copy button appear.

Code blocks & syntax highlighting

Fenced code renders as a <pre><code> with the info string reflected as data-language. Colour is opt-in: provideWrMarkdownHighlighter() takes a function returning coloured SPANS — not an HTML string — so a highlighted block inside an untrusted document is still never parsed as HTML. Every other Angular markdown renderer takes highlighted HTML and hands it to [innerHTML] through bypassSecurityTrustHtml, which puts a hole in the one component whose entire input is untrusted. The highlighter may be async and may return null for a language it does not know; both render the block as plain text. This site provides a shiki adapter — the blocks below are coloured by it.

Install it:

pnpm add ngwr

Render a document:

import { WrMarkdown } from 'ngwr/markdown';

@Component({ imports: [WrMarkdown], template: '<wr-markdown [value]="doc()" />' })
export class Answer {
  readonly doc = signal('# hello');
}

A fence with no info string has no grammar to pick, so it stays plain:

no language, no colour
<wr-markdown [value]="doc" [copyable]="true" />
import type { WrHighlightSpan, WrMarkdownHighlighter } from 'ngwr/markdown';
import type { ThemedToken } from 'shiki/core';

/** Fence info strings this app can colour, mapped to a loaded grammar. */
const LANGUAGES: Readonly<Record<string, string>> = { ts: 'typescript', bash: 'bash', /* … */ };

const THEMES = { light: 'github-light-high-contrast', dark: 'github-dark-high-contrast' } as const;

export const shikiMarkdownHighlighter: WrMarkdownHighlighter = async (code, language) => {
  const lang = language ? LANGUAGES[language.toLowerCase()] : undefined;
  // Unknown grammar, or the prerender pass in Node: plain text either way.
  if (!lang || typeof window === 'undefined') return null;

  const highlighter = await getHighlighter();
  const { tokens } = highlighter.codeToTokens(code, {
    lang,
    themes: THEMES,
    // One colour value that resolves per theme, so a cached span stays correct
    // when the theme flips — the contract has no theme dimension to re-ask on.
    defaultColor: 'light-dark()',
    colorsRendering: 'none',
  });

  return tokens.map(line => line.map((token): WrHighlightSpan => ({
    text: token.content,
    color: token.htmlStyle?.['color'],
  })));
};
import { provideWrMarkdownHighlighter } from 'ngwr/markdown';

bootstrapApplication(App, {
  providers: [provideWrMarkdownHighlighter(shikiMarkdownHighlighter)],
});

Two notes from writing that adapter. It answers null when there is no window: the showcase prerenders every route in Node, where nothing is waiting for colour and the promise would resolve after the HTML was already written — so the static page ships plain code and gains colour on hydration. And the colour it returns is a single light-dark(…) value rather than a hex per theme, because the contract has no theme dimension and the library caches an answer under (language, code); letting the browser resolve the pair against color-scheme keeps one cached span correct in both themes.

Copy button

copyable puts a copy button on code blocks — and only on CLOSED ones. Mid-stream the code is not all there, and a button that copies half a snippet is worse than no button, so it appears when the closing fence does. It is revealed on hover of the block and always for keyboard focus (an opacity-gated control that never appears without a pointer is unreachable), and it is always visible where hover does not exist. The accessible name comes from copyLabel / copiedLabel, both routed through the ngwr/i18n markdown.* catalog.

<wr-markdown [value]="doc" [copyable]="true" copyLabel="Copy snippet" copiedLabel="Copied!" />

Links & headings

linkTarget decides where a rendered link opens. _blank brings rel="noopener noreferrer" with it, and that is not optional — a target without it hands the opener to a page whose URL came from the document being rendered. The rel is bound rather than hard-coded, so a same-tab link does not carry a pointless one. Links whose scheme is not http, https, mailto, tel or ftp are refused outright and render as their own label text, which is more honest than a live anchor pointing at unsafe:javascript:….

A labelled link and a bare autolink, https://github.com/thekhegay/ngwr, take the same target.

Relative links — the tables section — are left alone.

<wr-markdown linkTarget="_blank" [value]="doc" />
<!-- ## Getting started  ->  <h2 id="user-content-getting-started">

     The namespace is not decoration. Without it a document containing
     "# Search" renders id="search" and takes it from the page around it —
     a <label for="search"> silently stops labelling its input, and nothing
     reports an error. GitHub prefixes untrusted markdown the same way, and
     a bare #fragment link inside the document is rewritten to match, so
     in-document anchors keep working. -->
<wr-markdown [value]="doc" />

<!-- A document you wrote yourself, where clean anchors are worth more. -->
<wr-markdown [value]="doc" headingIdPrefix="" />

<!-- Off entirely, e.g. when several documents share one page. -->
<wr-markdown [value]="doc" [headingIds]="false" />

Task lists

- [x] / - [ ] items render with a checkbox glyph and lose their bullet. The glyph is presentational, with the state as screen-reader text beside it: a real <input type="checkbox"> here would be an unlabelled form control — a serious axe violation — and making it operable would promise an interaction a renderer cannot honour. The announced strings are taskDoneLabel / taskTodoLabel, and default through the i18n catalog.

  • Done:escape raw HTML
  • Done:render partial documents
  • To do:parse HTML
  • an ordinary item, in the same list
    • To do:a task nested one level down
- [x] escape raw HTML
- [x] render partial documents
- [ ] parse HTML
- an ordinary item, in the same list
  - [ ] a task nested one level down

Tables

GFM tables, with the delimiter row driving per-column alignment. A short row gets empty cells and a long one is truncated to the header width, so a ragged table cannot shift the columns. Each table sits in its own horizontal scroller — a wide table must not push the page sideways.

Entry pointStreamsNotes
ngwr/markdownyes[streaming]
ngwr/typographynoprose styling
ngwr/codedoes not exist
| Entry point | Streams | Notes |
| :--- | :---: | ---: |
| `ngwr/markdown` | yes | `[streaming]` |
| `ngwr/typography` | no | prose styling |
| `ngwr/code` | — | does not exist |

App-wide defaults

Two keys, both app-wide policy rather than per-instance taste — which is the test a config key has to pass. A bound value always wins, and a bound false beats a configured true, so nothing has to be re-stated to escape the default.

import { provideWrConfig } from 'ngwr/config';

// App-wide policy rather than per-instance taste: whether rendered links leave
// the tab, and whether code blocks carry a copy button, are decided once.
provideWrConfig({
  markdown: { linkTarget: '_blank', copyable: true },
});

// <wr-markdown [value]="doc" />                     -> copyable, links open in a new tab
// <wr-markdown [value]="doc" [copyable]="false" />  -> the binding wins; `false` is a value

Parsing without rendering

The parser is exported. parseMarkdown() returns the same block tree the component renders, parseInlines() handles one line of inline markdown, plainText() flattens an inline tree, and safeMarkdownUrl() is the URL check the renderer itself applies. Useful for a table of contents, a search index or a plain-text summary.

import { parseMarkdown, parseInlines, plainText, safeMarkdownUrl } from 'ngwr/markdown';

// The same tree the component renders — useful for a summary, a search index,
// or a table of contents.
const blocks = parseMarkdown(source, { streaming: false });
const toc = blocks
  .filter(block => block.kind === 'heading')
  .map(heading => ({ id: heading.id, level: heading.level, text: plainText(heading.inlines) }));

// One line of inline markdown, for a label or a chip.
const inlines = parseInlines('a **bold** label');

// The URL check the renderer itself uses.
safeMarkdownUrl('javascript:alert(1)', 'link'); // null
safeMarkdownUrl('/docs', 'link'); // '/docs'

API

NameDescriptionTypeDefault
valueThe markdown source.string''
streamingThe source is a prefix of a longer document. Turns on partial-safe parsing — an open fence renders as code, a half-typed **bold renders as bold rather than as asterisks — and marks the host with .wr-markdown--streaming, which paints a caret after the last block.booleanfalse
copyableShow a copy button on finished code blocks. Falls back to markdown.copyable from provideWrConfig(), then false. Only CLOSED blocks get one: mid-stream the code is not all there, and a button that copies half a snippet is worse than no button.boolean | null, BooleanInputnull
linkTargetWhere links open. _blank also sets rel="noopener noreferrer", which is not optional — a target without it hands the opener to a page whose URL came from the document being rendered. Falls back to markdown.linkTarget from provideWrConfig(), then no target.'_blank' | '_self' | nullnull
headingIdsPut a slugged id on every heading, so the document can be linked into.booleantrue
headingIdPrefixNamespace for the generated heading ids, and for the in-document #fragment links that point at them. Defaults to 'user-content-', which is what GitHub emits for untrusted markdown, and for the same reason: without a namespace a document that happens to contain # Search renders id="search" and steals it from the page around it. That is not cosmetic — a <label for="search"> silently stops labelling its input, and getElementById starts answering with a heading. Anchors written INSIDE the document keep working, because a bare #fragment href gets the same prefix. Set it to '' for a document you author yourself and want clean anchors for.string'user-content-'
copyLabelAccessible name of the copy button. Falls back to markdown.copy, then 'Copy code'.string | nullnull
copiedLabelAnnounced after a successful copy. Falls back to markdown.copied, then 'Copied'.string | nullnull
taskDoneLabelRead out for a checked task item. Falls back to markdown.taskDone, then 'Done:'.string | nullnull
taskTodoLabelRead out for an unchecked task item. Falls back to markdown.taskTodo, then 'To do:'.string | nullnull

Highlighter contract

What provideWrMarkdownHighlighter() takes, and the shape it returns.

NameDescriptionTypeDefault
WrMarkdownHighlighterThe provided function: (code, language) => spans. May be async, and may return null for a language it does not handle — the block then renders as plain text, which is also what a prerendered page ships.(code: string, language: string | null) => readonly WrHighlightLine[] | null | Promise<…>
WrHighlightLineOne line of code, left to right. A readonly WrHighlightSpan[].readonly WrHighlightSpan[]
textrequiredText of the span. Rendered as a text node.string
colorAny CSS colour, bound as [style.color]. An omitted span inherits the colour of the code block.string
fontStyleBound as the matching style property.'italic' | 'bold' | 'underline'

See also