mdlib-pure
Documentation pages
On this page

ngx-mdlib-pure #

Markdown editor with live preview and Markdown viewer for Angular 21 and 22: standalone components, signals, forms (ngModel, reactive forms), server-side rendering with hydration. Plus a service for the AST, the table of contents and HTML, in the browser and on the server.

<mdlib-editor [(value)]="body" plugins="all" style="block-size: 30rem" />
<mdlib-markdown [source]="body()" plugins="all" />

Built on mdlib-pure: CommonMark 0.31.2 (652/652 spec examples), GitHub Flavored Markdown, math (KaTeX), Mermaid diagrams, containers, definition lists, highlights, front matter. Safe by default.

  • The preview never waits for Angular. Markdown is rendered in the browser on every keystroke; your signal or form control follows after a short typing pause (200 ms by default) and at once on blur.
  • The tag is the Web Component. <mdlib-editor> and <mdlib-markdown> are Angular components whose host element is mdlib-pure's custom element: no CUSTOM_ELEMENTS_SCHEMA, typed inputs, no wrapper element.
  • A real form control: ControlValueAccessor with validators, touched on blur, the disabled state, ng-invalid/ng-touched classes.
  • SSR: works with @angular/ssr and hydration. The elements upgrade in the browser; MarkdownService also runs on the server.
  • KaTeX and Mermaid are yours to add: pass () => import('mermaid'); they are downloaded only by pages whose Markdown has math or a diagram.
  • No dependencies besides Angular and mdlib-pure (peer dependencies).
  • A podcast rundown editor too, in ngx-mdlib-pure/podcast: see PodcastEditor.

Contents: How it works · Install · Setup · Editor · Forms · Viewer · Plugins · KaTeX and Mermaid · Named configs · MarkdownService · SSR and hydration · Styling · Security · Troubleshooting · Building from source

How it works #

mdlib-pure ships two Web Components (mdlib-pure/element): <mdlib-markdown> renders Markdown into itself, <mdlib-editor> is a toolbar, a text area and a live preview. They build DOM nodes directly (no HTML parsing) and are the same elements the Blazor wrapper uses.

This package adds Angular on top, without a second renderer:

  • provideMdlibPure() defines the elements in the browser, with your loaders and named configs, before the app renders or hydrates.
  • MarkdownEditor (selector mdlib-editor) and MarkdownViewer (selector mdlib-markdown) only set the element's attributes from their inputs and listen to its events. The element renders its own content.
  • The editor's debounced input event drives [(value)] and the form control; its change event (blur) marks the control touched. When Angular writes back a value the editor already sent while the user keeps typing, the element recognizes the echo and ignores it: typing is never overwritten.

Install #

Not on the npm registry yet: neither ngx-mdlib-pure nor mdlib-pure is published, so npm install ngx-mdlib-pure fails. Until the first release there, install both tarballs from the GitHub releases (angular-v0.2.0, mdlib-pure-v0.1.1):

npm install https://github.com/MichelePolo/mdlib-pure/releases/download/mdlib-pure-v0.1.1/mdlib-pure-0.1.1.tgz \
  https://github.com/MichelePolo/mdlib-pure/releases/download/angular-v0.2.0/ngx-mdlib-pure-0.2.0.tgz
npm install katex mermaid   # optional: math and diagrams

npm records the URLs in your package.json and the checksums in package-lock.json, so npm ci works on CI and for your colleagues. Once published: npm install mdlib-pure ngx-mdlib-pure.

To build the tarballs yourself instead:

  1. Build them from a clone of the repository (the Angular CLI 22 needs Node ^22.22.3, ^24.15 or >=26):

    npm ci                              # mdlib-pure's dev tools
    npm ci --prefix bindings/angular    # Angular, ng-packagr…
    npm run angular:build               # mdlib-pure dist/, then the Angular package and its demo
    npm run angular:pack                # → bindings/angular/dist/mdlib-pure-0.1.1.tgz
                                        #   bindings/angular/dist/ngx-mdlib-pure-0.2.0.tgz
    
  2. Copy them into your app (for example a vendor/ folder, committed) and install both:

    npm install ./vendor/mdlib-pure-0.1.1.tgz ./vendor/ngx-mdlib-pure-0.2.0.tgz
    

    npm records them as file:vendor/…tgz in your package.json.

