Angular Markdown renderer with Shiki syntax highlighting, Angular component embedding, and incremental block rendering.
shikidown is an Angular library that renders Markdown documents with:
- Shiki v4 syntax highlighting — IDE-quality, dual dark/light theme via CSS variables
- Angular components embedded by selector — any component registered as a Custom Element can be placed directly in Markdown
- Incremental block rendering — only blocks whose source text changed are re-parsed; unchanged DOM nodes (including Web Component state) are preserved between keystrokes
MarkdownComponent,MarkdownPipe, andMarkdownService— three integration points depending on your use case
- Installation
- Quick start
- Configuration
- MarkdownComponent
- MarkdownPipe
- Embedding Angular components
- Incremental rendering
- Mermaid diagrams
- MarkdownService API
- Styles & dark mode
- Exported types
- Project structure
npm install shikidown shiki markdown-it
npm install --save-dev @types/markdown-itPeer dependencies (already present in any Angular 22 project):
| Package | Version |
|---|---|
@angular/core |
^22.0.0 |
@angular/elements |
^22.0.0 |
markdown-it |
^14.0.0 |
shiki |
^4.0.0 |
Optional:
mermaid(>=11) is an optional peer dependency. Install it only if you render diagrams — see Mermaid diagrams. It is loaded exclusively through theshikidown/mermaidentry point, so consumers who don't use it never pullmermaidinto their bundle.
1. Register the provider in app.config.ts:
import { provideMarkdown } from 'shikidown';
export const appConfig: ApplicationConfig = {
providers: [
provideMarkdown({
theme: { dark: 'github-dark', light: 'catppuccin-latte' },
}),
],
};2. Use the component in a template:
import { MarkdownComponent } from 'shikidown';
@Component({
imports: [MarkdownComponent],
template: `<shikidown [content]="md" class="prose dark:prose-invert max-w-none" />`,
})
export class MyComponent {
readonly md = `# Hello\n\nThis is **shikidown**.`;
}provideMarkdown() accepts a MarkdownConfig object:
provideMarkdown({
// Shiki theme — string or dark/light pair
// Full intellisense: same type as createHighlighter({ themes })
theme: { dark: 'github-dark', light: 'catppuccin-latte' },
// Shiki languages to preload (intellisense matches createHighlighter({ langs }))
languages: ['typescript', 'javascript', 'html', 'css', 'bash', 'json'],
// Angular components to embed in Markdown, registered as Custom Elements
components: {
'my-alert': AlertComponent,
'my-counter': CounterComponent,
},
// Additional markdown-it plugins
plugins: [markdownItAnchor, markdownItFootnote],
// Override markdown-it options (merged with defaults)
markdownOptions: { breaks: true },
// Incremental block rendering (default: false)
incrementalRendering: true,
// LRU block cache capacity (default: 256)
blockCacheSize: 512,
})| Property | Type | Default | Description |
|---|---|---|---|
theme |
string | { dark, light } |
{ dark: 'github-dark', light: 'poimandres' } |
Shiki theme(s). A string uses the same theme for both modes. |
languages |
StringLiteralUnion<BundledLanguage>[] |
17 common languages¹ | Shiki languages to preload at startup. |
components |
Record<string, Type<unknown>> |
{} |
Angular components registered as Custom Elements. |
plugins |
Array<(md: unknown) => void> |
[] |
markdown-it plugins applied in order. |
markdownOptions |
MarkdownItOptions |
— | markdown-it constructor options, merged with the library defaults (html: true, linkify: true, typographer: true). |
incrementalRendering |
boolean |
false |
Enable block-level incremental rendering in MarkdownComponent. Has no effect on MarkdownPipe. |
blockCacheSize |
number |
256 |
Maximum number of rendered blocks kept in the LRU cache. |
¹ Default languages:
typescript,javascript,jsx,tsx,html,css,scss,json,yaml,bash,shell,markdown,sql,python,rust,go.
<shikidown
[content]="markdownString"
class="prose prose-slate dark:prose-invert max-w-none"
/>| Input | Type | Default | Description |
|---|---|---|---|
content |
string |
'' |
Markdown source to render. |
components |
Record<string, Type<unknown>> |
{} |
Additional Angular components registered as Custom Elements for this instance only. Merged with those declared in provideMarkdown(). |
Components passed via [components] are registered idempotently — customElements.define() is only called once per selector regardless of how many instances use it.
<shikidown
[content]="md"
[components]="{ 'local-chart': ChartComponent }"
/>Returns a Signal<SafeHtml> — call it as a function in the template:
<!-- Simple -->
<div [innerHTML]="(markdownString | markdown)()"></div>
<!-- With @let -->
@let html = (markdownString | markdown)();
@if (html) {
<div [innerHTML]="html"></div>
}import { MarkdownPipe } from 'shikidown';
@Component({
imports: [MarkdownPipe],
template: `<div [innerHTML]="(md | markdown)()"></div>`,
})
export class MyComponent {
readonly md = '# Hello from the pipe';
}Note:
MarkdownPipedoes not support incremental rendering. UseMarkdownComponentwithincrementalRendering: truefor that feature.
Any component registered via provideMarkdown({ components }) or the [components] input can be placed in Markdown using its CSS selector:
# My document
Here is an interactive counter:
<my-counter initial-count="5" label="Votes"></my-counter>
And an alert:
<my-alert type="warning" title="Heads up" message="This is important."></my-alert>Under the hood, shikidown uses @angular/elements — each component is wrapped in a standard Custom Element and registered with customElements.define(). The browser instantiates them when the rendered HTML is inserted into the DOM.
HTML attributes are strings. The browser converts kebab-case attribute names to camelCase automatically when upgrading a Custom Element.
| HTML attribute | Angular input() |
|---|---|
initial-count |
initialCount |
label |
label |
is-active |
isActive |
For numeric or boolean inputs, use Angular's built-in transform functions:
import { numberAttribute, booleanAttribute } from '@angular/core';
@Component({ selector: 'my-counter' })
export class CounterComponent {
readonly initialCount = input(0, { transform: numberAttribute });
readonly disabled = input(false, { transform: booleanAttribute });
}| Method | Scope | When to use |
|---|---|---|
provideMarkdown({ components }) |
Whole app | Components used across many pages |
[components] input |
Single <shikidown> instance |
Page-specific or lazy-loaded components |
When incrementalRendering: true, MarkdownComponent renders the document as a list of independent root blocks rather than a single HTML string.
Content change
│
▼
md.parse() ──► token grouping (depth-count) ──► blocks[]
│
┌───────────────────┘
▼
FNV-1a hash(block.source)
│
┌───────────┴────────────┐
│ hash in LRU cache? │
yes no
│ │
HTML reused Shiki highlights
│ result cached
└───────────┬────────────┘
▼
@for (block of displayedBlocks; track block.hash)
│
┌───────────┴────────────┐
│ same SafeHtml ref? │
yes no
│ │
Angular skips [innerHTML] updated
innerHTML write → DOM replaced
| Without incremental | With incremental | |
|---|---|---|
| Shiki re-highlighting | Every block, every time | Only changed blocks |
| DOM mutation | Full replacement | Only changed block nodes |
| Web Component state | Lost (counters reset) | Preserved in unchanged blocks |
Call MarkdownService.clearCache() whenever you change the Shiki theme at runtime to force all blocks to be re-highlighted.
import { MarkdownService } from 'shikidown';
@Component({ ... })
export class ThemeSwitcher {
private readonly md = inject(MarkdownService);
switchTheme(): void {
// update your theme config...
this.md.clearCache();
}
}shikidown handles ```mermaid fenced blocks natively — no markdown-it plugin required.
The MarkdownService emits a <pre class="mermaid" data-mermaid-src="…"> placeholder instead of
syntax-highlighting the fence, and the MermaidDirective renders those placeholders to SVG
client-side.
Because mermaid is heavy and optional, it lives in its own entry point — shikidown/mermaid
— and mermaid itself is an optional peer dependency. If you never import from
shikidown/mermaid, mermaid never enters your dependency graph.
1. Install mermaid:
npm install mermaid2. Import the directive from the shikidown/mermaid entry point and place it on <shikidown>:
import { MarkdownComponent } from 'shikidown';
import { MermaidDirective } from 'shikidown/mermaid';
@Component({
selector: 'app-docs',
imports: [MarkdownComponent, MermaidDirective],
template: `<shikidown mermaid [content]="markdown" />`,
})
export class DocsComponent {
readonly markdown = '```mermaid\ngraph TD\n A[Start] --> B{Choice}\n```';
}- Without the directive,
```mermaidblocks stay rendered as raw code. mermaidis only loaded (import('mermaid')) the first time the directive runs — and only in the browser (SSR-safe).- The directive is event-driven: it watches for newly inserted diagrams and for dark/light
theme changes (via the
.darkclass on<html>), re-theming rendered diagrams automatically.
Inject MarkdownService directly for headless usage (SSR pre-rendering, custom pipes, etc.):
import { MarkdownService, type RenderedBlock } from 'shikidown';
@Injectable({ providedIn: 'root' })
export class ContentService {
private readonly md = inject(MarkdownService);
async toHtml(markdown: string): Promise<string> {
return this.md.parseAsync(markdown);
}
async toBlocks(markdown: string): Promise<RenderedBlock[]> {
return this.md.parseBlocksAsync(markdown);
}
}| Method | Signature | Description |
|---|---|---|
initialize |
() => Promise<void> |
Loads Shiki. Called automatically by all async methods. Safe to call multiple times. |
parseAsync |
(content: string) => Promise<string> |
Renders the full Markdown document, returns raw HTML. |
parse |
(content: string) => string |
Synchronous render. Throws if called before initialize() resolves. |
parseBlocksAsync |
(content: string) => Promise<RenderedBlock[]> |
Renders as independent blocks with LRU caching. Used internally by MarkdownComponent in incremental mode. |
clearCache |
() => void |
Empties the block LRU cache. |
shikidown ships no default styles — bring your own typography. The recommended approach is Tailwind CSS Typography:
<shikidown
[content]="md"
class="prose prose-slate dark:prose-invert max-w-none"
/>Configure Tailwind v4 with a class-based dark variant and add the Shiki overrides:
/* styles.css */
@import "tailwindcss";
@plugin "@tailwindcss/typography";
/* Class-based dark mode — toggle .dark on <html> */
@custom-variant dark (&:where(.dark, .dark *));
/* Shiki dual-theme: light theme is applied inline by Shiki (style="color:#xyz").
Dark theme values live in --shiki-dark-* variables — activate them in dark mode. */
.dark .shiki {
background-color: var(--shiki-dark-bg) !important;
}
.dark .shiki span {
color: var(--shiki-dark) !important;
font-style: var(--shiki-dark-font-style) !important;
font-weight: var(--shiki-dark-font-weight) !important;
text-decoration: var(--shiki-dark-text-decoration) !important;
}Why
!important? Shiki applies light-theme colors as inlinestyleattributes, which have the highest CSS specificity.!importantis the only way to override them with the--shiki-dark-*CSS variables.
Add this inline script before your app bundle to apply the saved preference before first paint:
<script>
(function () {
const stored = localStorage.getItem('dark');
const prefersDark = stored === 'true'
|| (stored === null && window.matchMedia('(prefers-color-scheme: dark)').matches);
if (prefersDark) document.documentElement.classList.add('dark');
})();
</script>import type {
MarkdownConfig, // Full configuration object for provideMarkdown()
MarkdownThemePair, // { dark: StringLiteralUnion<BundledTheme>; light: StringLiteralUnion<BundledTheme> }
ParsedBlock, // { hash, startLine, endLine, tokens, source }
RenderedBlock, // { hash: string; html: string }
} from 'shikidown';markdown-shiki-renderer/
├── projects/
│ └── shikidown/ # Library source (ng-packagr)
│ ├── src/ # Primary entry point → import from 'shikidown'
│ │ └── lib/
│ │ ├── markdown.component.ts # <shikidown> component
│ │ ├── markdown.pipe.ts # markdown pipe
│ │ ├── markdown.service.ts # parsing, Shiki init, LRU cache
│ │ ├── markdown.provider.ts # provideMarkdown() + registerAsCustomElement()
│ │ ├── markdown.config.ts # MarkdownConfig interface
│ │ ├── markdown.tokens.ts # MARKDOWN_CONFIG injection token
│ │ └── markdown.types.ts # ParsedBlock, RenderedBlock, hashSource
│ └── mermaid/ # Secondary entry point → import from 'shikidown/mermaid'
│ └── src/
│ └── mermaid.directive.ts # MermaidDirective (dynamic import('mermaid'))
└── src/ # Demo application
├── pages/
│ ├── home/ # Landing page
│ ├── guide/ # Full documentation (FR / EN)
│ ├── playground/ # Live Markdown editor
│ └── components/ # Demo component showcase
├── components/
│ ├── navbar/ # Navigation + dark mode + language toggle
│ ├── demo-counter/ # Stateful counter Web Component
│ ├── demo-alert/ # Alert Web Component
│ └── demo-badge/ # Badge Web Component
└── services/
└── language.service.ts # FR/EN language signal
MIT