@fsegurai/ngx-markdown is an Angular library that combines...
- Marked to parse Markdown to HTML
- Prism.js for language syntax highlight
- Emoji-Toolkit for emoji support
- KaTeX for math expression rendering
- Mermaid for diagrams and charts visualization
- Clipboard.js to copy code blocks to the clipboard
- Installation
- Configuration
- Usage
- Plugins
- Renderer
- Re-renderer Markdown
- Syntax highlight
- SSR / prerendering
- Demo application
- License
To add @fsegurai/ngx-markdown along with the required marked library to your package.json use the following commands.
npm install @fsegurai/ngx-markdown marked@^18.0.0 --saveπ Requirements β
@fsegurai/ngx-markdown22.x requires Angular^22(TypeScript>=6.0 <6.1, Node.js^22.22.3 || ^24.15.0 || >=26) andmarked ^18. The plugin packages (prismjs,emoji-toolkit,katex,mermaid,clipboard) are optional peer dependencies: they are not installed with the library, so install each one explicitly (see the sections below) only when you use it. The optional plugins are tested withkatex ^0.19,mermaid ^12andemoji-toolkit ^11. Mermaid 12 requires Node.js β₯ 22.12 for build tooling and a modern browser (e.g. Safari 17.4+). Thedistpaths used below for KaTeX, Mermaid and Emoji-Toolkit are unchanged.
Browser support β the browsers of Angular 22's default browserslist, "Baseline widely available" (on 2026-05-07): Chrome and Edge 119+, Firefox 119+, Safari and iOS Safari 17+, and the current Chrome and Firefox for Android. Newer browser features are used when present, with these fallbacks:
| Feature | Where it is missing |
|---|---|
scheduler.yield() (yields of the time-sliced parse and the chunked plugins) |
A MessageChannel message, else setTimeout(0) |
requestAnimationFrame (progressive insertion) |
The same yields, without frame alignment (also in a hidden tab) |
content-visibility: auto (contentVisibility) |
Ignored: every block is rendered, as without the option |
IntersectionObserver (Mermaid lazy) |
Every diagram is drawn at once |
scrollMargin of IntersectionObserver (Mermaid lazy in a scrolling element) |
A diagram inside a scrolling element is drawn once it is visible there |
HTMLDialogElement.showModal() (lightbox) |
The dialog opens with its open attribute (not modal) |
Static ClipboardJS.copy (older ClipboardJS releases) |
One ClipboardJS instance per button, created on its first click |
π Syntax highlight is optional, skip this step if you are not planning to use it
To add Prism.js library to your package.json use the following command.
npm install prismjs@^1.30.0 --saveTo activate Prism.js syntax highlight, you will need to include...
- prism.js core library -
node_modules/prismjs/prism.jsfile - a highlight CSS theme - from
node_modules/prismjs/themesdirectory - desired code language syntax files - from
node_modules/prismjs/componentsdirectory
Additional themes can be found by browsing the web such as Prism-Themes or Mokokai for example.
If you are using Angular CLI you can follow the angular.json example below...
Then provide the Prism plugin, which highlights the code blocks of every <markdown> (see
Clipboard and Prism plugins). Without it, nothing is highlighted at render time.
provideMarkdown({ plugins: [withPrism()] })"styles": [
"styles.css",
+ "node_modules/prismjs/themes/prism-okaidia.css"
],
"scripts": [
+ "node_modules/prismjs/prism.js",
+ "node_modules/prismjs/components/prism-csharp.min.js", # c-sharp language syntax
+ "node_modules/prismjs/components/prism-css.min.js" # css language syntax
]To use the line numbers plugin that shows line numbers in code blocks, in addition to Prism.js configuration files, you will need to include the following files from prismjs/plugins/line-numbers directory to your application:
- CSS styling for line numbers -
prism-line-numbers.css - line numbers plugin script -
prism-line-numbers.js
If you are using Angular CLI you can follow the angular.json example below...
"styles": [
"src/styles.css",
"node_modules/prismjs/themes/prism-okaidia.css",
+ "node_modules/prismjs/plugins/line-numbers/prism-line-numbers.css"
],
"scripts": [
"node_modules/prismjs/prism.js",
"node_modules/prismjs/components/prism-csharp.min.js",
"node_modules/prismjs/components/prism-css.min.js",
+ "node_modules/prismjs/plugins/line-numbers/prism-line-numbers.js"
]Using markdown component and/or directive, you will be able to use the lineNumbers property to activate the plugin. The property can be used in combination with either data for variable binding, src for remote content or using transclusion for static Markdown.
Additionally, you can use start input property to specify the offset number for the first display line.
<markdown
[lineNumbers]="true"
[start]="5"
[src]="path/to/file.js">
</markdown>To use the line highlight plugin that highlights specific lines and/or line ranges in code blocks, in addition to Prism.js configuration files, you will need to include the following files from prismjs/plugins/line-highlight directory to your application:
- CSS styling for line highlight -
prism-line-highlight.css - line highlight plugin script -
prism-line-highlight.js
If you are using Angular CLI you can follow the angular.json example below...
"styles": [
"src/styles.css",
"node_modules/prismjs/themes/prism-okaidia.css",
+ "node_modules/prismjs/plugins/line-highlight/prism-line-highlight.css"
],
"scripts": [
"node_modules/prismjs/prism.js",
"node_modules/prismjs/components/prism-csharp.min.js",
"node_modules/prismjs/components/prism-css.min.js",
+ "node_modules/prismjs/plugins/line-highlight/prism-line-highlight.js"
]Using markdown component and/or directive, you will be able to use the lineHighlight property to activate the plugin. The property can be used in combination with either data for variable binding, src for remote content or using transclusion for static Markdown.
Use line input property to specify the line(s) to highlight and optionally there is a lineOffset property to specify the starting line of code your snippet represents.
line takes comma-separated line numbers or ranges, as a string or a string array ('6, 10-16', ['6', '10-16']), and lineOffset (like the line numbers start) takes an integer, 0 included. Invalid values are ignored, with a one-time [ngx-markdown] warning.
<markdown
[lineHighlight]="true"
[line]="'6, 10-16'"
[lineOffset]="5"
[src]="path/to/file.js">
</markdown>To use the command line plugin that displays a command line with a prompt and, optionally, the output/response from the commands, you will need to include the following files from prismjs/plugins/command-line directory to your application:
- CSS styling for command line -
prism-command-line.css - command line plugin script -
prism-command-line.js
If you are using Angular CLI you can follow the angular.json example below...
"styles": [
"src/styles.css",
"node_modules/prismjs/themes/prism-okaidia.css",
+ "node_modules/prismjs/plugins/command-line/prism-command-line.css"
],
"scripts": [
"node_modules/prismjs/prism.js",
"node_modules/prismjs/components/prism-csharp.min.js",
"node_modules/prismjs/components/prism-css.min.js",
+ "node_modules/prismjs/plugins/command-line/prism-command-line.js"
]Using markdown component and/or directive, you will be able to use the commandLine property to activate the plugin. The property can be used in combination with either data for variable binding, src for remote content or using transclusion for static Markdown.
For a server command line, specify the user and host names using the user and host input properties. The resulting prompt displays a # for the root user and $ for all other users. For any other command line, such as a Windows prompt, you may specify the entire prompt using the prompt input property.
You may also specify the lines to be presented as output (no prompt and no highlighting) through the output property in the following simple format:
- A single number refers to the line with that number
- Ranges are denoted by two numbers, separated with a hyphen (-)
- Commas separate multiple line numbers or ranges
- Whitespace is allowed anywhere and will be stripped off
<markdown
[commandLine]="true"
[user]="'chris'"
[host]="'remotehost'"
[output]="'2, 4-8'"
[src]="'path/to/file.bash'">
</markdown>Optionally, to automatically present some lines as output without providing the line numbers, you can prefix those lines with any string and specify the prefix using the filterOutput input property. For example, [filterOutput]="'(out)'" will treat lines beginning with (out) as output and remove the prefix.
<markdown
[commandLine]="true"
[prompt]="'PS C:\Users\Chris>'"
[filterOutput]="'(out)'">
```PowerShell
Get-Date
(out)
(out)Sunday, November 7, 2021 8:19:21 PM
(out)
`β``
</markdown>π Emoji support is optional, skip this step if you are not planning to use it
To add Emoji-Toolkit library to your package.json use the following command.
npm install emoji-toolkit@^11.0.0 --saveTo activate Emoji-Toolkit for emoji support, you will need to include...
- Emoji-Toolkit library -
node_modules/emoji-toolkit/lib/js/joypixels.min.js
If you are using Angular CLI you can follow the angular.json example below...
"scripts": [
+ "node_modules/emoji-toolkit/lib/js/joypixels.min.js",
]Provide the plugin with provideMarkdown({ plugins: [withEmoji()] }) (see Plugins); without it, [emoji]
does nothing (a dev-mode warning names withEmoji()).
Using markdown component and/or directive, you will be able to use the emoji property to activate Emoji-Toolkit plugin that converts emoji shortnames such as :heart: to native Unicode emojis.
<markdown [emoji]="true">
I :heart: @fsegurai/ngx-markdown
</markdown>Only the text is converted: shortcodes inside code blocks, inline code, raw HTML tags and link URLs are left as written.
The label of a link written as [label](url) or [label][ref] is converted; an autolink (<https://β¦>) or a bare URL
shows its URL, so it is left as written, like its href.
The shortcodes are looked up in Emoji-Toolkit's emojiList, with the same result as joypixels.shortnameToUnicode
(alternate shortnames, skin tones, flags and ZWJ sequences included; unknown shortcodes stay as written). With
joypixels.ascii = true (ASCII smileys such as :)), shortnameToUnicode itself is used.
π You can refer to this Emoji Cheat Sheet for a complete list of shortnames.
π Math rendering is optional, skip this step if you are not planning to use it
To add KaTeX library to your package.json use the following command.
npm install katex@^0.19.0 --saveTo activate KaTeX math rendering, you will need to include...
- KaTeX JavaScript library -
node_modules/katex/dist/katex.min.jsfile (it provides the globalkatex) - KaTeX CSS -
node_modules/katex/dist/katex.min.cssfile
If you are using Angular CLI you can follow the angular.json example below...
"styles": [
"styles.css",
+ "node_modules/katex/dist/katex.min.css"
],
"scripts": [
+ "node_modules/katex/dist/katex.min.js",
]π The KaTeX Auto-Render extension (
katex/dist/contrib/auto-render.min.js) is no longer used: remove it from yourscripts.
Provide the plugin with provideMarkdown({ plugins: [withKatex(options)] }) (see Plugins); without it,
[katex] does nothing (a dev-mode warning names withKatex()).
Using markdown component and/or directive, you will be able to use the katex property to activate KaTeX plugin that renders mathematical expression to HTML.
<markdown
[katex]="true"
[src]="path/to/file.md">
</markdown>The math is rendered with katex.renderToString() while the Markdown is parsed, by a small built-in Marked
extension, so _, * and \ inside math are never turned into emphasis or escapes ($a_1 * b_2$ renders as math).
| Syntax | Renders |
|---|---|
$β¦$ |
inline math |
$$β¦$$ in a line |
display math |
$$, the expression, $$ on 3 lines |
a display math block (a single $ on each line gives an inline-mode block) |
\$ |
a literal dollar |
- The opening
$must start the text or follow whitespace (a space, a tab or a line break) or an opening bracket or quote, and the closing$must be followed by whitespace, punctuation, a closing bracket or quote, or the end of the text, so($x$)renders andcosts $5 and $10stays text. SetnonStandard: trueto also render math without surrounding spaces (a$x$b). - Math in code spans and code blocks is left untouched. Math inside raw HTML blocks (e.g.
<p align="center">$x$</p>) is not rendered, as Marked does not parse Markdown there. - Other delimiters (
\(β¦\),\[β¦\],\begin{equation}β¦) are not supported: write them as$β¦$or$$β¦$$(an environment such as\begin{aligned}β¦\end{aligned}works inside$$β¦$$). - The extension is opt-in per parse: without
katex,$x$stays text, and the globalkatexis not needed.
Optionally, you can use the katexOptions property to specify KaTeX options,
plus nonStandard. Application-wide defaults go in withKatex(options); the katexOptions input is merged over them
and wins. displayMode is set by the delimiters.
import {KatexOptions} from '@fsegurai/ngx-markdown';
public options: KatexOptions = {
throwOnError: false,
errorColor: '#cc0000',
macros: { '\\RR': '\\mathbb{R}' },
};<markdown
[katex]="true"
[katexOptions]="options"
[src]="path/to/file.md">
</markdown>// app.config.ts: defaults for every <markdown [katex]>
provideMarkdown({ katexOptions: { throwOnError: false } })π Follow the official KaTeX options documentation for more details on the available options.
Errors. With throwOnError: true (KaTeX's default), an invalid expression does not fail the whole content: it is
left as its source text and the error is logged with console.error. With throwOnError: false, KaTeX renders it in
errorColor with the error as its tooltip. In the browser, [katex] without the global katex throws
ERROR_KATEX_NOT_LOADED (emitted through the error output).
SSR. katex.renderToString() needs no DOM, so math is rendered on the server too when katex is available there
as a global (e.g. globalThis.katex = katex in server.ts, with import katex from 'katex'). Without it, the
server leaves the math as its source text (with its $ delimiters, _ and * kept), and the browser renders it.
Sanitizing. KaTeX's HTML relies on inline style attributes (the vertical position of sub- and superscripts,
fractions, strut heights), MathML (for screen readers) and inline SVG (radicals, wide accents). Angular's sanitizer
(SecurityContext.HTML, the default) strips all three, so the KaTeX output is inserted after the sanitizer has
run, in place of a placeholder: the rest of the content is sanitized as usual, with any sanitize setting. This is
the same security model as the 21.x post-render step: KaTeX escapes the TeX it renders, so its output only carries
user-controlled links or attributes when trust allows them.
With a custom sanitize function, the placeholders must survive it: allow empty <span> elements with their class
attribute (<span class="ngx-katex-β¦"></span>). The placeholder may be re-serialised (single quotes, extra spaces),
but if the sanitizer removes it, removes its class or changes the class value, that math is dropped and one warning
prefixed with [ngx-markdown] is logged per parse, with the number of dropped expressions. A SecurityContext always
keeps them. DOMPurify's default configuration keeps them too; with an allow-list, include span and class:
import DOMPurify from 'dompurify';
provideMarkdown({
sanitize: (html: string) =>
DOMPurify.sanitize(html, {
ALLOWED_TAGS: ['p', 'a', 'em', 'strong', 'code', 'pre', 'ul', 'ol', 'li', 'span'], // keep `span`
ALLOWED_ATTR: ['href', 'class'], // keep `class` (span[class^="ngx-katex-"])
}),
})
β οΈ KaTeX'strustoption enables commands such as\href,\url,\includegraphics,\htmlClass,\htmlId,\htmlStyleand\htmlData, which add links, images and HTML attributes to the output. The KaTeX output is inserted after the HTML sanitization, so this output is not sanitized: keeptrustset tofalse(the default) for untrusted content, or pass a function that only trusts what you allow, e.g.trust: ({ command, protocol }) => command === '\\href' && protocol === 'https'.
π Diagram support is optional, skip this step if you are not planning to use it
To add Mermaid library to your package.json use the following command.
npm install mermaid@^12.1.0 --saveMermaid can be loaded in two ways: on demand, through a loader function (recommended), or as a global script.
Either way, provide the Mermaid plugin with withMermaid() (see
Mermaid and Mermaid export plugins); without it, [mermaid] does nothing.
Option 1: load Mermaid on demand (loader). Give withMermaid() a function that imports Mermaid. The import
becomes a lazy chunk: only pages that render a diagram download Mermaid, and only the diagram types they use.
provideMarkdown({
plugins: [withMermaid({ loader: () => import('mermaid').then((m) => m.default) })],
}),- The loader is called on the first render that has a diagram to draw, and its instance is reused by every later render
(and every
<markdown>using the sameMarkdownService).() => import('mermaid')works too: when the loader resolves to the module namespace, itsdefaultexport is used. - It is never called on the server (SSR, prerendering): diagrams are only drawn in the browser, as before.
- When the loader fails (e.g. a network error while fetching the chunk), every diagram of that render shows a
.mermaid-errorbox and the render rejects with aMermaidRenderError(theerroroutput of<markdown>), as for any other Mermaid failure. The failed load is not kept: the next render calls the loader again. The load is bounded by the render timeout (renderTimeout), counted once from when the load starts (renders that join a pending load share its remaining time), so a load that never settles fails instead of blocking every later render; with the timeout disabled (0), a stalled load waits indefinitely. - The loader takes precedence over a global
mermaid. It is typedMermaidLoader(() => Promise<MermaidLike>);MermaidLikeis the structural part of the Mermaid API the library uses, so the library itself never importsmermaid, which stays an optional peer. - Mermaid pulls in a few CommonJS packages, so the Angular CLI warns about them. List them in the build options of
angular.jsonto silence the warnings:
"allowedCommonJsDependencies": ["@braintree/sanitize-url", "cytoscape-cose-bilkent", "cytoscape-fcose", "dayjs", "elkjs"]The demo application uses this setup: its pages without diagrams no longer download the 5.5 MB Mermaid script.
Option 2: load Mermaid as a global script. Without a loader, the library uses the global mermaid of the
prebuilt file, node_modules/mermaid/dist/mermaid.min.js, which must be loaded before a diagram is rendered (otherwise
rendering throws ERROR_MERMAID_NOT_LOADED). It is simpler, needs no bundler support for dynamic imports, but every page
downloads all of Mermaid (about 5.5 MB) up front.
If you are using Angular CLI you can follow the angular.json example below...
"scripts": [
+ "node_modules/mermaid/dist/mermaid.min.js",
]As a scripts entry the file is re-bundled on every build, counts toward the initial bundle budget, and makes esbuild
print a harmless Comparison with -0 warning from Mermaid's own code. To avoid that, copy it as an asset under a
versioned path (so browsers fetch the new file after an upgrade) and load it from index.html:
"assets": [
+ {
+ "glob": "mermaid.min.js",
+ "input": "node_modules/mermaid/dist",
+ "output": "mermaid/12.1.0/"
+ }
]<head>
+ <script src="mermaid/12.1.0/mermaid.min.js"></script>
</head>Keep the version in both paths in sync with the installed
mermaidversion.
Using markdown component and/or directive, you will be able to use the mermaid property to activate Mermaid plugin that renders Markdown-inspired text definitions to create and modify diagrams dynamically.
<markdown
[mermaid]="true"
[src]="path/to/file.md">
</markdown>You can provide a global configuration for mermaid configuration options to use across your application with the withMermaid() plugin feature (see Mermaid and Mermaid export plugins):
provideMarkdown({
plugins: [withMermaid({ config: { darkMode: true, look: 'handDrawn' } })],
}),Mermaid 12 changed how diagrams look when nothing is configured:
- the layout engine defaults to ELK (
layout: 'elk') instead of dagre; - most diagrams default to the
redux-colortheme and theneolook.
To restore the classic (Mermaid 11) appearance, pin these options in the config of withMermaid() (or in the
component mermaidOptions):
provideMarkdown({
plugins: [withMermaid({ config: { layout: 'dagre', look: 'classic', theme: 'default' } })],
}),Additionally, you can specify mermaid configuration options on a component directly using mermaidOptions property.
import {MermaidAPI} from '@fsegurai/ngx-markdown';
public options: MermaidAPI.MermaidConfig = {
darkMode: true,
look: 'handDrawn',
...
};<markdown
[mermaid]="true"
[mermaidOptions]="options"
[src]="'path/to/file.md'">
</markdown>mermaidOptions is the one rendering input of <markdown> that is tracked: when its value changes after the content
has been rendered with mermaid enabled, only the Mermaid diagrams are rendered again, from their kept sources. The
Markdown is not parsed again, so highlighted code, clipboard buttons, KaTeX and the scroll position are kept. Bind it to
a signal or a computed derived from your theme:
import { computed, inject } from '@angular/core';
import { MermaidAPI } from '@fsegurai/ngx-markdown';
private readonly themeService = inject(ThemeService); // your app theme, e.g. with an `isDark` signal
readonly mermaidOptions = computed<MermaidAPI.MermaidConfig>(() =>
this.themeService.isDark() ? { theme: 'dark', darkMode: true } : { theme: 'default', darkMode: false },
);<markdown [mermaid]="true" [mermaidOptions]="mermaidOptions()" [src]="'path/to/file.md'" />- The component emits
readyagain once the diagrams are re-rendered, orerrorwith aMermaidRenderErrorwhen one fails (diagrams that showed an error box are rendered again from their source too). - Changes in a row are coalesced: only the latest re-render draws its diagrams and emits; a render it supersedes does not emit.
- A value equal to the last one (compared by content, not by reference) does nothing, and so does a change while the
mermaidinput isfalseor before the first render. A change while new content is still loading is applied to that render instead.
Without the component (e.g. with the markdown pipe and render()), or when only a global theme changes, call
MarkdownService.rerenderMermaid() on the element you rendered:
// Re-renders the .mermaid diagrams of the element; resolves, or rejects with a MermaidRenderError
await markdownService.rerenderMermaid(element, { mermaidOptions: { theme: 'dark', darkMode: true } });Its options are merged like a render's (defaults, then the config of withMermaid(), then mermaidOptions). It goes through the
shared Mermaid queue and supersedes the element's pending Mermaid render, so the latest call wins.
π Follow official Mermaid documentation for more details on diagrams and charts syntax.
Each .mermaid element is rendered on its own with mermaid.render():
- The diagram source is read from the element's text (the HTML-escaped source decodes back to the original
characters) and kept, so the element can be rendered again after its SVG (or error box) has replaced the source. If
you reuse a
.mermaidelement and replace its content with a new diagram, its new text is rendered. - Each diagram gets a page-unique id, its SVG replaces the element content, and its interactions (
clickcallbacks,securityLevel: 'loose'only) are bound to the element. - Each
mermaid.render()call has 30 seconds to settle by default (see Render timeout). A diagram that takes longer fails with an[ngx-markdown] Mermaid diagram did not render within 30000 mserror, like any other failure, so a hung diagram never blocks the diagrams of the page; a result that arrives later is ignored. - A diagram that fails does not stop the others. Its content is replaced by an error box, logged with an
[ngx-markdown]prefix, and the render rejects once every diagram is settled:<markdown>emitserror(instead ofready), the pipe reports it to theErrorHandler. The rejection is aMermaidRenderErrorwhosefailureslist each failedelementand Mermaid'serror. - When
mermaid.initialize()throws (e.g. an invalid configuration), no diagram of the render is drawn: each one shows the error box with that error, and the render rejects with aMermaidRenderErrorlisting every diagram.
<div class="mermaid">
<div class="mermaid-error" role="status">Mermaid diagram error: Parse error on line 2: ...</div>
</div>The box has no inline styles; style it with the .mermaid-error class, e.g.:
.mermaid-error {
padding: 0.75rem 1rem;
border: 1px solid #d33;
border-radius: 4px;
color: #b00;
white-space: pre-wrap;
}Mermaid's configuration is global to the page, so every MarkdownService shares one rendering queue: a render calls
mermaid.initialize() only when its merged options (defaults, then withMermaid()'s config, then mermaidOptions) differ
from the ones last applied, then renders its diagrams before the next render starts. Components with different
mermaidOptions therefore no longer race. If your own code calls mermaid.initialize(), the next render with
unchanged options does not apply them again; render with different options (or load Mermaid only through this library).
Options that cannot be compared by value (class instances, symbols) make every render call mermaid.initialize() again.
A new render() or a cleanup() of the same element, or the element leaving the document, supersedes the diagrams it
has not drawn yet: they are no longer drawn, their failures are not reported, and the older render resolves.
Set mermaidExport to add an export button to every rendered diagram (not to the .mermaid-error boxes). A click
downloads the diagram as an SVG file, named diagram-1.svg, diagram-2.svg⦠after the position of the diagram in the
content. It is opt-in, like clipboard, needs mermaid, and is provided with withMermaidExport():
<markdown
[data]="markdown"
[mermaid]="true"
[mermaidExport]="true"
(mermaidExported)="onExported($event)"
(mermaidExportError)="onExportError($event)">
</markdown>- What is downloaded: the SVG markup Mermaid returned for the diagram, as a
Blob(image/svg+xml;charset=utf-8) saved through a temporary<a download>. It is a standalone file (Mermaid adds thexmlnsand embeds its styles in the<svg>), it never contains the button, and it follows the theme: a re-render (e.g. amermaidOptionschange) recreates the buttons for the new SVGs. WithsecurityLevel: 'sandbox', Mermaid renders an<iframe>, so those diagrams get no button. - Button: the default
MermaidExportButtonComponentis a<button type="button" class="markdown-mermaid-export-button">labelledSVG, with anaria-labelnaming the file. It has no inline styles; it sits in a.markdown-mermaid-toolbaradded to the.mermaidelement after the<svg>, at its top right (the.mermaidelement getsposition: relativewhen it has no positioning of its own). Style both classes globally (or with::ng-deep). - Custom button:
mermaidExportButtonComponent(a component) ormermaidExportButtonTemplate(anng-template); a click anywhere in it downloads the file. A component gets thesvgandfilenameinputs it declares (the others are not set, so it may declare none), e.g. to copy the markup withnavigator.clipboard.writeText(svg()). - File name:
mermaidExportFilenamePrefixreplacesdiagram(flowgivesflow-1.svg). - Events:
mermaidExported(MarkdownMermaidExportEvent:{ element, svg, filename }) once the download has been triggered,mermaidExportError(MarkdownMermaidExportErrorEvent:{ error, element, filename }, theerrorhas theERROR_MERMAID_EXPORT_FAILEDmessage and the thrown error ascause) when it could not be. - Global options:
provideMarkdown({ plugins: [withMermaid(), withMermaidExport({ buttonComponent, filenamePrefix })] }). The inputs win over them, unset inputs never override them, and a button set on the component (a component or a template) replaces the global one. Enabling the export stays per component. - Pipe and service: pass
{ mermaid: true, mermaidExport: true, mermaidExportOptions: { onExported, onExportError } }(RenderOptions); aViewContainerRefis required (ERROR_MERMAID_EXPORT_VIEW_CONTAINER_REQUIRED), andMarkdownService.rerenderMermaid(element, options, viewContainerRef)takes the same options. Nothing is created on the server.
@Component({
selector: 'app-export-button',
template: `<button type="button" (click)="copy()">Download {{ filename() }}</button>`,
})
export class ExportButtonComponent {
readonly svg = input<string>();
readonly filename = input<string>();
copy(): void {
// The click also downloads the file; this copies the markup as well
void navigator.clipboard.writeText(this.svg() ?? '');
}
}Set the time one mermaid.render() call may take with withMermaid({ renderTimeout }), in milliseconds. It defaults to 30000; 0 or Infinity disables it. An invalid
value (a negative number, NaN) falls back to 30000 with an [ngx-markdown] warning.
provideMarkdown({ plugins: [withMermaid({ renderTimeout: 120000 })] }) // large diagrams: 2 minutesThe timeout is a tradeoff:
- It does not stop Mermaid: the timed-out
mermaid.render()keeps running while the queue moves on. If a later render uses other options, the timed-out diagram may finish under that render'smermaid.initialize()configuration. Its result is ignored either way (the diagram keeps its error box). - A very slow but valid diagram (e.g. a large ELK layout) can be failed by the limit. Raise the timeout, or disable it
with
0, if your diagrams need longer; without a timeout, a diagram that never settles holds every later diagram of the page.
Each diagram costs Mermaid one long task (70β90 ms for a small flowchart in Chromium), even when it is far below the
fold. With lazy, a diagram is drawn only when it comes near the viewport (an IntersectionObserver):
provideMarkdown({ plugins: [withMermaid({ loader: () => import('mermaid'), lazy: true })] })<!-- or per component -->
<markdown [data]="markdown" mermaid [plugins]="{ mermaid: { lazy: true } }" />readyemits once the diagrams near the viewport at render time are drawn; the others are drawn as the reader scrolls, and emit nothing. Angular's stability (whenStable(), SSR) does not wait for them either. A diagram drawn later that fails shows its.mermaid-errorbox and is logged (it is not emitted onerror).lazyMargin(default'100% 0px', one viewport above and below) is the observer'srootMarginandscrollMargin. Browsers withoutscrollMargindraw a diagram inside a scrolling element only once it is visible there.- Waiting diagrams keep their source text, with the
mermaid-pendingclass andaria-busy="true". They are drawn before they come into view, and browsers keep the scroll position when a diagram above the viewport grows (scroll anchoring). To reserve space anyway:.mermaid.mermaid-pending { min-height: 240px; }. - Cache: a diagram the service already drew (same source and options) is drawn at once, wherever it is.
- Print: every waiting diagram is drawn on
beforeprint. Mermaid draws asynchronously, so a print started right after the render can still show some of them as text: keeplazyoff for content that is printed at once. - Unchanged: the export buttons, the lightbox (it skips diagrams),
[mermaidOptions]re-renders (the diagrams far from the viewport are redrawn when they come near it) and the server render (nothing is drawn). WithoutIntersectionObserver, every diagram is drawn at once. Withloader, Mermaid is loaded only once a diagram needs it.
The default securityLevel is set explicitly to 'strict' (also Mermaid's own default): Mermaid encodes HTML in
labels, disables click callbacks and sanitizes the SVG with DOMPurify. antiscript and sandbox also sanitize.
β οΈ The Angular sanitizer only sees the escaped diagram source, never the SVG Mermaid draws from it. WithsecurityLevel: 'loose', diagram labels may contain HTML that nothing sanitizes, whether or notdisableSanitizeris set, so only use it with trusted content. When'loose'is combined withdisableSanitizer, an[ngx-markdown]warning is logged once.
MermaidAPI.MermaidConfig and the per-diagram config interfaces track Mermaid 12.1: they include the v12 themes
(neo, neo-dark, redux, redux-dark, redux-color, redux-dark-color), the per-diagram theme, look and
layout, the ELK options, dompurifyConfig, and the newer diagram configs (radar, venn, treeView, railroad...).
They are copied from Mermaid's own config.type.d.ts, so the library never imports mermaid and it stays an optional
peer dependency. dompurifyConfig is typed as object, so any DOMPurify Config is accepted without importing
dompurify. Options for mermaid.run() are typed as MermaidRunOptions (MermaidAPI.RunOptions is a deprecated
alias).
When contributing, after updating the mermaid dependency regenerate the types with bun run generate:mermaid-types
and review the diff. bun run check:mermaid-types (also run by build:lib) fails when
lib/src/configuration/mermaid-options.ts is out of date with the installed Mermaid.
π Copy-to-clipboard support is optional, skip this step if you are not planning to use it
To add Clipboard library to your package.json use the following command.
npm install clipboard@^2.0.11 --saveTo activate Clipboard allowing copy-to-clipboard, you will need to include...
- Clipboard JavaScript library -
node_modules/clipboard/dist/clipboard.min.jsfile
If you are using Angular CLI you can follow the angular.json example below...
"scripts": [
+ "node_modules/clipboard/dist/clipboard.min.js",
]Provide the plugin with provideMarkdown({ plugins: [withClipboard(options)] }) (see
Clipboard and Prism plugins); without it, [clipboard] does nothing (a dev-mode warning
names withClipboard()). Using markdown component and/or directive, you will be able to use the clipboard property to activate Clipboard plugin that enable copy-to-clipboard for code block from a single click.
<markdown
[clipboard]="true"
[src]="path/to/file.md">
</markdown>The clipboard plugin provide an unstyled default button with a default behavior out of the box if no alternative is used.
The default button shows its "copied" state (the .copied class and the clipboardButtonTextCopied text) for 3 seconds
when Clipboard.js reports a successful copy. A copy that fails does not show it.
With [clipboardLanguageButton]="true", the default button is labelled with the language of the code block: the
language-xxx class of its <code> element (else of its <pre> element), alone. A code block with
class="language-ts line-numbers" is labelled ts. A code block without a language is labelled Copy, and
clipboardButtonTextCopy wins over the language.
The clipboard button is placed inside a wrapper element that can be customized using the .markdown-clipboard-toolbar CSS selector in your global styles.css/scss file.
This allows overriding the default positioning of the clipboard button and play with the visibility of the button using the .hover CSS selector that is applied on the toolbar when the mouse cursor enters and leaves the code block element.
To customize the default button styling, use the .markdown-clipboard-button CSS selector in your global styles.css/scss file. You can also customize the "copied" state shown after a successful copy using the .copied CSS selector.
Screen readers hear the result of every copy, whatever the button (default, custom component or template): one visually hidden polite role="status" region at the end of <body> (.markdown-clipboard-status) announces statusTextCopied (Copied to clipboard) or statusTextCopyFailed (Copy failed), both options of withClipboard(), so a custom button needs no live region of its own.
You can provide a custom component to use globally across your application with withClipboard({ buttonComponent }):
provideMarkdown({
plugins: [withClipboard({ buttonComponent: ClipboardButtonComponent })],
})You can also provide your custom component using the clipboardButtonComponent input property when using the clipboard directive.
import {Component} from '@angular/core';
@Component({
selector: 'app-clipboard-button',
template: `<button (click)="onClick()">Copy</button>`,
})
export class ClipboardButtonComponent {
onClick() {
alert('Copied to clipboard!');
}
}import {ClipboardButtonComponent} from './clipboard-button-component';
@Component({...})
export class ExampleComponent {
readonly clipboardButton = ClipboardButtonComponent;
}<markdown
[clipboard]="true"
[clipboardButtonComponent]="clipboardButton">
</markdown>A custom button component (from clipboardButtonComponent or withClipboard({ buttonComponent })) gets these inputs
(ClipboardButtonInputs), set with ComponentRef.setInput() when it creates the button:
| Input | Value |
|---|---|
language |
The language of the code block (its language-xxx class); not set when it has none. |
code |
The text of the code block, when the button is created. |
buttonTextCopy |
The clipboardButtonTextCopy input (buttonTextCopy option), when set. |
buttonTextCopied |
The clipboardButtonTextCopied input (buttonTextCopied option), when set. |
Each input is set only when the component declares it (with input(), model() or @Input(), by its public name)
and the value is defined, so a component may declare any of them, or none: an undeclared input is never set, and never
raises Angular's unknown-input error. Declare defaults for the optional ones.
import {Component, input} from '@angular/core';
@Component({
selector: 'app-clipboard-button',
template: `<button type="button" [attr.aria-label]="'Copy ' + (language() ?? 'code')">{{ buttonTextCopy() }}</button>`,
})
export class ClipboardButtonComponent {
readonly language = input<string>();
readonly code = input<string>('');
readonly buttonTextCopy = input('Copy');
}The click on the button is handled by Clipboard.js, which copies the code block; use the copied output (below) to
react to the result.
Alternatively, the clipboard directive can be used in conjunction with ng-template to provide a custom button implementation via the clipboardButtonTemplate input property on the markdown component.
<ng-template #buttonTemplate>
<button (click)="onCopyToClipboard()">...</button>
</ng-template>
<markdown
[clipboard]="true"
[clipboardButtonTemplate]="buttonTemplate">
</markdown>The markdown component emits the result of each copy reported by Clipboard.js, whatever the button (default,
component or template):
copied(MarkdownCopyEvent):{ text, language?, element }, the copied text, the language of the code block and its<pre>element;copyError(MarkdownCopyErrorEvent):{ error, language?, element }, when the copy failed (e.g. the browser refused it).erroris anErrorwith theERROR_CLIPBOARD_COPY_FAILEDmessage: Clipboard.js reports no error of its own.
<markdown
[clipboard]="true"
[src]="'path/to/file.md'"
(copied)="onCopied($event)"
(copyError)="onCopyError($event)">
</markdown>import {type MarkdownCopyErrorEvent, type MarkdownCopyEvent} from '@fsegurai/ngx-markdown';
onCopied({ text, language }: MarkdownCopyEvent): void {
this.snackbar.open(`Copied ${text.length} characters of ${language ?? 'code'}`);
}
onCopyError({ error }: MarkdownCopyErrorEvent): void {
console.warn(error.message);
}The events come from the buttons of the latest render only: the buttons of a previous render, and of a destroyed component, are destroyed with their Clipboard.js instances and emit nothing.
A pipe has no outputs: pass the same results as callbacks in the clipboardOptions of the pipe options,
{ clipboard: true, clipboardOptions: { onCopied, onCopyError } } (also available on MarkdownService.render()).
π Refer to the
@fsegurai/ngx-markdownclipboard plugin demo for live examples.
Provide the plugin with provideMarkdown({ plugins: [withLightbox(options)] }) (see Plugins); without it,
[lightbox] does nothing (a dev-mode warning names withLightbox()).
Set lightbox to open the images of the content in a built-in viewer when they are clicked. It has no dependency: the
viewer is a native <dialog> opened with showModal(), so it sits in the top layer above the page.
<markdown [data]="markdown" [lightbox]="true" (imageClick)="onImageClick($event)"></markdown>- Which images: the
<img>elements of the rendered content, in document order, except those inside a link (the link wins), a.mermaiddiagram or KaTeX output. They gettabindex="0",role="button"and themarkdown-lightbox-triggerclass (attributes the content already set are kept), so Enter and Space open them too. Everything added is removed by the next render and when the component is destroyed. - Viewer: the full image (
currentSrc, elsesrc), thealttext as caption (else thetitle), previous/next buttons (not shown for a single image) and ann / totalcounter in anaria-liveregion. Arrow keys browse (wrapping around from the last image to the first; setwrap: falseto stop at both ends, with disabled buttons), Esc, the close button or a click on the backdrop closes it, and the focus moves into the dialog on open and back to the image on close. The viewer is the exportedMarkdownLightboxComponent, created on the first opening and destroyed with the content, which closes it. - Options:
lightboxOptions(LightboxOptions):wrap(true), and thearia-labelsdialogLabel(Image viewer),closeLabel(Close),previousLabel(Previous image) andnextLabel(Next image), andopenLabel(Open image 2 of 5; a string or(index, total) => string): thearia-labelof an image trigger with no alt text,titleor label of its own. Every trigger getsaria-haspopup="dialog". A viewer caption equal to the alt text of its image is hidden from assistive technology, so it is not read twice. - Global options:
provideMarkdown({ plugins: [withLightbox({ wrap: false })] }). ThelightboxOptionsinput is merged over them, and its unset (undefined) values never override them. Enabling the lightbox stays per component. imageClick: emitted only withlightboxon, before the viewer opens, with aMarkdownImageClickEvent:{ image, index, images, preventDefault(), defaultPrevented }(imagesare the eligible images,indexthe position ofimagein them). CallingpreventDefault()keeps the built-in viewer closed, so you can open another one, e.g. LightGallery (GPLv3 or commercial, so it is not bundled) or PhotoSwipe:
onImageClick(event: MarkdownImageClickEvent): void {
event.preventDefault();
this.myViewer.open(event.images.map((image) => image.currentSrc || image.src), event.index);
}- Theming: set these CSS custom properties on the page (e.g.
:root) or an ancestor of<markdown>:
| Property | Default | Use |
|---|---|---|
--markdown-lightbox-backdrop |
rgb(0 0 0 / 85%) |
background behind the image (the dialog covers the viewport) |
--markdown-lightbox-text-color |
#fff |
caption and counter |
--markdown-lightbox-font |
inherit |
caption and counter font |
--markdown-lightbox-max-width |
90vw |
maximum image width |
--markdown-lightbox-max-height |
80vh |
maximum image height |
--markdown-lightbox-button-background |
rgb(255 255 255 / 15%) |
previous, next and close buttons |
--markdown-lightbox-button-color |
#fff |
button text and focus outline |
The viewer elements have the markdown-lightbox, markdown-lightbox-image, markdown-lightbox-caption,
markdown-lightbox-counter and markdown-lightbox-button (-previous, -next, -close) classes; give the images a
zoom-in cursor with .markdown-lightbox-trigger { cursor: zoom-in; }.
- Service:
RenderOptions.lightboxandlightboxOptions(with anonImageClickcallback) bind it inMarkdownService.render(); aViewContainerRefis required (ERROR_LIGHTBOX_VIEW_CONTAINER_REQUIRED). Nothing happens on the server. - Pipe: not supported. The pipe has no outputs, and its post-render step runs on the whole template of its host
component, so use the
markdowncomponent for a lightbox.
Configure @fsegurai/ngx-markdown with the provideMarkdown provide-function, then import the standalone MarkdownComponent and/or MarkdownPipe where you use them.
Use the provideMarkdown provide-function in your application configuration ApplicationConfig to configure the MarkdownComponent and MarkdownPipe and/or inject the MarkdownService. It accepts an optional MarkdownConfig object.
import { ApplicationConfig } from '@angular/core';
+ import { provideMarkdown } from '@fsegurai/ngx-markdown';
export const appConfig: ApplicationConfig = {
providers: [
+ provideMarkdown(),
],
};import { Component } from '@angular/core';
+ import { MarkdownComponent, MarkdownPipe } from '@fsegurai/ngx-markdown';
@Component({
selector: 'app-home',
+ imports: [MarkdownComponent, MarkdownPipe],
templateUrl: './home.component.html',
})
export class HomeComponent { }π
<markdown>and themarkdownpipe also work withoutprovideMarkdown(): the defaults apply, including HTML sanitization (SecurityContext.HTML). UseprovideMarkdown()to change the configuration.
22.0.0 contains breaking changes. Check each item below; the CHANGELOG lists them all.
- Minimum versions: Angular
^22, TypeScript>=6.0 <6.1, Node.js^22.22.3 || ^24.15.0 || >=26andmarked ^18(see Installation). MarkdownModuleis removed: useprovideMarkdown(config)and import the standaloneMarkdownComponent/MarkdownPipe(see Migrating from MarkdownModule).- Optional peer dependencies:
prismjs,emoji-toolkit,katex,mermaidandclipboardare no longer installed with the library. Install each one you use explicitly. - Plugins need their
withX()feature: the per-plugin fields ofprovideMarkdown()(katexOptions,mermaidOptions,mermaidLoader,mermaidRenderTimeout,mermaidExportOptions,clipboardOptions,lightboxOptions) and their tokens are removed. Provide each feature inprovideMarkdown({ plugins: [...] }); Prism highlighting needswithPrism()and[emoji]needswithEmoji(). See the replacement table in Migrating to 22: plugins. - Asynchronous highlighting:
MarkdownService.render()returns aPromise<void>and highlights the code blocks after it returns.await render()(or use thereadyoutput of<markdown>) before reading the highlighted markup or the clipboard buttons. Errors (Mermaid, Prism, a missing plugin file) reject the Promise instead of throwing, and every enabled plugin still runs when another one fails (see Service). erroroutput type:<markdown>emits aMarkdownError(Error | HttpErrorResponse | string) instead ofstring | Error; update handlers typed(error: string | Error)(see Loading and rendering behavior).SANITIZEtoken:provideMarkdown({ sanitize })now also accepts a sanitize function, provided as the newSANITIZEtoken.SECURITY_CONTEXTis deprecated and will be removed in v23 (see Sanitization).- Emoji in code: shortcodes inside code blocks, inline code, raw HTML and link URLs are no longer converted (see Emoji support).
cacheSrc:srcresponses can now be cached per service withprovideMarkdown({ cacheSrc: true }). It is opt-in, so every load still fetches unless you enable it (see Caches).- Incremental updates (on by default): when
dataorsrcchanges, the top-level blocks whose HTML did not change keep their DOM nodes. DOM edits made in a(ready)handler must be idempotent (a kept node already has them), and a node reference may be kept or replaced. Opt out withprovideMarkdown({ incrementalUpdates: false })(see Long documents). - Time slicing (on by default): in the browser, content of 16 KiB or more is parsed over several tasks, so its
HTML arrives after later tasks. Wait for
ready(orApplicationRef.whenStable(),fixture.whenStable()in tests) before reading the DOM. Opt out withprovideMarkdown({ timeSlicing: false }). - Superseded pipe values: a
markdownpipe value replaced while its parse is still running resolves to''.
KaTeX is also rendered while parsing now (the Auto-Render script is no longer used): see Math rendering.
MarkdownModule was removed in v22. Migrate an NgModule-based setup as follows:
| Before (v21) | After (v22) |
|---|---|
MarkdownModule.forRoot(config) |
provideMarkdown(config) in your application providers |
MarkdownModule.forChild() |
nothing (the root configuration applies everywhere) |
imports: [MarkdownModule] |
imports: [MarkdownComponent, MarkdownPipe] (what you use) |
MarkdownModuleConfig |
MarkdownConfig (MarkdownModuleConfig is a deprecated alias) |
@NgModule({
imports: [
- MarkdownModule.forRoot({ sanitize: SecurityContext.NONE }),
+ MarkdownComponent,
],
+ providers: [provideMarkdown({ sanitize: SecurityContext.NONE })],
})
export class AppModule { }The [src] attribute loads remote files with Angular's HttpClient, which is injectable by default since Angular 21, so no extra setup is required.
Only call provideHttpClient(...) when you need to configure it (interceptors, etc.). The loader option of provideMarkdown() is optional:
providers: [
+ provideHttpClient(withInterceptors([myInterceptor])), // optional
+ provideMarkdown(),
],As of @fsegurai/ngx-markdown@v19.0.0 sanitization is enabled by default and uses Angular DomSanitizer with SecurityContext.HTML to avoid XSS vulnerabilities. It also applies without provideMarkdown(). The sanitize property of provideMarkdown() (the SANITIZE injection token) accepts either a SecurityContext or a sanitize function.
import {SecurityContext} from '@angular/core';
// enable default sanitization
provideMarkdown()
// turn off sanitization
provideMarkdown({
sanitize: SecurityContext.NONE
})To use another sanitizer, such as DOMPurify, pass a function. It receives the HTML parsed from the Markdown, after asynchronous marked extensions have resolved, and its result is rendered as is:
import DOMPurify from 'dompurify';
provideMarkdown({
sanitize: (html: string) => DOMPurify.sanitize(html),
})
β οΈ The function replaces Angular's sanitizer: it must return safe HTML. Whatever it returns is inserted in the page.
β οΈ TheSECURITY_CONTEXTinjection token is deprecated in favor ofSANITIZEand will be removed in v23. ASECURITY_CONTEXTprovider is still honoured whensanitizeis not set.
π Follow Angular DomSanitizer documentation for more information on sanitization and security contexts.
You can bypass sanitization using the Markdown component, directive or pipe using the disableSanitizer option as follows:
<!-- disable sanitizer using a Markdown component -->
<markdown
[data]="markdown"
[disableSanitizer]="true">
</markdown>
<!-- disable sanitizer using a Markdown directive -->
<div markdown
[data]="markdown"
[disableSanitizer]="true">
</div>
<!-- disable sanitizer using markdown pipe -->
<div [innerHTML]="markdown | markdown : { disableSanitizer: true } | async"></div>Optionally, markdown parsing can be configured using MarkedOptions that can be provided with the MARKED_OPTIONS injection token via the markedOptions property of provideMarkdown().
// imports
import {MARKED_OPTIONS, provideMarkdown} from '@fsegurai/ngx-markdown';
// using default options
provideMarkdown(),
// using specific options with ValueProvider
provideMarkdown({
markedOptions: {
provide: MARKED_OPTIONS,
useValue: {
gfm: true,
breaks: false,
pedantic: false,
},
},
}),MarkedOptions also exposes the renderer property which allows you to override token rendering for your whole application.
The example uses a factory function and overrides the default blockquote token rendering by adding a CSS class for custom styling when using Bootstrap CSS:
import {MARKED_OPTIONS, MarkedOptions, MarkedRenderer, MarkedToken} from '@fsegurai/ngx-markdown';
// function that returns `MarkedOptions` with renderer override
export function markedOptionsFactory(): MarkedOptions {
const renderer = new MarkedRenderer();
renderer.blockquote = ({ text }: MarkedToken.Blockquote) => {
return '<blockquote class="blockquote"><p>' + text + '</p></blockquote>';
};
return {
renderer: renderer,
gfm: true,
breaks: false,
pedantic: false,
};
}// using specific option with FactoryProvider
provideMarkdown({
markedOptions: {
provide: MARKED_OPTIONS,
useFactory: markedOptionsFactory,
},
}),With provideMarkdown(), you can provide marked extensions using the MARKED_EXTENSION injection token via the markedExtensions property, which accepts an array of providers and supports Angular dependency injection.
import {gfmHeadingId} from 'marked-gfm-heading-id';
provideMarkdown({
markedExtensions: [
{
provide: MARKED_EXTENSIONS,
useFactory: gfmHeadingId,
multi: true,
},
{
provide: MARKED_EXTENSIONS,
useFactory: myExtensionFactory,
deps: [SomeService],
multi: true,
},
],
}),@fsegurai/ngx-markdown provides different approaches to help you parse Markdown to your application depending on your needs.
π‘ As of Angular 6, the template compiler strips whitespace by default. Use
ngPreserveWhitespacesdirective to preserve whitespaces such as newlines in order for the markdown-formatted content to render as intended.
https://angular.io/api/core/Component#preserveWhitespaces
You can use markdown component to either parse static Markdown directly from your HTML markup, load the content from a remote URL using src property or bind a variable to your component using data property. You can get a hook on a load complete using load output event property, on loading error using error output event property or when parsing is completed using ready output event property.
<!-- static markdown -->
<markdown ngPreserveWhitespaces>
# Markdown
</markdown>
<!-- loaded from remote url -->
<markdown
[src]="'path/to/file.md'"
(load)="onLoad($event)"
(error)="onError($event)">
</markdown>
<!-- variable binding -->
<markdown
[data]="markdown"
(ready)="onReady()">
</markdown>
<!-- inline parser, omitting rendering top-level paragraph -->
<markdown
[data]="markdown"
[inline]="true">
</markdown>The same way the component works, you can use markdown directive to achieve the same thing.
<!-- static markdown -->
<div markdown ngPreserveWhitespaces>
# Markdown
</div>
<!-- loaded from remote url -->
<div markdown
[src]="'path/to/file.md'"
(load)="onLoad($event)"
(error)="onError($event)">
</div>
<!-- variable binding -->
<div markdown
[data]="markdown"
(ready)="onReady()">
</div>
<!-- inline parser, omitting rendering top-level paragraph -->
<div markdown
[data]="markdown"
[inline]="true">
</div>- Only
data,srcandMarkdownService.reload()trigger a render. The rendering options (inline,emoji,clipboard,katex,mermaid, the Prism plugin inputs...) are read when rendering: changing one of them alone does not re-render, the new value applies to the nextdata/srcchange orreload().mermaidOptionsis the deliberate exception: a change re-renders the Mermaid diagrams alone (see Following a light/dark theme). - The latest request wins: when
dataorsrcchanges while a previoussrcrequest or render is still in flight, the previous one is discarded (its HTTP request is cancelled and it never updates the DOM or emits). - A non-empty
datatakes precedence oversrc: when both are set,srcis ignored and a one-time[ngx-markdown]warning is logged. Settingdatato''clears the rendered content when nosrcis set; with asrcset, thesrcis loaded (as in 21.x), so wrappers that defaultdatato''keep working. reload()also re-renders transcluded (static) content.- A
srcfile whose extension is not.mdor.markdown(case-insensitive; the query and hash are ignored) is rendered as a code block of that language. The code fence is longer than any backtick run in the file, so the file content cannot close it early (thelanguagepipe does the same). loadis emitted forsrconly, after the content has been rendered.readyis emitted once the content, including Mermaid diagrams, is rendered.erroremits aMarkdownError: anHttpErrorResponsewhen thesrcrequest fails, or theErrorthrown while parsing or rendering (e.g. a missing plugin, or aMermaidRenderErrorwhen Mermaid diagrams fail; the other diagrams are still rendered).
import { MarkdownError } from '@fsegurai/ngx-markdown';
onError(error: MarkdownError) {
console.error(error);
}Clicks on links in the rendered content are handled by the component, unless disableRouterLinkHandler is set. The
routerLinkOptions input enables the handlers:
internalBrowserHandlerβ navigates with the AngularRouterfor#fragment,/routerLink:/path#fragment, relative and absolute paths, applying thepaths['/path']orglobalNavigationExtras./localFile:pathlinks openpathin a new tab.internalDesktopHandlerβ scrolls to the element whose id is the link fragment (#intro,/docs#intro).externalBrowserHandlerβ opens external links (http(s)://,www.ashttps://,mailto:...) in a new tab.
Pages opened in a new tab get noopener,noreferrer. Clicks with a modifier key (Ctrl, Meta, Shift, Alt) or another
button than the main one are always left to the browser. javascript:, data: and vbscript: links are always
blocked (whatever the handlers), with an [ngx-markdown] warning.
<markdown
[src]="'path/to/file.md'"
[routerLinkOptions]="{
internalBrowserHandler: true,
externalBrowserHandler: true,
global: { queryParamsHandling: 'preserve' },
paths: { '/docs': { queryParams: { tab: 'api' } } },
}">
</markdown>By default, relative URLs in the content resolve against the page, as in 21.x. Two inputs change that:
srcRelativeLinkβ the relative links and images of asrcfile resolve against the file itself, as on GitHub: withsrc="docs/guide/intro.md",loadsdocs/guide/img.png. It has no effect ondataor transcluded content.baseUrlβ the base URL of the content, fordata,srcand transcluded content alike. It wins oversrcRelativeLink. Themarkdownpipe andMarkdownService.parse()take it as thebaseUrloption.
<markdown src="docs/guide/intro.md" srcRelativeLink></markdown>
<markdown [data]="markdown" baseUrl="https://cdn.example.com/docs/"></markdown>
<div [innerHTML]="markdown | markdown: { baseUrl: 'docs/' } | async"></div>Like the other rendering options, baseUrl and srcRelativeLink apply when the content is rendered: changing them
afterwards takes effect on the next data/src change or MarkdownService.reload().
The base is a URL, as with a <base href>: end a folder with /. docs/guide is a file, so URLs resolve against
docs/. Its query and hash are ignored.
| URL in the content | baseUrl="https://example.com/docs/guide/" |
baseUrl="docs/guide/" |
|---|---|---|
img.png, ./img.png |
https://example.com/docs/guide/img.png |
docs/guide/img.png |
../img.png |
https://example.com/docs/img.png |
docs/img.png |
../../../img.png |
https://example.com/img.png |
../img.png (stays relative) |
/assets/img.png (root-relative) |
https://example.com/assets/img.png (base origin) |
unchanged |
https://β¦, mailto:β¦, data:β¦, any scheme |
unchanged | unchanged |
//cdn.example.com/x.js (protocol-relative) |
unchanged | unchanged |
#section |
unchanged | unchanged |
/routerLink:β¦, /localFile:β¦ |
unchanged | unchanged |
- Covered: Markdown links and images (inline and reference), and the
hrefof raw HTML<a>tags and thesrcof raw HTML<img>tags. Other raw HTML (srcset,<source>,<video>,<link>...) is left as is. - A
baseUrlwith a scheme other thanhttp:orhttps:is ignored. A protocol-relativebaseUrl(//host/docs/) gives protocol-relative URLs. - The URLs are resolved before rendering, so a custom renderer (
MarkedOptions.renderer,markdownService.renderer, therendererof a Marked extension) receives the resolvedhrefin itslinkandimagemethods. - There is no global default: a global
baseUrlwould also apply to everydataand pipe content. Set the inputs where the content comes from.
A front-matter block sits at the very start of the content, between two --- lines (YAML) or two +++ lines (TOML):
---
title: Release notes
tags: [angular, markdown]
---
# Release notesBy default it is rendered as Markdown, as in 21.x (a horizontal rule, then text or a setext heading). The
frontMatter input strips it before parsing, and the metadata output emits it on each render:
<markdown src="docs/release.md" frontMatter (metadata)="onMetadata($event)"></markdown>import type { MarkdownFrontMatter } from '@fsegurai/ngx-markdown';
onMetadata({ data, raw }: MarkdownFrontMatter): void {
this.title = data['title'];
}-
Detection: the opening
---(or+++) must be alone on the first line; only a BOM and blank lines may come before it (so indented transcluded content works). The block ends at the next line holding the same delimiter alone. CRLF line endings are fine. A later---(a horizontal rule or a setext heading) is never front-matter, and a block without a closing delimiter is rendered as is. -
Output:
metadataemits aMarkdownFrontMatter,{ data, raw }:rawis the block text without its delimiters (line endings normalized to\n). Content without a block emits{ data: {}, raw: '' }, so the previous value is replaced. It is emitted once the content is in the element, beforeready. WithoutfrontMatter, nothing is emitted. -
Supported subset (no YAML or TOML library, nothing is evaluated): top-level
key: valueentries (key = valuein a+++block), with plain or quoted keys, whose value is:Value Example dataString: plain, "double"(JSON-like escapes) or'single'(''is a quote)title: "A: B"'A: B'Number (decimal) weight: 1.51.5Boolean draft: true/Falsetrue/falseNull x: null,x: ~,x:nullOne-line array of scalars tags: [a, "b, c", 3]['a', 'b, c', 3]Block list of scalars (YAML) tags:then- alines['a']A trailing
# commentis dropped. Any other value stays its raw string: nested mappings and block scalars (|,>) give their indented lines without the common indentation,{ a: 1 }maps and nested arrays their text. TOML tables ([table]) and everything after them are left toraw. The__proto__,constructorandprototypekeys are ignored, and so is any other key that exists onObject.prototype(hasOwnProperty,toString,valueOfβ¦), sodatanever shadows its methods. -
Pipe and service:
frontMatter: truestrips the block; theonFrontMatter(frontMatter)callback receives it (synchronously, before Marked runs), asmetadatadoes.extractFrontMatter(markdown)returns{ frontMatter, content }without rendering anything (frontMatterisnullwhen there is no block).
<div [innerHTML]="markdown | markdown: { frontMatter: true, onFrontMatter: setMetadata } | async"></div>The block is stripped after the HTML entities of transcluded content are decoded, and before emoji, KaTeX and Marked run, so none of them sees it.
The headings output emits the headings of the content, in document order, on each render. Build a table of contents
from it:
<nav>
@for (heading of headings(); track $index) {
<a [href]="'#' + heading.id" [class]="'toc-level-' + heading.level"
(click)="$event.preventDefault(); heading.element?.scrollIntoView()">{{ heading.text }}</a>
}
</nav>
<markdown src="docs/guide.md" headingIds (headings)="headings.set($event)"></markdown>import { signal } from '@angular/core';
import type { MarkdownHeading } from '@fsegurai/ngx-markdown';
readonly headings = signal<MarkdownHeading[]>([]);- Output:
headingsemits aMarkdownHeading[]; each heading is{ level, text, id, element? }.levelis 1 to 6,textis the plain text as the page shows it (no HTML or Markdown markup:## Hello *world*givesHello world; images give no text; character references decoded:©givesΒ©),elementis the heading element in the page andiditsidattribute (''when it has none). Content without headings emits[], so the previous list is replaced. It is emitted once the content is in the element, beforeready, likemetadata; a render superseded by a newer one emits nothing. A Mermaid-only re-render (mermaidOptions) does not change the headings and does not emit them again. - Ids: an
idwritten by the heading renderer is kept:marked-gfm-heading-id(see Marked extensions), aMARKED_EXTENSIONSheading renderer, or a customrenderer.heading. Otherwise the opt-inheadingIdsinput gives the heading a GitHub-style slug: lower-cased, every character dropped but letters, marks and numbers (in any script),_,-and spaces, then whitespace turned into-(Hello, World!giveshello-world; a heading without any kept character givesheading). Slugs are unique within a render (intro,intro-1,intro-2β¦) and never reuse an id written by a renderer. WithoutheadingIdsand without a renderer id,idis''. - Precedence:
headingIdsruns after the whole renderer chain (ownrenderer.headingmethod, thenMARKED_EXTENSIONS, then the default renderer), and only fills the headings that chain rendered without anid. - Sanitizer: Angular's default sanitizer (
SecurityContext.HTML) removesidattributes, so with it the headings have noidin the page (idis then'',elementis still set: scroll to it withelement.scrollIntoView()). To keep the ids, use asanitizefunction that keeps them (e.g. DOMPurify) or, for trusted content only,SecurityContext.NONE. - Elements: each heading is matched to the next
h1β¦h6element of its level with itsidor its text, so headings written as raw HTML are not listed (a raw HTML heading with the level and text of the next Markdown heading may be taken for it).elementis unset when no element matches. - Pipe and service:
headingIds: trueadds the slugs; theonHeadings(headings)callback receives the headings of each parse once Marked has run ([]for inline parsing), withoutelement, and with theidMarked rendered, before sanitizing.
<div [innerHTML]="markdown | markdown: { headingIds: true, onHeadings: setHeadings } | async"></div>Using markdown pipe to transform Markdown to HTML allow you to chain pipe transformations and will update the DOM when value changes. It is important to note that, because the marked parsing method returns a Promise, it requires the use of the async pipe.
For long or frequently edited documents prefer <markdown [data]>: the pipe parses long values in time slices, but [innerHTML] inserts the whole HTML at once and replaces all of it on every change, while the component inserts it in batches and updates only the changed blocks (see Long documents).
<!-- chain `language` pipe with `markdown` pipe to convert typescriptMarkdown variable content -->
<div [innerHTML]="typescriptMarkdown | language : 'typescript' | markdown | async"></div>The markdown pipe allow you to use all the same plugins as the component by providing the option parameters.
<!-- provide options parameters to activate plugins or for configuration -->
<div [innerHTML]="typescriptMarkdown | language : 'typescript' | markdown : { emoji: true, inline: true } | async"></div>This is the MarkdownPipeOptions parameters interface, those options are the same as the ones available for the markdown component:
export interface MarkdownPipeOptions {
decodeHtml?: boolean;
inline?: boolean;
emoji?: boolean;
katex?: boolean;
katexOptions?: KatexOptions;
mermaid?: boolean;
mermaidOptions?: MermaidAPI.MermaidConfig;
mermaidExport?: boolean;
mermaidExportOptions?: MermaidExportRenderOptions;
markedOptions?: MarkedOptions;
disableSanitizer?: boolean;
baseUrl?: string;
frontMatter?: boolean;
onFrontMatter?: (frontMatter: MarkdownFrontMatter) => void;
headingIds?: boolean;
onHeadings?: (headings: MarkdownHeading[]) => void;
plugins?: MarkdownPluginToggles; // see [Plugins](#plugins)
}The pipe resolves to an empty string for null/undefined, and for a non-string value (an [ngx-markdown] error is
logged). KaTeX math is rendered while parsing, so it is scoped to the content. A pipe cannot access the element it is
bound to, so the post-render step (highlight, clipboard, Mermaid) runs on the host element of the component that uses the pipe, i.e. on its whole template. Prefer the
markdown component when that step must be scoped to the Markdown content. A pipe has no outputs: with clipboard, the copy
results are passed to the clipboardOptions.onCopied and clipboardOptions.onCopyError callbacks (see Copy events), and
with mermaidExport the downloads to mermaidExportOptions.onExported and onExportError (see SVG export).
The image lightbox is not supported through the pipe (see Image lightbox).
You can use MarkdownService to have access to Markdown parsing, rendering and syntax highlight methods.
import {Component, OnInit} from '@angular/core';
import {MarkdownService} from '@fsegurai/ngx-markdown';
@Component({...})
export class ExampleComponent implements OnInit {
constructor(private markdownService: MarkdownService) {
}
ngOnInit() {
// outputs: <p>I am using <strong>markdown</strong>.</p>
console.log(this.markdownService.parse('I am using __markdown__.'));
}
}MarkdownService.render(element, options, viewContainerRef) returns a Promise<void> that resolves once every
feature, including Mermaid diagrams, is rendered, and rejects with a MermaidRenderError when diagrams fail (see
Rendering and errors). Pass disableSanitizer in its options, as <markdown> and the pipe
do, to get the 'loose' warning. It destroys the clipboard
and Mermaid export buttons created by the previous render of the same element, and supersedes its Mermaid diagrams not drawn yet; call
cleanup(element) when you discard that content.
Each MarkdownService parses with its own Marked instance, so MARKED_EXTENSIONS and the configured renderer never
modify the global marked object.
This is the configuration of every optional feature of the library: a plugin works only when its withX() feature is
in provideMarkdown({ plugins: [...] }), and its global options are the options of that feature. Components then
enable it with its input ([katex], [mermaid], [clipboard]β¦) or the [plugins] record.
import {
provideMarkdown,
withClipboard,
withEmoji,
withKatex,
withLightbox,
withMermaid,
withMermaidExport,
withPrism,
} from '@fsegurai/ngx-markdown';
export const appConfig: ApplicationConfig = {
providers: [
provideMarkdown({
plugins: [
withPrism({ lineNumbers: true }),
withKatex({ throwOnError: false }),
withMermaid({ loader: () => import('mermaid').then((m) => m.default), config: { look: 'classic' } }),
withMermaidExport(),
withClipboard({ buttonComponent: ClipboardButtonComponent }),
withEmoji(),
withLightbox(),
],
// plain Marked extensions need no plugin
markedExtensions: [{ provide: MARKED_EXTENSIONS, useFactory: gfmHeadingId, multi: true }],
}),
],
};Upgrading from 21.x or an earlier 22 pre-release? See Migrating to 22: plugins.
The Markdown pipeline runs plugins: plain objects with hooks that the service calls in a fixed order. There are two kinds:
| Kind | Plugins | Registration |
|---|---|---|
| Core built-ins | frontMatter, baseUrl |
Always registered, nothing to provide: they have no dependency and are tiny, so tree-shaking would gain nothing. |
| Feature plugins | emoji (withEmoji()), katex (withKatex()), lightbox (withLightbox()), clipboard (withClipboard()), prism (withPrism()), mermaid (withMermaid()), mermaidExport (withMermaidExport()), your own |
Provided with a withX() feature in provideMarkdown({ plugins: [...] }), so their code is only bundled when used. |
A registered plugin still runs only where it is enabled: per component with the [plugins] input, or with its
specific input ([frontMatter], [baseUrl]β¦), and per parse/render with the plugins option of the markdown pipe and
of MarkdownService.parse()/render():
<!-- true enables, false disables, an object enables and overrides options -->
<markdown [data]="markdown" [plugins]="{ frontMatter: true, baseUrl: { url: 'docs/' } }"></markdown>
<!-- a specific input wins over the record: front-matter stays off here -->
<markdown [data]="markdown" [plugins]="{ frontMatter: true }" [frontMatter]="false"></markdown>
<div [innerHTML]="markdown | markdown: { plugins: { externalLinks: true } } | async"></div>- A plugin left out of the record follows its
enabledByDefault(defaultfalse). - Options are merged in this order,
undefinednever overriding: the plugindefaults, then thewithX(options)value, then the[plugins]options object, then the specific inputs. - A plugin enabled but not provided (by the record or by its specific input, e.g.
[katex]withoutwithKatex()) is ignored, with one[ngx-markdown]warning per plugin in dev mode that names thewithX()to add.MarkdownService.rerenderMermaid()withoutwithMermaid()throwsERROR_MERMAID_NOT_PROVIDED. - Like the other rendering options,
[plugins]is read when the content renders: changing it alone does not re-render.
import { provideMarkdown, withEmoji, withKatex, withLightbox } from '@fsegurai/ngx-markdown';
provideMarkdown({
plugins: [
withEmoji(), // needs the joypixels global (Emoji-Toolkit), no options
withKatex({ throwOnError: false, macros: { '\\RR': '\\mathbb{R}' } }), // needs the katex global
withLightbox({ wrap: false }),
],
});<!-- the specific inputs still work, and win over the record -->
<markdown [data]="markdown" emoji katex [katexOptions]="{ errorColor: '#c00' }" lightbox (imageClick)="open($event)"></markdown>
<!-- or the record: an options object enables the plugin and overrides its options -->
<markdown [data]="markdown" [plugins]="{ emoji: true, katex: { output: 'mathml' }, lightbox: true }"></markdown>[emoji],[katex]and[lightbox]are unset by default, so the[plugins]record decides;falsedisables the plugin even when the record enables it.[katexOptions]and[lightboxOptions]are merged over the record options.imageClickis emitted however the lightbox is enabled.- KaTeX is an
enforce: 'pre'plugin: it inserts the rendered math before thepostSanitizehooks of the other plugins run, so they see the final math HTML.
import { provideMarkdown, withClipboard, withPrism } from '@fsegurai/ngx-markdown';
provideMarkdown({
plugins: [
withClipboard({ languageButton: true }), // needs the ClipboardJS global
withPrism({ lineNumbers: true }), // needs the Prism global; enabled by default once provided
],
});<!-- clipboard: per component, with its input or the record -->
<markdown [data]="markdown" clipboard [clipboardButtonTemplate]="button" (copied)="onCopied($event)"></markdown>
<markdown [data]="markdown" [plugins]="{ clipboard: { buttonTextCopy: 'Copy code' } }"></markdown>
<!-- prism: highlights every <markdown> once provided; the Prism inputs are its per-component options -->
<markdown [data]="markdown" lineHighlight line="2-4" [lineNumbers]="false"></markdown>
<markdown [data]="markdown" [plugins]="{ prism: false }"></markdown>withClipboard(options)takesClipboardOptions(buttonComponent,buttonTextCopy,buttonTextCopied,languageButton).[clipboard]is unset by default (the record decides); theclipboard*inputs are merged over the record options, andcopied/copyErrorare emitted however the plugin is enabled. A button set by a higher layer (a per-component template or component) replaces the global button component.withPrism(options)takesPrismOptions:lineNumbers,start,lineHighlight,line,lineOffset,commandLine,filterOutput,host,prompt,output,user(the classes anddata-*attributes of Prism's Line Numbers, Line Highlight and Command Line plugins). The<markdown>inputs of the same names are merged over them (unset inputs never override). It isenabledByDefaultandenforce: 'pre': it sets thepreattributes before the otherrenderhooks run. A hook of another plugin that throws (e.g. the clipboard without ClipboardJS) does not stop the highlighting: the code is still highlighted and the render then rejects with that error. The clipboard reads the languages of the code blocks before the highlighting marks the blocks without onelanguage-none. WithoutwithPrism(), nothing is highlighted at render time and the Prism inputs do nothing.MarkdownService.highlight(element)keeps working on its own (with or withoutwithPrism()).- Code blocks of
@fsegurai/marked-extended-code: the per-block class tokens override the Prism options for their block, afigcaptiontitle shows in the clipboard toolbar, andwithClipboard({ skipRemovedLines })(defaulttrue) leaves the removed diff lines out of the copied text (see Marked Extended integration). A custom button component also gets atitleinput, andcopied/copyErrorcarry thetitle.
import { provideMarkdown, withMermaid, withMermaidExport } from '@fsegurai/ngx-markdown';
provideMarkdown({
plugins: [
withMermaid({
loader: () => import('mermaid'), // or the `mermaid` global of its script build when left out
config: { theme: 'neutral' }, // the `mermaid.initialize()` configuration
renderTimeout: 30000, // per `mermaid.render()` call (and the load); 0 or Infinity disables it
lazy: false, // true: draw each diagram when it comes near the viewport (`lazyMargin`, default '100% 0px')
}),
withMermaidExport({ filenamePrefix: 'chart' }), // requires withMermaid()
],
});<!-- per component, with the inputs or the record -->
<markdown [data]="markdown" mermaid [mermaidOptions]="{ theme: 'dark' }" mermaidExport></markdown>
<markdown [data]="markdown" [plugins]="{ mermaid: { config: { theme: 'dark' } }, mermaidExport: true }"></markdown>withMermaid(options)takesMermaidPluginOptions:config(merged over{ startOnLoad: false, securityLevel: 'strict' }),loader,renderTimeout,lazy,lazyMargin.configis merged key by key across the layers (withMermaid(), the record, then[mermaidOptions]), so a per-component{ theme: 'dark' }keeps the other keys of the global one.[mermaid]and[mermaidExport]are unset by default (the record decides);falsedisables the plugin even when the record enables it. A[mermaidOptions]change re-renders the diagrams alone (no parse), whether[mermaid]or the record enabled Mermaid; so doesMarkdownService.rerenderMermaid(element, options, viewContainerRef), which runs thererenderhooks of both plugins (it throwsERROR_MERMAID_NOT_PROVIDEDwithoutwithMermaid()).withMermaidExport(options)takesbuttonComponent,filenamePrefixandbuttonLabel((index, filename) => string, the accessible name of the default button,Download <filename> (SVG)by default;(SVG)is appended to a label without "SVG", WCAG 2.5.3); themermaidExport*inputs are merged over them (a per-component button replaces the global one), andmermaidExported/mermaidExportErrorare emitted however the export is enabled. The export only adds buttons where Mermaid is enabled too, and needs aViewContainerRef(ERROR_MERMAID_EXPORT_VIEW_CONTAINER_REQUIRED: the render rejects before any diagram is drawn).- Plugin order: the export always runs after Mermaid, whatever their registration order (it
requiresMermaid), and does not run when Mermaid's checks fail, so a failedrerenderMermaid()leaves the existing buttons in place. A plugin that fails (e.g.withClipboard()without ClipboardJS) does not stop the diagrams. When several plugins fail, the first one in plugin order wins.
@fsegurai/marked-extended-* 19.0.0-beta.0+ (tabs,
alerts, accordions, code blocks, footnotesβ¦) plugs in with withMarkedExtended(features, options?). ngx-markdown has no dependency on those
packages: install the ones you use and pass their objects in.
import { provideMarkdown, withMarkedExtended, withPrism } from '@fsegurai/ngx-markdown';
import markedExtendedAlert from '@fsegurai/marked-extended-alert';
import markedExtendedCode, { attachCode, wrapLines } from '@fsegurai/marked-extended-code';
import markedExtendedTabs, { attachTabs } from '@fsegurai/marked-extended-tabs';
provideMarkdown({
plugins: [
withPrism(),
withMarkedExtended([
{ name: 'alert', extension: markedExtendedAlert() },
{ name: 'tabs', extension: markedExtendedTabs(), attach: attachTabs, options: { persistSelection: true } },
// Lines are wrapped after Prism (see below), so the runtime does not wrap them itself
{ name: 'code', extension: markedExtendedCode(), attach: attachCode, options: { lineClasses: false }, afterHighlight: wrapLines },
]),
],
});<markdown [data]="markdown" (extendedEvent)="onExtendedEvent($event)"></markdown>
<markdown [data]="markdown" [plugins]="{ markedExtended: false }"></markdown>
<markdown [data]="markdown" [plugins]="{ markedExtended: { runtimes: { tabs: false } } }"></markdown>- A feature is
{ name?, extension?, attach?, options?, afterHighlight? }:extensionis a Marked extension (or an array of them),attachthe package runtime (attachX),optionswhatattachreceives, andnamethe key of theruntimesoption (defaults to theruntimeNameofattach). A bare runtime is a feature too, e.g. the bundle'sattachAll:withMarkedExtended([attachAll], { runtimes: { attachAll: { tabs: { persistSelection: true } } } })(register the extensions with their own features, withoutattach). - Parse: the extensions apply to every parse the plugin is enabled in, in
marked.use()order: theirextensions(tokenizers and renderers),walkTokens,rendererandtokenizeroverrides (e.g. thelisttokenizer of@fsegurai/marked-extended-lists, for alphabetic and Roman numbering),async,hooks.preprocess, andhooks.postprocess(which runs before the sanitizer). The overrides apply to that parse only, never toMARKED_EXTENSIONSor other parses. Other fields (other hooks, Marked options) are not applied; a dev-mode warning lists them. - Sanitizer-safe: the 19 markup only uses classes,
aria-*,role,hiddenandopen, so it survives Angular's sanitizer (the defaultSecurityContext.HTML): nodisableSanitizerand nobypassSecurityTrustHtml. The runtimes then restore the behaviour in the browser. - Runtimes (browser only): each
attachruns once the HTML is in the element, withruntimes[name]when set, else the featureoptions(falseleaves the runtime out). When the same element is rendered again, the runtime is refreshed (attachX.refresh(root), orhandle.refresh()); it is disposed and attached again when it cannot refresh or its options changed (compared by value: a new object with the same values, such as a template object literal, refreshes).MarkdownService.cleanup()and the destroy of<markdown>dispose it. Nothing runs on the server; the extensions still apply to the parse. - Events: the
marked-extended:<pkg>:<event>events of the content are emitted by the(extendedEvent)output as{ type, detail, event }. The output is emitted during the dispatch, soevent.preventDefault()cancels a cancelable event (e.g.marked-extended:tabs:before-switch). A DOM listener cannot match a prefix: the known names are listed inMARKED_EXTENDED_EVENTS, andwithMarkedExtended(features, { events: ['β¦'] })adds others. - Code blocks with Prism:
withPrism()reads the class tokens of each marked-extended<pre>(mx-code-ln,mx-code-start-N,mx-code-highlight-β¦,mx-code-mark-β¦): they override the Prism options (lineNumbers,start,line,lineOffset) for that block only, and a block without tokens keeps the global and per-component options. A block with its own numbering (mx-code-ln) and no ranges highlights no line: the globallineandlineOffsetcount against the numbering it replaces. TheafterHighlightfunction of a feature runs on each block right after Prism highlights it (inside the chunked loop), or on the plain blocks when Prism is disabled, not provided or not loaded: passwrapLinesthere, andlineClasses: falsetoattachCode, since highlighting would undo a wrapping done before it. - Clipboard: with
withClipboard(), a titled block (<figure class="mx-code">with a<figcaption>) shows its title in the toolbar (.markdown-clipboard-title), and the removed lines of a diff (del={β¦},// [!code --]) are not copied (skipRemovedLines, defaulttrue). - Enabled by default once provided:
[plugins]="{ markedExtended: false }"disables it per component, and an options object (runtimes,events) is merged over thewithMarkedExtended()options (runtimeskey by key).
π Refer to the
@fsegurai/ngx-markdownMarked Extended demo for live examples of every package, with Angular's sanitizer on.
22.0.0 removes the per-plugin configuration of provideMarkdown() and its injection tokens, with no compatibility
bridge: every optional feature is now a plugin, provided with its withX() feature. A plugin works only when its
feature is provided: a component enabling a plugin that is not provided ([katex], [mermaid], [clipboard]β¦)
gets nothing, and one [ngx-markdown] warning per plugin in dev mode names the withX() to add. Prism highlighting
(and the pre attributes of [lineNumbers], [lineHighlight], [commandLine]) now needs withPrism().
| Removed | Replacement |
|---|---|
provideMarkdown({ katexOptions }), KATEX_OPTIONS |
plugins: [withKatex(katexOptions)] |
provideMarkdown({ mermaidOptions: { provide: MERMAID_OPTIONS, useValue } }), MERMAID_OPTIONS |
plugins: [withMermaid({ config })] |
provideMarkdown({ mermaidLoader }), MERMAID_LOADER |
plugins: [withMermaid({ loader })] |
provideMarkdown({ mermaidRenderTimeout }), MERMAID_RENDER_TIMEOUT |
plugins: [withMermaid({ renderTimeout })] |
provideMarkdown({ mermaidExportOptions: { provide: MERMAID_EXPORT_OPTIONS, useValue } }), MERMAID_EXPORT_OPTIONS |
plugins: [withMermaidExport(options)] |
provideMarkdown({ clipboardOptions: { provide: CLIPBOARD_OPTIONS, useValue } }), CLIPBOARD_OPTIONS |
plugins: [withClipboard(options)] |
provideMarkdown({ lightboxOptions: { provide: LIGHTBOX_OPTIONS, useValue } }), LIGHTBOX_OPTIONS |
plugins: [withLightbox(options)] |
[emoji] without a feature |
plugins: [withEmoji()] |
highlighting whenever the Prism global is loaded |
plugins: [withPrism(options?)] |
Unchanged: MARKED_OPTIONS / markedOptions, MARKED_EXTENSIONS / markedExtensions, loader, cacheSrc,
sanitize, every per-component input ([katex], [katexOptions], [mermaid], [mermaidOptions], [clipboard],
clipboard*, [emoji], [lightbox], [lightboxOptions], [mermaidExport], mermaidExport*, the Prism inputs) and
the matching ParseOptions/RenderOptions of the pipe and the service: they still win over the [plugins] record.
The option types (KatexOptions, ClipboardOptions, LightboxOptions, MermaidExportOptions, MermaidLoader,
MermaidAPIβ¦) are still exported.
Before (21.x):
provideMarkdown({
katexOptions: { throwOnError: false },
mermaidLoader: () => import('mermaid').then((m) => m.default),
mermaidOptions: { provide: MERMAID_OPTIONS, useValue: { look: 'handDrawn' } },
mermaidRenderTimeout: 60000,
mermaidExportOptions: { provide: MERMAID_EXPORT_OPTIONS, useValue: { filenamePrefix: 'chart' } },
clipboardOptions: { provide: CLIPBOARD_OPTIONS, useValue: { buttonComponent: ClipboardButtonComponent } },
lightboxOptions: { provide: LIGHTBOX_OPTIONS, useValue: { wrap: false } },
markedExtensions: [{ provide: MARKED_EXTENSIONS, useFactory: gfmHeadingId, multi: true }],
});After (22.0.0):
provideMarkdown({
plugins: [
withKatex({ throwOnError: false }),
withMermaid({
loader: () => import('mermaid').then((m) => m.default),
config: { look: 'handDrawn' },
renderTimeout: 60000,
}),
withMermaidExport({ filenamePrefix: 'chart' }),
withClipboard({ buttonComponent: ClipboardButtonComponent }),
withLightbox({ wrap: false }),
withEmoji(), // if a component uses [emoji]
withPrism(), // if the Prism global highlights your code blocks
],
markedExtensions: [{ provide: MARKED_EXTENSIONS, useFactory: gfmHeadingId, multi: true }], // unchanged
});A token provided directly (e.g. { provide: MERMAID_OPTIONS, useValue } in a component's providers) has no
replacement token: give that component's injector its own provideMarkdown({ plugins: [...] }) (it creates a
MarkdownService for it, which reads MARKED_OPTIONS and MARKED_EXTENSIONS from the parent injector).
Behaviour of a render also changed: MarkdownService.render() (and rerenderMermaid()) no longer throw when a plugin
cannot run (missing ClipboardJS, Mermaid or ViewContainerRef); every enabled plugin still runs, and the returned
Promise rejects with the first error in plugin order. <markdown> emits it through error, as before.
MARKED_EXTENSIONS (provideMarkdown({ markedExtensions }), see Marked extensions) stay fully
supported and are not part of the plugin migration: use them for a plain Marked extension (e.g. marked-gfm-heading-id)
that applies to every parse of the service. Write a plugin when the feature also needs a per-component toggle
([plugins]), options merged per component, a render hook on the element (DOM work, views) or a cleanup. Both work
together: the plugin walkTokens run before those of MARKED_EXTENSIONS, MARKED_EXTENSIONS tokenizers are tried
before the plugin tokenizers, and MARKED_EXTENSIONS renderer overrides stay in the renderer chain.
A plugin implements MarkdownPlugin<Options>; every hook is optional:
export interface MarkdownPlugin<O extends object> {
readonly name: string; // its key in [plugins]; a later registration with the same name replaces it
readonly enabledByDefault?: boolean; // runs without being enabled per component (default false)
readonly workerSafe?: boolean; // reserved for the opt-in parse worker
readonly enforce?: 'pre' | 'post'; // runs before / after the plugins without it
readonly defaults?: Partial<O>;
preprocess?(markdown: string, ctx: MarkdownParseContext<O>): string;
markedExtensions?(ctx: MarkdownParseContext<O>): MarkedExtension[];
readonly tokenizerExtensions?: readonly MarkdownTokenizerExtension<O>[];
postprocess?(html: string, ctx: MarkdownParseContext<O>): string; // before the sanitizer
postSanitize?(html: string, ctx: MarkdownParseContext<O>): string;
render?(element: HTMLElement, ctx: MarkdownRenderContext<O>): MarkdownRenderResult;
rerender?(element: HTMLElement, ctx: MarkdownRenderContext<O>): MarkdownRenderResult;
afterHighlight?(code: HTMLElement, ctx: MarkdownRenderContext<O>): void; // each code block, once highlighted
readonly highlightsCode?: boolean; // its render hook highlights the code and calls ctx.afterHighlight (Prism)
readonly incremental?: boolean; // its render hook can run on new top-level nodes alone (ctx.nodes); see below
cleanup?(element: HTMLElement): void;
}The hooks run in this order, each for the enabled plugins in plugin order: the core built-ins first, then the
enforce: 'pre' plugins, the plugins without enforce, and the enforce: 'post' plugins. Within a group, a plugin
runs after the plugins of the group it requires, in registration order otherwise (a requirement in a later group
does not move it: enforce wins; a requirement cycle keeps registration order).
preprocess: the Markdown text, after the indentation trim (and the HTML decoding of transcluded content).markedExtensions: the Marked extensions of the parse, called once per parse. TheirwalkTokensrun before those ofMARKED_EXTENSIONSand of the Marked options;async: truemakes the parse asynchronous;renderermethods run before the configured renderer (MARKED_OPTIONS,MARKED_EXTENSIONS,MarkdownService.renderer) and fall back to it when they returnfalse(the heading ids andonHeadingssee their output);tokenizermethods (list,headingβ¦) run before the configured tokenizer (MARKED_OPTIONS,MARKED_EXTENSIONS, Marked's own) and fall back to it when they returnfalse, withthis.lexerof the parse, as with Marked'suse. Both apply to that parse only, a later plugin's first. Other fields (hooks,extensionsβ¦) are ignored, with a dev-mode warning.tokenizerExtensions({ name, level, start?, tokenizer, renderer?, childTokens? }, as in Marked'sextensions) are registered once per service, but only work in the parses the plugin is enabled in; each function gets the parse context as its last argument (tokenizer(src, tokens, ctx),renderer(token, ctx)). Withouttokenizerandlevel, it is a renderer extension for tokens of typenamemade elsewhere (e.g. by awalkTokens).- Marked parses,
postprocesstransforms its HTML (like a Markedhooks.postprocess), then the core sanitizes it (sanitize). postSanitize: the sanitized HTML. What it inserts is not sanitized: escape it.render: the element holding the inserted HTML, once per render. It may return a cleanup function (or a Promise of one). The hooks start one after another, with an abort check before each, and run concurrently;render()(and thereadyoutput) waits for them. Each hook is isolated: one that throws synchronously stops neither the others nor theirsignal;render()never throws, it rejects with the first error in plugin order (thrown or rejected) once every started hook has settled. Only the plugins thatrequirea plugin whose hook threw do not start.afterHighlight(code, ctx): called in the browser for each code block (pre > code) once it is highlighted: by the plugin that highlights code (highlightsCode: true, i.e. Prism, right after each block, inside its chunked loop), or, when none is enabled in the render, by the runner itself on the plain blocks, after a yield, in chunks. It stops once the render is aborted; an error is reported (console.error) without stopping the others. E.g. a line wrapping that highlighting would undo. A highlighting plugin callsctx.afterHighlight(code)after each block, orctx.afterHighlightAll()when it highlighted nothing block by block.- Cleanup: before the next render of the element, on
MarkdownService.cleanup()and when<markdown>is destroyed, the cleanup functions run (last first), then each plugin'scleanup(element).
Incremental updates: when every enabled plugin with a render hook declares incremental: true (every built-in plugin
does), a
data/src change of <markdown> that leaves some top-level blocks unchanged keeps their nodes, and render runs
with ctx.nodes set to the new top-level nodes alone. Such a hook enhances only those nodes and their descendants (e.g.
node.querySelectorAll('pre > code'), which also finds the code of a top-level <pre>), may read the whole element,
and never adds, removes or moves top-level nodes (wrapping one, as the clipboard does with a top-level <pre>, is fine).
ctx.nodes is empty when an update only removed nodes. The cleanups of an update run when a later update removes one of
its nodes (its nodes are then replaced together) and on the cleanup of the element. ctx.onCleanup(fn, node) ties a
cleanup to one node (e.g. its button view): it runs when an update removes that node, or on the cleanup of the element.
Without incremental, every change is a whole render.
Two more fields: validate(ctx) runs for every enabled plugin before any preprocess hook (throw there when the parse
cannot run, e.g. a missing library: no other hook, and no onFrontMatter, has run), and exclusiveOptions lists groups
of options that replace each other across the option layers (e.g. [['buttonComponent', 'buttonTemplate']]: a
per-component template replaces a global component). nestedOptions lists options whose object values are merged key
by key across the layers (Mermaid's config); requires names the plugins this one works with (it runs after them,
does not run when one of their hooks threw in the same render, and a dev-mode warning says when one is not provided); rerender(element, ctx) is a partial re-render outside of the full render (no parse, no
cleanup of the other plugins), e.g. MarkdownService.rerenderMermaid().
Each hook gets a context: options (merged as above), platform ('browser' or 'server'), injector (the injector
of the MarkdownService), state (a fresh object per parse, shared by the parse hooks and tokenizer extensions of that
parse, and per render) and serviceState (one object per plugin and service, e.g. for a cache). The render context adds signal (aborted when the render is superseded or the element cleaned up),
viewContainerRef, onCleanup(fn, node?) (registers a cleanup at once, e.g. before asynchronous work: it still runs
when the hook later rejects; with node, it belongs to that node and runs when an incremental update removes it) and isEnabled(name) (whether another plugin is enabled in the same render).
Example: open the external links of the content in a new tab.
import { type MarkdownPlugin, type MarkdownPluginFeature, withMarkdownPlugin } from '@fsegurai/ngx-markdown';
interface ExternalLinksOptions {
target?: string;
rel?: string;
}
const externalLinksPlugin: MarkdownPlugin<ExternalLinksOptions> = {
name: 'externalLinks',
defaults: { target: '_blank', rel: 'noopener noreferrer' },
render(element, { options }) {
const links = Array.from(element.querySelectorAll<HTMLAnchorElement>('a[href^="http://"], a[href^="https://"]'));
for (const link of links) {
link.setAttribute('target', options.target ?? '_blank');
link.setAttribute('rel', options.rel ?? 'noopener noreferrer');
}
return () => {
for (const link of links) {
link.removeAttribute('target');
link.removeAttribute('rel');
}
};
},
};
export function withExternalLinks(options?: ExternalLinksOptions): MarkdownPluginFeature {
return withMarkdownPlugin(externalLinksPlugin, options);
}
// app.config.ts
provideMarkdown({ plugins: [withExternalLinks({ rel: 'noopener' })] });<markdown [data]="markdown" [plugins]="{ externalLinks: true }"></markdown>withMarkdownPlugin(plugin, options?, providers?) returns the feature; its third argument adds providers the plugin
reads through ctx.injector. This example is tested end to end in the library (external-links.example.spec.ts).
Tokens can be rendered in a custom manner by either...
- providing the
rendererproperty with theMarkedOptionsofprovideMarkdown()in your application providers (see Configuration section) - using
MarkdownServiceexposedrenderer
Here is an example of overriding the default heading token rendering through MarkdownService by adding an embedded anchor tag like on GitHub:
import {Component, OnInit} from '@angular/core';
import {MarkdownService, MarkedToken} from '@fsegurai/ngx-markdown';
@Component({
selector: 'app-example',
template: '<markdown># Heading</markdown>',
})
export class ExampleComponent implements OnInit {
constructor(private markdownService: MarkdownService) {
}
ngOnInit() {
this.markdownService.renderer.heading = ({text, depth}: MarkedToken.Heading): string => {
const parsedText = this.markdownService.parseInline(text); // Parse inline Markdown text to HTML
const escapedText = text
.toLowerCase()
.split(/\W+/)
.filter(Boolean)
.join('-'); // Remove special characters and join words with hyphens. E.g. "Hello, World!" -> "hello-world"
return '<h' + depth + '>' +
'<a name="' + escapedText + '" class="anchor" href="#' + escapedText + '">' +
'<span class="header-link"></span>' +
'</a>' + parsedText +
'</h' + depth + '>';
};
}
}This code will output the following HTML:
<h1>
<a class="anchor" href="#heading">
<span class="header-link"></span>
</a>
Heading
</h1>π Follow official marked.renderer documentation for the list of tokens that can be overridden.
In some situations, you might need to re-render Markdown after making changes. If you've updated the text, this would be done automatically, however, if the changes are internal to the library such as rendering options, you will need to inform the MarkdownService that it needs to update.
To do so, inject the MarkdownService and call the reload() function as shown below.
import {MarkdownService} from '@fsegurai/ngx-markdown';
constructor(private markdownService: MarkdownService){
}
update(){
this.markdownService.reload();
}π Refer to the
@fsegurai/ngx-markdownre-render demo for a live example.
Each MarkdownService keeps small caches (the last 50 entries each), so repeated renders do less work:
srctext (opt-in): withprovideMarkdown({ cacheSrc: true }), the samesrcis fetched once and shared by every<markdown>that loads it, including while the request is pending and when a component is mounted again. There is no expiry: a re-mounted component gets the cached text, not updated server content. A failed request is not cached, andreload()drops the cache, so everysrcis fetched again. Off by default: every load fetches, and the browser HTTP cache applies.- KaTeX output: identical math with the same
displayModeand options is rendered once. An expression KaTeX throws on is never cached, nor math whose options hold a class instance or a symbol (they have no stable key); with amacrosoption, a parse stops using the cache once an expression may define a global macro (\gdef,\newcommandβ¦). - Mermaid SVG: an identical diagram (same source and merged options, including the theme) is drawn from the cache with fresh element ids. Diagrams with interactions (
click,call,href,callback,link) and failed diagrams are always rendered by Mermaid. An explicitrerenderMermaid()never reads the cache (it refreshes it), so it still redraws after the web fonts load or after a globalmermaid.initialize().
When using static Markdown, you are responsible to provide the code block with a related language.
<markdown ngPreserveWhitespaces>
+ ```typescript
const myProp: string = 'value';
+ ```
When using remote URL `@fsegurai/ngx-markdown` will use the file extension to automatically resolve the code language.
```html
<!-- will use HTML highlights -->
<markdown [src]="'path/to/file.html'"></markdown>
<!-- will use php highlights -->
<markdown [src]="'path/to/file.php'"></markdown>When using variable binding you can optionally use language pipe to specify the language of the variable content (default value is Markdown when pipe is not used).
<markdown [data]="markdown | language : 'typescript'"></markdown><markdown> renders long documents without one long main-thread task. Nothing needs to be configured:
- Time-sliced parse: in the browser, content of 16 KiB or more is parsed and sanitized in slices of about 8 ms,
yielding to the browser in between, instead of one task of 100β200 ms. Marked's steps run one by one (the block
lexing, then the inline lexing of each block,
walkTokensand the rendering of each top-level block), and Angular's HTML sanitizer runs on chunks of the HTML that end where every tag is closed. The HTML, the headings and the front matter are those ofMarkdownService.parse(), and a newer content stops the parse of the previous one. - Progressive insertion: a large rendered document (16 KB of HTML or more) is inserted in three batches of top-level elements, one frame apart (the top of the document first, then the rest in two halves).
- Chunked plugins: highlighting and clipboard buttons work in chunks.
- Incremental updates: when
dataorsrcchanges (an editor preview, a playground), only the top-level blocks whose HTML changed are replaced (see below).
headings, metadata and ready are emitted once the whole document is in place.
The time-sliced parse falls back to a whole parse on the server, for an asynchronous parse (an async Marked
extension), with Marked's silent option, and with extensions that replace hooks.provideLexer or
hooks.provideParser. The sanitizer runs whole with a sanitize function (e.g. DOMPurify) or a postprocess hook.
Marked extensions that keep state between parses and reset it in a hook (e.g. marked-gfm-heading-id, the marked-extended
footnotes) stay correct: when another parse runs between two slices of such a parse, that parse starts again, whole.
Turn it off with provideMarkdown({ timeSlicing: false }). The markdown pipe parses in time slices too (it is
asynchronous already; a new value stops the parse of the previous one, whose Promise resolves to ''), and
MarkdownService.parse() stays synchronous.
On a 5000-line document (median of 9 runs, alternated with the previous build) it lowered the total blocking time from
435 to 383 ms in Chromium and from 816 to 685 ms in Firefox with Angular's HTML sanitizer, and from 410 to 187 ms in
Chromium without a sanitizer, without delaying ready. The long tasks left are the layout and paint of the inserted
content, which contentVisibility reduces.
Incremental updates. When data or src changes, the new content is still parsed whole (reference links,
footnotes, heading ids and front matter always see the whole document), then compared with the content on screen, top-level
block by top-level block. Unchanged HTML (with the same inputs) is not written again and no plugin runs again. Otherwise
only the top-level nodes between the first and the last block that differ are replaced: the others keep their DOM nodes
and what the plugins did on them (Prism highlighting, line numbers, copy buttons, drawn diagrams and their export
buttons, lightbox images, marked-extended runtime state such as a selected tab), and the plugins run on the new nodes
alone; what they did on the removed nodes is released. The DOM
is the one a whole render gives, and headings, metadata and ready are emitted as usual. A whole render is used
when a plugin with a render hook that is not incremental is enabled (every built-in plugin is incremental or has no
render hook), when
a rendering input changed since the last render, when the plugins of the content on screen have not finished (or
failed), when more than half of the blocks changed, on MarkdownService.reload() and on the server. Turn it off with
provideMarkdown({ incrementalUpdates: false }) (MARKDOWN_INCREMENTAL_UPDATES).
Editing a 5000-line document with Prism, KaTeX and emoji (a paragraph typed near the middle, 8 edits, median of 3 runs)
took 49 ms from the change to ready with no long task in Chromium (255 ms and 1280 ms of total blocking time when
rendered whole), and 225 ms with 917 ms of blocking time in Firefox (496 and 3385 ms whole; 181 and 468 ms with
contentVisibility). Firefox lays out the whole document after any change to it.
The markdown pipe binds its HTML with [innerHTML], which sets it at once (one long layout task) and replaces
everything on each change: use <markdown [data]> for long or edited documents.
The browser still has to lay out and paint the whole document. For very long documents, turn on contentVisibility:
the top-level blocks (headings excepted) get content-visibility: auto, so the browser skips the style, layout and
paint of the blocks that are off screen.
<markdown contentVisibility [src]="'docs/handbook.md'"></markdown>// or for every <markdown> of the app ([contentVisibility]="false" still turns it off for one)
provideMarkdown({ contentVisibility: true });On a 5000-line document it lowered the total blocking time from 425 to 170 ms in Chromium and from 800 to 567 ms in Firefox, and the longest task from 275 to 157 ms in Chromium (median of 7 runs, Angular's HTML sanitizer on). It is opt-in because each block becomes a container, on screen or not:
- Overflow is clipped: paint containment clips what overflows a block (a box shadow, a list marker hanging outside a list without padding, a focus ring). Headings are left out, so a heading anchor icon placed outside the heading still shows.
- Margins of nested elements no longer collapse through the block (e.g. the margin of the first paragraph in a
<blockquote>stays inside it). - Estimated heights: until a block has been shown once, its height is an 80px estimate (then its real height is
kept), so the scrollbar size can shift while scrolling, and a jump far down the page (
scrollIntoView(), a#sectionlink) can land up to a few hundred pixels off, because the blocks around the target get their real height only once they are shown. A secondscrollIntoView()after a frame lands exactly. Override the estimate with any rule (the built-in one has no specificity), e.g.markdown > * { contain-intrinsic-block-size: auto 120px; }for content with taller blocks.
Unaffected: the content stays in the DOM, so find-in-page, text selection and accessibility still see it; printing
renders every block; Mermaid, KaTeX, Prism, the clipboard buttons and the lightbox work as usual. The rule ships with the
component (.markdown-content-visibility, a class on the host, exported as CONTENT_VISIBILITY_CLASS), so nothing has
to be added to your styles; the class also scopes an override of the estimate to the components that have it on, e.g.
markdown.markdown-content-visibility > * { contain-intrinsic-block-size: auto 120px; }.
The library is SSR-safe: it works with server-side rendering, static prerendering (outputMode: "static") and client hydration (provideClientHydration()). Browser-only work (syntax highlighting, clipboard, Mermaid, emoji) runs only in the browser (KaTeX math is rendered on the server only when katex is available there, see Math rendering), and the markdown component and pipe register pending tasks, so the server waits for the rendered markdown before it serializes the page.
Hydration and transcluded content. On the server, a <markdown> with transcluded content (<markdown># Title</markdown>)
replaces its projected text with the rendered HTML, so it also keeps the Markdown source in a data-markdown-transclusion
attribute of its host (the source is in the page twice). The hydrated client renders that source and removes the
attribute, instead of parsing the rendered HTML again as Markdown, and Angular hydrates the component without
ngSkipHydration. [data] and [src] need nothing: the client renders them again (with [src], Angular's HTTP transfer
cache answers the request made on the server).
When you use [src] during SSR or prerendering, keep HttpClient on its default Fetch backend (the default since Angular 22). The Angular build serves the application's own assets to the prerenderer through fetch, so the XHR backend (withXhr()) cannot load them.
To see the components in action, check out the [DEMO].
To set up the demo locally, follow the next steps:
git clone https://github.com/fsegurai/ngx-markdown.git
bun install
bun startThis will serve the application locally at http://localhost:4200.
Licensed under MIT.