Peer dependency Version
@angular/core, @angular/common, @angular/forms ^21.0.0 || ^22.0.0
mdlib-pure ^0.1.1

Setup #

app.config.ts

import { provideMdlibPure } from 'ngx-mdlib-pure'

export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(routes),
    provideClientHydration(),
    provideMdlibPure(), // with loaders and configs: see below
  ],
}

With SSR, app.config.server.ts merges this config as usual: nothing to add there. On the server the provider defines no element.

Styles, in src/styles.css:

@import 'mdlib-pure/theme.css';   /* Markdown typography, on every .md */
@import 'mdlib-pure/editor.css';  /* the editor's toolbar and panes */

Components: import them where you use them (standalone).

import { MarkdownEditor, MarkdownViewer } from 'ngx-mdlib-pure'

@Component({
  imports: [MarkdownEditor, MarkdownViewer],
  template: `
    <mdlib-editor [(value)]="body" plugins="all" style="block-size: 30rem" />
    <mdlib-markdown [source]="body()" plugins="all" />
  `,
})
export class ArticlePage {
  readonly body = signal('# Release notes')
}

<mdlib-editor>: MarkdownEditor #

A toolbar (bold, italic, heading, link, lists, quote, code; Ctrl+B / Ctrl+I / Ctrl+K), the Markdown source and the live preview, side by side (split), source only (write) or preview only (preview).

<!-- Two-way binding to a signal (or a plain property) -->
<mdlib-editor [(value)]="body" plugins="all" style="block-size: 30rem" />

<!-- Save after each typing pause, and on blur -->
<mdlib-editor [value]="draft()" (valueChange)="saveDraft($event)" (valueCommit)="saveNow($event)" delay="1000" />
Input Type Default
value string '' The Markdown. Two-way: [(value)]. Updated after each typing pause, and at once on blur.
plugins MarkdownPlugins none 'all', one name or a list: see Plugins.
config string Name of a named config.
view 'split' | 'write' | 'preview' 'split' Panes shown at first; the toolbar's buttons switch them in the browser.
delay number 200 Milliseconds of typing pause before valueChange.
label string 'Markdown' Accessible name of the text area.
name string Form field name for native form posts (the element is form-associated).
disabled boolean false Disables the editor. With forms, use the control's disable() instead.
Output Payload
valueChange string After each typing pause (and on blur, if a change is pending). Part of [(value)].
valueCommit string On blur, with the text at that moment.
Member
element The <mdlib-editor> element (.value is the current text).
focus(options?) Focus the text area.
refresh() Render the preview again.

Give the editor a height (style="block-size: 30rem" or a CSS rule): the panes fill it and scroll. Any other attribute (id, class, aria-*, data-*) goes on the element as usual.

Forms #

The editor is a ControlValueAccessor: use it like an <input>.

@Component({
  imports: [MarkdownEditor, ReactiveFormsModule],
  template: `
    <form [formGroup]="form" (ngSubmit)="save()">
      <mdlib-editor formControlName="body" [plugins]="['gfm']" label="Description" />
      @if (form.controls.body.touched && form.controls.body.hasError('required')) {
        <p class="error">A description is required.</p>
      }
      <button [disabled]="form.invalid">Save</button>
    </form>
  `,
})
export class EditArticle {
  readonly form = new FormGroup({ body: new FormControl('', { nonNullable: true, validators: Validators.required }) })
}
<!-- Template-driven -->
<mdlib-editor [(ngModel)]="notes" name="notes" required />
  • Value: the control gets the text after each typing pause and on blur; setValue(), reset() and patchValue() reach the editor. updateOn: 'blur' works.
  • Touched on blur. Angular's ng-valid/ng-invalid, ng-dirty, ng-touched classes are on the <mdlib-editor> element: mdlib-editor.ng-invalid.ng-touched { border-color: crimson; }.
  • Disabled (control.disable(), [disabled] with ngModel): the element gets disabled and behaves like a disabled <textarea>: no typing, the formatting buttons are disabled (the view buttons still work), its value is left out of native form data, and it matches :disabled. An enclosing <fieldset disabled> does the same. editor.css dims it; restyle it with mdlib-editor:disabled { … }.
  • Native forms: with name, the text is submitted with a plain <form> like a <textarea> (the element is form-associated). ngModel's name sets it too.

