# 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.

Source: https://ngwr.dev/reference/components/markdown  
Kind: Component, Standalone, SSR-safe

## Installation

```angular-ts
import { WrMarkdown } from 'ngwr/markdown';

@Component({ imports: [WrMarkdown] })
export class MyComponent {
  protected readonly doc = signal('# hello');
}
```

```scss
// 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.

_Also shown on the page: hostile.md._

```angular-html
<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.

_Also shown on the page: release-notes.md._

```angular-html
<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.

```markdown
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.

_Also shown on the page: TS._

```angular-html
<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.

_Also shown on the page: answer.md._

```angular-html
<wr-markdown [value]="doc" [copyable]="true" />
```

```typescript
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'],
  })));
};
```

```typescript
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.

```angular-html
<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:…`.

_Also shown on the page: links.md._

```angular-html
<wr-markdown linkTarget="_blank" [value]="doc" />
```

```angular-html
<!-- ## 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.

```markdown
- [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.

```markdown
| 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.

```typescript
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.

```typescript
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

| Name | Description | Type | Default |
| --- | --- | --- | --- |
| `value` | The markdown source. | `string` | `''` |
| `streaming` | The 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. | `boolean` | `false` |
| `copyable` | Show 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, BooleanInput` | `null` |
| `linkTarget` | Where 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' \| null` | `null` |
| `headingIds` | Put a slugged `id` on every heading, so the document can be linked into. | `boolean` | `true` |
| `headingIdPrefix` | Namespace 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-'` |
| `copyLabel` | Accessible name of the copy button. Falls back to `markdown.copy`, then `'Copy code'`. | `string \| null` | `null` |
| `copiedLabel` | Announced after a successful copy. Falls back to `markdown.copied`, then `'Copied'`. | `string \| null` | `null` |
| `taskDoneLabel` | Read out for a checked task item. Falls back to `markdown.taskDone`, then `'Done:'`. | `string \| null` | `null` |
| `taskTodoLabel` | Read out for an unchecked task item. Falls back to `markdown.taskTodo`, then `'To do:'`. | `string \| null` | `null` |

## Highlighter contract

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

| Name | Description | Type | Default |
| --- | --- | --- | --- |
| `WrMarkdownHighlighter` | The 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<…>` | `—` |
| `WrHighlightLine` | One line of code, left to right. A `readonly WrHighlightSpan[]`. | `readonly WrHighlightSpan[]` | `—` |
| `text`required | Text of the span. Rendered as a text node. | `string` | — |
| `color` | Any CSS colour, bound as `[style.color]`. An omitted span inherits the colour of the code block. | `string` | `—` |
| `fontStyle` | Bound as the matching style property. | `'italic' \| 'bold' \| 'underline'` | `—` |

## See also

- [CSP](https://ngwr.dev/guides/csp) — Why nothing here needs unsafe-inline or a trusted-HTML bypass.
- [[wrTypography]](https://ngwr.dev/reference/directives/typography) — Prose styling for markup an app writes itself.
- [Keyboard](https://ngwr.dev/reference/components/keyboard) — Keycaps for shortcuts, where a code span would under-read.
