On this page ▾
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→/welcomesrc/content/getting-started/about.md→/getting-started/aboutsrc/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→/helpsrc/content/help/contribute.md→/help/contributesrc/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.
Sidebar status badges
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 pagesupdated gold, for recently revised pagesalpha red, for pre-public exploratory surfacebeta amber, for unstable or in-progress areasstable emerald, for settled APIsdeprecated zinc grey with strike-through
Drop the status field to remove the chip. The same chip shape is available inline anywhere in prose via <ngmd-badge variant="...">. See Badge in the components reference for copy-paste examples and how to add a new variant.
Header and community links
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.discordadds a Discord icon to the header and a Discord link to the footer.site.links.sponsoradds a "Sponsor" link to the footer.sponsorslists{name, login}entries by GitHub login.<app-sponsor-list>(src/app/components/sponsor-list.ts) renders them as round GitHub avatars in any.page.tstemplate, 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 <router-outlet>. 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 <router-outlet>.
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.
Link integrity
#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:
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-docsnpm create ngmd@latest my-docsyarn create ngmd my-docsbun create ngmd my-docsHighlighting 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
<analog-markdown> renders the body via [innerHTML] after bypassSecurityTrustHtml. Angular doesn't compile component selectors inside innerHTML, so a naive <ngmd-callout> 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:
.page.ts wrapper, no special pipeline. The Custom Element registration in src/app/register-elements.ts is what makes this render.
.md at the right path and the catch-all routes it. Write a named .page.ts only when the page needs a bespoke layout.
@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
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.