<mdlib-markdown>: MarkdownViewer #

A read-only viewer. The element always has the class md, the one the theme styles.

<mdlib-markdown [source]="article.body" [plugins]="['gfm', 'math']" class="article" />
Input Type
source string The Markdown.
plugins MarkdownPlugins See Plugins. Default: plain CommonMark.
config string Name of a named config.

Members: element (the <mdlib-markdown> element), refresh().

<mdlib-podcast-editor>: PodcastEditor #

The rundown editor of mdlib-pure's podcast sample, from the secondary entry point ngx-mdlib-pure/podcast: @name badges and completion, planned durations [](time:M:SS:CC), checkboxes, supporting documents [scheda](name), with a live preview. It is the same element as the Blazor wrapper's PodcastEditor. The whole episode is bound as a typed PodcastEpisode:

import { PodcastEditor, type PodcastEpisode, podcastEpisode } from 'ngx-mdlib-pure/podcast'

@Component({
  imports: [PodcastEditor],
  template: `
    <mdlib-podcast-editor [(value)]="episode" [participants]="people" delay="300" />
    <p>Planned: {{ episode().durationCentiseconds / 100 }} s</p>
  `,
})
export class RundownPage {
  readonly people = ['Nora', 'Elio', 'Vera']
  readonly episode = signal<PodcastEpisode>(podcastEpisode({
    source: '# An imaginary garden\n\n[ ] @Nora Introduction [](time:5:00:00)\n\n[scheda](Notes)',
    documents: { Notes: '@Elio Ideas for the garden.' },
  }))
}

Available since 0.2.0 (GitHub release angular-v0.2.0).

