`, ``, ``); markdown doesn't render there. Write `@` as `@`.
+- Raw HTML external links need `target="_blank" rel="noopener noreferrer"` or the build fails. Markdown links get it automatically.
+- `*Angular`, `*Analog`, `*Devframe`, `*NgRx`, `*MCP`, `*Vite` are keyword links (`keywords` in `ngmd.config.ts`). The asterisk is intentional. Never "fix" it.
+
+## 6. Code samples
+
+- Always set the language. Put the file path in a comment on the first line: `// src/app/app.config.ts`.
+- Highlight lines with `{3}` or `{2,5-7}` after the language. Count the path comment as line 1.
+- Install commands use a code group: ` ```bash group="install" name="pnpm" active ` then npm, yarn, bun.
+- `file="path#L5-L20"` imports a real file (relative to `apps/docs`, nothing outside it). It is not a title.
+- Samples must run: every import, real export names from `packages/ng-devtools/package.json`, real option names and defaults.
+- Source samples from the demos: `src/` (Angular Travel) and `examples/analog`.
+- Load the overlay only in dev, with the `ngDevMode` dynamic import used on the installation page.
+- Secure by default: no `auth: false` or `allowedOrigins: false` in copyable code unless the page explains it.
+- UI code in samples follows accessibility basics: labels on controls, alt text on images.
+- Realistic names (`TripSearch`, `authGuard`), no `Foo` or `prop1`. Comments explain why, and most samples need none.
+
+## 7. Check claims against the code
+
+| Page | Source of truth |
+| ------------------ | ------------------------------------------------------------------------------------------------- |
+| inspectors/\* | `app/src/pages/*.ts` (the tab) and `packages/ng-devtools/src/*` (collectors, actions) |
+| agents/\* | `packages/ng-devtools/src/devframe.ts`, `rpc/*.ts`, `rpc/analog-register.ts`, Devframe built-ins |
+| getting-started/\* | `packages/ng-devtools/package.json` exports, `hub.ts`, `vite.ts`, `overlay.ts`, `popup.ts`, demos |
+| security | `hub.ts`, `vite.ts`, `forms-privacy.ts`, `forms-actions.ts`, router and Analog redaction |
+| contributing/\* | root `package.json`, `nx.json`, `project.json` files, `.github/workflows`, `extension/` |
+
+Check names exactly. When code changes, update the docs in the same PR. When unsure, say less rather than guess.
+
+## 8. Verify
+
+From the repo root:
+
+1. `pnpm docs:dev` and open every changed page (TOC, links, dark mode).
+2. `pnpm docs:build` (link and anchor guards).
+3. `pnpm exec prettier --check "apps/docs/**/*.{ts,json,css,html}"`. Content markdown isn't formatted; check tables by eye.
+4. Grep changed files for `—`, "will ", "we ", "new ", "recently", "simply", "just ".
diff --git a/AGENTS.md b/AGENTS.md
index f6acb89..9863cc9 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -61,8 +61,13 @@ You are an expert in TypeScript, Angular, and scalable web application developme
## Serving Locally
- **Demo app (SSR):** `pnpm build --configuration development && node dist/angular-devtools/server/server.mjs` → http://localhost:4000
-- **Devtools SPA (hot reload):** `pnpm devtools:dev` → http://localhost:5173 (requires the SSR server running for RPC data)
+- **Devtools SPA (hot reload):** `pnpm devtools:dev` → http://localhost:5173 (serves its own RPC, so source-scan data works; live tabs need an app page connected, so use the SSR server on 4000 for those)
- **Demo app (SPA, no SSR):** `pnpm start` → http://localhost:4200 (runs `ng serve` with SSR and hot reload; devtools popup + RPC work without a separate server)
- The devtools popup appears on the demo app page; click it to open the inspector panel
- Changes to `app/src/` (devtools SPA) are visible live via `pnpm devtools:dev`; the SSR server serves the SPA built into `packages/ng-devtools/dist/public` (or the npm-published copy when it has not been built), so run `pnpm devtools:build-pkg` to refresh it
- To publish: update the version in `packages/ng-devtools/package.json`, then run `pnpm devtools:publish` (the package build bundles the SPA)
+
+## Documentation
+
+- Use the `devtools-docs` skill (`.claude/skills/devtools-docs`) for any change in `apps/docs/src/content`, the docs home page or `README.md`.
+- The human-readable version is `apps/docs/src/content/contributing/writing-docs.md`.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 94cd540..b2a415f 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -15,16 +15,16 @@ cd angular-devtools
pnpm install
```
-## Project Structure
+## Project structure
-```
+```text
app/ # Devtools UI SPA (Angular + Vite)
src/app.ts # Root component with tab navigation
- src/pages/ # Dashboard, Components, Routes, Signals, Injectors, Store, Forms
+ src/pages/ # Dashboard, Components, Routes, Signals, Injectors, Store, Forms, Pipes, SSR & HTTP, Analog
vite.config.ts # Vite config with Analog Angular plugin
packages/
ng-devtools/ # Publishable npm package
- src/devframe.ts # defineDevframe() — tool definition
+ src/devframe.ts # defineDevframe(), the tool definition
src/overlay.ts # Client script running in user's page
src/rpc/ # Node-side RPC functions
extension/ # Chrome DevTools extension
@@ -47,29 +47,33 @@ pnpm start
pnpm extension:build
```
-## Making Changes
+## Make changes
-### Adding a new RPC function
+### Add an RPC function
1. Create the function in `packages/ng-devtools/src/rpc/`
2. Register it in `packages/ng-devtools/src/devframe.ts`
3. Call it from the UI in `app/src/pages/`
-### Adding a new tab
+### Add a tab
1. Create a component in `app/src/pages/`
2. Import and add it to `app/src/app.ts` (imports array, tabs array, template switch)
3. Add a card to `app/src/pages/dashboard.ts`
-### Adding agent tools
+### Add agent tools
Add `agent: { description }` to any RPC function, or use `ctx.agent.registerTool()` in the devframe setup.
-## Code Style
+## Documentation
+
+The docs site lives in `apps/docs`. Run it with `pnpm docs:dev`. Before you change a page, read the [writing guide](./apps/docs/src/content/contributing/writing-docs.md). Coding agents get the same rules from the `devtools-docs` skill in `.claude/skills`.
+
+## Code style
- Follow the conventions in `AGENTS.md`
-- Use `signal()`, `computed()`, `input()`, `output()` — not decorators
-- Use `@if`/`@for`/`@switch` control flow — not structural directives
+- Use `signal()`, `computed()`, `input()`, `output()`, not decorators
+- Use `@if`/`@for`/`@switch` control flow, not structural directives
- Keep components small with inline templates where practical
## Testing
@@ -81,7 +85,7 @@ pnpm typecheck # host app + specs, devtools UI, devtools package + its tes
pnpm format:check
```
-## Submitting a PR
+## Submit a pull request
1. Fork and create a branch from `main`
2. Make your changes
diff --git a/README.md b/README.md
index de8669f..e8c327f 100644
--- a/README.md
+++ b/README.md
@@ -1,464 +1,29 @@
# Angular DevTools
-Inspect Angular component trees, signals, dependency injection, and routes — at dev time, build time, or through a coding agent. Built with [Devframe](https://devfra.me) so the same tool runs as an embedded panel, standalone CLI, static report, MCP server, or Chrome DevTools extension.
+Inspect Angular component trees, signals, dependency injection, routes, forms, pipes and NgRx stores at dev time, build time, or through a coding agent. Built with [Devframe](https://devfra.me) so the same tool runs as an embedded panel, standalone CLI, static report, MCP server, or Chrome DevTools extension.
-## Features
-
-- **Component inspector** — discover components, inputs, outputs, and source files; view injected providers per component
-- **Signal graph** — visualize signal, computed, linkedSignal, effect nodes and their dependency edges (Angular 19+)
-- **DI inspector** — browse the injector hierarchy (element and environment) with providers at each level (Angular 17+)
-- **Route inspector** — the live route, every navigation as a full story (who started it, redirects, per-phase timing, which guard or resolver decided it, errors explained), the live route config with URL testing, router setup, route lint, and actions to navigate, replay, probe and abort
-- **NgRx Store inspector**: live `@ngrx/signals` stores (state, computed, methods, the component fields that use them) with a change log, per-change diffs and state restore (call `registerNgrxSignals({ patchState })` from `@santoshyadavdev/ng-devtools/overlay` once, so restore also notifies `watchState` listeners), plus the `@ngrx/store` state and action log (time travel with `provideStoreDevtools()`); source scan of `signalStore` members, `signalState`, `signalMethod`, actions, reducers, effects, selectors and features
-- **Forms inspector** — every form on the page (Signal Forms, reactive and template-driven) with each field's value, status, touched/dirty state and readable errors, plus a timeline of recent changes; hover a field to highlight its input
-- **SSR & HTTP inspector** — the TransferState payload, a timeline of HTTP calls made during SSR and on the client, hydration stats and warnings, and fault injection (status, delay, mock JSON body) per URL pattern on the client, in SSR or both
-- **Build metadata** — Angular version, TypeScript version, SSR status
-- **In-page popup** — floating devtools panel with dock modes (float, bottom, right), drag, resize, and localStorage persistence
-- **Agent-native** — all inspectors exposed as MCP tools and resources
-- **Deep linking** — URL hash navigates to a specific tab (`#tab=signals`)
-- **Page overlay** — highlights components in the running app
-
-## Install
+## Get started
```sh
npm install @santoshyadavdev/ng-devtools devframe
```
-MCP agent support (`@devframes/agentic`) is included.
-
-## How to Use
-
-### Embedded in an Angular app (Express SSR)
-
-Mount the devtools hub in your Express server:
-
-```ts
-// server.ts
-import { initNgDevtoolsHub } from '@santoshyadavdev/ng-devtools/hub';
-
-const devtools = initNgDevtoolsHub({ ws: false });
-app.use(devtools.nodeMiddleware);
-```
-
-Load the overlay in development (see [Browser Overlay](#browser-overlay)) and a floating button appears on your page. It opens the devtools with one dock entry per tool:
-
-| Dock entry | Shows |
-| ------------ | ------------------------------------------------------------------------------ |
-| Angular | Dashboard, components, routes, signals, injectors and forms |
-| NgRx | Store patterns from source, and live state and actions |
-| Analog | File routes, server calls, render modes and lint (a notice in non-Analog apps) |
-| NativeScript | Coming soon |
-| Capacitor | Coming soon |
-
-The full-page viewer is at `http://localhost:4000/__devframes/`. The hub is built on [`@devframes/hub`](https://github.com/devframes/devframe), so other devframe tools can join the same dock.
-
-To mount only the devtools panel without the dock, use `initDevframe(ngDevtools, { base: '/__ng-devtools/' })` from `devframe/initiate`, as before.
-
-To fill the SSR & HTTP tab, add the interceptor and hydration hooks to your app config:
-
-```ts
-// app.config.ts
-import { provideHttpClient, withFetch } from '@angular/common/http';
-import { provideNgDevtoolsHttp, withNgDevtools } from '@santoshyadavdev/ng-devtools/http';
-
-export const appConfig: ApplicationConfig = {
- providers: [
- provideClientHydration(),
- provideHttpClient(withFetch(), withNgDevtools()),
- provideNgDevtoolsHttp(),
- ],
-};
-```
-
-### Standalone CLI
-
-```sh
-# Dev server with live RPC
-npx @santoshyadavdev/ng-devtools dev
-
-# Static report (offline HTML)
-npx @santoshyadavdev/ng-devtools build --outDir dist-report
-
-# MCP server for coding agents
-npx @santoshyadavdev/ng-devtools mcp
-```
-
-### MCP Server for Coding Agents
-
-**Claude Desktop** — add to `claude_desktop_config.json`:
-
-```json
-{
- "mcpServers": {
- "ng-devtools": {
- "command": "npx",
- "args": ["@santoshyadavdev/ng-devtools", "mcp"]
- }
- }
-}
-```
-
-**VS Code** — add to `.vscode/mcp.json`:
-
-```json
-{
- "servers": {
- "ng-devtools": {
- "command": "npx",
- "args": ["@santoshyadavdev/ng-devtools", "mcp"]
- }
- }
-}
-```
-
-When embedded in Express, the MCP endpoint is also available over HTTP at `/__devframes/__mcp` (or `/__ng-devtools/__mcp` without the hub).
-
-#### Agent Tools
-
-MCP clients see these with an underscore, as `ng-devtools_get-routes`.
-
-| Tool | Description |
-| ------------------------------------ | ----------------------------------------------------------- |
-| `ng-devtools:get-routes` | List Angular routes from source |
-| `ng-devtools:get-components` | Discover components and directives, with inputs and outputs |
-| `ng-devtools:get-signals` | Signal declarations from source |
-| `ng-devtools:get-providers` | DI providers from source |
-| `ng-devtools:build-meta` | Angular/TS versions, SSR status |
-| `ng-devtools:highlight` | Highlight a component in the page |
-| `ng-devtools:inspect-signals` | Signal graph a connected page reported |
-| `ng-devtools:inspect-providers` | Injector tree a connected page reported |
-| `ng-devtools:get-ngrx-store` | Scan source for NgRx store patterns |
-| `ng-devtools:inspect-forms` | Forms on the page with every field's state and errors |
-| `ng-devtools:explain-form-invalid` | Which fields make a form invalid, and why |
-| `ng-devtools:explain-field` | One field: error sources, skip reasons, binding, source |
-| `ng-devtools:explain-submit` | What submit will do, and why it might do nothing |
-| `ng-devtools:form-payload` | What the form sends: value vs raw value, unvalidated fields |
-| `ng-devtools:form-history` | Change timeline with origin (user, code, devtools) |
-| `ng-devtools:form-diff` | Net change since a marker |
-| `ng-devtools:lint-forms` | Form bugs and model-aware accessibility checks |
-| `ng-devtools:explain-custom-control` | How a field is bound, and what is wrong with the binding |
-| `ng-devtools:export-form` | JSON snapshot or test fixture |
-| `ng-devtools:wait-for-form` | Wait until settled, valid, not pending or submitted |
-| `ng-devtools:form-action` | Set, touch, revalidate, reset, submit, focus, snapshot |
-| `ng-devtools:fill-form` | Fill several fields through the inputs |
-| `ng-devtools:inspect-route` | The current route with params, data, guards and resolvers |
-| `ng-devtools:explain-navigation` | Recent navigations and why each succeeded or not |
-| `ng-devtools:list-routes` | Live route config; match a URL; audit guard protection |
-| `ng-devtools:lint-routes` | Route config mistakes, with fixes |
-| `ng-devtools:router-config` | Router options, features and strategies in effect |
-| `ng-devtools:export-navigation` | Markdown repro of a navigation |
-| `ng-devtools:explain-render-mode` | ServerRoute and render mode for a URL |
-| `ng-devtools:navigate` | Navigate, abort, replay, probe, instrument (dev only) |
-| `ng-devtools:analog-routes` | Analog file routes with their page, layout and server files |
-| `ng-devtools:analog-explain-url` | Which Analog files render a URL, or why nothing matches |
-| `ng-devtools:analog-current-page` | The open page's files, load() data and hydration state |
-| `ng-devtools:analog-server-calls` | Page renders, load(), server function and API calls |
-| `ng-devtools:analog-api-routes` | Server routes with method, URL and file |
-| `ng-devtools:analog-call-api` | Send a request to a server route (non-GET needs confirm) |
-| `ng-devtools:analog-render-modes` | SSR, prerendered or client only, per page |
-| `ng-devtools:analog-prerender-plan` | prerender.routes compared with pages and build output |
-| `ng-devtools:analog-content` | Markdown content with slug and frontmatter |
-| `ng-devtools:analog-lint` | Analog routing, server, prerender and content mistakes |
-
-#### Forms
-
-The Forms tab and the forms tools read Signal Forms, reactive forms and template-driven forms from the running page, in development builds only. Signal Forms need Angular 21 or later. The live change timeline for reactive and template-driven forms uses `control.events` (Angular 18+); on Angular 17 changes are picked up every few seconds instead, without submit and reset events.
-
-- Each field shows its value, status, touched/dirty state and errors, plus: Signal Forms constraints (`min`, `max`, `minLength`, `maxLength`, `pattern`), a pending `debounce`, `submitting`, and disabled reasons; for reactive and template-driven forms, whether validators and async validators are attached, the value `reset()` goes back to, `updateOn`, and the bound `ControlValueAccessor`.
-- `ng-devtools:explain-form-invalid` is the tool to reach for first: without arguments it lists every form that is invalid or waiting on async validation, with each failing field's current value, the validator that failed, its message and whether it was touched. Pass `form` (an id like `form-1`, or part of a label like `SignupComponent`) to explain one form.
-- `ng-devtools:inspect-forms` lists the forms with their status and error counts. Pass `form` for a field tree, plus `path` (e.g. `address.city`), `onlyInvalid` or `includeValues: false` to narrow it down.
-- Both tools note when the page last reported, so an agent can tell when the data is stale.
-- Each error says where it comes from: a validator, a template attribute, a cross-field rule (and on which ancestor), async, parse, a server/submission error, or `setErrors()`. `explain-field` adds why validation is skipped (hidden, disabled, readonly), typed-but-uncommitted values (`updateOn`, `debounce`), stale validity after validator changes, the binding, whether the error text is visible, and the file and line of the form and its rules.
-- Agents can loop: inspect, act (`form-action`, `fill-form`), `wait-for-form`, then `form-diff` from the marker they had. Writes need a development build; `reset`, `submit` and `restore` need `confirm: true`.
-- The Forms tab has Fields (with filters and per-field actions), Timeline, Submit and Lint views. Pick a field on the page to select it, or open a form from its component in the Components tab.
-- Timeline recording (a checkbox in the Timeline view, or `form-action` with `instrument`) adds the calling code of each change, validator changes, async validation times and component renders per keystroke. Array items are tracked by identity, so moves show as moves.
-
-Form values leave the page: they are sent to the devtools server, shown in the Forms tab and returned to agents. Values of password fields, fields with a password, one-time-code or credit-card `autocomplete`, fields inside `.sentry-mask`, `.rr-mask`, `[data-private]` or `[data-ng-devtools="mask"]`, and fields whose name contains a secret word (password, token, card, cvv, apiKey and similar) are replaced with `[redacted]`, and those values are also removed from error messages. `[data-ng-devtools="unmask"]` opts a field back in; `window.__NG_DEVTOOLS_FORMS__ = { mask: ['iban'], unmask: ['passport'] }` does the same by key. DevTools never writes secret fields. Other values are sent as they are, so keep real credentials out of forms you inspect, and don't expose the dev server beyond localhost.
-
-#### Router
-
-The Routes tab and the router tools read the running app's Router, in development builds only. The Router is found through the debug helper `provideRouter()` publishes, or through the injector for `RouterModule.forRoot()` apps. Without debug utils (a production build) only navigation events are available, and the Setup view says so.
-
-The Routes tab has five views:
-
-- **Current**: the URL (and the browser URL when they differ), the navigation in flight with an Abort button, each active route with its component, params and data and where each value comes from (own, inherited, static or resolved), the route title and whether it is inherited, and the outlet tree with the inputs the router binds.
-- **Navigations**: every navigation as one story: where it came from, who started it (a RouterLink, the code that called `navigate`, back/forward), extras, redirect chains and loops, a phase bar (recognize, guards, resolve, activate), guards and resolvers, lazy loads, reused components, HTTP requests, scroll, the title afterwards, router warnings, and the cancel or error reason. Turn on "Record each guard and resolver" to see each one's verdict and time (for example `authGuard returned UrlTree /login`). Replay a navigation, copy a markdown repro, or export the list as JSON.
-- **Routes**: the live route config with lazy children merged in once they load and the active branch marked. Test a URL to predict which route matches it (or the nearest ones), probe it with the real matcher, navigate to any route (with its params), or read the routes of a lazy route that has not loaded.
-- **Setup**: provideRouter or forRoot, effective options with set/default markers, enabled features, strategies, base href and hydration.
-- **Lint**: route config mistakes (unreachable routes after `**`, a `:param` shadowing a literal, duplicate paths, empty-path redirects without `pathMatch: 'full'`, redirect cycles, deprecated class guards and `canLoad`, lazy chunks downloaded before a rejecting `canActivate`, missing or duplicate titles, param/input typos, `routerLinkActive` without `ariaCurrentWhenActive`, emails in URLs, return URLs taken from query params), each with a fix and whether Angular throws or stays silent.
-
-Components rendered by the router show the route and outlet in the Components tab.
-
-For agents:
-
-- `ng-devtools:explain-navigation` answers "why did this navigation not work" or "why was I redirected": pass `url` or `id` to narrow it, `limit` for more than the last 5, or `perf` for the slowest navigations and preloads. NG04xxx and related errors are explained.
-- `ng-devtools:inspect-route` describes the route the page is on right now; pass `selector` (a component class, tag or link text) to see which route a component was rendered for or whether a link counts as active.
-- `ng-devtools:list-routes` lists the live config with source files and example URLs; `match` predicts which route a URL hits, `audit` lists the guards that protect each page.
-- `ng-devtools:lint-routes`, `ng-devtools:router-config` and `ng-devtools:export-navigation` give the lint findings, the setup and a repro.
-- `ng-devtools:explain-render-mode` reads the workspace's `*.routes.server.ts` and says which render mode a URL gets.
-- `ng-devtools:navigate` acts on the router: `navigate` (a relative URL, or a pattern with params), `abort`, `replay`, `probe` (runs the real matcher without navigating; it runs `canMatch` and may load lazy chunks), `instrument` and `resolve-lazy`. It only accepts same-origin relative URLs.
-
-Without instrumentation, the guards listed for a navigation are candidates (the `canDeactivate` guards of the page being left and the `canActivate`/`canActivateChild` guards of the target), because the router reports one result for all of them. Instrumentation wraps each guard and resolver in the live config to record its verdict; it is off by default and undone when turned off. A navigation that finished before the devtools connected is listed without timing or guard details.
-
-Query, matrix and fragment keys that look secret (token, password, api key, code, sig, session, jwt and similar), including inside encoded return URLs, JWTs, bearer tokens, long opaque tokens and route params with such names are replaced with `[redacted]` in URLs, params, data and messages. A secret route param is only known once the route is recognized or found in the config, so a navigation that fails before that (for example inside a lazy route that failed to load) can still show it in its URL.
-
-#### Analog
-
-For [Analog](https://analogjs.org) apps, add the Vite plugin next to `analog()` and load the overlay in `main.ts`:
-
-```ts
-// vite.config.ts
-import analog from '@analogjs/platform';
-import ngDevtools from '@santoshyadavdev/ng-devtools/vite';
-import { defineConfig } from 'vite';
-
-export default defineConfig({
- plugins: [analog(), ngDevtools()],
-});
-```
-
-```ts
-// src/main.ts
-bootstrapApplication(App, appConfig).then(() => {
- if (import.meta.env.DEV) void import('@santoshyadavdev/ng-devtools/overlay');
-});
-```
-
-The floating button appears on the page, the full viewer is at `/__devframes/` on the Vite dev server, and the MCP endpoint at `/__devframes/__mcp`.
-
-The devtools only answer this machine, and only pages served from `localhost`, `127.0.0.1` or the Chrome extension, so another website open in your browser can't reach them. If you open the dev server through another hostname that points to your machine (for example `myapp.test`), list it in Vite's `server.allowedHosts` and the devtools trust it too. Other origins can be added with `ngDevtools({ allowedOrigins: ['https://tunnel.example'] })`.
-
-The Analog dock shows:
-
-- Routes: every page, layout and markdown file with its URL, route groups, `[param]` and catch-all segments, `.server.ts` files and routeMeta. Test a URL to see which files render it.
-- Server: page renders (server rendered or client only), `load()` fetches, server functions and API calls with status, time and a redacted preview, plus a request playground for API routes and a button to clear the list. A `load()` that runs while a page is server rendered and again in the browser right after it loads is flagged.
-- Render: SSR, prerendered or client only per page, from config, build output and the last request.
-- Content and Lint: markdown files, and checks for duplicate URLs, missing default exports, layouts without ``, orphan `.server.ts` files, API method suffixes, prerender entries and frontmatter.
-
-The Analog dock is always in the rail, but it shows Analog data only in Analog apps; in other apps it shows a "This app doesn’t use Analog" page. In Analog apps it is also a tab when the panel is mounted without the hub, and the Routes tab and Dashboard switch to Analog's file routes and SSR setting. Tested with Analog 2.7 on Angular 20 (a fresh app from the official template, npm and pnpm) and Angular 22. The demo lives in `examples/analog` (`pnpm analog:dev`).
-
-#### SSR & HTTP
-
-The SSR & HTTP tab needs `withNgDevtools()` and `provideNgDevtoolsHttp()` (see [Embedded in an Angular app](#embedded-in-an-angular-app-express-ssr)), and SSR and the devtools middleware must run in the same Express process. It works in development builds only; in production the interceptor passes requests through untouched.
-
-Register `withNgDevtools()` before your own interceptors (`provideHttpClient(withNgDevtools(), withInterceptors([auth]))`), so it records requests as the app makes them and fault rules apply before anything else. Transfer cache hits are detected when the cached response comes back right away, or when the page's TransferState holds a GET or HEAD entry for the same URL, so an async interceptor after it does not hide them.
-
-- **HTTP timeline**: every `HttpClient` request, tagged SSR or Client, with method, URL, the page that made it, status, time, whether the transfer cache answered it, and whether a fault rule changed it. Click a row for a response preview. Pick the page at the top; the timeline shows its client calls and the SSR calls made while rendering its first URL. The picker stays on the page you picked until that tab closes. Calls are kept until you press Clear timeline.
-- **Fault injection**: add a rule with a URL pattern (a substring, or a glob where `*` matches anything, so `/api/*` matches both relative and absolute URLs), an optional method, where it applies (SSR + client, SSR only, client only), and a status, a delay (up to 10 s) and an optional JSON body. A status of 400 or more fails the request with an `HttpErrorResponse`; a lower status returns the body as a mocked response (a `responseType: 'text'` request gets the body as text). A rule with only a delay passes the request through. Client rules apply right away; SSR rules apply from the next page load. SSR mocks are not written to TransferState, so the browser requests the URL again; apply the rule on SSR + client to mock both.
-- **Hydration**: whether hydration is on (the server sent hydration annotations), hydrated components and nodes, skipped components, incremental defer blocks, mismatched components with the expected and actual DOM, and the hydration warnings (NG05xx) Angular logged. Warnings are captured only with `provideNgDevtoolsHttp()`.
-- **TransferState payload**: each entry in the page's `{APP_ID}-state` script with its size, with HttpClient and Analog cache entries decoded to status, URL and body, and `__nghData__` / `__nghDeferData__` labelled as hydration annotations.
-
-Routes that are prerendered at build time make no requests at runtime and ignore SSR rules. Use `RenderMode.Server` in `app.routes.server.ts` for pages you want to test this way.
-
-Response previews and TransferState values are not redacted: they are sent to the devtools server as they are, so don't expose the dev server beyond localhost.
-
-#### Agent Resources
-
-| Resource | Content |
-| ---------------------------- | ----------------------------- |
-| `ng-devtools:component-tree` | Live component hierarchy |
-| `ng-devtools:signal-graph` | Signal dependency graph |
-| `ng-devtools:injector-tree` | DI injector hierarchy |
-| `ng-devtools:ngrx-store` | Live NgRx stores & change log |
-| `ng-devtools:forms` | Live forms and recent changes |
-| `ng-devtools:router` | Live route and navigations |
-
-### Vite DevTools Dock
-
-Mount as a dock panel inside Vite DevTools:
-
-```ts
-// vite.config.ts
-import { viteDevframeHub } from '@devframes/vite/hub';
-import { createUi } from '@devframes/hub-ui';
-import ngDevtools from '@santoshyadavdev/ng-devtools/devframe';
-
-export default defineConfig({
- plugins: [
- viteDevframeHub({
- devframes: [ngDevtools],
- ui: createUi({ branding: { productName: 'Angular DevTools' } }),
- }),
- ],
-});
-```
-
-### Chrome DevTools Extension
-
-See the [Chrome Extension](#chrome-devtools-extension-1) section below for how to package this as a Chrome extension.
-
-### Browser Overlay
-
-The overlay runs inside the user's Angular page and collects live component, signal, DI, and NgRx data. Importing the module starts it, so in most apps that import is all that is needed:
-
-```ts
-import '@santoshyadavdev/ng-devtools/overlay';
-```
-
-It looks for the devframe connection next to the page, then at
-`/__ng-devtools/` and `/__devframes/ng-devtools/`. It also adds the floating
-button below; with the hub mounted, the button opens the whole hub (every dock
-in a side rail).
-
-`initOverlay` is exported for a devtools mounted somewhere else. Importing the
-module has already started an overlay on the default URLs by then, so dispose of
-that one before starting another, or the page ends up with two connections and
-two polling intervals:
-
-```ts
-import { initOverlay } from '@santoshyadavdev/ng-devtools/overlay';
-
-const dispose = await initOverlay({ baseURL: '/__my-devtools/' });
-```
-
-### In-Page Popup
-
-The devtools can appear as a floating popup directly on your page — no browser extension needed:
-
-```ts
-import { createDevtoolsPopup } from '@santoshyadavdev/ng-devtools/popup';
-
-createDevtoolsPopup();
-```
-
-This adds a floating button (bottom-right) that opens the full devtools UI in an iframe. Supports three dock modes (float, bottom, right), dragging, resizing, and persists position via localStorage. The popup is automatically loaded in development when using the demo app.
-
-## Demo App
-
-The repository includes a demo app, **Angular Travel** (`src/`), that looks and behaves like a real booking site so every inspector has something to show:
-
-- **Destinations**: search, region filter and sort kept in the URL, backed by an `@ngrx/signals` store (`withState`, `withComputed`, `withMethods`)
-- **Trip pages**: loaded by a resolver that redirects unknown trips, with a route title resolver
-- **Booking**: a Signal Forms checkout with a departure date rule, a seat limit and an unsaved-changes guard
-- **My Trips**: behind a sign-in guard that redirects to a reactive form and back
-- **DevTools Lab** (`/examples`): small, focused pages for signals, components, DI, routes and all three form APIs
-- **SSR & HTTP** (`/examples/http`): a product list fetched from `/api/products` during SSR and replayed from the transfer cache. The endpoint accepts `?delay=` and `?fail=` for backend scenarios; run the SSR server (`pnpm build --configuration development && node dist/angular-devtools/server/server.mjs`) to see server calls
-
-Run `pnpm start` and click the amber button in the corner to open the devtools. Destination photos are from Unsplash, credited in `public/destinations/CREDITS.md`.
-
-## Development
-
-```sh
-# Install dependencies
-pnpm install
-
-# Dev server for the devtools UI (with live RPC)
-pnpm devtools:dev
-
-# Build the devtools UI SPA
-pnpm devtools:build
-
-# Build the publishable package (library + UI in dist/)
-pnpm devtools:build-pkg
-
-# Run the Angular host app (builds the package first, includes in-page devtools popup)
-pnpm start
-```
-
-## Publishing
-
-The devtool ships as one npm package, `@santoshyadavdev/ng-devtools`: Node-side logic, RPC, CLI, overlay, popup, and the built UI in `dist/public`.
-
-```sh
-# Builds on prepack, then publishes
-pnpm devtools:publish
-```
-
-## Chrome DevTools Extension
-
-To distribute this as a Chrome DevTools extension, you need a thin Chrome extension shell that opens the devtools UI in a DevTools panel. The built SPA already works standalone — the extension just embeds it.
-
-### 1. Create the extension scaffold
-
-Create an `extension/` directory:
-
-```
-extension/
- manifest.json
- devtools.html
- devtools.js
- panel.html
-```
-
-### 2. `extension/manifest.json`
-
-```json
-{
- "manifest_version": 3,
- "name": "Angular DevTools",
- "version": "0.0.1",
- "description": "Inspect Angular components, signals, DI, and routes.",
- "devtools_page": "devtools.html",
- "permissions": ["scripting"],
- "host_permissions": [
- "http://localhost/*",
- "https://localhost/*",
- "http://127.0.0.1/*",
- "https://127.0.0.1/*"
- ],
- "icons": {
- "128": "icon-128.png"
- }
-}
-```
-
-### 3. `extension/devtools.html` and `extension/devtools.js`
-
-```html
-
-
-
-```
-
-```js
-// devtools.js — creates the panel in Chrome DevTools
-chrome.devtools.panels.create('Angular', 'icon-128.png', 'panel.html');
-```
-
-### 4. `extension/panel.html`
-
-This is where the built SPA loads. Copy the built assets (`dist/devtools-ui/`) into the extension and point `panel.html` at the SPA's `index.html`:
-
-```html
-
-
-
-
-
-
-
-
-
-
-```
-
-### 5. Build the extension
-
-```sh
-# Build the devtools SPA
-pnpm devtools:build
-
-# Copy into the extension
-mkdir -p extension/ui
-cp -r dist/devtools-ui/* extension/ui/
-```
-
-### 6. Load in Chrome
+Then follow the [installation guide](./apps/docs/src/content/getting-started/installation.md) for your setup: Angular CLI with Express, Vite and Analog, or the standalone CLI. For a coding agent, run `npx @santoshyadavdev/ng-devtools mcp`.
-1. Go to `chrome://extensions`
-2. Enable **Developer mode**
-3. Click **Load unpacked** → select the `extension/` directory
-4. Open DevTools on any Angular app → the **Angular** panel appears
+## Documentation
-### 7. Publish to Chrome Web Store
+The docs live in [`apps/docs`](./apps/docs). Run them locally with `pnpm docs:dev`.
-1. Zip the `extension/` directory
-2. Go to the [Chrome Developer Dashboard](https://chrome.google.com/webstore/devconsole)
-3. Click **New item** → upload the zip
-4. Fill in the listing details and submit for review
+- [Getting started](./apps/docs/src/content/getting-started/introduction.md)
+- [Inspectors](./apps/docs/src/content/inspectors/dashboard.md)
+- [Agent tools](./apps/docs/src/content/agents/mcp-server.md)
+- [Security](./apps/docs/src/content/security.md)
+- [Contributing](./CONTRIBUTING.md)
-### Connecting the extension to the running app
+## Maintainers
-The extension panel loads the SPA in static mode by default. To connect it to a live dev server for real-time RPC, the extension's content script or background service worker needs to detect the devframe's `__connection.json` on the inspected page and pass the connection to the panel. This is the same pattern the official Angular DevTools Chrome extension uses — a content script bridges the inspected page and the DevTools panel via `chrome.runtime.connect`.
+- [Santosh Yadav](https://github.com/santoshyadavdev)
+- [Erkam Yaman](https://github.com/erkamyaman)
## Community
@@ -468,7 +33,7 @@ Join the conversation, ask questions, and share feedback on [Discord](https://di
If Angular DevTools helps your work, please consider [sponsoring the project on GitHub](https://github.com/sponsors/santoshyadavdev). Your support keeps development going.
-Thanks to our current sponsors:
+Thanks to the current sponsors:
diff --git a/apps/docs/.gitignore b/apps/docs/.gitignore
new file mode 100644
index 0000000..f06235c
--- /dev/null
+++ b/apps/docs/.gitignore
@@ -0,0 +1,2 @@
+node_modules
+dist
diff --git a/apps/docs/.prettierignore b/apps/docs/.prettierignore
new file mode 100644
index 0000000..c140c8c
--- /dev/null
+++ b/apps/docs/.prettierignore
@@ -0,0 +1,18 @@
+node_modules
+dist
+.angular
+.analog
+.next
+.nitro
+.output
+.cache
+.vercel
+.netlify
+coverage
+public
+*.min.*
+pnpm-lock.yaml
+package-lock.json
+yarn.lock
+src/content
+create-ngmd/template
diff --git a/apps/docs/.prettierrc.json b/apps/docs/.prettierrc.json
new file mode 100644
index 0000000..cf91284
--- /dev/null
+++ b/apps/docs/.prettierrc.json
@@ -0,0 +1,25 @@
+{
+ "printWidth": 100,
+ "tabWidth": 2,
+ "useTabs": false,
+ "singleQuote": true,
+ "semi": true,
+ "quoteProps": "preserve",
+ "bracketSpacing": false,
+ "trailingComma": "all",
+ "overrides": [
+ {
+ "files": ["./.prettierrc.json"],
+ "options": {
+ "parser": "json"
+ }
+ },
+ {
+ "files": ["*.html"],
+ "excludeFiles": ["**/test/**"],
+ "options": {
+ "parser": "angular"
+ }
+ }
+ ]
+}
diff --git a/apps/docs/README.md b/apps/docs/README.md
new file mode 100644
index 0000000..df0a357
--- /dev/null
+++ b/apps/docs/README.md
@@ -0,0 +1,24 @@
+# Docs site
+
+The documentation site for this repository. It is built with [NgMd](https://github.com/erkamyaman/ngmd) on AnalogJS, Angular and Tailwind.
+
+## Run it
+
+From the repository root:
+
+```bash
+pnpm install
+pnpm docs:dev # dev server on http://localhost:5173
+pnpm docs:build # production build in apps/docs/dist
+```
+
+With Nx: `pnpm nx serve angular-devtools-docs`, `pnpm nx build angular-devtools-docs` and `pnpm nx test angular-devtools-docs`.
+
+## Edit
+
+- Pages are markdown files in `src/content`. The path becomes the URL: `src/content/inspectors/signals.md` is served at `/inspectors/signals`.
+- The sidebar, site name, links and site URL live in `src/ngmd.config.ts`.
+- Brand colors are CSS variables in `src/styles.css`.
+- The landing page is `src/app/pages/index.page.ts`.
+
+The build fails on broken internal links and anchors, so run `pnpm docs:build` before you open a PR.
diff --git a/apps/docs/angular.json b/apps/docs/angular.json
new file mode 100644
index 0000000..12e4fe5
--- /dev/null
+++ b/apps/docs/angular.json
@@ -0,0 +1,54 @@
+{
+ "$schema": "./node_modules/@angular/cli/lib/config/schema.json",
+ "version": 1,
+ "newProjectRoot": "projects",
+ "projects": {
+ "ngmd": {
+ "projectType": "application",
+ "root": ".",
+ "sourceRoot": "src",
+ "prefix": "app",
+ "architect": {
+ "build": {
+ "builder": "@analogjs/platform:vite",
+ "options": {
+ "configFile": "vite.config.ts",
+ "main": "src/main.ts",
+ "outputPath": "dist/client",
+ "tsConfig": "tsconfig.app.json"
+ },
+ "defaultConfiguration": "production",
+ "configurations": {
+ "development": {
+ "mode": "development"
+ },
+ "production": {
+ "sourcemap": false,
+ "mode": "production"
+ }
+ }
+ },
+ "serve": {
+ "builder": "@analogjs/platform:vite-dev-server",
+ "defaultConfiguration": "development",
+ "options": {
+ "buildTarget": "ngmd:build",
+ "port": 5173
+ },
+ "configurations": {
+ "development": {
+ "buildTarget": "ngmd:build:development",
+ "hmr": true
+ },
+ "production": {
+ "buildTarget": "ngmd:build:production"
+ }
+ }
+ },
+ "test": {
+ "builder": "@analogjs/vitest-angular:test"
+ }
+ }
+ }
+ }
+}
diff --git a/apps/docs/api-gen.plugin.spec.ts b/apps/docs/api-gen.plugin.spec.ts
new file mode 100644
index 0000000..e44ff33
--- /dev/null
+++ b/apps/docs/api-gen.plugin.spec.ts
@@ -0,0 +1,151 @@
+// @vitest-environment node
+import {mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync} from 'node:fs';
+import {tmpdir} from 'node:os';
+import {join} from 'node:path';
+import type {ResolvedConfig} from 'vite';
+import {apiGenPlugin} from './api-gen.plugin';
+import type {SymbolRecord} from './src/types/api';
+
+const FILES: Record = {
+ 'lib/math.ts': `/** License header. */
+
+/**
+ * Adds things.
+ * @deprecated Use \`sum\` instead.
+ */
+export function add(a: number, b: number): number;
+export function add(a: string, b: string): string;
+export function add(a: any, b: any): any {
+ return a + b;
+}
+
+/**
+ * Maps values.
+ * @beta
+ */
+export function map(
+ items: readonly T[],
+ fn: (item: T) => U,
+): U[] {
+ return items.map(fn);
+}
+
+function Deco(): ClassDecorator {
+ return () => {};
+}
+
+/** A box. */
+@Deco()
+export class Box extends Array {
+ value?: T;
+}
+
+export interface Shape {
+ area(): number;
+}
+
+export type Id = string | number;
+
+/** The answer. */
+export const ANSWER: number = 42;
+
+export default function main() {}
+`,
+ 'lib/sub/other.ts': `export class Thing {}\n`,
+ 'lib/index.ts': `export * from './math';\nexport {Thing as Renamed} from './sub/other';\n`,
+ 'lib/math.spec.ts': `export const specOnly = 1;\n`,
+};
+
+function apiConfig(groupBy: string): string {
+ return `import {defineApi} from './types';
+export default defineApi({
+ scope: ['lib/**/*.ts'],
+ exclude: ['**/*.spec.ts'],
+ groupBy: '${groupBy}',
+ badgesFromJsDoc: ['deprecated', 'beta'],
+});
+`;
+}
+
+describe('apiGenPlugin', () => {
+ let root: string;
+
+ beforeEach(() => {
+ root = realpathSync(mkdtempSync(join(tmpdir(), 'ngmd-api-')));
+ for (const [path, text] of Object.entries(FILES)) {
+ mkdirSync(join(root, path, '..'), {recursive: true});
+ writeFileSync(join(root, path), text);
+ }
+ });
+
+ afterEach(() => rmSync(root, {recursive: true, force: true}));
+
+ function records(): SymbolRecord[] {
+ const plugin = apiGenPlugin() as {
+ configResolved: (cfg: ResolvedConfig) => void;
+ load: (id: string) => string;
+ };
+ plugin.configResolved({root} as ResolvedConfig);
+ const code = plugin.load('\0virtual:ngmd/api-index');
+ return JSON.parse(code.slice(code.indexOf('['), code.lastIndexOf(']') + 1));
+ }
+
+ function byName(list: SymbolRecord[], name: string): SymbolRecord {
+ return list.find((r) => r.name === name)!;
+ }
+
+ it('emits an empty index without ngmd.api.ts', () => {
+ expect(records()).toEqual([]);
+ });
+
+ it('lists each declaration once at its own file and line, honouring exclude', () => {
+ writeFileSync(join(root, 'ngmd.api.ts'), apiConfig('directory'));
+ const list = records();
+ expect(list.map((r) => `${r.name} ${r.filePath}:${r.line} ${r.group}`).sort()).toEqual([
+ 'ANSWER lib/math.ts:41 lib',
+ 'Box lib/math.ts:29 lib',
+ 'Id lib/math.ts:38 lib',
+ 'Renamed lib/sub/other.ts:1 lib-sub',
+ 'Shape lib/math.ts:34 lib',
+ 'Thing lib/sub/other.ts:1 lib-sub',
+ 'add lib/math.ts:7 lib',
+ 'main lib/math.ts:43 lib',
+ 'map lib/math.ts:17 lib',
+ ]);
+ });
+
+ it('builds signatures without decorators, export keywords or bodies', () => {
+ writeFileSync(join(root, 'ngmd.api.ts'), apiConfig('directory'));
+ const list = records();
+ expect(byName(list, 'add').signature).toBe(
+ 'function add(a: number, b: number): number\nfunction add(a: string, b: string): string',
+ );
+ expect(byName(list, 'map').signature).toBe(
+ 'function map(\n items: readonly T[],\n fn: (item: T) => U,\n): U[]',
+ );
+ expect(byName(list, 'Box').signature).toBe('class Box extends Array');
+ expect(byName(list, 'Shape').signature).toBe('interface Shape {\n area(): number;\n}');
+ expect(byName(list, 'ANSWER').signature).toBe('const ANSWER: number');
+ expect(byName(list, 'main').signature).toBe('function main()');
+ });
+
+ it('reads the JSDoc next to the declaration and turns tags into badges', () => {
+ writeFileSync(join(root, 'ngmd.api.ts'), apiConfig('directory'));
+ const list = records();
+ expect(byName(list, 'add')).toMatchObject({
+ description: 'Adds things.',
+ badges: ['deprecated'],
+ });
+ expect(byName(list, 'map')).toMatchObject({description: 'Maps values.', badges: ['beta']});
+ expect(byName(list, 'ANSWER').description).toBe('The answer.');
+ expect(byName(list, 'Box').description).toBe('A box.');
+ });
+
+ it('groups by symbol kind', () => {
+ writeFileSync(join(root, 'ngmd.api.ts'), apiConfig('kind'));
+ const list = records();
+ expect(byName(list, 'add').group).toBe('function');
+ expect(byName(list, 'Box').group).toBe('class');
+ expect(byName(list, 'Id').group).toBe('type');
+ });
+});
diff --git a/apps/docs/api-gen.plugin.ts b/apps/docs/api-gen.plugin.ts
new file mode 100644
index 0000000..38cb331
--- /dev/null
+++ b/apps/docs/api-gen.plugin.ts
@@ -0,0 +1,314 @@
+import {existsSync} from 'node:fs';
+import {join, posix} from 'node:path';
+import type {ModuleNode, Plugin, ViteDevServer} from 'vite';
+import {Node, Project, ts} from 'ts-morph';
+import type {ApiConfig, SymbolRecord, SymbolKind} from './src/types/api.ts';
+
+/**
+ * API-reference auto-generation plugin.
+ *
+ * Pipeline (per build):
+ * 1. Load `ngmd.api.ts` via Vite's module loader. Missing file → silently
+ * no-op (API gen is off; no scope, no error).
+ * 2. Use ts-morph to load every source file matched by `scope` minus
+ * `exclude`. Cache the `Project` between rebuilds.
+ * 3. For each exported declaration, build a `SymbolRecord` (kind, name,
+ * JSDoc, signature, source location, badges from JSDoc tags).
+ * 4. Aggregate the records into a virtual module
+ * `virtual:ngmd/api-index` so the Cmd+K palette and a future API
+ * landing page can list every symbol without re-parsing.
+ * 5. (Not yet wired) emit a virtual `.page.ts` route per symbol so
+ * AnalogJS picks them up at `//`. Punted to a
+ * follow-up commit; the index alone is enough to wire the palette
+ * and validate parse coverage.
+ *
+ * The plugin is intentionally idempotent and safe to leave registered:
+ * without `ngmd.api.ts` it short-circuits and the build runs unchanged.
+ */
+
+const VIRTUAL_INDEX_ID = 'virtual:ngmd/api-index';
+const RESOLVED_INDEX_ID = '\0' + VIRTUAL_INDEX_ID;
+
+export function apiGenPlugin(): Plugin {
+ let root = process.cwd();
+ const configPath = () => posix.join(root, 'ngmd.api.ts');
+ let project: Project | null = null;
+ let recordsMemo: SymbolRecord[] | null = null;
+ let configMemo: ApiConfig | null | undefined;
+
+ function loadConfig(): ApiConfig | null {
+ if (configMemo === undefined) configMemo = readConfig();
+ return configMemo;
+ }
+
+ function readConfig(): ApiConfig | null {
+ const path = configPath();
+ if (!existsSync(path)) return null;
+ try {
+ const proj = new Project({
+ compilerOptions: {target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext},
+ });
+ const sourceFile = proj.addSourceFileAtPath(path);
+ // We can't trivially evaluate the TS without a runtime; instead lift
+ // the literal passed to `defineApi(...)` via AST traversal. For the
+ // skeleton, every supported field is read as a literal so static
+ // extraction is enough. Read the `export default` expression directly
+ // rather than the first `CallExpression` in the file — otherwise any
+ // helper call before the default export (even a harmless one) would
+ // be parsed as the config.
+ const exportAssignment = sourceFile.getExportAssignment((ea) => !ea.isExportEquals());
+ if (!exportAssignment) return null;
+ const callExpr = exportAssignment.getExpression().asKind(ts.SyntaxKind.CallExpression);
+ if (!callExpr) return null;
+ const literal = callExpr.getArguments()[0];
+ if (!literal || !literal.asKind(ts.SyntaxKind.ObjectLiteralExpression)) return null;
+ return parseLiteralAsConfig(literal as never);
+ } catch (err) {
+ console.warn('[ngmd-api-gen] failed to load ngmd.api.ts:', err);
+ return null;
+ }
+ }
+
+ function ensureProject(config: ApiConfig): Project {
+ if (project) return project;
+ project = new Project({
+ tsConfigFilePath: existsSync(join(root, 'tsconfig.json'))
+ ? join(root, 'tsconfig.json')
+ : undefined,
+ skipAddingFilesFromTsConfig: true,
+ });
+ project.addSourceFilesAtPaths([
+ ...config.scope.map((pattern) => posix.join(root, pattern)),
+ ...(config.exclude ?? []).map((pattern) => '!' + posix.join(root, pattern)),
+ ]);
+ return project;
+ }
+
+ function extractRecords(config: ApiConfig): SymbolRecord[] {
+ if (recordsMemo) return recordsMemo;
+ const proj = ensureProject(config);
+ const records: SymbolRecord[] = [];
+ const seen = new Set();
+ const badgeTags = new Set(config.badgesFromJsDoc ?? []);
+
+ for (const sourceFile of proj.getSourceFiles()) {
+ for (const [exportName, declarations] of sourceFile.getExportedDeclarations()) {
+ const decls = declarations.filter((d) => symbolKindOf(d));
+ const first = decls[0];
+ if (!first) continue;
+ const declFile = first.getSourceFile();
+ if (declFile.isInNodeModules() || declFile.isDeclarationFile()) continue;
+ const kind = symbolKindOf(first)!;
+ const name =
+ exportName === 'default'
+ ? ((first as {getName?: () => string | undefined}).getName?.() ?? exportName)
+ : exportName;
+ const filePath = posix.relative(root, declFile.getFilePath());
+ const key = `${filePath}:${first.getStart()}:${name}`;
+ if (seen.has(key)) continue;
+ seen.add(key);
+
+ const jsDocsPerDecl = decls.map((d) => {
+ const host = jsDocHostFor(d);
+ return Node.isJSDocable(host) ? host.getJsDocs() : [];
+ });
+ const jsDocs = jsDocsPerDecl.flat();
+ const description =
+ jsDocsPerDecl
+ .find((docs) => docs.length)
+ ?.at(-1)
+ ?.getDescription()
+ .trim() ?? '';
+ const tags = jsDocs.flatMap((d) => d.getTags().map((t) => t.getTagName()));
+ const badges = [...new Set(tags.filter((t) => badgeTags.has(t)))];
+
+ records.push({
+ kind,
+ name,
+ filePath,
+ line: first.getStartLineNumber(),
+ signature: signatureOf(decls),
+ description,
+ badges,
+ group: groupNameFor(filePath, config.groupBy ?? 'directory', kind),
+ });
+ }
+ }
+
+ recordsMemo = records;
+ return records;
+ }
+
+ return {
+ name: 'ngmd-api-gen',
+ configResolved(cfg) {
+ root = cfg.root;
+ project = null;
+ recordsMemo = null;
+ configMemo = undefined;
+ },
+ resolveId(id) {
+ if (id === VIRTUAL_INDEX_ID) return RESOLVED_INDEX_ID;
+ return null;
+ },
+ load(id) {
+ if (id !== RESOLVED_INDEX_ID) return null;
+ const config = loadConfig();
+ if (!config) return 'export const apiIndex = [];\n';
+ const records = extractRecords(config);
+ return `export const apiIndex = ${JSON.stringify(records, null, 2)};\n`;
+ },
+ configureServer(server) {
+ server.watcher.on('all', (event, file) => {
+ if (event !== 'add' && event !== 'unlink') return;
+ const mod = invalidate(file, server);
+ if (mod) void server.reloadModule(mod);
+ });
+ },
+ handleHotUpdate({file, server, modules}) {
+ const mod = invalidate(file, server);
+ return mod ? [...modules, mod] : undefined;
+ },
+ };
+
+ function invalidate(file: string, server: ViteDevServer): ModuleNode | undefined {
+ const isConfig = file === configPath();
+ if (!isConfig && !project?.getSourceFile(file) && !inScope(file)) return;
+ if (isConfig) configMemo = undefined;
+ project = null;
+ recordsMemo = null;
+ const mod = server.moduleGraph.getModuleById(RESOLVED_INDEX_ID);
+ if (mod) server.moduleGraph.invalidateModule(mod);
+ return mod;
+ }
+
+ function inScope(file: string): boolean {
+ if (!file.endsWith('.ts')) return false;
+ const config = loadConfig();
+ if (!config) return false;
+ const rel = posix.relative(root, file);
+ return (
+ config.scope.some((pattern) => posix.matchesGlob(rel, pattern)) &&
+ !(config.exclude ?? []).some((pattern) => posix.matchesGlob(rel, pattern))
+ );
+ }
+}
+
+function symbolKindOf(decl: Node): SymbolKind | null {
+ if (Node.isClassDeclaration(decl)) return 'class';
+ if (Node.isInterfaceDeclaration(decl)) return 'interface';
+ if (Node.isFunctionDeclaration(decl)) return 'function';
+ if (Node.isVariableDeclaration(decl)) return 'const';
+ if (Node.isTypeAliasDeclaration(decl)) return 'type';
+ if (Node.isEnumDeclaration(decl)) return 'enum';
+ return null;
+}
+
+/**
+ * Resolve the node that actually carries JSDoc for an exported declaration.
+ * Most declarations are themselves JSDocable, but a `VariableDeclaration`
+ * (`export const`) keeps its JSDoc on the enclosing `VariableStatement`.
+ */
+function jsDocHostFor(decl: Node): Node {
+ if (Node.isVariableDeclaration(decl)) {
+ return decl.getFirstAncestorByKind(ts.SyntaxKind.VariableStatement) ?? decl;
+ }
+ return decl;
+}
+
+/**
+ * Declaration text without decorators, `export`/`default` modifiers or
+ * implementation bodies. Overloaded functions list every overload
+ * signature; classes stop at the opening brace; interfaces, type aliases
+ * and enums keep their full shape.
+ */
+function signatureOf(decls: Node[]): string {
+ const [first] = decls;
+ if (Node.isFunctionDeclaration(first)) {
+ const overloads = decls.filter(Node.isFunctionDeclaration).filter((d) => d.isOverload());
+ return (overloads.length ? overloads : [first])
+ .map((d) => stripExport(textBefore(d, d.getBody())))
+ .join('\n');
+ }
+ if (Node.isClassDeclaration(first)) {
+ const decorators = first.getDecorators();
+ const start = decorators.length ? decorators.at(-1)!.getEnd() : first.getStart();
+ const brace = first.getFirstChildByKind(ts.SyntaxKind.OpenBraceToken);
+ const end = brace?.getStart() ?? first.getEnd();
+ return stripExport(first.getSourceFile().getFullText().slice(start, end));
+ }
+ if (Node.isVariableDeclaration(first)) {
+ const statement = first.getVariableStatement();
+ const keyword = statement?.getDeclarationKind() ?? 'const';
+ const type = first.getTypeNode()?.getText() ?? first.getType().getText(first);
+ return `${keyword} ${first.getName()}: ${type}`;
+ }
+ return stripExport(first.getText());
+}
+
+function textBefore(node: Node, body: Node | undefined): string {
+ const text = node.getText();
+ return body ? text.slice(0, body.getStart() - node.getStart()) : text;
+}
+
+function stripExport(text: string): string {
+ return text
+ .trim()
+ .replace(/^export\s+(default\s+)?/, '')
+ .replace(/;$/, '');
+}
+
+function groupNameFor(
+ filePath: string,
+ strategy: NonNullable,
+ kind: SymbolKind,
+): string {
+ if (strategy === 'kind') return kind;
+ if (strategy === 'package') {
+ const match = filePath.match(/^packages\/([^/]+)\//);
+ return match?.[1] ?? 'root';
+ }
+ // Sluggify the directory into a single URL- and label-friendly segment so
+ // groups stay short (`src/app/ui/api` → `src-app-ui-api`) instead of
+ // leaking multi-segment paths into URLs and headings.
+ const dir = filePath.split('/').slice(0, -1).join('/');
+ return slugifyGroup(dir) || 'root';
+}
+
+function slugifyGroup(value: string): string {
+ return value
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-+|-+$/g, '');
+}
+
+/**
+ * Read a literal object passed to `defineApi(...)` and coerce it into the
+ * `ApiConfig` shape. Only literal fields are supported; computed values
+ * are ignored. Enough for the v1 of the plugin; richer config can move to
+ * a runtime evaluation pass later.
+ */
+function parseLiteralAsConfig(literal: {
+ getProperties: () => Array<{
+ getName?: () => string;
+ getInitializer?: () => unknown;
+ }>;
+}): ApiConfig {
+ const out: Partial = {scope: [], exclude: []};
+ for (const prop of literal.getProperties()) {
+ const name = prop.getName?.();
+ const init = prop.getInitializer?.();
+ if (!name || !init) continue;
+ const text = (init as {getText: () => string}).getText().trim();
+ if (name === 'scope' || name === 'exclude' || name === 'badgesFromJsDoc') {
+ const matches = text.match(/'([^']+)'|"([^"]+)"/g) ?? [];
+ const values = matches.map((m) => m.slice(1, -1));
+ (out as Record)[name] = values;
+ } else if (name === 'basePath' || name === 'groupBy') {
+ const value = text.match(/'([^']+)'|"([^"]+)"/)?.[0]?.slice(1, -1) ?? '';
+ (out as Record)[name] = value;
+ }
+ }
+ if (!out.scope?.length) out.scope = [];
+ return out as ApiConfig;
+}
diff --git a/apps/docs/build-extensions.spec.ts b/apps/docs/build-extensions.spec.ts
new file mode 100644
index 0000000..e8ab16d
--- /dev/null
+++ b/apps/docs/build-extensions.spec.ts
@@ -0,0 +1,69 @@
+import {Marked, type MarkedExtension} from 'marked';
+import {getBuildExtensions} from './src/marked-extensions/index';
+import {ngmdCodeGroupExtension} from './src/marked-extensions/ngmd-code-group';
+import {ngmdCodeHighlightExtension} from './src/marked-extensions/ngmd-code-highlight';
+import {ngmdCodeImportExtension} from './src/marked-extensions/ngmd-code-import';
+
+function preprocess(extension: MarkedExtension, markdown: string): Promise {
+ return (extension.hooks!.preprocess as (markdown: string) => Promise)(markdown);
+}
+
+async function renderBuild(markdown: string, extension: MarkedExtension): Promise {
+ return new Marked({async: true}, extension).parse(markdown);
+}
+
+describe('build-time fence extensions', () => {
+ it('leaves examples nested in a longer fence alone', async () => {
+ const md = '````md\n```bash group="a"\none\n```\n\n```bash group="a"\ntwo\n```\n````\n';
+ expect(await preprocess(ngmdCodeGroupExtension, md)).toBe(md);
+ const hl = '````md\n```ts {1}\na\n```\n````\n';
+ expect(await preprocess(ngmdCodeHighlightExtension, hl)).toBe(hl);
+ });
+
+ it('merges adjacent same-group fences, escapes labels and keeps lone fences', async () => {
+ const html = await renderBuild(
+ '```bash group="g" name="&" active\r\none\r\n```\r\n\r\n```bash group="g" name="active tab"\r\ntwo\r\n```\r\n\r\ntext\r\n\r\n```bash group="g"\r\nlone\r\n```\r\n',
+ ngmdCodeGroupExtension,
+ );
+ expect(html.match(/class="ngmd-code-group"/g)).toHaveLength(1);
+ expect(html).toContain('data-active="true"><b>&');
+ expect(html).toContain('data-active="false">active tab');
+ expect(html).toContain('lone');
+ });
+
+ it('highlights clamped, reversed and out-of-range lines without blowing up', async () => {
+ const html = await preprocess(
+ ngmdCodeHighlightExtension,
+ '```typescript title="x" {3-2,1-999999999}\nconst a = 1;\nb\n```\n',
+ );
+ expect(html.match(/class="line highlighted"/g)).toHaveLength(2);
+ expect(html).not.toContain('class="line"');
+ expect(html).toContain('--shiki-light:#CF222E');
+ });
+
+ it('imports ranges, and warns and keeps the fence when the range does not fit', async () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
+ const ok = await preprocess(
+ ngmdCodeImportExtension,
+ '```ts title="t" file="src/marked-extensions/escape-html.ts#L1"\n```\n',
+ );
+ expect(ok).toContain('escape-html.ts#L1 ');
+ expect(ok).toContain('escapeHtml');
+ for (const range of ['#L5-L99', '#L3-L1', '#foo']) {
+ const md = `\`\`\`ts file="src/marked-extensions/escape-html.ts${range}"\n\`\`\`\n`;
+ expect(await preprocess(ngmdCodeImportExtension, md)).toBe(md);
+ }
+ expect(warn).toHaveBeenCalledTimes(3);
+ warn.mockRestore();
+ });
+
+ it('substitutes vars before the fence extensions run', async () => {
+ const extensions = await getBuildExtensions();
+ const html = await new Marked({async: true}, ...extensions).parse(
+ '```bash group="i"\nnpm i x@{{ngmd-version}}\n```\n\n```bash group="i"\npnpm add x@{{ ngmd-version }}\n```\n\n{{unknown}}',
+ );
+ expect(html).not.toContain('ngmd-version');
+ expect(html).toMatch(/x@\d+\.\d+\.\d+/);
+ expect(html).toContain('{{unknown}}');
+ });
+});
diff --git a/apps/docs/build-plugins.spec.ts b/apps/docs/build-plugins.spec.ts
new file mode 100644
index 0000000..fd744f9
--- /dev/null
+++ b/apps/docs/build-plugins.spec.ts
@@ -0,0 +1,142 @@
+import {mkdirSync, mkdtempSync, rmSync, writeFileSync} from 'node:fs';
+import {tmpdir} from 'node:os';
+import {dirname, join} from 'node:path';
+import type {Plugin} from 'vite';
+import {internalLinkGuard} from './link-guard.plugin';
+import {rawMdPlugin} from './raw-md.plugin';
+import {searchIndexPlugin} from './search-index.plugin';
+import {sitemapPlugin} from './sitemap.plugin';
+
+type Hook = (this: unknown, ...args: unknown[]) => unknown;
+
+function call(plugin: Plugin, hook: keyof Plugin, ctx: unknown, ...args: unknown[]): unknown {
+ return (plugin[hook] as Hook).call(ctx, ...args);
+}
+
+let root: string;
+
+function write(files: Record): void {
+ for (const [path, text] of Object.entries(files)) {
+ mkdirSync(dirname(join(root, path)), {recursive: true});
+ writeFileSync(join(root, path), text);
+ }
+}
+
+function emitted(plugin: Plugin): Map {
+ const out = new Map();
+ call(plugin, 'configResolved', undefined, {root});
+ call(plugin, 'generateBundle', {
+ emitFile: (f: {fileName: string; source: string}) => out.set(f.fileName, f.source),
+ });
+ return out;
+}
+
+beforeEach(() => {
+ root = mkdtempSync(join(tmpdir(), 'ngmd-plugins-'));
+ write({
+ 'public/logo.svg': '',
+ 'src/app/pages/index.page.ts': '',
+ 'src/app/pages/[...slug].page.ts': '',
+ 'src/app/pages/api/index.page.ts': '',
+ 'src/app/pages/api/[group]/[symbol].page.ts': '',
+ 'src/content/guide/index.md': '---\ntitle: Guide\n---\n## Café\n\n## Setup\n\n## Setup\n',
+ 'src/content/hidden.md': '---\r\nnoIndex: "true"\r\n---\r\nSecret\r\n',
+ 'src/content/some page.md': '# Some page\n',
+ });
+});
+
+afterEach(() => rmSync(root, {recursive: true, force: true}));
+
+describe('internalLinkGuard', () => {
+ function check(markdown: string): string[] {
+ write({'src/content/check.md': markdown});
+ const plugin = internalLinkGuard();
+ call(plugin, 'configResolved', undefined, {root, command: 'serve'});
+ const warnings: string[] = [];
+ call(
+ plugin,
+ 'transform',
+ {warn: (m: string) => warnings.push(m)},
+ '',
+ join(root, 'src/content/check.md?analog-content-file=true'),
+ );
+ return warnings;
+ }
+
+ it('accepts index routes, dynamic pages, public files, queries, slashes and encoding', () => {
+ expect(
+ check(
+ [
+ '[a](/guide) [b](/guide/#cafe) [c](/guide?tab=1#setup-1) [d](/api) [e](/api/core/Foo)',
+ ' [raw](/guide.md) [f](/some%20page) [g](#local) [h](//cdn.example.com/x)',
+ '## Local',
+ ].join('\n'),
+ ),
+ ).toEqual([]);
+ });
+
+ it('ignores links inside code and still reports real breakage', () => {
+ const [warning] = check(
+ '```md\n[x](/nope)\n```\n\n`[y](/nope2)`\n\n[z](/missing)  [w](/guide#nope)',
+ );
+ expect(warning).not.toContain('/nope"');
+ expect(warning).not.toContain('/nope2');
+ expect(warning).toContain('"/missing" is not a known route');
+ expect(warning).toContain('"/missing.png" is not a known route or file in public/');
+ expect(warning).toContain('"/guide#nope"');
+ });
+});
+
+describe('searchIndexPlugin', () => {
+ function index(): Array> {
+ const plugin = searchIndexPlugin();
+ call(plugin, 'configResolved', undefined, {root});
+ const code = call(plugin, 'load', undefined, '\0virtual:ngmd/search-index') as string;
+ return JSON.parse(code.replace(/^export const searchIndex = |;$/g, ''));
+ }
+
+ it('uses index routes, YAML frontmatter and TOC anchors, and skips noIndex pages', () => {
+ const docs = index();
+ expect(docs.some((d) => d['url'] === '/hidden')).toBe(false);
+ const guide = docs.filter((d) => d['url'] === '/guide');
+ expect(guide.map((d) => d['anchor'])).toEqual(['', 'cafe', 'setup', 'setup-1']);
+ expect(guide[0]['pageTitle']).toBe('Guide');
+ });
+
+ it('keeps inline code text, drops fenced code and loses no characters when chunking', () => {
+ const long = 'x'.repeat(700);
+ write({
+ 'src/content/code.md': `## Use \`\`\n\nCall \`a_b()\` now.\n\n~~~ts\nsecret()\n~~~\n\n## Long\n\n${long}\n`,
+ });
+ const docs = index().filter((d) => d['url'] === '/code');
+ expect(docs[1]['heading']).toBe('Use ');
+ expect(docs[2]['body']).toBe('Call a_b() now.');
+ expect(JSON.stringify(docs)).not.toContain('secret');
+ const chunks = docs.filter((d) => d['anchor'] === 'long' && d['kind'] === 'snippet');
+ expect(chunks.map((d) => d['body']).join('')).toBe(long);
+ });
+});
+
+describe('sitemapPlugin', () => {
+ it('lists static routes once, encoded, under a subpath, without noIndex pages', () => {
+ const plugin = sitemapPlugin({siteUrl: 'https://example.com/docs/'});
+ const out = emitted(plugin);
+ const locs = [...out.get('sitemap.xml')!.matchAll(/(.*)<\/loc>/g)].map((m) => m[1]);
+ expect(locs).toEqual([
+ 'https://example.com/docs/',
+ 'https://example.com/docs/api',
+ 'https://example.com/docs/guide',
+ 'https://example.com/docs/some%20page',
+ ]);
+ expect(out.get('robots.txt')).toContain('Sitemap: https://example.com/docs/sitemap.xml');
+ });
+});
+
+describe('rawMdPlugin', () => {
+ it('emits index pages at their route plus .md', () => {
+ const out = emitted(rawMdPlugin());
+ expect(out.get('guide.md')).toContain('## Setup');
+ expect(out.get('guide/index.md')).toBe(out.get('guide.md'));
+ expect(out.has('some page.md')).toBe(true);
+ });
+});
diff --git a/apps/docs/index.html b/apps/docs/index.html
new file mode 100644
index 0000000..6814794
--- /dev/null
+++ b/apps/docs/index.html
@@ -0,0 +1,54 @@
+
+
+
+
+ %SITE_NAME%
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/docs/link-guard.plugin.ts b/apps/docs/link-guard.plugin.ts
new file mode 100644
index 0000000..d46a47f
--- /dev/null
+++ b/apps/docs/link-guard.plugin.ts
@@ -0,0 +1,183 @@
+import {existsSync, readFileSync, statSync} from 'node:fs';
+import {join, relative} from 'node:path';
+import type {Plugin} from 'vite';
+import {
+ createSlugger,
+ fenceTracker,
+ pageRouteMatcher,
+ routeFromPagePath,
+ headingText,
+ slugify,
+ walkContentFiles,
+ walkPageFiles,
+ withoutCode,
+} from './plugin-utils.ts';
+
+/**
+ * Build-time guard that errors on broken internal links inside markdown files.
+ *
+ * Validates three cases:
+ * - `[text](#fragment)` — fragment must be a real heading slug in the same file
+ * - `[text](/path)` — `/path` must be a known route
+ * - `[text](/path#fragment)` — both the route and the heading slug must exist
+ *
+ * Routes are discovered by walking `src/content/**\/*.md` (each markdown
+ * file's path under content/ becomes its route) and `src/app/pages/**\/*.page.ts`.
+ * External (`http(s)://`), mail (`mailto:`), and relative (`./foo`) links are
+ * skipped; the existing externalLinkGuard covers raw HTML external anchors.
+ *
+ * Heading slugs are computed with the same algorithm the rendered TOC uses
+ * (see `plugin-utils.slugify`), so dev-time and runtime stay in sync.
+ */
+
+function extractHeadings(markdown: string): Set {
+ const slugs = new Set();
+ const inFence = fenceTracker();
+ const slug = createSlugger();
+ for (const line of markdown.split(/\r?\n/)) {
+ if (inFence(line)) continue;
+ const m = /^ {0,3}(#{1,6})\s+(.+?)\s*$/.exec(line);
+ if (!m) continue;
+ const text = headingText(m[2]);
+ slugs.add(m[1].length === 1 ? slugify(text) : slug(text));
+ }
+ return slugs;
+}
+
+function decode(s: string): string {
+ try {
+ return decodeURIComponent(s);
+ } catch {
+ return s;
+ }
+}
+
+export function internalLinkGuard(): Plugin {
+ let root = process.cwd();
+ // route → headings, populated lazily on first transform() call
+ const headingsByRoute = new Map>();
+ // route → source file (relative path)
+ const routes = new Map();
+ const dynamicRoutes: RegExp[] = [];
+ let primed = false;
+ let isBuild = true;
+
+ function prime(): void {
+ if (primed) return;
+ primed = true;
+
+ // .md → route (walk src/content/ tree)
+ const contentDir = join(root, 'src/content');
+ try {
+ statSync(contentDir);
+ for (const [rel, route] of walkContentFiles(contentDir, root)) {
+ const full = join(root, rel);
+ routes.set(route, rel);
+ headingsByRoute.set(route, extractHeadings(readFileSync(full, 'utf8')));
+ }
+ } catch {
+ // src/content missing — skip
+ }
+
+ // .page.ts → route (no heading scrape; just makes the route resolvable)
+ const pagesDir = join(root, 'src/app/pages');
+ try {
+ const pageFiles = walkPageFiles(pagesDir, root);
+ for (const rel of pageFiles) {
+ const matcher = pageRouteMatcher(rel);
+ if (matcher) dynamicRoutes.push(matcher);
+ const route = routeFromPagePath(rel);
+ if (!route) continue;
+ if (!routes.has(route)) routes.set(route, rel);
+ }
+ } catch {
+ // src/app/pages missing — fine for non-app projects
+ }
+ }
+
+ return {
+ name: 'ngmd-internal-link-guard',
+ enforce: 'pre',
+ configResolved(cfg) {
+ root = cfg.root;
+ isBuild = cfg.command === 'build';
+ },
+ watchChange(id) {
+ if (!id.endsWith('.md') && !id.endsWith('.page.ts')) return;
+ primed = false;
+ routes.clear();
+ dynamicRoutes.length = 0;
+ headingsByRoute.clear();
+ },
+ transform(_code, id) {
+ // Vite may append `?import` / `?raw` query suffixes
+ const cleanId = id.split('?')[0];
+ if (!cleanId.endsWith('.md')) return null;
+ prime();
+
+ const file = cleanId;
+ const content = readFileSync(file, 'utf8');
+ const ownSlugs = extractHeadings(content);
+ const issues: string[] = [];
+
+ const validate = (href: string, label: string) => {
+ if (!href) return;
+ // external / mail / relative — skip
+ if (!href.startsWith('#') && (!href.startsWith('/') || href.startsWith('//'))) return;
+
+ const hashAt = href.indexOf('#');
+ const fragment = hashAt === -1 ? '' : decode(href.slice(hashAt + 1));
+ const rawPath = (hashAt === -1 ? href : href.slice(0, hashAt)).split('?')[0];
+ if (rawPath === '') {
+ // in-page fragment: must exist in this file
+ if (fragment && !ownSlugs.has(fragment)) {
+ issues.push(` ${label} → "#${fragment}" has no matching heading in this file`);
+ }
+ return;
+ }
+
+ const path = decode(rawPath).replace(/(.)\/+$/, '$1');
+ if (!routes.has(path)) {
+ if (dynamicRoutes.some((re) => re.test(path))) return;
+ if (!/\.[^/]+$/.test(path)) {
+ issues.push(` ${label} → "${path}" is not a known route`);
+ } else if (
+ !(path.endsWith('.md') && routes.has(path.slice(0, -3))) &&
+ !existsSync(join(root, 'public', path))
+ ) {
+ issues.push(` ${label} → "${path}" is not a known route or file in public/`);
+ }
+ return;
+ }
+ if (fragment) {
+ const targetSlugs = headingsByRoute.get(path);
+ if (targetSlugs && !targetSlugs.has(fragment)) {
+ issues.push(` ${label} → "${path}#${fragment}" — fragment not found in target page`);
+ }
+ // if targetSlugs is undefined (e.g. .page.ts route), skip fragment check
+ }
+ };
+
+ const scanned = withoutCode(content);
+ const mdLinkRe = /\[([^\]]+)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g;
+ const htmlAnchorRe = /]*href=["']([^"']+)["']/g;
+ let m: RegExpExecArray | null;
+ while ((m = mdLinkRe.exec(scanned)) !== null) {
+ validate(m[2], `[${m[1]}](${m[2]})`);
+ }
+ while ((m = htmlAnchorRe.exec(scanned)) !== null) {
+ validate(m[1], ``);
+ }
+
+ if (issues.length > 0) {
+ const message =
+ `[ngmd] Broken internal links in ${relative(root, file)}:\n${issues.join('\n')}\n` +
+ `Fix the link target, or update the heading slug it points to.`;
+ if (isBuild) this.error(message);
+ this.warn(message);
+ }
+
+ return null;
+ },
+ };
+}
diff --git a/apps/docs/package.json b/apps/docs/package.json
new file mode 100644
index 0000000..e42925d
--- /dev/null
+++ b/apps/docs/package.json
@@ -0,0 +1,84 @@
+{
+ "name": "angular-devtools-docs",
+ "version": "0.0.0",
+ "private": true,
+ "description": "Documentation site for Angular DevTools",
+ "license": "MIT",
+ "type": "module",
+ "engines": {
+ "node": "^22.22.3 || ^24.15.0 || >=26.0.0"
+ },
+ "scripts": {
+ "ng": "ng",
+ "dev": "vite",
+ "start": "pnpm run dev",
+ "build": "vite build",
+ "watch": "vite build --watch",
+ "test": "vitest run",
+ "preview": "node dist/analog/server/index.mjs",
+ "format": "prettier --write .",
+ "format:check": "prettier --check .",
+ "test:watch": "vitest"
+ },
+ "dependencies": {
+ "@analogjs/content": "^2.7.5",
+ "@analogjs/router": "^2.7.5",
+ "@angular/common": "22.1.7",
+ "@angular/compiler": "22.1.7",
+ "@angular/core": "22.1.7",
+ "@angular/elements": "22.1.7",
+ "@angular/forms": "22.1.7",
+ "@angular/platform-browser": "22.1.7",
+ "@angular/platform-server": "22.1.7",
+ "@angular/router": "22.1.7",
+ "@lucide/angular": "^1.48.0",
+ "@orama/orama": "^3.1.18",
+ "@tailwindcss/typography": "^0.5.20",
+ "@tailwindcss/vite": "^4.3.3",
+ "front-matter": "^4.0.2",
+ "h3": "^1.13.0",
+ "marked": "^15.0.7",
+ "marked-gfm-heading-id": "^4.1.3",
+ "marked-highlight": "^2.2.3",
+ "marked-mangle": "^1.1.14",
+ "marked-shiki": "^1.2.1",
+ "motion": "^13.4.4",
+ "postcss": "^8.5.28",
+ "prismjs": "^1.29.0",
+ "rxjs": "~7.8.0",
+ "shiki": "^1.29.2",
+ "tailwindcss": "^4.3.3",
+ "ts-morph": "^28.0.0",
+ "tslib": "^2.3.0"
+ },
+ "devDependencies": {
+ "@analogjs/platform": "^2.7.5",
+ "@analogjs/vite-plugin-angular": "^2.7.5",
+ "@analogjs/vitest-angular": "^2.7.5",
+ "@angular/build": "^22.1.8",
+ "@angular/cli": "^22.1.8",
+ "@angular/compiler-cli": "22.1.7",
+ "jsdom": "^28.0.0",
+ "prettier": "^3.8.1",
+ "typescript": "~6.0.2",
+ "vite": "^8.3.0",
+ "vitest": "^4.0.8"
+ },
+ "nx": {
+ "name": "angular-devtools-docs",
+ "targets": {
+ "build": {
+ "outputs": [
+ "{projectRoot}/dist"
+ ]
+ },
+ "serve": {
+ "command": "vite",
+ "options": {
+ "cwd": "{projectRoot}"
+ },
+ "continuous": true
+ }
+ }
+ }
+}
diff --git a/apps/docs/page-meta.plugin.ts b/apps/docs/page-meta.plugin.ts
new file mode 100644
index 0000000..685941a
--- /dev/null
+++ b/apps/docs/page-meta.plugin.ts
@@ -0,0 +1,89 @@
+import {statSync} from 'node:fs';
+import {join} from 'node:path';
+import type {Plugin, ViteDevServer} from 'vite';
+import {gitDate, routeFromPagePath, walkContentFiles, walkPageFiles} from './plugin-utils.ts';
+
+/**
+ * Build-time map of page URL → { editUrl, lastUpdated }.
+ *
+ * Walks `src/app/pages` (for `.page.ts` routes) and `src/content/**\/*.md`
+ * (each markdown file's path under content/ becomes its route, matching the
+ * `[...slug].page.ts` catch-all), pulls the latest commit date via `git log`,
+ * and emits a typed module under the virtual id `virtual:ngmd/page-meta`
+ * which the runtime imports.
+ *
+ * If the file is uncommitted, lastUpdated falls back to its mtime in ISO
+ * date form so dev iteration still shows something.
+ */
+
+export interface PageMeta {
+ editUrl: string;
+ lastUpdated: string;
+}
+
+const VIRTUAL_ID = 'virtual:ngmd/page-meta';
+const RESOLVED_ID = '\0' + VIRTUAL_ID;
+
+export function pageMetaPlugin(opts: {repoUrl: string; branch?: string; dir?: string}): Plugin {
+ const branch = opts.branch ?? 'main';
+ const dir = opts.dir ? `${opts.dir.replace(/^\/+|\/+$/g, '')}/` : '';
+ let root = process.cwd();
+ let server: ViteDevServer | undefined;
+
+ return {
+ name: 'ngmd-page-meta',
+ configResolved(cfg) {
+ root = cfg.root;
+ },
+ configureServer(s) {
+ server = s;
+ },
+ watchChange(id) {
+ if (!server || !(id.endsWith('.md') || id.endsWith('.page.ts'))) return;
+ const mod = server.moduleGraph.getModuleById(RESOLVED_ID);
+ if (mod) server.moduleGraph.invalidateModule(mod);
+ },
+ resolveId(id) {
+ if (id === VIRTUAL_ID) return RESOLVED_ID;
+ return null;
+ },
+ load(id) {
+ if (id !== RESOLVED_ID) return null;
+ const map: Record = {};
+
+ // .page.ts → route
+ try {
+ const pageFiles = walkPageFiles(join(root, 'src/app/pages'), root);
+ for (const rel of pageFiles) {
+ const route = routeFromPagePath(rel);
+ if (!route) continue;
+ map[route] = {
+ editUrl: `${opts.repoUrl}/edit/${branch}/${dir}${rel}`,
+ lastUpdated: gitDate(rel, root),
+ };
+ }
+ } catch {
+ // src/app/pages missing, skip
+ }
+
+ // src/content/**/*.md → route (mirrors the [...slug] catch-all)
+ const contentDir = join(root, 'src/content');
+ try {
+ statSync(contentDir);
+ for (const [rel, route] of walkContentFiles(contentDir, root)) {
+ const date = gitDate(rel, root);
+ if (!date) continue;
+ // .md edit URL wins when present (more useful for prose pages)
+ map[route] = {
+ editUrl: `${opts.repoUrl}/edit/${branch}/${dir}${rel}`,
+ lastUpdated: date,
+ };
+ }
+ } catch {
+ // src/content missing — skip
+ }
+
+ return `export const pageMeta = ${JSON.stringify(map, null, 2)};`;
+ },
+ };
+}
diff --git a/apps/docs/plugin-utils.spec.ts b/apps/docs/plugin-utils.spec.ts
new file mode 100644
index 0000000..1de4f89
--- /dev/null
+++ b/apps/docs/plugin-utils.spec.ts
@@ -0,0 +1,132 @@
+import {mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync} from 'node:fs';
+import {tmpdir} from 'node:os';
+import {join} from 'node:path';
+import {
+ fenceTracker,
+ isNoIndex,
+ pageRouteMatcher,
+ parseFrontmatter,
+ resolveInside,
+ routeFromPagePath,
+ walkContentFiles,
+ withoutCode,
+} from './plugin-utils';
+
+function outsideFences(markdown: string): string[] {
+ const inFence = fenceTracker();
+ return markdown.split('\n').filter((line) => !inFence(line));
+}
+
+describe('fenceTracker', () => {
+ it('skips lines inside backtick and tilde fences', () => {
+ const md = ['## Real', '```bash', '## Not a heading', '```', '~~~', '# Nope', '~~~', 'after'];
+ expect(outsideFences(md.join('\n'))).toEqual(['## Real', 'after']);
+ });
+
+ it('closes only on the same character with at least the opener length', () => {
+ const md = ['````md', '```ts', '## Nested', '```', '~~~~', '````', '## Out'];
+ expect(outsideFences(md.join('\n'))).toEqual(['## Out']);
+ });
+
+ it('does not open a backtick fence whose info string has a backtick', () => {
+ const md = ['``` not `a fence`', '## Heading', 'after'];
+ expect(outsideFences(md.join('\n'))).toEqual(md);
+ });
+
+ it('does not close on a fence line with an info string', () => {
+ const md = ['```', '```ts', '## Still inside', '```', '## Out'];
+ expect(outsideFences(md.join('\n'))).toEqual(['## Out']);
+ });
+});
+
+describe('resolveInside', () => {
+ let base: string;
+ let root: string;
+
+ beforeEach(() => {
+ base = mkdtempSync(join(tmpdir(), 'ngmd-'));
+ root = join(base, 'site');
+ mkdirSync(join(root, 'src'), {recursive: true});
+ writeFileSync(join(root, 'src/app.ts'), 'inside');
+ writeFileSync(join(base, 'secret.txt'), 'outside');
+ symlinkSync(join(base, 'secret.txt'), join(root, 'link.txt'));
+ });
+
+ afterEach(() => rmSync(base, {recursive: true, force: true}));
+
+ it('resolves a file inside the root', () => {
+ expect(resolveInside(root, 'src/app.ts')).toMatch(/site\/src\/app\.ts$/);
+ });
+
+ it('refuses paths that climb out of the root', () => {
+ expect(() => resolveInside(root, '../secret.txt')).toThrow('outside the project root');
+ });
+
+ it('refuses a symlink that points outside the root', () => {
+ expect(() => resolveInside(root, 'link.txt')).toThrow('outside the project root');
+ });
+});
+
+describe('routeFromPagePath', () => {
+ it('maps index pages, route groups and dot segments like the Analog router', () => {
+ expect(routeFromPagePath('src/app/pages/index.page.ts')).toBe('/');
+ expect(routeFromPagePath('src/app/pages/api/index.page.ts')).toBe('/api');
+ expect(routeFromPagePath('src/app/pages/(docs)/guide.page.ts')).toBe('/guide');
+ expect(routeFromPagePath('src/app/pages/blog.post.page.ts')).toBe('/blog/post');
+ });
+
+ it('skips dynamic and catch-all pages, and matches dynamic ones by pattern', () => {
+ expect(routeFromPagePath('src/app/pages/[...slug].page.ts')).toBe('');
+ expect(routeFromPagePath('src/app/pages/api/[group]/[symbol].page.ts')).toBe('');
+ expect(pageRouteMatcher('src/app/pages/[...slug].page.ts')).toBeNull();
+ expect(pageRouteMatcher('src/app/pages/api/index.page.ts')).toBeNull();
+ const re = pageRouteMatcher('src/app/pages/api/[group]/[symbol].page.ts')!;
+ expect(re.test('/api/core/Foo')).toBe(true);
+ expect(re.test('/api/core')).toBe(false);
+ });
+});
+
+describe('walkContentFiles', () => {
+ let dir: string;
+ beforeEach(() => {
+ dir = mkdtempSync(join(tmpdir(), 'ngmd-content-'));
+ mkdirSync(join(dir, 'guide'));
+ writeFileSync(join(dir, 'guide/index.md'), '');
+ writeFileSync(join(dir, 'guide/setup.md'), '');
+ writeFileSync(join(dir, 'index.md'), '');
+ });
+ afterEach(() => rmSync(dir, {recursive: true, force: true}));
+
+ it('serves index.md at its folder route', () => {
+ expect(new Map(walkContentFiles(dir, dir).map(([rel, route]) => [rel, route]))).toEqual(
+ new Map([
+ ['guide/index.md', '/guide'],
+ ['guide/setup.md', '/guide/setup'],
+ ['index.md', '/'],
+ ]),
+ );
+ });
+});
+
+describe('parseFrontmatter', () => {
+ it('reads YAML the way Analog does, including CRLF and quotes', () => {
+ const {attributes, body} = parseFrontmatter(
+ '---\r\ntitle: "A: b"\r\nnoIndex: "true"\r\n---\r\nBody',
+ );
+ expect(attributes).toEqual({title: 'A: b', noIndex: 'true'});
+ expect(body).toBe('Body');
+ expect(parseFrontmatter('no frontmatter').attributes).toEqual({});
+ expect(parseFrontmatter('---\n: [broken\n---\nx').body).toContain('x');
+ });
+
+ it('accepts the documented noIndex spellings only', () => {
+ for (const v of [true, 'true', 'True', 'yes', 1]) expect(isNoIndex({noIndex: v})).toBe(true);
+ for (const v of [false, 'false', 'no', undefined]) expect(isNoIndex({noIndex: v})).toBe(false);
+ });
+});
+
+describe('withoutCode', () => {
+ it('blanks fenced and inline code', () => {
+ expect(withoutCode('a `[x](/y)` b\n~~~\n[x](/z)\n~~~\n[ok](/w)')).toBe('a b\n\n\n\n[ok](/w)');
+ });
+});
diff --git a/apps/docs/plugin-utils.ts b/apps/docs/plugin-utils.ts
new file mode 100644
index 0000000..2f499b0
--- /dev/null
+++ b/apps/docs/plugin-utils.ts
@@ -0,0 +1,169 @@
+import {execFileSync} from 'node:child_process';
+import {readdirSync, realpathSync, statSync} from 'node:fs';
+import {isAbsolute, join, relative, resolve} from 'node:path';
+import frontMatter from 'front-matter';
+
+export {createSlugger, headingText, slugify} from './src/app/utils/heading-slug.ts';
+
+/**
+ * Shared helpers for the build-time Vite plugins (`page-meta`, `sitemap`,
+ * `link-guard`, `search-index`). Every plugin walks `src/content/**\/*.md`
+ * and `src/app/pages/**\/*.page.ts` the same way; centralising those walks
+ * here keeps the discovery rules in sync.
+ *
+ * Routes are derived from filesystem paths:
+ * - `.md` under `src/content/`: `src/content/concepts/theming.md` → `/concepts/theming`
+ * - `.page.ts` under `src/app/pages/`: `home/index.page.ts` → `/home`,
+ * `index.page.ts` → `/`, dynamic / catch-all (`[...slug].page.ts`) → '' (skipped)
+ *
+ * `slugify` and `createSlugger` are re-exported from the runtime TOC's
+ * heading-id module so build-time link validation and runtime fragments
+ * stay aligned.
+ */
+
+/** Walk `src/app/pages/**\/*.page.ts` and return paths relative to `root`. */
+export function walkPageFiles(dir: string, root: string, out: string[] = []): string[] {
+ for (const entry of readdirSync(dir, {withFileTypes: true})) {
+ const full = join(dir, entry.name);
+ if (entry.isDirectory()) {
+ walkPageFiles(full, root, out);
+ } else if (entry.isFile() && entry.name.endsWith('.page.ts')) {
+ out.push(relative(root, full));
+ }
+ }
+ return out;
+}
+
+/**
+ * Walk `src/content/**\/*.md` and return `[relativePath, route]` pairs.
+ * `relativePath` is from `root`; `route` mirrors the path under `baseDir`
+ * (defaults to `dir`) with the `.md` stripped.
+ */
+export function walkContentFiles(
+ dir: string,
+ root: string,
+ baseDir: string = dir,
+ out: Array<[string, string]> = [],
+): Array<[string, string]> {
+ for (const entry of readdirSync(dir, {withFileTypes: true})) {
+ const full = join(dir, entry.name);
+ if (entry.isDirectory()) {
+ walkContentFiles(full, root, baseDir, out);
+ } else if (entry.isFile() && entry.name.endsWith('.md')) {
+ const rel = relative(root, full);
+ const fromContent = relative(baseDir, full)
+ .replace(/\\/g, '/')
+ .replace(/\.md$/, '')
+ .replace(/(^|\/)index$/, '');
+ out.push([rel, '/' + fromContent]);
+ }
+ }
+ return out;
+}
+
+/** `src/app/pages/foo/bar.page.ts` → `/foo/bar`. `index.page.ts` → `/`.
+ * Dynamic / catch-all (`[...slug].page.ts`) returns `''`, signalling "skip". */
+export function routeFromPagePath(rel: string): string {
+ const segments = pageRouteSegments(rel);
+ if (!segments || segments.some((s) => s.startsWith('['))) return '';
+ return '/' + segments.join('/');
+}
+
+export function pageRouteMatcher(rel: string): RegExp | null {
+ const segments = pageRouteSegments(rel);
+ if (!segments || !segments.some((s) => s.startsWith('['))) return null;
+ const pattern = segments
+ .map((s) => (s.startsWith('[') ? '[^/]+' : s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')))
+ .join('/');
+ return new RegExp(`^/${pattern}$`);
+}
+
+function pageRouteSegments(rel: string): string[] | null {
+ const trimmed = rel
+ .replace(/\\/g, '/')
+ .replace(/^src\/app\/pages\//, '')
+ .replace(/\.page\.ts$/, '');
+ if (trimmed.includes('[...')) return null;
+ return trimmed.split(/[/.]/).filter((s) => s !== 'index' && !/^\(.*\)$/.test(s));
+}
+
+/**
+ * Last-commit date for `file` (YYYY-MM-DD), via `git log -1 --format=%cs`.
+ * Falls back to file mtime when the file is uncommitted, and to `''`
+ * (or whatever `mtimeFallback` returns) when both are unavailable.
+ */
+export function gitDate(file: string, cwd: string, mtimeFallback: () => string = () => ''): string {
+ try {
+ const stamp = execFileSync('git', ['log', '-1', '--format=%cs', '--', file], {
+ cwd,
+ stdio: ['ignore', 'pipe', 'ignore'],
+ })
+ .toString()
+ .trim();
+ if (stamp) return stamp;
+ } catch {
+ // fall through to mtime
+ }
+ try {
+ return statSync(join(cwd, file)).mtime.toISOString().slice(0, 10);
+ } catch {
+ return mtimeFallback();
+ }
+}
+
+export function parseFrontmatter(text: string): {
+ attributes: Record;
+ body: string;
+} {
+ try {
+ const {attributes, body} = frontMatter(text);
+ return {
+ attributes:
+ attributes && typeof attributes === 'object' ? (attributes as Record) : {},
+ body,
+ };
+ } catch {
+ return {attributes: {}, body: text};
+ }
+}
+
+export function isNoIndex(attributes: Record): boolean {
+ return /^(true|yes|1)$/i.test(String(attributes['noIndex'] ?? ''));
+}
+
+export function fenceTracker(): (line: string) => boolean {
+ let open = '';
+ return (line) => {
+ const m = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line);
+ if (!open) {
+ if (!m || (m[1][0] === '`' && m[2].includes('`'))) return false;
+ open = m[1];
+ return true;
+ }
+ if (m && m[1][0] === open[0] && m[1].length >= open.length && !m[2].trim()) open = '';
+ return true;
+ };
+}
+
+export function withoutCode(markdown: string): string {
+ const inFence = fenceTracker();
+ return markdown
+ .split(/\r?\n/)
+ .map((line) => (inFence(line) ? '' : line))
+ .join('\n')
+ .replace(/(`+)[^\n]*?\1/g, ' ');
+}
+
+/**
+ * Resolve `path` against `root`, following symlinks, and throw when the
+ * real target lies outside the real root.
+ */
+export function resolveInside(root: string, path: string): string {
+ const realRoot = realpathSync(root);
+ const full = realpathSync(resolve(realRoot, path));
+ const rel = relative(realRoot, full);
+ if (rel.startsWith('..') || isAbsolute(rel)) {
+ throw new Error('path resolves outside the project root');
+ }
+ return full;
+}
diff --git a/apps/docs/public/apple-touch-icon.png b/apps/docs/public/apple-touch-icon.png
new file mode 100644
index 0000000..91f2263
Binary files /dev/null and b/apps/docs/public/apple-touch-icon.png differ
diff --git a/apps/docs/public/favicon.ico b/apps/docs/public/favicon.ico
new file mode 100644
index 0000000..7691afd
Binary files /dev/null and b/apps/docs/public/favicon.ico differ
diff --git a/apps/docs/public/favicon.svg b/apps/docs/public/favicon.svg
new file mode 100644
index 0000000..6a36763
--- /dev/null
+++ b/apps/docs/public/favicon.svg
@@ -0,0 +1,3 @@
+
diff --git a/apps/docs/public/images/cats.jpg b/apps/docs/public/images/cats.jpg
new file mode 100644
index 0000000..90f8ab6
Binary files /dev/null and b/apps/docs/public/images/cats.jpg differ
diff --git a/apps/docs/public/logo-mark.svg b/apps/docs/public/logo-mark.svg
new file mode 100644
index 0000000..b7d87e3
--- /dev/null
+++ b/apps/docs/public/logo-mark.svg
@@ -0,0 +1,10 @@
+
diff --git a/apps/docs/public/logo.svg b/apps/docs/public/logo.svg
new file mode 100644
index 0000000..b7d87e3
--- /dev/null
+++ b/apps/docs/public/logo.svg
@@ -0,0 +1,10 @@
+
diff --git a/apps/docs/public/logos/angular.svg b/apps/docs/public/logos/angular.svg
new file mode 100644
index 0000000..81ec283
--- /dev/null
+++ b/apps/docs/public/logos/angular.svg
@@ -0,0 +1,23 @@
+
diff --git a/apps/docs/public/logos/devframe.svg b/apps/docs/public/logos/devframe.svg
new file mode 100644
index 0000000..3b798a7
--- /dev/null
+++ b/apps/docs/public/logos/devframe.svg
@@ -0,0 +1,23 @@
+
diff --git a/apps/docs/public/logos/vite.svg b/apps/docs/public/logos/vite.svg
new file mode 100644
index 0000000..e30c20d
--- /dev/null
+++ b/apps/docs/public/logos/vite.svg
@@ -0,0 +1,132 @@
+
diff --git a/apps/docs/public/og.png b/apps/docs/public/og.png
new file mode 100644
index 0000000..e637854
Binary files /dev/null and b/apps/docs/public/og.png differ
diff --git a/apps/docs/raw-md.plugin.ts b/apps/docs/raw-md.plugin.ts
new file mode 100644
index 0000000..b2d38b0
--- /dev/null
+++ b/apps/docs/raw-md.plugin.ts
@@ -0,0 +1,92 @@
+import {readFileSync, statSync} from 'node:fs';
+import {extname, join} from 'node:path';
+import type {Plugin} from 'vite';
+import {walkContentFiles} from './plugin-utils.ts';
+import {substituteMdVars} from './vars.plugin.ts';
+
+/**
+ * Serves the raw markdown body at the same URL plus a `.md` suffix.
+ *
+ * /concepts/theming -> rendered prose page
+ * /concepts/theming.md -> the literal `.md` source as `text/markdown`
+ *
+ * Powers the "Copy Markdown" / "Open in ChatGPT" / "Open in Claude"
+ * dropdown so LLMs can fetch a page by URL and get clean markdown back
+ * instead of compiled HTML. Pattern is the same one react.dev and the
+ * PrimeNG docs site ship for their LLM-friendly pages.
+ *
+ * Dev mode: middleware reads from `src/content` on each request.
+ * Build mode: emits each `.md` body as a static asset at the matching
+ * route + `.md` so the same paths work after `vite build`.
+ */
+export function rawMdPlugin(): Plugin {
+ let root = process.cwd();
+
+ function resolveMd(rawPath: string): string | null {
+ if (!rawPath.endsWith('.md')) return null;
+ const route = rawPath.slice(0, -3).replace(/^\//, '');
+ if (!route) return null;
+ if (route.includes('..')) return null;
+ for (const file of [`${route}.md`, `${route}/index.md`]) {
+ const abs = join(root, 'src/content', file);
+ try {
+ if (!statSync(abs).isFile()) continue;
+ return substituteMdVars(readFileSync(abs, 'utf8'), root);
+ } catch {
+ continue;
+ }
+ }
+ return null;
+ }
+
+ return {
+ name: 'ngmd-raw-md',
+ configResolved(cfg) {
+ root = cfg.root;
+ },
+ configureServer(server) {
+ server.middlewares.use((req, res, next) => {
+ const raw = req.url?.split('?')[0] ?? '';
+ if (extname(raw) !== '.md') return next();
+ // URL paths arrive percent-encoded (`/concepts/some%20page.md`),
+ // but the on-disk filename is `some page.md`. Decode before
+ // resolving so the lookup matches. Malformed sequences fall
+ // through to the next middleware.
+ let url: string;
+ try {
+ url = decodeURIComponent(raw);
+ } catch {
+ return next();
+ }
+ const body = resolveMd(url);
+ if (body == null) return next();
+ res.setHeader('Content-Type', 'text/markdown; charset=utf-8');
+ res.setHeader('Cache-Control', 'public, max-age=0, must-revalidate');
+ res.end(body);
+ });
+ },
+ generateBundle() {
+ // Emit one `.md` asset per markdown source so the same URL
+ // works in production. Mirrors the dev middleware.
+ const contentDir = join(root, 'src/content');
+ try {
+ statSync(contentDir);
+ } catch {
+ return;
+ }
+ const files = walkContentFiles(contentDir, contentDir).map(([rel, route]) => [
+ rel.replace(/\\/g, '/'),
+ route,
+ ]);
+ const sources = new Set(files.map(([rel]) => rel));
+ for (const [rel, route] of files) {
+ const source = substituteMdVars(readFileSync(join(contentDir, rel), 'utf8'), root);
+ this.emitFile({type: 'asset', fileName: rel, source});
+ const alias = `${route.slice(1)}.md`;
+ if (route !== '/' && !sources.has(alias)) {
+ this.emitFile({type: 'asset', fileName: alias, source});
+ }
+ }
+ },
+ };
+}
diff --git a/apps/docs/search-index.plugin.ts b/apps/docs/search-index.plugin.ts
new file mode 100644
index 0000000..3ff0783
--- /dev/null
+++ b/apps/docs/search-index.plugin.ts
@@ -0,0 +1,221 @@
+import {readFileSync, statSync} from 'node:fs';
+import {join} from 'node:path';
+import type {Plugin, ViteDevServer} from 'vite';
+import type {IndexDoc, SearchHitKind} from './src/types/search.ts';
+import {
+ createSlugger,
+ fenceTracker,
+ headingText as headingTextOf,
+ isNoIndex,
+ parseFrontmatter,
+ walkContentFiles,
+} from './plugin-utils.ts';
+
+/**
+ * Build-time search index. Walks `src/content/**\/*.md` and emits a flat list
+ * of `IndexDoc` records (one per page + one per `##` heading + one per
+ * paragraph chunk) under the virtual id `virtual:ngmd/search-index`.
+ *
+ * The runtime Orama provider takes this raw list, builds an in-memory
+ * index, and queries it. Algolia's hosted index uses a similar
+ * page → section → snippet shape, so the same `SearchHit` UI works against
+ * either backend.
+ *
+ * Pages can opt out by setting `noIndex: true` in their frontmatter.
+ */
+
+const VIRTUAL_ID = 'virtual:ngmd/search-index';
+const RESOLVED_ID = '\0' + VIRTUAL_ID;
+
+const ENTITIES: Record = {
+ '<': '<',
+ '>': '>',
+ '&': '&',
+ '"': '"',
+ ''': "'",
+ ''': "'",
+ '@': '@',
+ ' ': ' ',
+};
+
+/** Strip markdown syntax so search hits show clean prose, not markup. */
+function stripMarkdown(s: string): string {
+ const code: string[] = [];
+ return s
+ .replace(/(`+)([\s\S]*?)\1/g, (_, _ticks: string, inner: string) => {
+ code.push(inner.trim());
+ return `\u0000${code.length - 1}\u0000`;
+ })
+ .replace(/<[^>]+>/g, ' ')
+ .replace(/!?\[([^\]]*)\]\([^)]+\)/g, '$1')
+ .replace(/[*_#>]/g, '')
+ .replace(/&(?:lt|gt|amp|quot|apos|nbsp|#39|#64);/g, (m) => ENTITIES[m] ?? m)
+ .replace(/\u0000(\d+)\u0000/g, (_, i: string) => code[Number(i)])
+ .replace(/\s+/g, ' ')
+ .trim();
+}
+
+/** Split raw markdown body into sections delimited by `##`+ headings.
+ * Returns an array where each entry has the heading text (or empty for
+ * the lead-in before the first heading) and the prose that follows. */
+function splitSections(body: string): Array<{heading: string; body: string}> {
+ const lines = body.split(/\r?\n/);
+ const sections: Array<{heading: string; body: string}> = [];
+ let current: {heading: string; body: string} = {heading: '', body: ''};
+ const inFence = fenceTracker();
+ for (const line of lines) {
+ if (inFence(line)) continue;
+ const m = line.match(/^ {0,3}(#{2,6})\s+(.+?)\s*$/);
+ if (m) {
+ if (current.heading || current.body.trim()) sections.push(current);
+ current = {heading: m[2], body: ''};
+ } else {
+ current.body += line + '\n';
+ }
+ }
+ if (current.heading || current.body.trim()) sections.push(current);
+ return sections;
+}
+
+/** Break a stripped body into snippet chunks. ~280 chars each, snapped to
+ * a sentence boundary if one is nearby and to a word boundary otherwise so
+ * chunks never start or end mid-word. */
+function chunkBody(body: string, target = 280): string[] {
+ if (!body) return [];
+ const chunks: string[] = [];
+ let i = 0;
+ while (i < body.length) {
+ let end = Math.min(body.length, i + target);
+ if (end < body.length) {
+ const sentence = body.lastIndexOf('. ', end);
+ if (sentence > i + 80) {
+ end = sentence + 1;
+ } else {
+ const word = body.lastIndexOf(' ', end);
+ if (word > i + 80) end = word;
+ }
+ }
+ const piece = body.slice(i, end).trim();
+ if (piece) chunks.push(piece);
+ i = end;
+ }
+ return chunks;
+}
+
+export function searchIndexPlugin(): Plugin {
+ let root = process.cwd();
+ let server: ViteDevServer | undefined;
+
+ return {
+ name: 'ngmd-search-index',
+ configResolved(cfg) {
+ root = cfg.root;
+ },
+ configureServer(s) {
+ server = s;
+ },
+ watchChange(id) {
+ if (!server || !id.endsWith('.md')) return;
+ const mod = server.moduleGraph.getModuleById(RESOLVED_ID);
+ if (mod) server.moduleGraph.invalidateModule(mod);
+ },
+ resolveId(id) {
+ if (id === VIRTUAL_ID) return RESOLVED_ID;
+ return null;
+ },
+ load(id) {
+ if (id !== RESOLVED_ID) return null;
+ const docs: IndexDoc[] = [];
+ const contentDir = join(root, 'src/content');
+ try {
+ statSync(contentDir);
+ } catch {
+ return `export const searchIndex = [];`;
+ }
+
+ for (const [rel, url] of walkContentFiles(contentDir, contentDir)) {
+ let raw: string;
+ try {
+ raw = readFileSync(join(contentDir, rel), 'utf8');
+ } catch {
+ continue;
+ }
+ const {attributes, body} = parseFrontmatter(raw);
+ if (isNoIndex(attributes)) continue;
+
+ const slug = url.split('/').pop() || '';
+ const pageTitle =
+ typeof attributes['title'] === 'string'
+ ? attributes['title']
+ : slug.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
+
+ // page record: title-only. Body matches surface through snippet
+ // records below, which carry their enclosing heading's anchor so
+ // a click jumps to the right section instead of the page top.
+ // Keeping body off the page record stops fuzzy hits from dragging
+ // unrelated pages into the result list on short queries.
+ docs.push({
+ id: `page:${url}`,
+ url,
+ anchor: '',
+ kind: 'page' as SearchHitKind,
+ pageTitle,
+ heading: pageTitle,
+ body: '',
+ });
+
+ // Split the body into sections delimited by `##`+ headings. Pure
+ // prose before the first heading goes under an empty anchor (lands
+ // on the page top). Each section produces (1) a section record at
+ // its heading anchor and (2) snippet records anchored to the same
+ // heading so clicking a snippet jumps to its section, not the top.
+ const anchorFor = createSlugger();
+ for (const section of splitSections(body)) {
+ if (section.heading) {
+ const headingText = stripMarkdown(
+ section.heading.replace(/]*>[\s\S]*?<\/ngmd-badge>/g, ''),
+ );
+ const anchor = anchorFor(headingTextOf(section.heading));
+ docs.push({
+ id: `section:${url}#${anchor}`,
+ url,
+ anchor,
+ kind: 'section',
+ pageTitle,
+ heading: headingText,
+ body: '',
+ });
+ const sectionBody = stripMarkdown(section.body);
+ for (const [i, chunk] of chunkBody(sectionBody).entries()) {
+ docs.push({
+ id: `snippet:${url}#${anchor}:${i}`,
+ url,
+ anchor,
+ kind: 'snippet',
+ pageTitle,
+ heading: headingText,
+ body: chunk,
+ });
+ }
+ } else {
+ // Pre-first-heading prose. Anchorless.
+ const sectionBody = stripMarkdown(section.body);
+ for (const [i, chunk] of chunkBody(sectionBody).entries()) {
+ docs.push({
+ id: `snippet:${url}:lead:${i}`,
+ url,
+ anchor: '',
+ kind: 'snippet',
+ pageTitle,
+ heading: '',
+ body: chunk,
+ });
+ }
+ }
+ }
+ }
+
+ return `export const searchIndex = ${JSON.stringify(docs)};`;
+ },
+ };
+}
diff --git a/apps/docs/sitemap.plugin.ts b/apps/docs/sitemap.plugin.ts
new file mode 100644
index 0000000..674ab5e
--- /dev/null
+++ b/apps/docs/sitemap.plugin.ts
@@ -0,0 +1,103 @@
+import {readFileSync, statSync} from 'node:fs';
+import {join} from 'node:path';
+import type {Plugin} from 'vite';
+import {
+ gitDate,
+ isNoIndex,
+ parseFrontmatter,
+ routeFromPagePath,
+ walkContentFiles,
+ walkPageFiles,
+} from './plugin-utils.ts';
+
+/**
+ * Emits `sitemap.xml` and `robots.txt` into the client build output.
+ *
+ * Discovery mirrors the page-meta plugin: walks `src/app/pages/*.page.ts`
+ * and `src/content/**\/*.md`, pulls each file's last commit date via
+ * `git log -1 --format=%cs` to populate ``, falls back to mtime
+ * for uncommitted files.
+ *
+ * Versioning is per-deployment (each docs version is its own site under the
+ * adev / PrimeNG model), so there are no in-repo version variants to
+ * special-case here — the sitemap simply covers this deployment's content.
+ *
+ * `robots.txt` is a one-liner pointing at the sitemap.
+ */
+
+function escapeXml(s: string): string {
+ return s
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+}
+
+export function sitemapPlugin(opts: {siteUrl: string}): Plugin {
+ let root = process.cwd();
+ const siteUrl = opts.siteUrl.replace(/\/+$/, '');
+ const today = () => new Date().toISOString().slice(0, 10);
+
+ return {
+ name: 'ngmd-sitemap',
+ apply: 'build',
+ configResolved(cfg) {
+ root = cfg.root;
+ },
+ generateBundle() {
+ const entries = new Map();
+
+ try {
+ const pageFiles = walkPageFiles(join(root, 'src/app/pages'), root);
+ for (const rel of pageFiles) {
+ const route = routeFromPagePath(rel);
+ if (!route) continue;
+ entries.set(route, gitDate(rel, root, today));
+ }
+ } catch {
+ // src/app/pages missing — fine
+ }
+
+ const contentDir = join(root, 'src/content');
+ try {
+ statSync(contentDir);
+ for (const [rel, route] of walkContentFiles(contentDir, root)) {
+ if (isNoIndex(parseFrontmatter(readFileSync(join(root, rel), 'utf8')).attributes)) {
+ continue;
+ }
+ entries.set(route, gitDate(rel, root, today));
+ }
+ } catch {
+ // src/content missing — skip
+ }
+
+ const urls = [...entries.entries()]
+ .sort(([a], [b]) => a.localeCompare(b))
+ .map(([route, lastmod]) => {
+ const loc = escapeXml(`${siteUrl}${encodeURI(route)}`);
+ return ` \n ${loc} \n ${lastmod} \n `;
+ })
+ .join('\n');
+
+ const sitemap =
+ '\n' +
+ '\n' +
+ urls +
+ '\n \n';
+
+ this.emitFile({
+ type: 'asset',
+ fileName: 'sitemap.xml',
+ source: sitemap,
+ });
+
+ const robots = `User-agent: *\nAllow: /\nSitemap: ${siteUrl}/sitemap.xml\n`;
+ this.emitFile({
+ type: 'asset',
+ fileName: 'robots.txt',
+ source: robots,
+ });
+ },
+ };
+}
diff --git a/apps/docs/src/app/app.config.server.ts b/apps/docs/src/app/app.config.server.ts
new file mode 100644
index 0000000..2397ce8
--- /dev/null
+++ b/apps/docs/src/app/app.config.server.ts
@@ -0,0 +1,10 @@
+import {mergeApplicationConfig, ApplicationConfig} from '@angular/core';
+import {provideServerRendering} from '@angular/platform-server';
+
+import {appConfig} from './app.config';
+
+const serverConfig: ApplicationConfig = {
+ providers: [provideServerRendering()],
+};
+
+export const config = mergeApplicationConfig(appConfig, serverConfig);
diff --git a/apps/docs/src/app/app.config.ts b/apps/docs/src/app/app.config.ts
new file mode 100644
index 0000000..2bfd18a
--- /dev/null
+++ b/apps/docs/src/app/app.config.ts
@@ -0,0 +1,64 @@
+import {provideHttpClient, withFetch, withInterceptors} from '@angular/common/http';
+import {
+ ApplicationConfig,
+ Injector,
+ inject,
+ provideAppInitializer,
+ provideBrowserGlobalErrorListeners,
+} from '@angular/core';
+import {
+ provideClientHydration,
+ withEventReplay,
+ withNoIncrementalHydration,
+} from '@angular/platform-browser';
+import {provideFileRouter, requestContextInterceptor} from '@analogjs/router';
+import {provideContent, withMarkdownRenderer} from '@analogjs/content';
+import {withShikiHighlighter} from '@analogjs/content/shiki-highlighter';
+import {withInMemoryScrolling, withViewTransitions, TitleStrategy} from '@angular/router';
+import {ViewportScroller} from '@angular/common';
+import {marked} from 'marked';
+import {ngmdRuntimeExtensions} from '../marked-extensions/runtime';
+import {NgmdTitleStrategy} from './title-strategy';
+import {registerNgmdElements} from './register-elements';
+
+export const appConfig: ApplicationConfig = {
+ providers: [
+ provideBrowserGlobalErrorListeners(),
+ provideFileRouter(
+ withInMemoryScrolling({
+ anchorScrolling: 'enabled',
+ scrollPositionRestoration: 'disabled',
+ }),
+ // Native browser View Transitions API: takes a snapshot of the old
+ // route, renders the new one, then crossfades. Hides the markdown
+ // resolution gap that caused the "flash of stale content" bug
+ // without needing a manual isNavigating signal or opacity hacks.
+ // Falls back to default behaviour on older browsers (Chrome <111).
+ withViewTransitions(),
+ ),
+ provideHttpClient(withFetch(), withInterceptors([requestContextInterceptor])),
+ provideClientHydration(withEventReplay(), withNoIncrementalHydration()),
+ provideContent(withMarkdownRenderer(), withShikiHighlighter()),
+ {provide: TitleStrategy, useClass: NgmdTitleStrategy},
+ // AnalogJS's runtime MarkedSetupService only registers gfm/mangle/shiki.
+ // The `markedOptions` in vite.config.ts only feeds the build-time
+ // MarkdownRouteComponent. Pages using `` parse
+ // at runtime, so register our preprocess hooks on the shared marked
+ // singleton here too.
+ provideAppInitializer(() => {
+ marked.use(...ngmdRuntimeExtensions);
+ // Sticky header is ~57px tall; offset anchor scroll so headings land
+ // below it with breathing room. Without this, Angular's anchor scroll
+ // ignores CSS scroll-margin-top and pins headings flush against the
+ // header, where backdrop-blur visually destroys them.
+ const scroller = inject(ViewportScroller);
+ scroller.setOffset([0, 88]);
+ // Register NgmdUi components as Custom Elements so they upgrade even
+ // when emitted via `bypassSecurityTrustHtml` inside ``. Without this, `` tags in `.md` files
+ // render as empty unknown HTML — the Angular compiler does not walk
+ // `[innerHTML]`. See `register-elements.ts` for the full mapping.
+ registerNgmdElements(inject(Injector));
+ }),
+ ],
+};
diff --git a/apps/docs/src/app/app.spec.ts b/apps/docs/src/app/app.spec.ts
new file mode 100644
index 0000000..095a591
--- /dev/null
+++ b/apps/docs/src/app/app.spec.ts
@@ -0,0 +1,53 @@
+import {Component} from '@angular/core';
+import {TestBed} from '@angular/core/testing';
+import {provideRouter, Router} from '@angular/router';
+import {provideLocationMocks} from '@angular/common/testing';
+
+import {App} from './app';
+
+@Component({template: ''})
+class Blank {}
+
+describe('App', () => {
+ beforeEach(async () => {
+ Element.prototype.scrollIntoView = vi.fn();
+ await TestBed.configureTestingModule({
+ imports: [App],
+ providers: [provideRouter([{path: '**', component: Blank}]), provideLocationMocks()],
+ }).compileComponents();
+ });
+
+ it('should create the app', () => {
+ const fixture = TestBed.createComponent(App);
+ const app = fixture.componentInstance;
+ expect(app).toBeTruthy();
+ });
+
+ it('traps focus in the open drawer and returns it on Escape', async () => {
+ const fixture = TestBed.createComponent(App);
+ document.body.appendChild(fixture.nativeElement);
+ await TestBed.inject(Router).navigateByUrl('/welcome');
+ await fixture.whenStable();
+ const el: HTMLElement = fixture.nativeElement;
+ const menu = el.querySelector('button[aria-label="Open menu"]')!;
+ menu.click();
+ await fixture.whenStable();
+ const drawer = el.querySelector('aside[aria-label="Documentation menu"]')!;
+ expect(drawer.hasAttribute('inert')).toBe(false);
+ expect(document.documentElement.classList.contains('max-lg:overflow-hidden')).toBe(true);
+
+ const links = drawer.querySelectorAll('a[href], button');
+ links[links.length - 1].focus();
+ document.dispatchEvent(new KeyboardEvent('keydown', {key: 'Tab'}));
+ expect(document.activeElement).toBe(menu);
+ document.dispatchEvent(new KeyboardEvent('keydown', {key: 'Tab'}));
+ expect(document.activeElement).toBe(links[0]);
+
+ document.dispatchEvent(new KeyboardEvent('keydown', {key: 'Escape'}));
+ await fixture.whenStable();
+ expect(drawer.hasAttribute('inert')).toBe(true);
+ expect(document.activeElement).toBe(menu);
+ expect(document.documentElement.classList.contains('max-lg:overflow-hidden')).toBe(false);
+ fixture.nativeElement.remove();
+ });
+});
diff --git a/apps/docs/src/app/app.ts b/apps/docs/src/app/app.ts
new file mode 100644
index 0000000..f31b42a
--- /dev/null
+++ b/apps/docs/src/app/app.ts
@@ -0,0 +1,380 @@
+import {
+ Component,
+ DestroyRef,
+ ElementRef,
+ Injector,
+ afterNextRender,
+ computed,
+ effect,
+ inject,
+ OnInit,
+ signal,
+ viewChild,
+} from '@angular/core';
+import {DOCUMENT} from '@angular/common';
+import {Router, RouterLink, RouterOutlet} from '@angular/router';
+import {
+ LucideDynamicIcon,
+ LucideMenu,
+ LucideMoon,
+ LucideSearch,
+ LucideSun,
+ LucideSunMoon,
+ LucideX,
+} from '@lucide/angular';
+import {GithubIcon} from './ui/github-icon';
+import {DiscordIcon} from './ui/discord-icon';
+import {ThemeService} from './theme';
+import {LayoutMode} from './layout-mode.service';
+import {RouteUrlService} from './services/route-url/route-url.service';
+import {onNavigation} from './utils/enhance-on-navigation';
+import siteConfig from '../ngmd.config';
+import {CommandPalette} from './components/command-palette';
+import {Sidebar} from './components/sidebar';
+import {Breadcrumb} from './components/breadcrumb';
+import {Toc} from './components/toc';
+import {CodeCopy} from './components/code-copy';
+import {ExternalLinks} from './components/external-links';
+import {HeadingAnchors} from './components/heading-anchors';
+import {CodeGroup} from './components/code-group';
+import {PageFooter} from './components/page-footer';
+import {SourceActions} from './components/source-actions';
+import {MediaEnhancer} from './components/media-enhancer';
+import {SiteFooter} from './components/site-footer';
+import {Toaster} from './components/toaster';
+import {VersionSwitcher} from './components/version-switcher';
+import {ContentBanners} from './components/content-banners';
+
+@Component({
+ selector: 'app-root',
+ host: {
+ '(document:keydown)': 'onDocumentKeydown($event)',
+ },
+ imports: [
+ RouterLink,
+ RouterOutlet,
+ LucideDynamicIcon,
+ GithubIcon,
+ DiscordIcon,
+ CommandPalette,
+ Sidebar,
+ Breadcrumb,
+ Toc,
+ CodeCopy,
+ ExternalLinks,
+ HeadingAnchors,
+ CodeGroup,
+ MediaEnhancer,
+ PageFooter,
+ SourceActions,
+ SiteFooter,
+ Toaster,
+ VersionSwitcher,
+ ContentBanners,
+ ],
+ template: `
+
+
+ @if (showSidebar()) {
+
+ }
+
+
+
+ {{ siteName }}
+
+
+ @if (headerNav.length > 0) {
+
+ }
+
+
+
+
+
+ @if (showSidebar()) {
+
+
+
+
+
+ }
+
+
+
+ @if (showBreadcrumb()) {
+
+ }
+ @if (showFooter()) {
+
+
+ }
+ @if (showToc()) {
+
+
+ On this page
+ ▾
+
+
+
+
+
+ }
+
+ @if (showFooter()) {
+
+
+
+ }
+
+
+
+
+ @if (showToc()) {
+
+ }
+
+
+
+
+
+
+
+
+
+
+ `,
+})
+export class App implements OnInit {
+ readonly theme = inject(ThemeService);
+ private readonly router = inject(Router);
+ private readonly destroyRef = inject(DestroyRef);
+ protected readonly layout = inject(LayoutMode);
+ private readonly routeUrl = inject(RouteUrlService);
+ private readonly injector = inject(Injector);
+ private readonly document = inject(DOCUMENT);
+ private readonly menuButton = viewChild>('menuButton');
+ private readonly drawer = viewChild>('drawer');
+
+ readonly menuIcon = LucideMenu;
+ readonly closeIcon = LucideX;
+ readonly searchIcon = LucideSearch;
+ readonly sunIcon = LucideSun;
+ readonly moonIcon = LucideMoon;
+ readonly autoIcon = LucideSunMoon;
+
+ readonly siteName = siteConfig.site.name;
+ readonly githubUrl = siteConfig.site.githubUrl;
+ readonly discordUrl = siteConfig.site.links?.discord;
+ readonly headerNav = siteConfig.headerNav ?? [];
+
+ readonly drawerOpen = signal(false);
+
+ constructor() {
+ effect(() =>
+ this.document.documentElement.classList.toggle('max-lg:overflow-hidden', this.drawerOpen()),
+ );
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return;
+ const desktop = window.matchMedia('(min-width: 64rem)');
+ const onChange = () => {
+ if (desktop.matches) this.drawerOpen.set(false);
+ };
+ desktop.addEventListener('change', onChange);
+ this.destroyRef.onDestroy(() => desktop.removeEventListener('change', onChange));
+ }
+
+ private readonly isDocsRoute = computed(() => {
+ const url = this.routeUrl.cleanUrl();
+ return url !== '/' && url !== '' && !this.layout.chromeHidden();
+ });
+ readonly showSidebar = this.isDocsRoute;
+ readonly showBreadcrumb = this.isDocsRoute;
+ readonly showToc = this.isDocsRoute;
+ readonly showFooter = this.isDocsRoute;
+
+ isExternal(href: string): boolean {
+ return /^https?:\/\//.test(href);
+ }
+
+ toggleDrawer(): void {
+ if (this.drawerOpen()) {
+ this.closeDrawer();
+ return;
+ }
+ this.drawerOpen.set(true);
+ afterNextRender(
+ () => this.drawer()?.nativeElement.querySelector('a[href], button')?.focus(),
+ {injector: this.injector},
+ );
+ }
+
+ closeDrawer(): void {
+ if (!this.drawerOpen()) return;
+ this.drawerOpen.set(false);
+ this.menuButton()?.nativeElement.focus();
+ }
+
+ onDocumentKeydown(event: KeyboardEvent): void {
+ if (!this.drawerOpen()) return;
+ if (event.key === 'Escape') {
+ this.closeDrawer();
+ return;
+ }
+ const menuButton = this.menuButton()?.nativeElement;
+ const drawer = this.drawer()?.nativeElement;
+ if (event.key !== 'Tab' || !menuButton || !drawer) return;
+ const focusable = [
+ menuButton,
+ ...drawer.querySelectorAll('a[href], button:not([disabled])'),
+ ];
+ const index = focusable.indexOf(this.document.activeElement as HTMLElement);
+ const step = event.shiftKey ? -1 : 1;
+ const next = index === -1 ? (event.shiftKey ? focusable.length - 1 : 0) : index + step;
+ event.preventDefault();
+ focusable[(next + focusable.length) % focusable.length].focus();
+ }
+
+ ngOnInit(): void {
+ this.theme.initFromStorage();
+ onNavigation(this.router, this.destroyRef, () => {
+ this.drawerOpen.set(false);
+ if (typeof window === 'undefined' || window.location.hash) return;
+ setTimeout(() => window.scrollTo({top: 0, behavior: 'smooth'}), 0);
+ });
+ }
+}
diff --git a/apps/docs/src/app/components/breadcrumb.spec.ts b/apps/docs/src/app/components/breadcrumb.spec.ts
new file mode 100644
index 0000000..93bd5e6
--- /dev/null
+++ b/apps/docs/src/app/components/breadcrumb.spec.ts
@@ -0,0 +1,62 @@
+import {signal} from '@angular/core';
+import {TestBed} from '@angular/core/testing';
+import {provideRouter} from '@angular/router';
+import config from '../../ngmd.config';
+import {RouteUrlService} from '../services/route-url/route-url.service';
+import {Breadcrumb, crumbLabel} from './breadcrumb';
+
+const section = config.nav[0];
+const item = section.items[0];
+
+describe('crumbLabel', () => {
+ it('uses the nav item label for a page', () => {
+ expect(crumbLabel(item.href)).toBe(item.label);
+ });
+
+ it('uses the section label for a folder whose pages share one section', () => {
+ for (const s of config.nav) {
+ for (const i of s.items) {
+ const folder = i.href.split('/').slice(0, -1).join('/');
+ const owners = new Set(
+ config.nav.filter((o) => o.items.some((x) => x.href.startsWith(folder + '/'))),
+ );
+ if (folder && owners.size === 1) expect(crumbLabel(folder)).toBe(s.label);
+ }
+ }
+ });
+
+ it('returns null for paths outside the nav', () => {
+ expect(crumbLabel('/zz-nowhere')).toBeNull();
+ });
+});
+
+describe('Breadcrumb', () => {
+ const cleanUrl = signal(item.href);
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({
+ imports: [Breadcrumb],
+ providers: [provideRouter([]), {provide: RouteUrlService, useValue: {cleanUrl}}],
+ });
+ });
+
+ const labels = (el: HTMLElement) =>
+ [...el.querySelectorAll('li')].map((li) => li.textContent?.trim());
+
+ it('renders a labelled trail that marks the current page', async () => {
+ const fixture = TestBed.createComponent(Breadcrumb);
+ await fixture.whenStable();
+ const el: HTMLElement = fixture.nativeElement;
+ expect(el.querySelector('nav')?.getAttribute('aria-label')).toBe('Breadcrumb');
+ expect(el.querySelector('a')?.getAttribute('aria-label')).toBe('Home');
+ expect(labels(el).at(-1)).toBe(item.label);
+ expect(el.querySelector('[aria-current="page"]')?.textContent).toBe(item.label);
+ });
+
+ it('falls back to humanised segments', async () => {
+ cleanUrl.set('/zz-nowhere/some-group');
+ const fixture = TestBed.createComponent(Breadcrumb);
+ await fixture.whenStable();
+ expect(labels(fixture.nativeElement)).toEqual(['', 'Zz Nowhere', 'Some Group']);
+ });
+});
diff --git a/apps/docs/src/app/components/breadcrumb.ts b/apps/docs/src/app/components/breadcrumb.ts
new file mode 100644
index 0000000..77eb6f3
--- /dev/null
+++ b/apps/docs/src/app/components/breadcrumb.ts
@@ -0,0 +1,73 @@
+import {Component, computed, inject} from '@angular/core';
+import {RouterLink} from '@angular/router';
+import {LucideDynamicIcon, LucideChevronRight, LucideHouse} from '@lucide/angular';
+import {navItems} from '../../ngmd.config';
+import {RouteUrlService} from '../services/route-url/route-url.service';
+
+interface Crumb {
+ label: string;
+ href: string;
+}
+
+export function crumbLabel(href: string): string | null {
+ const item = navItems.find((n) => n.href === href);
+ if (item) return item.label;
+ const sections = new Set(
+ navItems.filter((n) => n.href.startsWith(href + '/')).map((n) => n.section),
+ );
+ return sections.size === 1 ? [...sections][0] : null;
+}
+
+@Component({
+ selector: 'app-breadcrumb',
+ imports: [RouterLink, LucideDynamicIcon],
+ template: `
+ @if (crumbs().length > 0) {
+
+ }
+ `,
+})
+export class Breadcrumb {
+ private readonly cleanUrl = inject(RouteUrlService).cleanUrl;
+ readonly home = LucideHouse;
+ readonly chevron = LucideChevronRight;
+
+ readonly crumbs = computed(() => {
+ const segments = this.cleanUrl()
+ .split('/')
+ .filter((s) => s.length > 0);
+ return segments.map((segment, i) => {
+ const href = '/' + segments.slice(0, i + 1).join('/');
+ return {href, label: crumbLabel(href) ?? this.humanize(segment)};
+ });
+ });
+
+ private humanize(segment: string): string {
+ return segment.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
+ }
+}
diff --git a/apps/docs/src/app/components/code-copy.ts b/apps/docs/src/app/components/code-copy.ts
new file mode 100644
index 0000000..6cc209c
--- /dev/null
+++ b/apps/docs/src/app/components/code-copy.ts
@@ -0,0 +1,76 @@
+import {AfterViewInit, Component, DestroyRef, inject} from '@angular/core';
+import {Router} from '@angular/router';
+import {ToastService} from '../services/toast/toast.service';
+import {writeToClipboard} from '../utils/clipboard';
+import {enhanceOnNavigation} from '../utils/enhance-on-navigation';
+
+/**
+ * Scans rendered markdown for code blocks and injects a copy button
+ * into each one. Runs on initial mount and after every route change.
+ */
+@Component({
+ selector: 'app-code-copy',
+ template: '',
+ styles: `
+ :host {
+ display: none;
+ }
+ `,
+})
+export class CodeCopy implements AfterViewInit {
+ private readonly router = inject(Router);
+ private readonly destroyRef = inject(DestroyRef);
+ private readonly toast = inject(ToastService);
+
+ ngAfterViewInit(): void {
+ enhanceOnNavigation(
+ this.router,
+ this.destroyRef,
+ 'analog-markdown-route pre:not([data-copy-enhanced]), analog-markdown pre:not([data-copy-enhanced])',
+ (pre) => this.enhance(pre),
+ );
+ }
+
+ private enhance(pre: HTMLElement): void {
+ pre.setAttribute('data-copy-enhanced', 'true');
+ pre.style.position = 'relative';
+ pre.classList.add('group/code');
+
+ const button = document.createElement('button');
+ button.type = 'button';
+ button.setAttribute('aria-label', 'Copy code');
+ button.className =
+ 'absolute top-2 right-2 inline-flex items-center justify-center size-7 rounded-md bg-zinc-200/80 text-zinc-600 hover:bg-zinc-300 hover:text-zinc-900 dark:bg-zinc-800/80 dark:text-zinc-300 dark:hover:bg-zinc-700 dark:hover:text-white opacity-0 transition-opacity group-hover/code:opacity-100 focus-visible:opacity-100 [@media(hover:none)]:opacity-100';
+ button.innerHTML = COPY_ICON;
+
+ let resetTimer: ReturnType | undefined;
+ button.addEventListener('click', async (e) => {
+ e.stopPropagation();
+ const code = pre.querySelector('code')?.textContent ?? pre.textContent ?? '';
+ const ok = await writeToClipboard(code);
+ if (!ok) {
+ this.toast.error('Could not copy code.');
+ return;
+ }
+ this.toast.success('Code copied to clipboard.');
+ button.innerHTML = CHECK_ICON;
+ clearTimeout(resetTimer);
+ resetTimer = setTimeout(() => (button.innerHTML = COPY_ICON), 1500);
+ });
+
+ pre.appendChild(button);
+ }
+}
+
+const COPY_ICON = `
+
+`;
+
+const CHECK_ICON = `
+
+`;
diff --git a/apps/docs/src/app/components/code-group.spec.ts b/apps/docs/src/app/components/code-group.spec.ts
new file mode 100644
index 0000000..555618d
--- /dev/null
+++ b/apps/docs/src/app/components/code-group.spec.ts
@@ -0,0 +1,64 @@
+import {TestBed} from '@angular/core/testing';
+import {provideRouter} from '@angular/router';
+import {CodeGroup} from './code-group';
+
+function group(id: string, labels: string[]): string {
+ const tabs = labels
+ .map(
+ (l, i) =>
+ ``,
+ )
+ .join('');
+ const panels = labels
+ .map(
+ (_, i) =>
+ `${i}`,
+ )
+ .join('');
+ return `${tabs}${panels}`;
+}
+
+describe('CodeGroup', () => {
+ let main: HTMLElement;
+
+ beforeEach(() => {
+ localStorage.clear();
+ main = document.createElement('main');
+ main.innerHTML = group('a', ['pnpm', 'npm']) + group('b', ['pnpm', 'npm', 'yarn']);
+ document.body.appendChild(main);
+ TestBed.configureTestingModule({imports: [CodeGroup], providers: [provideRouter([])]});
+ });
+
+ afterEach(() => main.remove());
+
+ const selected = () =>
+ [...main.querySelectorAll('[role="tab"][aria-selected="true"]')].map((t) => t.textContent);
+
+ it('adds tab semantics with a roving tabindex', async () => {
+ const fixture = TestBed.createComponent(CodeGroup);
+ await fixture.whenStable();
+ const tabs = main.querySelectorAll('[role="tab"]');
+ expect(main.querySelectorAll('[role="tablist"]').length).toBe(2);
+ expect(tabs[0].getAttribute('aria-controls')).toBe('a-0');
+ expect(main.querySelector('#a-0')?.getAttribute('role')).toBe('tabpanel');
+ expect(main.querySelector('#a-0')?.getAttribute('aria-labelledby')).toBe('a-0-tab');
+ expect([tabs[0].tabIndex, tabs[1].tabIndex]).toEqual([0, -1]);
+ });
+
+ it('moves with arrow keys and syncs the choice across groups', async () => {
+ const fixture = TestBed.createComponent(CodeGroup);
+ await fixture.whenStable();
+ const first = main.querySelector('[data-target="a-0"]')!;
+ first.dispatchEvent(new KeyboardEvent('keydown', {key: 'ArrowRight'}));
+ expect(selected()).toEqual(['npm', 'npm']);
+ expect(main.querySelector('[data-id="b-1"]')?.getAttribute('data-active')).toBe('true');
+ expect(localStorage.getItem('ngmd-code-group')).toBe('npm');
+ });
+
+ it('restores the stored choice', async () => {
+ localStorage.setItem('ngmd-code-group', 'yarn');
+ const fixture = TestBed.createComponent(CodeGroup);
+ await fixture.whenStable();
+ expect(selected()).toEqual(['pnpm', 'yarn']);
+ });
+});
diff --git a/apps/docs/src/app/components/code-group.ts b/apps/docs/src/app/components/code-group.ts
new file mode 100644
index 0000000..6465fea
--- /dev/null
+++ b/apps/docs/src/app/components/code-group.ts
@@ -0,0 +1,114 @@
+import {AfterViewInit, Component, DestroyRef, inject} from '@angular/core';
+import {Router} from '@angular/router';
+import {enhanceOnNavigation} from '../utils/enhance-on-navigation';
+
+/**
+ * Wires tab-switching for `` blocks emitted by
+ * the `ngmd-code-group` marked extension. Each tab's `data-target` points at
+ * a sibling panel's `data-id`; clicking flips `data-active` on the pair.
+ *
+ * Same pattern as CodeCopy / ExternalLinks / HeadingAnchors: scan ``
+ * after each route change, idempotent via `data-enhanced` marker.
+ */
+@Component({
+ selector: 'app-code-group',
+ template: '',
+ styles: `
+ :host {
+ display: none;
+ }
+ `,
+})
+export class CodeGroup implements AfterViewInit {
+ private readonly router = inject(Router);
+ private readonly destroyRef = inject(DestroyRef);
+
+ ngAfterViewInit(): void {
+ enhanceOnNavigation(
+ this.router,
+ this.destroyRef,
+ 'main .ngmd-code-group:not([data-enhanced])',
+ (group) => this.enhance(group),
+ );
+ }
+
+ private enhance(group: HTMLElement): void {
+ group.setAttribute('data-enhanced', 'true');
+ const tabs = [...group.querySelectorAll('.ngmd-code-group__tab')];
+ group.querySelector('.ngmd-code-group__tabs')?.setAttribute('role', 'tablist');
+
+ for (const tab of tabs) {
+ const target = tab.getAttribute('data-target');
+ const panel = target ? group.querySelector(`[data-id="${target}"]`) : null;
+ if (!target || !panel) continue;
+ tab.id = `${target}-tab`;
+ tab.setAttribute('role', 'tab');
+ tab.setAttribute('aria-controls', target);
+ panel.id = target;
+ panel.setAttribute('role', 'tabpanel');
+ panel.setAttribute('aria-labelledby', tab.id);
+ panel.tabIndex = 0;
+
+ tab.addEventListener('click', () => {
+ selectTab(tab);
+ const label = tabLabel(tab);
+ try {
+ localStorage.setItem(STORAGE_KEY, label);
+ } catch {}
+ for (const other of document.querySelectorAll(
+ '.ngmd-code-group[data-enhanced] .ngmd-code-group__tab',
+ )) {
+ if (other !== tab && tabLabel(other) === label) selectTab(other);
+ }
+ });
+ tab.addEventListener('keydown', (event) => {
+ const index = tabs.indexOf(tab);
+ const next =
+ event.key === 'ArrowRight'
+ ? (index + 1) % tabs.length
+ : event.key === 'ArrowLeft'
+ ? (index - 1 + tabs.length) % tabs.length
+ : event.key === 'Home'
+ ? 0
+ : event.key === 'End'
+ ? tabs.length - 1
+ : -1;
+ if (next === -1) return;
+ event.preventDefault();
+ tabs[next].focus();
+ tabs[next].click();
+ });
+ }
+
+ let stored: string | null = null;
+ try {
+ stored = localStorage.getItem(STORAGE_KEY);
+ } catch {}
+ const initial =
+ tabs.find((t) => stored !== null && tabLabel(t) === stored) ??
+ tabs.find((t) => t.getAttribute('data-active') === 'true') ??
+ tabs[0];
+ if (initial) selectTab(initial);
+ }
+}
+
+const STORAGE_KEY = 'ngmd-code-group';
+
+function tabLabel(tab: HTMLElement): string {
+ return tab.textContent?.trim() ?? '';
+}
+
+function selectTab(tab: HTMLButtonElement): void {
+ const group = tab.closest('.ngmd-code-group');
+ if (!group) return;
+ for (const t of group.querySelectorAll('.ngmd-code-group__tab')) {
+ const active = t === tab;
+ t.setAttribute('data-active', String(active));
+ t.setAttribute('aria-selected', String(active));
+ t.tabIndex = active ? 0 : -1;
+ }
+ const target = tab.getAttribute('data-target');
+ for (const p of group.querySelectorAll('.ngmd-code-group__panel')) {
+ p.setAttribute('data-active', String(p.getAttribute('data-id') === target));
+ }
+}
diff --git a/apps/docs/src/app/components/command-palette.spec.ts b/apps/docs/src/app/components/command-palette.spec.ts
new file mode 100644
index 0000000..dca526c
--- /dev/null
+++ b/apps/docs/src/app/components/command-palette.spec.ts
@@ -0,0 +1,109 @@
+import {Component} from '@angular/core';
+import {TestBed} from '@angular/core/testing';
+import {provideRouter, Router} from '@angular/router';
+import {CommandPalette} from './command-palette';
+
+vi.mock('virtual:ngmd/search-index', () => ({
+ searchIndex: ['alpha', 'alphabet', 'alphanumeric'].map((word) => ({
+ id: `page:/${word}`,
+ url: `/${word}`,
+ anchor: '',
+ kind: 'page',
+ pageTitle: word,
+ heading: word,
+ body: `All about ${word}.`,
+ })),
+}));
+vi.mock('virtual:ngmd/api-index', () => ({apiIndex: []}));
+
+@Component({template: ''})
+class Blank {}
+
+describe('CommandPalette', () => {
+ let trigger: HTMLButtonElement;
+
+ beforeEach(() => {
+ Element.prototype.scrollIntoView = vi.fn();
+ TestBed.configureTestingModule({providers: [provideRouter([{path: '**', component: Blank}])]});
+ trigger = document.body.appendChild(document.createElement('button'));
+ trigger.focus();
+ });
+
+ afterEach(() => {
+ document.body.innerHTML = '';
+ localStorage.clear();
+ });
+
+ function key(target: EventTarget, k: string, init: KeyboardEventInit = {}) {
+ target.dispatchEvent(new KeyboardEvent('keydown', {key: k, bubbles: true, ...init}));
+ }
+
+ async function openWithResults(query: string) {
+ const fixture = TestBed.createComponent(CommandPalette);
+ document.body.appendChild(fixture.nativeElement);
+ key(document, 'k', {ctrlKey: true});
+ await fixture.whenStable();
+ const input = fixture.nativeElement.querySelector('input') as HTMLInputElement;
+ input.value = query;
+ input.dispatchEvent(new Event('input'));
+ await vi.waitFor(
+ async () => {
+ await fixture.whenStable();
+ expect(fixture.nativeElement.querySelectorAll('[role=option]').length).toBe(3);
+ },
+ {timeout: 3000},
+ );
+ return {fixture, input};
+ }
+
+ it('focuses the combobox and exposes the listbox semantics', async () => {
+ const {fixture, input} = await openWithResults('alpha');
+ expect(document.activeElement).toBe(input);
+ expect(input.getAttribute('aria-expanded')).toBe('true');
+ expect(input.getAttribute('aria-controls')).toBe('ngmd-search-results');
+ expect(input.getAttribute('aria-activedescendant')).toBe('ngmd-search-option-0');
+ expect(fixture.nativeElement.querySelector('[role=dialog]').getAttribute('aria-modal')).toBe(
+ 'true',
+ );
+ expect(fixture.nativeElement.querySelector('[role=status]').textContent.trim()).toBe(
+ '3 results',
+ );
+ });
+
+ it('moves the active option with arrows, Home and End, wrapping at the ends', async () => {
+ const {fixture, input} = await openWithResults('alpha');
+ const active = async () => {
+ await fixture.whenStable();
+ return input.getAttribute('aria-activedescendant');
+ };
+ key(input, 'ArrowUp');
+ expect(await active()).toBe('ngmd-search-option-2');
+ key(input, 'ArrowDown');
+ expect(await active()).toBe('ngmd-search-option-0');
+ key(input, 'End');
+ expect(await active()).toBe('ngmd-search-option-2');
+ key(input, 'Home');
+ expect(await active()).toBe('ngmd-search-option-0');
+ });
+
+ it('navigates to the active option on Enter and returns focus to the opener', async () => {
+ const {fixture, input} = await openWithResults('alpha');
+ const router = TestBed.inject(Router);
+ key(input, 'ArrowDown');
+ await fixture.whenStable();
+ const target = fixture.nativeElement.querySelector('[aria-selected=true]').textContent.trim();
+ key(input, 'Enter');
+ await fixture.whenStable();
+ expect(router.url).toBe(`/${target}`);
+ expect(fixture.nativeElement.querySelector('[role=dialog]')).toBeNull();
+ expect(document.activeElement).toBe(trigger);
+ });
+
+ it('closes on Escape and restores focus', async () => {
+ const {fixture} = await openWithResults('alpha');
+ key(document, 'Escape');
+ await fixture.whenStable();
+ expect(fixture.nativeElement.querySelector('[role=dialog]')).toBeNull();
+ expect(document.activeElement).toBe(trigger);
+ });
+});
diff --git a/apps/docs/src/app/components/command-palette.ts b/apps/docs/src/app/components/command-palette.ts
new file mode 100644
index 0000000..4c61765
--- /dev/null
+++ b/apps/docs/src/app/components/command-palette.ts
@@ -0,0 +1,566 @@
+import {
+ Component,
+ ElementRef,
+ computed,
+ effect,
+ inject,
+ signal,
+ untracked,
+ viewChild,
+} from '@angular/core';
+import {NgTemplateOutlet, ViewportScroller} from '@angular/common';
+import {Router} from '@angular/router';
+import {
+ LucideDynamicIcon,
+ LucideArrowRight,
+ LucideBraces,
+ LucideClock,
+ LucideFileText,
+ LucideHash,
+ LucideSearch,
+ LucideStar,
+ LucideTrash,
+ LucideX,
+} from '@lucide/angular';
+import type {SearchHit} from '../../types/search';
+import {SearchService, type HistoryItem} from '../services/search/search.service';
+
+/**
+ * Cmd+K palette. The heavy lifting lives in `SearchService`; this component
+ * is the open / close / navigation shell on top of it.
+ *
+ * Empty state shows recent visits from localStorage as plain buttons
+ * (arrow keys move between them). Typing kicks the service (debounced) and
+ * renders highlighted hits as a combobox listbox: arrows, Home/End and
+ * Enter drive `aria-activedescendant`, pointer movement highlights a row,
+ * click navigates and records the visit. Esc closes and focus returns to
+ * whatever opened the palette.
+ */
+@Component({
+ selector: 'app-command-palette',
+ imports: [LucideDynamicIcon, NgTemplateOutlet],
+ host: {'(document:keydown)': 'onKeydown($event)'},
+ template: `
+ @if (open()) {
+
+
+
+
+ = 0 ? optionId(active()) : null"
+ [value]="search.query()"
+ (input)="onInput($event)"
+ (keydown)="onInputKeydown($event)"
+ />
+ @if (search.loading()) {
+
+ }
+
+
+
+ @if (showingHistory()) {
+ @if (search.favorites().length) {
+
+ Favourites
+
+
+ @for (item of search.favorites(); track item.url) {
+
+ }
+
+ }
+ @if (search.recents().length) {
+
+ Recent
+
+
+
+ @for (item of search.recents(); track item.url) {
+
+ }
+
+ }
+
+
+
+
+ @if (!favorite) {
+
+ }
+
+
+
+ } @else if (search.loading() && !search.results().length) {
+
+ Searching docs…
+
+ } @else if (search.hasNoResults()) {
+
+ No results found
+
+ } @else if (search.results().length) {
+
+ @for (item of search.results(); track item.id; let i = $index) {
+
+
+
+
+ @if (item.subLabelHtml) {
+
+ }
+ @if (item.contentHtml) {
+
+ }
+
+
+ }
+
+ } @else if (!search.query().trim() && !search.history().length) {
+
+ Start typing to see results
+
+ }
+
+ {{ status() }}
+
+
+ esc
+ close
+
+ @if (search.isAlgolia) {
+
+
+ Search by
+
+
+ } @else {
+
+
+ Search by
+
+ orama
+
+ }
+
+
+
+ }
+ `,
+})
+export class CommandPalette {
+ private readonly router = inject(Router);
+ private readonly scroller = inject(ViewportScroller);
+ protected readonly search = inject(SearchService);
+ private readonly input = viewChild>('input');
+ private readonly dialog = viewChild>('dialog');
+
+ readonly searchIcon = LucideSearch;
+ readonly arrowIcon = LucideArrowRight;
+ readonly hashIcon = LucideHash;
+ readonly fileIcon = LucideFileText;
+ readonly symbolIcon = LucideBraces;
+ readonly clockIcon = LucideClock;
+ readonly trashIcon = LucideTrash;
+ readonly starIcon = LucideStar;
+ readonly closeIcon = LucideX;
+
+ readonly listboxId = 'ngmd-search-results';
+ readonly open = signal(false);
+ /** Index of the highlighted result, shared by keyboard and pointer. */
+ readonly active = signal(-1);
+
+ /** Tracks which history row the pointer is over so the row, the star
+ * toggle, and (when present) the result-side hover state share one
+ * source of truth. URL not index, because favorites + recents render
+ * as two lists with independent indices. */
+ readonly hoverUrl = signal(null);
+
+ private returnFocus: HTMLElement | null = null;
+
+ readonly showingHistory = computed(
+ () => !this.search.query().trim() && this.search.history().length > 0,
+ );
+
+ readonly expanded = computed(() => !this.showingHistory() && this.search.results().length > 0);
+
+ readonly status = computed(() => {
+ if (this.showingHistory() || !this.search.query().trim() || this.search.loading()) return '';
+ const count = this.search.results().length;
+ if (!count) return 'No results found';
+ return count === 1 ? '1 result' : `${count} results`;
+ });
+
+ constructor() {
+ effect(() => {
+ if (typeof document === 'undefined') return;
+ document.body.style.overflow = this.open() ? 'hidden' : '';
+ });
+ effect(() => {
+ const results = this.search.results();
+ this.active.set(results.length ? 0 : -1);
+ });
+ effect(() => this.input()?.nativeElement.focus());
+ effect(() => {
+ const i = this.active();
+ if (i < 0 || typeof document === 'undefined') return;
+ document.getElementById(this.optionId(i))?.scrollIntoView({block: 'nearest'});
+ });
+ // External components (404 catch-all, etc.) can pop the palette open
+ // pre-filled by calling `search.requestOpen(query)`. The initial tick
+ // value of 0 fires once at startup; ignore it so we don't auto-open.
+ effect(() => {
+ const tick = this.search.openTick();
+ if (tick === 0) return;
+ untracked(() => this.show());
+ });
+ }
+
+ optionId(i: number): string {
+ return `ngmd-search-option-${i}`;
+ }
+
+ iconFor(item: SearchHit) {
+ if (item.kind === 'section') return this.hashIcon;
+ if (item.kind === 'snippet') return this.fileIcon;
+ if (item.kind === 'symbol') return this.symbolIcon;
+ return this.arrowIcon;
+ }
+
+ onKeydown(event: KeyboardEvent) {
+ if ((event.metaKey || event.ctrlKey) && event.key?.toLowerCase() === 'k') {
+ event.preventDefault();
+ this.toggle();
+ return;
+ }
+ if (this.open() && event.key === 'Escape') {
+ event.preventDefault();
+ this.close();
+ }
+ }
+
+ toggle() {
+ if (this.open()) {
+ this.close();
+ return;
+ }
+ this.search.query.set('');
+ this.show();
+ }
+
+ close() {
+ if (!this.open()) return;
+ this.open.set(false);
+ this.hoverUrl.set(null);
+ this.returnFocus?.focus({preventScroll: true});
+ this.returnFocus = null;
+ }
+
+ onInput(event: Event) {
+ this.search.query.set((event.target as HTMLInputElement).value);
+ }
+
+ onInputKeydown(event: KeyboardEvent) {
+ if (event.isComposing) return;
+ if (this.showingHistory()) {
+ const items = this.historyItems();
+ if (event.key === 'ArrowDown' && items.length) items[0].focus();
+ else if (event.key === 'ArrowUp' && items.length) items[items.length - 1].focus();
+ else return;
+ event.preventDefault();
+ return;
+ }
+ const results = this.search.results();
+ if (!results.length) return;
+ const last = results.length - 1;
+ const i = this.active();
+ switch (event.key) {
+ case 'ArrowDown':
+ this.active.set(i >= last ? 0 : i + 1);
+ break;
+ case 'ArrowUp':
+ this.active.set(i <= 0 ? last : i - 1);
+ break;
+ case 'Home':
+ this.active.set(0);
+ break;
+ case 'End':
+ this.active.set(last);
+ break;
+ case 'Enter':
+ this.select(results[Math.max(i, 0)]);
+ break;
+ default:
+ return;
+ }
+ event.preventDefault();
+ }
+
+ onDialogKeydown(event: KeyboardEvent) {
+ if (event.key === 'Tab') {
+ this.trapTab(event);
+ return;
+ }
+ const target = event.target as HTMLElement;
+ if (!target.hasAttribute('data-history-item')) return;
+ const items = this.historyItems();
+ const i = items.indexOf(target as HTMLButtonElement);
+ let next: HTMLElement | undefined;
+ if (event.key === 'ArrowDown') next = items[i + 1] ?? this.input()?.nativeElement;
+ else if (event.key === 'ArrowUp') next = items[i - 1] ?? this.input()?.nativeElement;
+ else if (event.key === 'Home') next = items[0];
+ else if (event.key === 'End') next = items[items.length - 1];
+ if (!next) return;
+ event.preventDefault();
+ next.focus();
+ }
+
+ select(hit: SearchHit) {
+ this.search.recordVisit(hit);
+ this.navigateTo(hit.url);
+ }
+
+ selectHistory(item: HistoryItem) {
+ // Re-record so a re-visited recent moves to the top of the list.
+ this.search.recordVisit({
+ id: item.id,
+ kind: 'page',
+ url: item.url,
+ labelHtml: item.labelHtml,
+ subLabelHtml: item.subLabelHtml,
+ });
+ this.navigateTo(item.url);
+ }
+
+ toggleFavorite(url: string): void {
+ this.search.toggleFavorite(url);
+ this.focusInput();
+ }
+
+ clearRecents(): void {
+ this.search.clearRecents();
+ this.focusInput();
+ }
+
+ /** Drop a row and clear the hover highlight if it was on this URL.
+ * Without this, a later row that happens to share the URL would render
+ * pre-highlighted before the user moves the pointer over it. */
+ removeAt(url: string): void {
+ this.search.removeFromHistory(url);
+ if (this.hoverUrl() === url) this.hoverUrl.set(null);
+ this.focusInput();
+ }
+
+ private show(): void {
+ if (!this.open() && typeof document !== 'undefined') {
+ this.returnFocus = document.activeElement as HTMLElement | null;
+ }
+ this.open.set(true);
+ this.focusInput();
+ }
+
+ private focusInput(): void {
+ this.input()?.nativeElement.focus();
+ }
+
+ private historyItems(): HTMLButtonElement[] {
+ const root = this.dialog()?.nativeElement;
+ return root ? Array.from(root.querySelectorAll('button[data-history-item]')) : [];
+ }
+
+ private trapTab(event: KeyboardEvent): void {
+ const root = this.dialog()?.nativeElement;
+ if (!root) return;
+ const focusable = Array.from(
+ root.querySelectorAll('input, button:not([disabled]), a[href]'),
+ );
+ const first = focusable[0];
+ const last = focusable[focusable.length - 1];
+ if (!first) return;
+ const current = document.activeElement;
+ if (event.shiftKey && (current === first || !root.contains(current))) {
+ event.preventDefault();
+ last.focus();
+ } else if (!event.shiftKey && (current === last || !root.contains(current))) {
+ event.preventDefault();
+ first.focus();
+ }
+ }
+
+ private navigateTo(url: string): void {
+ const [pathAndQuery, fragment] = url.split('#');
+ const samePath = this.router.url.split('#')[0] === pathAndQuery;
+ this.close();
+ this.router.navigateByUrl(url).then(() => {
+ if (fragment) this.scrollToWhenReady(fragment);
+ else if (samePath) this.scroller.scrollToPosition([0, 0], {behavior: 'smooth'});
+ });
+ }
+
+ private scrollToWhenReady(slug: string, attempt = 0): void {
+ if (typeof document === 'undefined' || attempt > 30) return;
+ if (!document.getElementById(slug)) {
+ setTimeout(() => this.scrollToWhenReady(slug, attempt + 1), 50);
+ return;
+ }
+ this.scroller.scrollToAnchor(slug, {behavior: 'smooth'});
+ }
+}
diff --git a/apps/docs/src/app/components/content-banners.ts b/apps/docs/src/app/components/content-banners.ts
new file mode 100644
index 0000000..529cb0b
--- /dev/null
+++ b/apps/docs/src/app/components/content-banners.ts
@@ -0,0 +1,100 @@
+import {Component, computed, inject} from '@angular/core';
+import {
+ LucideDynamicIcon,
+ LucideArchive,
+ LucideExternalLink,
+ LucideRocket,
+ LucideTriangleAlert,
+} from '@lucide/angular';
+import {VersionService} from '../services/version/version.service';
+
+/**
+ * Banner rendered above every documentation route when THIS deployment
+ * isn't the production current. Reads `versions.self` (the entry for
+ * this deployment) and `versions.current` (the entry whose status is
+ * `'current'`) and points visitors at the latter when they're stuck on
+ * a `next` / `rc` / `deprecated` deployment.
+ *
+ * No DOM when versions config is absent, when self matches the current,
+ * or when there's no current entry to link to. The banner is opt-out via
+ * config: drop the registry and nothing renders.
+ */
+@Component({
+ selector: 'app-content-banners',
+ imports: [LucideDynamicIcon],
+ template: `
+ @if (banner(); as b) {
+
+
+
+ {{ b.title }}
+
+ {{ b.prefix }}
+
+ {{ b.currentLabel }}
+ .
+
+
+
+ }
+ `,
+})
+export class ContentBanners {
+ private readonly versions = inject(VersionService);
+
+ readonly externalIcon = LucideExternalLink;
+
+ readonly banner = computed(() => {
+ const self = this.versions.self();
+ const current = this.versions.current();
+ if (!self || !current) return null;
+ if (self.status === 'current') return null;
+
+ const currentUrl = current.url;
+ const currentLabel = current.label;
+
+ if (self.status === 'next') {
+ return {
+ title: "You're reading the next-release docs.",
+ prefix: 'The current stable is',
+ currentUrl,
+ currentLabel,
+ icon: LucideRocket,
+ containerClass: 'border-amber-200 dark:border-amber-900 bg-amber-50 dark:bg-amber-950/40',
+ iconClass: 'text-amber-600',
+ bodyClass: 'text-amber-700 dark:text-amber-300',
+ };
+ }
+ if (self.status === 'rc') {
+ return {
+ title: "You're reading a release candidate.",
+ prefix: 'The current stable is',
+ currentUrl,
+ currentLabel,
+ icon: LucideTriangleAlert,
+ containerClass: 'border-amber-200 dark:border-amber-900 bg-amber-50 dark:bg-amber-950/40',
+ iconClass: 'text-amber-600',
+ bodyClass: 'text-amber-700 dark:text-amber-300',
+ };
+ }
+ return {
+ title: `This is ${self.label}, no longer the current release.`,
+ prefix: 'The current stable is',
+ currentUrl,
+ currentLabel,
+ icon: LucideArchive,
+ containerClass: 'border-zinc-200 dark:border-zinc-800 bg-zinc-50 dark:bg-zinc-900',
+ iconClass: 'text-zinc-500',
+ bodyClass: 'text-zinc-600 dark:text-zinc-400',
+ };
+ });
+}
diff --git a/apps/docs/src/app/components/external-links.ts b/apps/docs/src/app/components/external-links.ts
new file mode 100644
index 0000000..11609d7
--- /dev/null
+++ b/apps/docs/src/app/components/external-links.ts
@@ -0,0 +1,36 @@
+import {AfterViewInit, Component, DestroyRef, inject} from '@angular/core';
+import {Router} from '@angular/router';
+import {enhanceOnNavigation} from '../utils/enhance-on-navigation';
+
+/**
+ * Adds `target="_blank" rel="noopener noreferrer"` to external anchors in
+ * rendered markdown after each route change.
+ */
+@Component({
+ selector: 'app-external-links',
+ template: '',
+ styles: `
+ :host {
+ display: none;
+ }
+ `,
+})
+export class ExternalLinks implements AfterViewInit {
+ private readonly router = inject(Router);
+ private readonly destroyRef = inject(DestroyRef);
+
+ ngAfterViewInit(): void {
+ enhanceOnNavigation(
+ this.router,
+ this.destroyRef,
+ 'main analog-markdown a[href^="http"]:not([data-external-enhanced]), main analog-markdown-route a[href^="http"]:not([data-external-enhanced])',
+ (node) => {
+ const a = node as HTMLAnchorElement;
+ a.setAttribute('data-external-enhanced', 'true');
+ if (a.origin === window.location.origin) return;
+ a.setAttribute('target', '_blank');
+ a.setAttribute('rel', 'noopener noreferrer');
+ },
+ );
+ }
+}
diff --git a/apps/docs/src/app/components/heading-anchors.ts b/apps/docs/src/app/components/heading-anchors.ts
new file mode 100644
index 0000000..c0eb4c4
--- /dev/null
+++ b/apps/docs/src/app/components/heading-anchors.ts
@@ -0,0 +1,86 @@
+import {AfterViewInit, Component, DestroyRef, inject} from '@angular/core';
+import {Router} from '@angular/router';
+import {ToastService} from '../services/toast/toast.service';
+import {writeToClipboard} from '../utils/clipboard';
+import {enhanceOnNavigation} from '../utils/enhance-on-navigation';
+
+/**
+ * Scans rendered docs pages for h2/h3 with an id and appends a copy-link
+ * button that writes the absolute URL with `#fragment` to the clipboard.
+ * Runs on mount and after every route change, same pattern as CodeCopy.
+ */
+@Component({
+ selector: 'app-heading-anchors',
+ template: '',
+ styles: `
+ :host {
+ display: none;
+ }
+ `,
+})
+export class HeadingAnchors implements AfterViewInit {
+ private readonly router = inject(Router);
+ private readonly destroyRef = inject(DestroyRef);
+ private readonly toast = inject(ToastService);
+
+ ngAfterViewInit(): void {
+ enhanceOnNavigation(
+ this.router,
+ this.destroyRef,
+ 'main h1[id]:not([data-anchor-enhanced]), main h2[id]:not([data-anchor-enhanced]), main h3[id]:not([data-anchor-enhanced])',
+ (h) => this.enhance(h),
+ );
+ }
+
+ private enhance(heading: HTMLElement): void {
+ heading.setAttribute('data-anchor-enhanced', 'true');
+ heading.style.scrollMarginTop = heading.style.scrollMarginTop || '6rem';
+ heading.classList.add('group/heading');
+
+ const button = document.createElement('button');
+ button.type = 'button';
+ button.setAttribute('aria-label', 'Copy link to this section');
+ button.className =
+ 'ml-2 inline-flex items-center justify-center size-5 align-middle relative -top-[2px] rounded text-zinc-500 dark:text-zinc-400 hover:text-[color:var(--accent)] opacity-0 transition-opacity group-hover/heading:opacity-100 focus-visible:opacity-100 [@media(hover:none)]:opacity-100';
+ button.innerHTML = this.linkIcon();
+
+ // h1 is the page itself; copying #h1-slug duplicates the path in the URL.
+ // For h1, copy + show the bare page URL with no fragment.
+ const isH1 = heading.tagName === 'H1';
+
+ let resetTimer: ReturnType | undefined;
+ button.addEventListener('click', async (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ const base = `${location.origin}${location.pathname}`;
+ const url = isH1 ? base : `${base}#${heading.id}`;
+ if (!(await writeToClipboard(url))) {
+ this.toast.error('Could not copy link.');
+ return;
+ }
+ this.toast.success('Link copied to clipboard.');
+ button.innerHTML = this.checkIcon();
+ clearTimeout(resetTimer);
+ resetTimer = setTimeout(() => (button.innerHTML = this.linkIcon()), 1500);
+ });
+
+ heading.appendChild(button);
+ }
+
+ private linkIcon(): string {
+ return `
+
+ `;
+ }
+
+ private checkIcon(): string {
+ return `
+
+ `;
+ }
+}
diff --git a/apps/docs/src/app/components/llm-actions.ts b/apps/docs/src/app/components/llm-actions.ts
new file mode 100644
index 0000000..bb0136c
--- /dev/null
+++ b/apps/docs/src/app/components/llm-actions.ts
@@ -0,0 +1,331 @@
+import {
+ Component,
+ DestroyRef,
+ ElementRef,
+ Injector,
+ afterNextRender,
+ computed,
+ inject,
+ signal,
+ viewChild,
+} from '@angular/core';
+import {
+ LucideDynamicIcon,
+ type LucideIcon,
+ LucideCheck,
+ LucideChevronDown,
+ LucideCopy,
+ LucideLink,
+} from '@lucide/angular';
+import {GithubIcon} from '../ui/github-icon';
+import {pageMeta} from 'virtual:ngmd/page-meta';
+import {ToastService} from '../services/toast/toast.service';
+import {RouteUrlService} from '../services/route-url/route-url.service';
+import {writeToClipboard} from '../utils/clipboard';
+import siteConfig from '../../ngmd.config';
+import {ClaudeIcon, OpenaiIcon} from '../ui/brand-icons';
+
+interface MenuItem {
+ label: string;
+ icon: LucideIcon | 'github' | 'claude' | 'openai';
+ /** Either a click handler or a target URL — drives the `