NgMd
On this page ▾
Drop a markdown file at the right path and the catch-all handles routing, rendering, sidebar, TOC, prev/next, and edit-on-github. No per-page wrapper to write.

Markdown Routes

NgMd turns markdown files into routes automatically via AnalogJS content collections.

File-based routing

Drop a .md file under src/content/, get a route at the matching path. No per-page wrapper to write. The mapping is direct: the path under src/content/ becomes the URL.

  • src/content/welcome.md → /welcome
  • src/content/getting-started/about.md → /getting-started/about
  • src/content/concepts/theming.md → /concepts/theming

A common pattern is a section parent at root plus children in a same-named folder:

  • src/content/help.md → /help
  • src/content/help/contribute.md → /help/contribute
  • src/content/help/sponsor.md → /help/sponsor

The parent and its children are independent routes. No index.md convention needed.

One shared src/app/pages/[...slug].page.ts handles every prose route. It reads the slug from the URL, fetches the matching markdown body, and renders it with <analog-markdown [content]>. The pattern mirrors adev (angular.dev) where docs.component.ts serves every documentation page.

For pages that need bespoke layouts or want to compose authoring components directly (callouts, tabs, cards, workflows, hero), write a named .page.ts in src/app/pages/ instead. Angular's router prefers the more specific match, so a named route wins over the catch-all.

Page frontmatter

Frontmatter at the top of each markdown file is parsed and made available as typed attributes:

---
title: Welcome
description: A friendly intro
order: 1
---

You can read these in your page component:

const welcome$ = injectContent<{ title: string; order: number }>('slug');

The 'slug' argument names the route param that the catch-all populates with the URL path. For a named .page.ts that handles a specific file, pass { customFilename: 'welcome' } instead.

Add status: to any nav item in ngmd.config.ts and a coloured chip renders next to its sidebar label. Same pattern adev uses on its NavigationItem. Lives on the config so all the lifecycle markers for the site sit in one file.

{label: 'Search', href: '/concepts/search', status: 'new'},

Six values are supported, each with its own colour so meaning is consistent across the docs:

  • new sky blue, for freshly shipped pages
  • updated gold, for recently revised pages
  • alpha red, for pre-public exploratory surface
  • beta amber, for unstable or in-progress areas
  • stable emerald, for settled APIs
  • deprecated zinc grey with strike-through

Drop the status field to remove the chip. The same chip shape is available inline anywhere in prose via &lt;ngmd-badge variant="..."&gt;. See Badge in the components reference for copy-paste examples and how to add a new variant.

The header links next to the brand come from headerNav in ngmd.config.ts. Internal paths route in-app; http(s) URLs open in a new tab. Leave it out for no header links.

headerNav: [
  {label: 'Docs', href: '/welcome'},
  {label: 'Help', href: '/help/get-help'},
],

Community links are optional too. Each one renders only when it is set:

  • site.links.discord adds a Discord icon to the header and a Discord link to the footer.
  • site.links.sponsor adds a "Sponsor" link to the footer.
  • sponsors lists {name, login} entries by GitHub login. &lt;app-sponsor-list&gt; (src/app/components/sponsor-list.ts) renders them as round GitHub avatars in any .page.ts template, with [size] and [showNames] inputs. It renders nothing when the list is empty.
site: {
  // ...
  links: {
    discord: 'https://discord.gg/your-invite',
    sponsor: 'https://github.com/sponsors/your-name',
  },
},
sponsors: [{name: 'Ada Lovelace', login: 'ada'}],

Dynamic and catch-all routes

Two kinds of bracket syntax in AnalogJS file routing:

Pattern File path Matches
Single segment src/app/pages/blog/[slug].page.ts /blog/anything (one segment)
Catch-all src/app/pages/[...slug].page.ts /anything/at/any/depth

NgMd ships only the catch-all ([...slug].page.ts) to serve every prose route. Single-segment dynamic routes work the same way if you need them for a specific section. The slug param becomes available via injectActivatedRoute or by passing 'slug' as the first arg to injectContent.

Layouts and nested routes

Layouts are just Angular components rendered around the &lt;router-outlet&gt;. NgMd's app.ts is the default docs layout: header, sidebar accordion, breadcrumb, scroll-spy TOC, page footer. Replace or extend it like any other Angular component. The catch-all and component pages render inside its &lt;router-outlet&gt;.

Code highlighting

All fenced code blocks pass through Shiki at build time. NgMd emits dual-theme HTML (github-light-default and github-dark-default in one pass) and swaps the active palette under .dark via a small CSS rule in styles.css. To change themes, edit shikiOptions in vite.config.ts:

analog({
  content: {
    highlighter: 'shiki',
    shikiOptions: {
      highlight: { themes: { light: 'github-light-default', dark: 'github-dark-default' }, defaultColor: false },
    },
  },
});

Inline media

Two marked extensions ship runtime-side so you can drop media into prose without writing TypeScript.

<ngmd-video src="https://www.youtube.com/watch?v=..." title="Demo"></ngmd-video>

<ngmd-image src="/images/cats.jpg" alt="Two tabby kittens, Angular and Excel, looking up, one sitting in a flower pot" caption="Local images live in public/"></ngmd-image>

YouTube and Vimeo URLs are normalised to player iframes. Images get figure plus caption plus lazy-load by default.

Linking between pages

Link to another page by its route, [Theming](/concepts/theming), or by its file, [Theming](./theming.md). A relative .md link resolves from the file that contains it, and a #fragment carries over:

[Theming](./theming.md)
[Algolia setup](./search.md#switching-to-algolia)
[Changelog](../getting-started/changelog.md)

At build time md-links.plugin.ts rewrites these to site routes (/concepts/theming, /concepts/search#switching-to-algolia, /getting-started/changelog), and an index.md maps to its folder's route. The same markdown then works when someone reads it on GitHub, where the .md path is the real file. Only relative links ending in .md are rewritten. Routes, fragments, external URLs and other files are left alone.

This page links to Theming this way.

The build pipeline fails on broken anchors. Internal #fragment, /route#fragment and relative ./page.md#fragment markdown links must resolve to real pages and headings. External links inside raw HTML must carry target="_blank". Broken links error at build time rather than reaching production.

This is enforced by two Vite plugins: link-guard.plugin.ts (internal anchors) and the externalLinkGuard defined inline in vite.config.ts. Both walk every .md body at build and abort if anything would 404. The internal guard resolves a relative .md link the same way md-links.plugin.ts does, then checks the route and the fragment. A relative .md link that points outside src/content (say ../../README.md) fails the build too, since it has no page on the site. In the dev server, a broken internal link is a terminal warning and the page still renders. Editing a page refreshes the heading index, so a link to a heading you just added resolves without a restart.

Heading ids are slugs of the heading text. When two headings on a page share the same text, the second gets -1, the third -2, and so on, like GitHub. Two ### Flags headings become #flags and #flags-1. The TOC, the link guard and the search index all use the same ids.

Importing code from real files

To keep doc examples in sync with the source, import the file directly with file="..." in the fence. GitHub-style line ranges (#L5-L10) work too:

src/app/pages/[...slug].page.ts#L1-L7
import {AsyncPipe} from '@angular/common';
import {
  Component,
  CUSTOM_ELEMENTS_SCHEMA,
  OnDestroy,
  computed,
  effect,

Lines tagged with // ngmd-ignore-line are stripped from the imported snippet, so you can hide setup boilerplate from doc readers while keeping the source file runnable.

Grouped code tabs

Tag adjacent fences with group="..." (and an optional name="..." for the tab label) to merge them into a tabbed UI. The active flag picks the initial tab.

pnpm create ngmd@latest my-docs
npm create ngmd@latest my-docs
yarn create ngmd my-docs
bun create ngmd my-docs

Highlighting specific lines

Append {1,3-5} after the language to highlight matching lines. The selector accepts comma-separated single lines or ranges.

import { Component } from '@angular/core';

@Component({
  selector: 'app-hello',
  template: '<h1>Hello, NgMd</h1>',
})
export class Hello {}

Auto-linked keywords

Define keywords in ngmd.config.ts > keywords. Prefix any name with * in markdown prose to turn it into a link without writing the URL each time.

For example, NgMd is built on AnalogJS with Tailwind v4 and Shiki for code highlighting. Compare against VitePress, Starlight, Nextra, and Docusaurus to see where the bar sits.

Unknown keywords (*WrongName) log a warning at build time and fall back to literal text so the build never fails on a typo.

Authoring components inside markdown

&lt;analog-markdown&gt; renders the body via [innerHTML] after bypassSecurityTrustHtml. Angular doesn't compile component selectors inside innerHTML, so a naive &lt;ngmd-callout&gt; in .md would render as an empty unknown element. NgMd registers 16 NgmdUi components as Custom Elements via @angular/elements at app init (code-block is the exception, since fenced ``` covers that use case), and the browser upgrades them whenever they appear in the DOM, including inside the markdown body. Drop them straight into prose:

No .page.ts wrapper, no special pipeline. The Custom Element registration in src/app/register-elements.ts is what makes this render. Mixed prose + components scale on the same page. Use components for the structured bits, markdown for the rest. Every NgmdUi component rendered in context. CSS variables and the fuchsia accent wiring. Drop a .md at the right path and the catch-all routes it. Write a named .page.ts only when the page needs a bespoke layout. The 16 inline-in-markdown components plus @angular/elements add around 15-20KB gzipped to the markdown-route chunk. Every prose page pays that once, in exchange for the full component vocabulary inline in .md.

Status badges work inline: API stability tags like Beta or Deprecated sit next to text without breaking the line.

ngmd-video and ngmd-image are wired separately as runtime marked extensions (src/marked-extensions/runtime.ts), so they work in markdown regardless of what the catch-all imports.

Per-instance spacing

Every NgmdUi block component (ngmd-accordion, ngmd-callout, ngmd-card-grid, ngmd-code-block, ngmd-image, ngmd-tabs, ngmd-video, ngmd-hero, ngmd-workflow, ngmd-alert, ngmd-pill-row) defaults to margin: 1.5rem 0 on the host. Override per instance with a Tailwind margin utility on the tag:

<ngmd-callout class="mt-10">Bigger top gap</ngmd-callout>
<ngmd-card-grid columns="2" class="mt-2">Tighter top</ngmd-card-grid>
<ngmd-accordion class="my-0">No vertical margin</ngmd-accordion>

mt-*, mb-*, my-*, mx-* all work. The host rule lives in @layer base, so any utility class from @layer utilities wins on cascade.

Tailwind v4 only scans source files for class names. styles.css opts the content tree in:

@source "./content/**/*.md";

Skip this line and any margin utility you write in .md silently drops out of the bundle.

Where to next