PodcastEpisode has source (the main rundown), documents (supporting documents by name), activeDocument ('Scaletta' for the main rundown) and durationCentiseconds (computed by the browser from the main rundown's time tags). podcastEpisode({ … }) fills in the defaults. The participant names and the default sample names are fictional; names contain 1–32 letters, digits, underscores or hyphens.

Input Type Default
value PodcastEpisode empty episode Two-way: [(value)]. Updated after each typing pause, and at once when focus leaves the editor.
participants string[] the sample's names @ completion and badges. Live.
view 'split' | 'write' | 'preview' 'split' Panes shown at first.
delay number 200 Milliseconds of typing pause before valueChange.
label string 'Sorgente Markdown' Accessible name of the text area.
name string Form field name: native form posts send the episode as JSON.
disabled boolean false Makes the editor inert and leaves it out of form data. With forms, use disable().
grammar PodcastGrammar the sample's Read when the element connects: durations ('centiseconds' or 'clock': m:ss, h:mm:ss, 5m, 90s), tags (mentions: 'participants' or 'any'; hashtags), lineBlocks, looseTasks.
tagColors Record<string, string> Fixed tag colours by name, ignoring case. Live.
tasks 'edit' | 'notify' 'edit' notify: a checkbox click leaves the source alone and emits taskToggle; your app changes value.
documents 'local' | 'host' 'local' host: no tabs; a document button emits openDocument.
readOnly boolean false Preview only; checkboxes and document buttons stay live. Live.
compact boolean false No header, legend, status bar or pane labels. Read when the element connects.
currentLine number | null null 0-based source line highlighted in the preview. Live.
runningTimer PodcastTimer | null null { line, index, text? }: the timer chip shown as running; text replaces its planned value (a countdown). Live.
Output Payload
valueChange PodcastEpisode Part of [(value)].
valueCommit PodcastEpisode When focus leaves the editor.
taskToggle PodcastTaskEvent { document, id, checked, line, box } (0-based), with tasks="notify".
openDocument PodcastDocumentEvent { name, title }, with documents="host".
activeDocumentChange string The document shown, when the tabs switch.

It is a ControlValueAccessor too ([formControl], formControlName, [(ngModel)]): the control's value is the PodcastEpisode, touched when focus leaves the editor. The element is defined the first time a PodcastEditor is created in the browser (nothing to add to provideMdlibPure()); on the server it renders as an empty element with its attributes, like the other components. Its markup and styles live in a shadow root, so instances never share IDs; customize its tokens through the exposed part, in a global stylesheet:

mdlib-podcast-editor::part(editor) {
  --podcast-accent: #b8e986;
  --podcast-height: 60vh;     /* panes height (default 650px) */
  --podcast-font-size: 18px;  /* preview text (default 15px) */
}

The demo's /podcast page shows two-way binding, live participants, replacement from code, document tabs and a form control.

Plugins #

plugins (inputs) and the plugins argument of MarkdownService take 'all', one name, or a list of names (MarkdownPlugins). Nothing (the default), null or [] is plain CommonMark. Typos are compile errors: plugins="gfm" and [plugins]="['gfm', 'math']" type-check, plugins="gmf" does not.

Name Syntax
gfm GitHub Flavored Markdown: tables, ~~strikethrough~~, autolinks (www.example.com), task lists (- [x]), footnotes ([^1]), alerts (> [!NOTE])
headings GitHub-style heading ids (## Next steps → id="next-steps"), for anchors and the TOC
math $inline$, $$display$$, typeset by KaTeX (loader)
mermaid ```mermaid code blocks, drawn by Mermaid (loader), colored from the theme (light and dark)
container ::: warning Title … ::: (note, tip, important, warning, caution, or any word)
deflist Term then : Definition
mark ==highlight==
frontmatter YAML (---) or TOML (+++) at the top, kept out of the output
'all' all of the above

MARKDOWN_PLUGINS lists the names.

KaTeX and Mermaid (loaders) #

Neither is a dependency of this package. Install them in your app and pass loaders: the bundler splits them into lazy chunks, and the elements call a loader only when a document has math or a diagram.

provideMdlibPure({
  loaders: {
    mermaid: () => import('mermaid'),
    katex: () => import('katex/contrib/auto-render').then((m) => m.default),
    katexStylesheet: '/katex/katex.min.css',
  },
})

The KaTeX stylesheet and its fonts must be served by your app: copy them with angular.json → build.options.assets:

{ "glob": "{katex.min.css,fonts/*.woff2}", "input": "node_modules/katex/dist", "output": "katex" }
  • The katex loader wraps renderMathInElement because KaTeX types its first parameter as HTMLElement; the elements always pass an HTMLElement.
  • The stylesheet is added once, when math first appears (as an adopted stylesheet: frameworks that morph the page cannot remove it).
  • Mermaid is large (the demo's lazy chunks total several MB, split per diagram type) but only pages with a diagram download it. Its CommonJS dependencies print build warnings; silence them with "allowedCommonJsDependencies": ["@braintree/sanitize-url", "cytoscape-cose-bilkent", "cytoscape-fcose", "dayjs", "elkjs"].
  • With SSR, the loaders also end up in the server bundle (the config is shared) but never run there.

Named configs (highlighting, URL policy) #

mdlib-pure options that are functions (highlight, allowUrl, attributes, sanitize) or that change what is safe (html) can only be set in TypeScript, under a name, never from a template:

provideMdlibPure({
  configs: {
    docs: {
      // External links open in a new tab.
      attributes: (node) => (node.type === 'link' && /^https?:/i.test(node['url'] as string) ? { target: '_blank', rel: 'noopener noreferrer' } : null),
      // Only links to our own domains (default: relative, http(s), mailto, tel, sms).
      allowUrl: (url) => url.startsWith('/') || /^https:\/\/([a-z0-9-]+\.)*example\.com\//i.test(url),
      // Classes per node type (Tailwind…), syntax highlighting:
      classes: { heading: 'font-semibold' },
      highlight: (code, lang) => (lang && hljs.getLanguage(lang) ? hljs.highlight(code, { language: lang }).value : null),
    },
  },
})
<mdlib-editor [(value)]="body" config="docs" />
<mdlib-markdown [source]="body()" config="docs" />

MarkdownService uses the same configs (service.render(text, 'all', 'docs')). Options reference: mdlib-pure's docs/api.md.

MarkdownService #

mdlib-pure from TypeScript, synchronous, in the browser and on the server.

@Injectable({ providedIn: 'root' })
class MarkdownService {
  parse(source: string, plugins?: MarkdownPlugins | null, config?: string | null): Root
  toc(source: string, plugins?: MarkdownPlugins | null, config?: string | null): TocEntry[] // [{ depth, id, text }]
  render(source: string, plugins?: MarkdownPlugins | null, config?: string | null): string
  instance(plugins?: MarkdownPlugins | null, config?: string | null): Markdown // the elements' shared instance
}
readonly #markdown = inject(MarkdownService)
readonly body = signal('# Title')

// A table of contents that follows the editor, rendered on the server too.
readonly outline = computed(() => this.#markdown.toc(this.body(), 'all'))

// The AST (mdast style): links, images, open tasks…
links() {
  const out: string[] = []
  visit(this.#markdown.parse(this.body(), 'gfm'), (n) => { if (n.type === 'link') out.push(n['url'] as string) })
  return out
}

(visit is exported by mdlib-pure.)

  • toc() always enables headings, so its ids match a viewer with the headings plugin (<a [href]="'#' + h.id">).
  • render() returns the viewer's HTML before KaTeX and Mermaid run (math stays \(…\), diagrams <pre class="mermaid">).
  • On the server the provider registers your configs with mdlib-pure's full HTML entity table (mdlib-pure/entities, loaded only by the server bundle), so &copy; or &ndash; decode as in the browser. Add provideMdlibPure() to the app config: without it the server only knows &amp; &lt; &gt; &quot; &apos; &nbsp;.
  • Node types and properties: mdlib-pure's docs/api.md and each plugin's source header.

SSR and hydration #

Server Browser
<mdlib-editor>, <mdlib-markdown> rendered empty, with their attributes defined by provideMdlibPure(), they render themselves
<mdlib-podcast-editor> rendered empty, with its attributes defined by the first PodcastEditor, renders in its shadow root
[(value)], forms, outputs initial value in the value attribute live
MarkdownService ✓ (entities included) ✓
KaTeX, Mermaid not loaded loaded on demand
  • Nothing is rendered on the server inside the elements: the Markdown text is not in the server HTML (no SEO for it), by design, like the Blazor wrapper. The rest of the page (and anything you compute with MarkdownService, like an outline) is.
  • Hydration: the components carry ngSkipHydration, Angular's opt-out for DOM it does not own. Angular keeps the element, empties it, and the element puts its content back at once (the editor keeps its text and toolbar). Everything else on the page is hydrated normally; there are no hydration mismatch errors. Tested end to end with Angular 22's default provideClientHydration() (event replay and incremental hydration on).
  • Client-side navigation: components created in the browser behave the same.
  • Zoneless: the components use signals and are OnPush; nothing depends on zone.js (the demo is zoneless, the default of new Angular apps).

Styling #

mdlib-pure/theme.css styles every .md container (the viewer, the editor's preview); mdlib-pure/editor.css the editor (.mdlib-editor-toolbar, .mdlib-editor-input, .mdlib-editor-preview). All their rules are in @layer mdlib with zero specificity: any rule of yours wins.

  • Customize with the --md-* tokens (docs/tokens.md):

    :root { --md-color-link: #0f6cbd; --md-font-heading: "Segoe UI", sans-serif; }
    
  • Dark mode follows color-scheme: <meta name="color-scheme" content="light dark"> in index.html, or :root { color-scheme: light dark; }.

  • Tailwind CSS v4: @import "tailwindcss"; @import "mdlib-pure/tailwind.css"; then @import 'mdlib-pure/editor.css';.

  • Put these imports in a global stylesheet (styles.css), not in a component's styles: Angular's view encapsulation would scope them away from the elements' content.

Security #

  • Raw HTML is off: <script>, <iframe>, onerror=… in the Markdown are shown as text. It cannot be turned on from a template; only a named config (html: true, ideally with sanitize) can, for trusted content.
  • URL allowlist: every href/src goes through it. Allowed: relative URLs, http:, https:, mailto:, tel:, sms:, and data:image/png|gif|jpeg|webp for images. javascript: and other schemes lose their URL.
  • No HTML parsing: the elements build DOM nodes directly; the components only set attributes. No innerHTML, no bypassSecurityTrust….
  • MarkdownService.render() output is safe with the default options. If you bind it with [innerHTML], Angular's sanitizer runs too (and may strip attributes it does not know); prefer <mdlib-markdown>. Never trust HTML from a config with html: true and no sanitize.
  • Mermaid runs with securityLevel: 'strict' (no HTML labels, no click handlers). KaTeX runs with its defaults (trust: false: no \href, no \includegraphics).
  • Heading ids (headings plugin) come from the content: a document can create an element with id="config", which browsers expose as window.config ("DOM clobbering"). With untrusted authors, do not rely on global variables named like possible headings, or leave headings off.
  • Content-Security-Policy: everything is served from your origin (no CDN, no eval). The KaTeX stylesheet is fetched (connect-src 'self'); Mermaid inserts <style> elements into its SVGs, which a style-src without 'unsafe-inline' blocks. Not tested under a strict policy.

More: mdlib-pure's docs/security.md.

Troubleshooting #

Symptom Cause
The elements stay empty provideMdlibPure() is missing from the app config (nothing defines the elements).
Build warnings Module 'dayjs' used by '…mermaid…' is not ESM Mermaid's CommonJS dependencies: see allowedCommonJsDependencies above.
TS2322 on the katex loader Wrap renderMathInElement as shown above.
TS2322 on plugins="gfm math" Use a list: [plugins]="['gfm', 'math']".
Math shows as \(x^2\), diagrams as source No katex/mermaid loader, or the math/mermaid plugin is not enabled.
Math has no fonts katexStylesheet URL not served: check the assets entry.
&ndash; stays literal in server-rendered MarkdownService output provideMdlibPure() missing from the config the server uses.
Text typed in the editor reverts Some code sets the value to an older one while the editor is not focused (after a button click): the element applies values that are not echoes of its own.
The Angular CLI requires a minimum Node.js version Angular CLI 22 needs Node ^22.22.3, ^24.15 or >=26 (mdlib-pure itself needs ≥ 22.18).

Building from source #

This folder lives in the mdlib-pure repository (bindings/angular/), an Angular CLI workspace with its own package.json:

bindings/angular/
  projects/ngx-mdlib-pure/   the library (ng-packagr): src/lib/*.ts and *.spec.ts
    podcast/                 the ngx-mdlib-pure/podcast entry point (PodcastEditor)
  demo/                      SSR demo app (zoneless): editor, viewer, service, forms, podcast
  scripts/podcast-element.mjs  bundles templates/podcast/element.js (shared with Blazor) and its
                             styles into podcast/src/element.generated.ts, before `ng build` and `ng test`
  scripts/package-files.mjs  copies this README and the LICENSE into the package
  scripts/fresh-deps.mjs     drops Vite's dependency caches before `ng test` and `ng serve`
  dist/                      build output (gitignored): ngx-mdlib-pure/, demo/, *.tgz

From the repository root:

npm ci && npm ci --prefix bindings/angular
npm run angular:build   # mdlib-pure dist/ (the workspace links it: "mdlib-pure": "file:../.."), library, demo
npm run angular:unit    # Vitest in Chromium, through Angular's unit-test builder
npm run test:angular    # end to end in Chromium: builds, serves the SSR demo, drives it
npm run angular:pack    # the two tarballs, in bindings/angular/dist/

Inside bindings/angular: npm start serves the demo with live reload (http://localhost:4200); npm run serve:ssr:demo runs the production server after npm run build (http://localhost:4000, PORT to change it). Unit tests need Playwright's Chromium once: npx playwright install chromium. Both npm test and npm start first drop Vite's dependency caches (scripts/fresh-deps.mjs): Vite would otherwise keep serving the mdlib-pure it pre-bundled before your last rebuild, since the link does not change the lockfile.

License #

MIT © Michele Polo.

Rendered by mdlib-pure at build time from bindings/angular/README.md in the repository. Markdown version.