From eeb68944df45167d4114d45acb3e4b7c1963c1e6 Mon Sep 17 00:00:00 2001 From: Kam Date: Mon, 28 Sep 2026 23:51:58 +0300 Subject: [PATCH 01/13] docs: add the documentation site in apps/docs The README had grown to hold setup, every inspector, the agent tools and contributor notes. Move that content into a docs site built with NgMd (AnalogJS), checked against the current code, and keep the README to an intro, a quick start, community and sponsors. The site has getting started guides, a page per inspector, the MCP server with every tool and resource, security, guides, contributing and sponsors, with the devtools branding and an amber theme. It is its own Nx project (angular-devtools-docs) with docs:dev and docs:build scripts. Closes #42 --- README.md | 439 +------ apps/docs/.gitignore | 2 + apps/docs/README.md | 24 + apps/docs/angular.json | 54 + apps/docs/api-gen.plugin.ts | 257 +++++ apps/docs/index.html | 54 + apps/docs/link-guard.plugin.ts | 138 +++ apps/docs/package.json | 84 ++ apps/docs/page-meta.plugin.ts | 83 ++ apps/docs/plugin-utils.ts | 104 ++ apps/docs/public/apple-touch-icon.png | Bin 0 -> 9326 bytes apps/docs/public/favicon.ico | Bin 0 -> 9458 bytes apps/docs/public/favicon.svg | 3 + apps/docs/public/logo-mark.svg | 10 + apps/docs/public/logo.svg | 10 + apps/docs/public/og.png | Bin 0 -> 79500 bytes apps/docs/raw-md.plugin.ts | 100 ++ apps/docs/search-index.plugin.ts | 221 ++++ apps/docs/sitemap.plugin.ts | 93 ++ apps/docs/src/app/app.config.server.ts | 10 + apps/docs/src/app/app.config.ts | 64 ++ apps/docs/src/app/app.spec.ts | 20 + apps/docs/src/app/app.ts | 292 +++++ apps/docs/src/app/components/breadcrumb.ts | 66 ++ apps/docs/src/app/components/code-copy.ts | 78 ++ apps/docs/src/app/components/code-group.ts | 51 + .../src/app/components/command-palette.ts | 419 +++++++ .../src/app/components/content-banners.ts | 101 ++ .../docs/src/app/components/external-links.ts | 36 + .../src/app/components/heading-anchors.ts | 80 ++ apps/docs/src/app/components/llm-actions.ts | 258 +++++ .../docs/src/app/components/media-enhancer.ts | 79 ++ apps/docs/src/app/components/page-footer.ts | 123 ++ apps/docs/src/app/components/sidebar.ts | 101 ++ apps/docs/src/app/components/site-footer.ts | 49 + .../docs/src/app/components/source-actions.ts | 55 + apps/docs/src/app/components/sponsor-list.ts | 37 + apps/docs/src/app/components/toaster.ts | 148 +++ apps/docs/src/app/components/toc.ts | 182 +++ .../app/components/version-switcher.spec.ts | 43 + .../src/app/components/version-switcher.ts | 146 +++ apps/docs/src/app/layout-mode.service.ts | 10 + apps/docs/src/app/pages/[...slug].page.ts | 178 +++ apps/docs/src/app/pages/index.page.ts | 264 +++++ apps/docs/src/app/pages/sponsors.page.ts | 60 + apps/docs/src/app/register-elements.spec.ts | 24 + apps/docs/src/app/register-elements.ts | 89 ++ .../services/route-url/route-url.service.ts | 40 + .../app/services/search/algolia-provider.ts | 134 +++ .../src/app/services/search/orama-provider.ts | 157 +++ .../src/app/services/search/search.service.ts | 246 ++++ .../src/app/services/toast/toast.service.ts | 59 + .../app/services/version/version.service.ts | 48 + apps/docs/src/app/theme.ts | 53 + apps/docs/src/app/title-strategy.ts | 49 + apps/docs/src/app/ui/accordion.spec.ts | 35 + apps/docs/src/app/ui/accordion.ts | 105 ++ apps/docs/src/app/ui/alert.ts | 77 ++ apps/docs/src/app/ui/badge.ts | 32 + apps/docs/src/app/ui/callout.ts | 62 + apps/docs/src/app/ui/card-grid.ts | 27 + apps/docs/src/app/ui/card.ts | 140 +++ apps/docs/src/app/ui/code-block.ts | 155 +++ apps/docs/src/app/ui/discord-icon.ts | 19 + apps/docs/src/app/ui/github-icon.ts | 24 + apps/docs/src/app/ui/hero.ts | 44 + apps/docs/src/app/ui/image.ts | 26 + apps/docs/src/app/ui/index.ts | 55 + apps/docs/src/app/ui/pill.ts | 63 + apps/docs/src/app/ui/tabs.ts | 220 ++++ apps/docs/src/app/ui/video.ts | 41 + apps/docs/src/app/ui/workflow.ts | 93 ++ apps/docs/src/app/utils/clipboard.ts | 18 + .../src/app/utils/enhance-on-navigation.ts | 64 ++ .../src/app/utils/watch-host-attribute.ts | 27 + apps/docs/src/content/agents/mcp-server.md | 91 ++ apps/docs/src/content/agents/resources.md | 39 + apps/docs/src/content/agents/tools.md | 99 ++ apps/docs/src/content/community.md | 18 + .../content/contributing/chrome-extension.md | 59 + .../src/content/contributing/demo-apps.md | 33 + .../src/content/contributing/development.md | 106 ++ .../src/content/contributing/publishing.md | 22 + .../getting-started/chrome-extension.md | 33 + apps/docs/src/content/getting-started/cli.md | 46 + .../src/content/getting-started/express.md | 119 ++ .../content/getting-started/installation.md | 44 + .../content/getting-started/introduction.md | 55 + .../src/content/getting-started/overlay.md | 40 + .../content/getting-started/popup-and-hub.md | 67 ++ apps/docs/src/content/getting-started/vite.md | 62 + apps/docs/src/content/guides/analog.md | 69 ++ .../content/guides/ngrx-signals-restore.md | 55 + apps/docs/src/content/guides/ssr-http.md | 67 ++ apps/docs/src/content/inspectors/analog.md | 45 + .../docs/src/content/inspectors/components.md | 53 + apps/docs/src/content/inspectors/dashboard.md | 41 + apps/docs/src/content/inspectors/forms.md | 65 ++ apps/docs/src/content/inspectors/injectors.md | 41 + .../docs/src/content/inspectors/ngrx-store.md | 47 + apps/docs/src/content/inspectors/pipes.md | 52 + apps/docs/src/content/inspectors/router.md | 41 + apps/docs/src/content/inspectors/signals.md | 52 + apps/docs/src/content/inspectors/ssr-http.md | 33 + apps/docs/src/content/security.md | 83 ++ apps/docs/src/main.server.ts | 7 + apps/docs/src/main.ts | 6 + apps/docs/src/marked-extensions/index.ts | 37 + .../src/marked-extensions/ngmd-code-group.ts | 130 +++ .../marked-extensions/ngmd-code-highlight.ts | 104 ++ .../src/marked-extensions/ngmd-code-import.ts | 119 ++ apps/docs/src/marked-extensions/ngmd-image.ts | 45 + .../src/marked-extensions/ngmd-keywords.ts | 76 ++ apps/docs/src/marked-extensions/ngmd-video.ts | 52 + apps/docs/src/marked-extensions/runtime.ts | 11 + .../src/marked-extensions/shiki-shared.ts | 42 + apps/docs/src/ngmd.config.ts | 234 ++++ apps/docs/src/styles.css | 819 +++++++++++++ apps/docs/src/test-setup.ts | 6 + apps/docs/src/types/api.ts | 52 + apps/docs/src/types/badge.ts | 21 + apps/docs/src/types/search.ts | 55 + apps/docs/src/vite-env.d.ts | 21 + apps/docs/tsconfig.app.json | 10 + apps/docs/tsconfig.json | 31 + apps/docs/tsconfig.spec.json | 11 + apps/docs/vars.plugin.ts | 67 ++ apps/docs/vite.config.ts | 111 ++ package.json | 4 +- pnpm-lock.yaml | 1019 ++++++++++++++++- pnpm-workspace.yaml | 1 + 131 files changed, 11151 insertions(+), 469 deletions(-) create mode 100644 apps/docs/.gitignore create mode 100644 apps/docs/README.md create mode 100644 apps/docs/angular.json create mode 100644 apps/docs/api-gen.plugin.ts create mode 100644 apps/docs/index.html create mode 100644 apps/docs/link-guard.plugin.ts create mode 100644 apps/docs/package.json create mode 100644 apps/docs/page-meta.plugin.ts create mode 100644 apps/docs/plugin-utils.ts create mode 100644 apps/docs/public/apple-touch-icon.png create mode 100644 apps/docs/public/favicon.ico create mode 100644 apps/docs/public/favicon.svg create mode 100644 apps/docs/public/logo-mark.svg create mode 100644 apps/docs/public/logo.svg create mode 100644 apps/docs/public/og.png create mode 100644 apps/docs/raw-md.plugin.ts create mode 100644 apps/docs/search-index.plugin.ts create mode 100644 apps/docs/sitemap.plugin.ts create mode 100644 apps/docs/src/app/app.config.server.ts create mode 100644 apps/docs/src/app/app.config.ts create mode 100644 apps/docs/src/app/app.spec.ts create mode 100644 apps/docs/src/app/app.ts create mode 100644 apps/docs/src/app/components/breadcrumb.ts create mode 100644 apps/docs/src/app/components/code-copy.ts create mode 100644 apps/docs/src/app/components/code-group.ts create mode 100644 apps/docs/src/app/components/command-palette.ts create mode 100644 apps/docs/src/app/components/content-banners.ts create mode 100644 apps/docs/src/app/components/external-links.ts create mode 100644 apps/docs/src/app/components/heading-anchors.ts create mode 100644 apps/docs/src/app/components/llm-actions.ts create mode 100644 apps/docs/src/app/components/media-enhancer.ts create mode 100644 apps/docs/src/app/components/page-footer.ts create mode 100644 apps/docs/src/app/components/sidebar.ts create mode 100644 apps/docs/src/app/components/site-footer.ts create mode 100644 apps/docs/src/app/components/source-actions.ts create mode 100644 apps/docs/src/app/components/sponsor-list.ts create mode 100644 apps/docs/src/app/components/toaster.ts create mode 100644 apps/docs/src/app/components/toc.ts create mode 100644 apps/docs/src/app/components/version-switcher.spec.ts create mode 100644 apps/docs/src/app/components/version-switcher.ts create mode 100644 apps/docs/src/app/layout-mode.service.ts create mode 100644 apps/docs/src/app/pages/[...slug].page.ts create mode 100644 apps/docs/src/app/pages/index.page.ts create mode 100644 apps/docs/src/app/pages/sponsors.page.ts create mode 100644 apps/docs/src/app/register-elements.spec.ts create mode 100644 apps/docs/src/app/register-elements.ts create mode 100644 apps/docs/src/app/services/route-url/route-url.service.ts create mode 100644 apps/docs/src/app/services/search/algolia-provider.ts create mode 100644 apps/docs/src/app/services/search/orama-provider.ts create mode 100644 apps/docs/src/app/services/search/search.service.ts create mode 100644 apps/docs/src/app/services/toast/toast.service.ts create mode 100644 apps/docs/src/app/services/version/version.service.ts create mode 100644 apps/docs/src/app/theme.ts create mode 100644 apps/docs/src/app/title-strategy.ts create mode 100644 apps/docs/src/app/ui/accordion.spec.ts create mode 100644 apps/docs/src/app/ui/accordion.ts create mode 100644 apps/docs/src/app/ui/alert.ts create mode 100644 apps/docs/src/app/ui/badge.ts create mode 100644 apps/docs/src/app/ui/callout.ts create mode 100644 apps/docs/src/app/ui/card-grid.ts create mode 100644 apps/docs/src/app/ui/card.ts create mode 100644 apps/docs/src/app/ui/code-block.ts create mode 100644 apps/docs/src/app/ui/discord-icon.ts create mode 100644 apps/docs/src/app/ui/github-icon.ts create mode 100644 apps/docs/src/app/ui/hero.ts create mode 100644 apps/docs/src/app/ui/image.ts create mode 100644 apps/docs/src/app/ui/index.ts create mode 100644 apps/docs/src/app/ui/pill.ts create mode 100644 apps/docs/src/app/ui/tabs.ts create mode 100644 apps/docs/src/app/ui/video.ts create mode 100644 apps/docs/src/app/ui/workflow.ts create mode 100644 apps/docs/src/app/utils/clipboard.ts create mode 100644 apps/docs/src/app/utils/enhance-on-navigation.ts create mode 100644 apps/docs/src/app/utils/watch-host-attribute.ts create mode 100644 apps/docs/src/content/agents/mcp-server.md create mode 100644 apps/docs/src/content/agents/resources.md create mode 100644 apps/docs/src/content/agents/tools.md create mode 100644 apps/docs/src/content/community.md create mode 100644 apps/docs/src/content/contributing/chrome-extension.md create mode 100644 apps/docs/src/content/contributing/demo-apps.md create mode 100644 apps/docs/src/content/contributing/development.md create mode 100644 apps/docs/src/content/contributing/publishing.md create mode 100644 apps/docs/src/content/getting-started/chrome-extension.md create mode 100644 apps/docs/src/content/getting-started/cli.md create mode 100644 apps/docs/src/content/getting-started/express.md create mode 100644 apps/docs/src/content/getting-started/installation.md create mode 100644 apps/docs/src/content/getting-started/introduction.md create mode 100644 apps/docs/src/content/getting-started/overlay.md create mode 100644 apps/docs/src/content/getting-started/popup-and-hub.md create mode 100644 apps/docs/src/content/getting-started/vite.md create mode 100644 apps/docs/src/content/guides/analog.md create mode 100644 apps/docs/src/content/guides/ngrx-signals-restore.md create mode 100644 apps/docs/src/content/guides/ssr-http.md create mode 100644 apps/docs/src/content/inspectors/analog.md create mode 100644 apps/docs/src/content/inspectors/components.md create mode 100644 apps/docs/src/content/inspectors/dashboard.md create mode 100644 apps/docs/src/content/inspectors/forms.md create mode 100644 apps/docs/src/content/inspectors/injectors.md create mode 100644 apps/docs/src/content/inspectors/ngrx-store.md create mode 100644 apps/docs/src/content/inspectors/pipes.md create mode 100644 apps/docs/src/content/inspectors/router.md create mode 100644 apps/docs/src/content/inspectors/signals.md create mode 100644 apps/docs/src/content/inspectors/ssr-http.md create mode 100644 apps/docs/src/content/security.md create mode 100644 apps/docs/src/main.server.ts create mode 100644 apps/docs/src/main.ts create mode 100644 apps/docs/src/marked-extensions/index.ts create mode 100644 apps/docs/src/marked-extensions/ngmd-code-group.ts create mode 100644 apps/docs/src/marked-extensions/ngmd-code-highlight.ts create mode 100644 apps/docs/src/marked-extensions/ngmd-code-import.ts create mode 100644 apps/docs/src/marked-extensions/ngmd-image.ts create mode 100644 apps/docs/src/marked-extensions/ngmd-keywords.ts create mode 100644 apps/docs/src/marked-extensions/ngmd-video.ts create mode 100644 apps/docs/src/marked-extensions/runtime.ts create mode 100644 apps/docs/src/marked-extensions/shiki-shared.ts create mode 100644 apps/docs/src/ngmd.config.ts create mode 100644 apps/docs/src/styles.css create mode 100644 apps/docs/src/test-setup.ts create mode 100644 apps/docs/src/types/api.ts create mode 100644 apps/docs/src/types/badge.ts create mode 100644 apps/docs/src/types/search.ts create mode 100644 apps/docs/src/vite-env.d.ts create mode 100644 apps/docs/tsconfig.app.json create mode 100644 apps/docs/tsconfig.json create mode 100644 apps/docs/tsconfig.spec.json create mode 100644 apps/docs/vars.plugin.ts create mode 100644 apps/docs/vite.config.ts diff --git a/README.md b/README.md index de8669f..9583e52 100644 --- a/README.md +++ b/README.md @@ -1,35 +1,26 @@ # 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 +## Documentation -- **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 +The full documentation lives in [`apps/docs`](./apps/docs/src/content/getting-started/introduction.md): -## Install +- [Getting started](./apps/docs/src/content/getting-started/introduction.md): install, Express SSR, Vite and Analog, the CLI, the popup and hub, the overlay, and the Chrome extension +- [Inspectors](./apps/docs/src/content/inspectors/dashboard.md): Dashboard, Components, Injectors, Signals, NgRx Store, Forms, Router, Pipes, SSR & HTTP, and Analog +- [Agent tools](./apps/docs/src/content/agents/mcp-server.md): the MCP server, every tool and resource +- [Security](./apps/docs/src/content/security.md): local-only access and what is redacted +- [Contributing](./apps/docs/src/content/contributing/development.md): development setup, demo apps, the extension and publishing + +Run the docs site locally with `pnpm docs:dev`. + +## Quick start ```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: +**Angular app with SSR (Express)**: mount the devtools hub in your server. ```ts // server.ts @@ -39,171 +30,7 @@ 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`: +**Analog (Vite)**: add the plugin next to `analog()`. ```ts // vite.config.ts @@ -216,250 +43,18 @@ export default defineConfig({ }); ``` -```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: +Then load the overlay in development, and a floating button appears on your page: ```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`. +**Coding agents**: run the MCP server. ```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/ +npx @santoshyadavdev/ng-devtools mcp ``` -### 6. Load in Chrome - -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 - -### 7. Publish to Chrome Web Store - -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 - -### Connecting the extension to the running app - -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`. - ## Community Join the conversation, ask questions, and share feedback on [Discord](https://discord.gg/YRTyJd6Qx). 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/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.ts b/apps/docs/api-gen.plugin.ts new file mode 100644 index 0000000..8f72040 --- /dev/null +++ b/apps/docs/api-gen.plugin.ts @@ -0,0 +1,257 @@ +import { existsSync } from 'node:fs'; +import { join, relative } from 'node:path'; +import type { Plugin } from 'vite'; +import { 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(); + let project: Project | null = null; + let recordsMemo: SymbolRecord[] | null = null; + + function loadConfig(): ApiConfig | null { + const path = join(root, 'ngmd.api.ts'); + 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, + }); + for (const pattern of config.scope) { + project.addSourceFilesAtPaths(join(root, pattern)); + } + for (const pattern of config.exclude ?? []) { + const matches = project.getSourceFiles(pattern); + for (const f of matches) project.removeSourceFile(f); + } + return project; + } + + function extractRecords(config: ApiConfig): SymbolRecord[] { + if (recordsMemo) return recordsMemo; + const proj = ensureProject(config); + const records: SymbolRecord[] = []; + const badgeTags = new Set(config.badgesFromJsDoc ?? []); + + for (const sourceFile of proj.getSourceFiles()) { + const filePath = relative(root, sourceFile.getFilePath()); + const group = groupNameFor(filePath, config.groupBy ?? 'directory'); + + for (const [name, declarations] of sourceFile.getExportedDeclarations()) { + const first = declarations[0]; + if (!first) continue; + const kind = symbolKindOf(first); + if (!kind) continue; + // `VariableDeclaration` nodes don't expose `getJsDocs()`; the JSDoc + // is attached to the enclosing `VariableStatement`. Resolve the + // right host so `export const` symbols pick up their description + // and `@deprecated`/`@experimental`/`@beta` badges. + const jsDocHost = jsDocHostFor(first); + const jsDoc = ( + jsDocHost && 'getJsDocs' in jsDocHost + ? (jsDocHost as { getJsDocs: () => unknown[] }).getJsDocs() + : [] + ) as Array<{ + getDescription: () => string; + getTags: () => Array<{ getTagName: () => string }>; + }>; + const description = jsDoc[0]?.getDescription().trim() ?? ''; + const tags = jsDoc.flatMap((d) => d.getTags().map((t) => t.getTagName())); + const badges = tags.filter((t) => badgeTags.has(t)); + const signature = first.getText().split('\n')[0]?.trim() ?? name; + + records.push({ + kind, + name, + filePath, + line: first.getStartLineNumber(), + signature, + description, + badges, + group, + }); + } + } + + recordsMemo = records; + return records; + } + + return { + name: 'ngmd-api-gen', + configResolved(cfg) { + root = cfg.root; + project = null; + recordsMemo = null; + }, + 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`; + }, + handleHotUpdate({ file, server }) { + // Invalidate the project cache when any source under scope changes. + // Cheap because `Project` re-uses TypeScript's incremental machinery. + if (file.endsWith('.ts') || file.endsWith('ngmd.api.ts')) { + project = null; + recordsMemo = null; + // Clearing the memo isn't enough — Vite caches the virtual module's + // `load()` result, so invalidate it explicitly and return it so the + // client gets a fresh `apiIndex` without a full reload. + const mod = server.moduleGraph.getModuleById(RESOLVED_INDEX_ID); + if (mod) { + server.moduleGraph.invalidateModule(mod); + return [mod]; + } + } + return undefined; + }, + }; +} + +function symbolKindOf(decl: unknown): SymbolKind | null { + const kindGetter = (decl as { getKindName?: () => string }).getKindName; + const kindName = typeof kindGetter === 'function' ? kindGetter.call(decl) : ''; + switch (kindName) { + case 'ClassDeclaration': + return 'class'; + case 'InterfaceDeclaration': + return 'interface'; + case 'FunctionDeclaration': + return 'function'; + case 'VariableDeclaration': + return 'const'; + case 'TypeAliasDeclaration': + return 'type'; + case 'EnumDeclaration': + return 'enum'; + default: + 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: unknown): unknown { + const node = decl as { + getKindName?: () => string; + getFirstAncestorByKind?: (kind: ts.SyntaxKind) => unknown; + }; + if (typeof node.getKindName === 'function' && node.getKindName() === 'VariableDeclaration') { + return node.getFirstAncestorByKind?.(ts.SyntaxKind.VariableStatement) ?? node; + } + return node; +} + +function groupNameFor(filePath: string, strategy: NonNullable): string { + if (strategy === 'kind') return 'symbols'; + 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/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..9e52187 --- /dev/null +++ b/apps/docs/link-guard.plugin.ts @@ -0,0 +1,138 @@ +import { readFileSync, statSync } from 'node:fs'; +import { join, relative } from 'node:path'; +import type { Plugin } from 'vite'; +import { routeFromPagePath, slugify, walkContentFiles, walkPageFiles } 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 headingRe = /^#{1,6}\s+(.+?)\s*$/gm; + let m; + while ((m = headingRe.exec(markdown)) !== null) { + slugs.add(slugify(m[1])); + } + return slugs; +} + +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(); + let primed = false; + + 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 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; + }, + 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 (/^(https?:|mailto:|tel:|#)/.test(href) === false && !href.startsWith('/')) return; + if (/^(https?:|mailto:|tel:)/.test(href)) return; + + const [path, fragment] = href.split('#'); + if (path === '') { + // 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; + } + + // absolute route: must be a known route + if (!routes.has(path)) { + issues.push(` ${label} → "${path}" is not a known route`); + 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 mdLinkRe = /\[([^\]]+)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g; + const htmlAnchorRe = /]*href=["']([^"']+)["']/g; + let m: RegExpExecArray | null; + while ((m = mdLinkRe.exec(content)) !== null) { + validate(m[2], `[${m[1]}](${m[2]})`); + } + while ((m = htmlAnchorRe.exec(content)) !== null) { + validate(m[1], ``); + } + + if (issues.length > 0) { + this.error( + `[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.`, + ); + } + + 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..67f946b --- /dev/null +++ b/apps/docs/page-meta.plugin.ts @@ -0,0 +1,83 @@ +import { statSync } from 'node:fs'; +import { join } from 'node:path'; +import type { Plugin } 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 prefix = opts.dir ? `${opts.dir.replace(/\/+$/, '')}/` : ''; + let root = process.cwd(); + + return { + name: 'ngmd-page-meta', + configResolved(cfg) { + root = cfg.root; + }, + /** Invalidate the virtual module when any markdown file changes so + * `lastUpdated` reflows without a full restart. */ + handleHotUpdate(ctx) { + if (!ctx.file.endsWith('.md')) return; + const mod = ctx.server.moduleGraph.getModuleById(RESOLVED_ID); + if (mod) ctx.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 + 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}/${prefix}${rel}`, + lastUpdated: gitDate(rel, root), + }; + } + + // 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}/${prefix}${rel}`, + lastUpdated: date, + }; + } + } catch { + // src/content missing — skip + } + + return `export const pageMeta = ${JSON.stringify(map, null, 2)};`; + }, + }; +} diff --git a/apps/docs/plugin-utils.ts b/apps/docs/plugin-utils.ts new file mode 100644 index 0000000..dcb55c6 --- /dev/null +++ b/apps/docs/plugin-utils.ts @@ -0,0 +1,104 @@ +import { execFileSync } from 'node:child_process'; +import { readdirSync, statSync } from 'node:fs'; +import { join, relative } from 'node:path'; + +/** + * 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) + * + * The `slugify` rule matches the runtime TOC's heading-id algorithm 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$/, ''); + 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 trimmed = rel.replace(/^src\/app\/pages\//, '').replace(/\.page\.ts$/, ''); + if (trimmed === 'index') return '/'; + if (trimmed.startsWith('[')) return ''; + return '/' + trimmed; +} + +/** + * 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(); + } +} + +/** + * Heading slug. Matches the algorithm `toc.ts` uses at runtime to + * overwrite every rendered heading id, and the one `search-index.plugin.ts` + * uses to anchor search snippets, so all three stay in sync. + * + * Lowercase, collapse every run of non-alphanumeric characters (including + * `.`, `_`, `*`, spaces, etc.) into a single `-`, then trim outer hyphens. + */ +export function slugify(s: string): string { + return s + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, ''); +} diff --git a/apps/docs/public/apple-touch-icon.png b/apps/docs/public/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..91f22633de1fd857928761356be32bb43bacbb1a GIT binary patch literal 9326 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 literal 0 HcmV?d00001 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/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/og.png b/apps/docs/public/og.png new file mode 100644 index 0000000000000000000000000000000000000000..e637854828bfa61b78e7bc4228fd0cf9a3e80a36 GIT binary patch literal 79500 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 literal 0 HcmV?d00001 diff --git a/apps/docs/raw-md.plugin.ts b/apps/docs/raw-md.plugin.ts new file mode 100644 index 0000000..99089c8 --- /dev/null +++ b/apps/docs/raw-md.plugin.ts @@ -0,0 +1,100 @@ +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { extname, join, relative } from 'node:path'; +import type { Plugin } from 'vite'; +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; + const abs = join(root, 'src/content', `${route}.md`); + try { + const s = statSync(abs); + if (!s.isFile()) return null; + } catch { + return null; + } + try { + return substituteMdVars(readFileSync(abs, 'utf8'), root); + } catch { + 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'); + + const walk = (dir: string): string[] => { + const out: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) out.push(...walk(full)); + else if (entry.isFile() && entry.name.endsWith('.md')) out.push(full); + } + return out; + }; + + try { + statSync(contentDir); + } catch { + return; + } + for (const file of walk(contentDir)) { + const route = relative(contentDir, file).replace(/\\/g, '/'); + const body = substituteMdVars(readFileSync(file, 'utf8'), root); + this.emitFile({ + type: 'asset', + fileName: route, + source: body, + }); + } + }, + }; +} diff --git a/apps/docs/search-index.plugin.ts b/apps/docs/search-index.plugin.ts new file mode 100644 index 0000000..85c7a9f --- /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 } from 'vite'; +import type { IndexDoc, SearchHitKind } from './src/types/search.ts'; +import { slugify, 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; + +interface Frontmatter { + title?: string; + noIndex?: boolean; +} + +function parseFrontmatter(text: string): { fm: Frontmatter; body: string } { + const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/); + if (!match) return { fm: {}, body: text }; + const fm: Frontmatter = {}; + for (const line of match[1].split(/\r?\n/)) { + const m = line.match(/^\s*(\w+)\s*:\s*(.+?)\s*$/); + if (!m) continue; + const key = m[1]; + const raw = m[2].replace(/^['"]|['"]$/g, ''); + if (key === 'title') fm.title = raw; + if (key === 'noIndex') fm.noIndex = /^(true|yes|1)$/i.test(raw); + } + return { fm, body: match[2] }; +} + +const ENTITIES: Record = { + '<': '<', + '>': '>', + '&': '&', + '"': '"', + ''': "'", + ''': "'", + '@': '@', + ' ': ' ', +}; + +/** Strip markdown syntax so search hits show clean prose, not markup. */ +function stripMarkdown(s: string): string { + return s + .replace(/```[\s\S]*?```/g, ' ') + .replace(/`([^`]+)`/g, '$1') + .replace(/<[^>]+>/g, ' ') + .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') + .replace(/[*_#>]/g, '') + .replace(/&(?:lt|gt|amp|quot|apos|nbsp|#39|#64);/g, (m) => ENTITIES[m] ?? m) + .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: '' }; + for (const line of lines) { + const m = line.match(/^(##+)\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 < body.length ? end + 1 : end; + } + return chunks; +} + +export function searchIndexPlugin(): Plugin { + let root = process.cwd(); + + return { + name: 'ngmd-search-index', + configResolved(cfg) { + root = cfg.root; + }, + /** Markdown edits invalidate the virtual module so HMR rebuilds the + * index without a server restart. */ + handleHotUpdate(ctx) { + if (!ctx.file.endsWith('.md')) return; + const mod = ctx.server.moduleGraph.getModuleById(RESOLVED_ID); + if (mod) ctx.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 { fm, body } = parseFrontmatter(raw); + if (fm.noIndex) continue; + + const slug = url.split('/').pop() || ''; + const pageTitle = + fm.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. + for (const section of splitSections(body)) { + if (section.heading) { + const headingText = stripMarkdown(section.heading); + const anchor = slugify(headingText); + 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..7c8ca78 --- /dev/null +++ b/apps/docs/sitemap.plugin.ts @@ -0,0 +1,93 @@ +import { statSync } from 'node:fs'; +import { join } from 'node:path'; +import type { Plugin } from 'vite'; +import { gitDate, 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)) { + 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}${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..0da63b0 --- /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..58f631b --- /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..6ac92e2 --- /dev/null +++ b/apps/docs/src/app/app.spec.ts @@ -0,0 +1,20 @@ +import { TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; +import { provideLocationMocks } from '@angular/common/testing'; + +import { App } from './app'; + +describe('App', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [App], + providers: [provideRouter([]), provideLocationMocks()], + }).compileComponents(); + }); + + it('should create the app', () => { + const fixture = TestBed.createComponent(App); + const app = fixture.componentInstance; + expect(app).toBeTruthy(); + }); +}); diff --git a/apps/docs/src/app/app.ts b/apps/docs/src/app/app.ts new file mode 100644 index 0000000..8a6b2a2 --- /dev/null +++ b/apps/docs/src/app/app.ts @@ -0,0 +1,292 @@ +import { Component, DestroyRef, computed, inject, OnInit, signal } from '@angular/core'; +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'; + +@Component({ + selector: 'app-root', + imports: [ + RouterLink, + RouterOutlet, + LucideDynamicIcon, + GithubIcon, + DiscordIcon, + CommandPalette, + Sidebar, + Breadcrumb, + Toc, + CodeCopy, + ExternalLinks, + HeadingAnchors, + CodeGroup, + MediaEnhancer, + PageFooter, + SourceActions, + SiteFooter, + Toaster, + VersionSwitcher, + ], + template: ` +

+
+ @if (showSidebar()) { + + } + + + + {{ siteName }} + + + + +
+ + + + + + + + @if (discordUrl) { + + + + } + + +
+
+ +
+ @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); + + 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 drawerOpen = signal(false); + + 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; + + 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.ts b/apps/docs/src/app/components/breadcrumb.ts new file mode 100644 index 0000000..ec63a50 --- /dev/null +++ b/apps/docs/src/app/components/breadcrumb.ts @@ -0,0 +1,66 @@ +import { Component, computed, inject } from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { LucideDynamicIcon, LucideChevronRight, LucideHouse } from '@lucide/angular'; +import { RouteUrlService } from '../services/route-url/route-url.service'; + +interface Crumb { + label: string; + href: string; +} + +const LABELS: Record = { + '': 'Home', + welcome: 'Introduction', + 'getting-started': 'Getting Started', + installation: 'Installation', + 'quick-start': 'Quick Start', + introduction: 'Introduction', + about: 'About & Credits', + concepts: 'Core Concepts', + 'markdown-routes': 'Markdown Routes', + theming: 'Theming', + components: 'Components', +}; + +@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) => ({ + label: LABELS[segment] ?? this.humanize(segment), + href: '/' + segments.slice(0, i + 1).join('/'), + })); + }); + + 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..e4bf7ef --- /dev/null +++ b/apps/docs/src/app/components/code-copy.ts @@ -0,0 +1,78 @@ +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';
+
+    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 focus:opacity-100';
+    button.innerHTML = `
+      
+        
+        
+      
+    `;
+
+    pre.addEventListener('mouseenter', () => (button.style.opacity = '1'));
+    pre.addEventListener('mouseleave', () => (button.style.opacity = '0'));
+
+    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;
+      }
+      button.innerHTML = `
+        
+          
+        
+      `;
+      setTimeout(() => {
+        button.innerHTML = `
+          
+            
+            
+          
+        `;
+      }, 1500);
+    });
+
+    pre.appendChild(button);
+  }
+}
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..7c2ea92
--- /dev/null
+++ b/apps/docs/src/app/components/code-group.ts
@@ -0,0 +1,51 @@
+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'); + const panels = group.querySelectorAll('.ngmd-code-group__panel'); + + tabs.forEach((tab) => { + tab.addEventListener('click', () => { + const target = tab.getAttribute('data-target'); + if (!target) return; + tabs.forEach((t) => t.setAttribute('data-active', t === tab ? 'true' : 'false')); + panels.forEach((p) => + p.setAttribute('data-active', p.getAttribute('data-id') === target ? 'true' : 'false'), + ); + }); + }); + } +} 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..af6ba70 --- /dev/null +++ b/apps/docs/src/app/components/command-palette.ts @@ -0,0 +1,419 @@ +import { + Component, + ElementRef, + HostListener, + computed, + effect, + inject, + signal, + viewChild, +} from '@angular/core'; +import { NgTemplateOutlet } from '@angular/common'; +import { Router } from '@angular/router'; +import { + LucideDynamicIcon, + LucideArrowRight, + LucideClock, + LucideFileText, + LucideHash, + LucideSearch, + LucideStar, + LucideTrash, + LucideX, +} from '@lucide/angular'; +import type { SearchHit } from '../../types/search'; +import { SearchService } 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. Typing kicks the + * service (debounced) and renders highlighted hits. Hover highlights a + * row, click navigates and records the visit. Esc closes. Keyboard + * navigation (arrow + Enter) is intentionally not wired yet — planned + * for a future polish pass. + */ +@Component({ + selector: 'app-command-palette', + imports: [LucideDynamicIcon, NgTemplateOutlet], + template: ` + @if (open()) { +
+
+
+ + + @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) { + + } + } @else if (!search.query().trim() && !search.history().length) { +
+ Start typing to see results +
+ } +
+ +
+ + esc + close + + @if (search.isAlgolia) { + + + Search by + + + + + + + + + + + + + } @else { + + + Search by + + orama + + } +
+
+
+ } + `, +}) +export class CommandPalette { + private readonly router = inject(Router); + protected readonly search = inject(SearchService); + private readonly input = viewChild>('input'); + + readonly searchIcon = LucideSearch; + readonly arrowIcon = LucideArrowRight; + readonly hashIcon = LucideHash; + readonly fileIcon = LucideFileText; + readonly clockIcon = LucideClock; + readonly trashIcon = LucideTrash; + readonly starIcon = LucideStar; + readonly closeIcon = LucideX; + + readonly open = signal(false); + /** Mouse-hover highlight only. Arrow-key keyboard nav is intentionally + * not wired yet; the focus/scroll polish wasn't worth shipping rough. */ + 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); + + readonly showingHistory = computed( + () => !this.search.query().trim() && this.search.history().length > 0, + ); + + constructor() { + effect(() => { + if (typeof document === 'undefined') return; + document.body.style.overflow = this.open() ? 'hidden' : ''; + }); + // Clear hover highlight whenever the visible list changes. + effect(() => { + this.search.results(); + this.search.history(); + this.active.set(-1); + }); + // 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; + this.open.set(true); + this.active.set(-1); + queueMicrotask(() => this.input()?.nativeElement.focus()); + }); + } + + iconFor(item: SearchHit) { + if (item.kind === 'section') return this.hashIcon; + if (item.kind === 'snippet') return this.fileIcon; + return this.arrowIcon; + } + + @HostListener('document:keydown', ['$event']) + 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() { + this.open.update((v) => !v); + if (this.open()) { + this.search.query.set(''); + this.active.set(-1); + queueMicrotask(() => this.input()?.nativeElement.focus()); + } + } + + close() { + this.open.set(false); + } + + onInput(event: Event) { + this.search.query.set((event.target as HTMLInputElement).value); + } + + select(hit: SearchHit) { + this.search.recordVisit(hit); + this.navigateTo(hit.url); + } + + selectHistory(item: { id: string; url: string; labelHtml: string; subLabelHtml: string }) { + // 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); + } + + /** 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); + } + + private navigateTo(url: string): void { + this.close(); + const [path, hash] = url.split('#'); + const samePath = this.router.url.split('#')[0].split('?')[0] === path; + if (samePath) { + // Already on the target route. Skip the router round-trip and just + // scroll, otherwise Angular short-circuits and nothing happens. + if (hash) this.scrollToWhenReady(hash); + else window.scrollTo({ top: 0, behavior: 'smooth' }); + return; + } + this.router.navigateByUrl(path).then(() => { + if (hash) this.scrollToWhenReady(hash); + }); + } + + private scrollToWhenReady(slug: string, attempt = 0): void { + if (typeof document === 'undefined' || attempt > 30) return; + const el = document.getElementById(slug); + if (!el) { + setTimeout(() => this.scrollToWhenReady(slug, attempt + 1), 50); + return; + } + el.scrollIntoView({ behavior: 'smooth', block: 'start' }); + history.replaceState(null, '', `${location.pathname}#${slug}`); + } +} 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..4a7a927 --- /dev/null +++ b/apps/docs/src/app/components/content-banners.ts @@ -0,0 +1,101 @@ +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..bad610a --- /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.href.startsWith(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..26080b6 --- /dev/null +++ b/apps/docs/src/app/components/heading-anchors.ts @@ -0,0 +1,80 @@ +import { AfterViewInit, Component, DestroyRef, inject } from '@angular/core'; +import { Router } from '@angular/router'; +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); + + 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'; + + const button = document.createElement('button'); + button.type = 'button'; + button.setAttribute('aria-label', `Copy link to ${heading.id}`); + button.className = + 'ml-2 inline-flex items-center justify-center size-5 align-middle relative -top-[2px] rounded text-zinc-400 hover:text-[color:var(--accent)] opacity-0 transition-opacity focus:opacity-100'; + button.innerHTML = this.linkIcon(); + + heading.addEventListener('mouseenter', () => (button.style.opacity = '1')); + heading.addEventListener('mouseleave', () => (button.style.opacity = '0')); + + // 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'; + + 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))) return; + button.innerHTML = this.checkIcon(); + 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..e9a5ed2 --- /dev/null +++ b/apps/docs/src/app/components/llm-actions.ts @@ -0,0 +1,258 @@ +import { Component, DestroyRef, HostListener, computed, inject, signal } from '@angular/core'; +import { + LucideDynamicIcon, + type LucideIcon, + LucideCheck, + LucideChevronDown, + LucideCopy, + LucideLink, + LucideMessageSquare, + LucideSparkles, +} 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'; + +interface MenuItem { + label: string; + icon: LucideIcon | 'github'; + /** Either a click handler or a target URL — drives the ` + +
+ @if (open()) { + + } + + } + `, +}) +export class LlmActions { + private readonly toast = inject(ToastService); + private readonly cleanUrl = inject(RouteUrlService).cleanUrl; + private copiedTimer: ReturnType | null = null; + + constructor() { + // Belt-and-braces: if the user navigates away mid-flash, kill the + // pending setTimeout so we don't tick a signal on a destroyed component. + inject(DestroyRef).onDestroy(() => this.clearCopiedTimer()); + } + + private clearCopiedTimer(): void { + if (this.copiedTimer != null) { + clearTimeout(this.copiedTimer); + this.copiedTimer = null; + } + } + + readonly copyIcon = LucideCopy; + readonly checkIcon = LucideCheck; + readonly chevronIcon = LucideChevronDown; + readonly linkIcon = LucideLink; + readonly chatGptIcon = LucideSparkles; + readonly claudeIcon = LucideMessageSquare; + + readonly open = signal(false); + readonly copied = signal(false); + + protected readonly editUrl = computed(() => pageMeta[this.cleanUrl()]?.editUrl ?? ''); + + /** True only for routes whose source is a `.md` file under `src/content/`. + * Routes backed by `.page.ts` (the home `index.page.ts`, the catch-all, + * the components reference page) don't have a corresponding raw markdown + * source, so the dropdown hides itself to avoid leading the user to a + * dead `.md` URL. */ + protected readonly hasMdSource = computed(() => { + const edit = this.editUrl(); + return !!edit && /\/src\/content\/.+\.md$/.test(edit); + }); + + /** Permalink to the raw `.md`. Built from the current pathname + `.md`, + * served by `raw-md.plugin.ts` in dev and emitted as a static asset in + * production. Absolute (with origin) so LLM URLs are shareable. */ + protected readonly mdUrl = computed(() => { + const path = this.cleanUrl(); + if (typeof window === 'undefined') return `${path}.md`; + return `${window.location.origin}${path}.md`; + }); + + private prompt(): string { + return `Please read this ${siteConfig.site.name} documentation page and help me with it: ${this.mdUrl()}`; + } + + protected readonly items = computed(() => [ + { label: 'Copy Markdown Link', icon: this.linkIcon, handler: () => this.copyLinkAction() }, + { label: 'Open in GitHub', icon: 'github', href: this.editUrl() }, + { + label: 'Open in ChatGPT', + icon: this.chatGptIcon, + href: `https://chatgpt.com/?q=${encodeURIComponent(this.prompt())}`, + }, + { + label: 'Open in Claude', + icon: this.claudeIcon, + href: `https://claude.ai/new?q=${encodeURIComponent(this.prompt())}`, + }, + ]); + + toggle(event: Event): void { + event.stopPropagation(); + this.open.update((v) => !v); + } + + /** Main split-button action: copies the markdown directly and flashes a + * 1.5s "Copied!" confirmation in place of the label. Only flashes when + * the underlying fetch + clipboard write succeed. Also closes the + * dropdown if it happened to be open — matches the behaviour of items + * inside the menu. */ + async copyMarkdownAction(event: Event): Promise { + event.stopPropagation(); + this.close(); + const ok = await this.copyMarkdown(); + if (!ok) { + this.toast.error('Could not copy markdown.'); + return; + } + this.copied.set(true); + this.clearCopiedTimer(); + this.copiedTimer = setTimeout(() => this.copied.set(false), 1500); + } + + close(): void { + this.open.set(false); + } + + async runAndClose(fn: () => void | Promise): Promise { + try { + await fn(); + } finally { + this.close(); + } + } + + @HostListener('document:click') onDocClick(): void { + if (this.open()) this.close(); + } + + @HostListener('document:keydown.escape') onEsc(): void { + if (this.open()) this.close(); + } + + private async copyMarkdown(): Promise { + if (typeof window === 'undefined') return false; + try { + const res = await fetch(this.mdUrl()); + if (!res.ok) return false; + return writeToClipboard(await res.text()); + } catch { + return false; + } + } + + private async copyLink(): Promise { + return writeToClipboard(this.mdUrl()); + } + + private async copyLinkAction(): Promise { + const ok = await this.copyLink(); + if (ok) this.toast.success('Link copied to clipboard.'); + else this.toast.error('Could not copy link.'); + } +} diff --git a/apps/docs/src/app/components/media-enhancer.ts b/apps/docs/src/app/components/media-enhancer.ts new file mode 100644 index 0000000..6da000d --- /dev/null +++ b/apps/docs/src/app/components/media-enhancer.ts @@ -0,0 +1,79 @@ +import { AfterViewInit, Component, DestroyRef, inject } from '@angular/core'; +import { Router } from '@angular/router'; +import { enhanceOnNavigation } from '../utils/enhance-on-navigation'; + +/** + * Hydrates the placeholder divs emitted by the ngmd-video and ngmd-image + * marked extensions. The marked extensions output plain `
` + * elements; this enhancer creates the real +
+ `, +}) +export class NgmdVideo { + private readonly sanitizer = inject(DomSanitizer); + + readonly src = input.required(); + readonly title = input('Video player'); + + readonly safeUrl = computed(() => this.sanitizer.bypassSecurityTrustResourceUrl(this.embedUrl())); + + private readonly embedUrl = computed(() => { + const src = this.src(); + if (src.startsWith('https://www.youtube.com/embed/')) return src; + const yt = src.match(/youtube\.com\/watch\?v=([\w-]+)/); + if (yt) return `https://www.youtube.com/embed/${yt[1]}`; + const ytShort = src.match(/youtu\.be\/([\w-]+)/); + if (ytShort) return `https://www.youtube.com/embed/${ytShort[1]}`; + const vm = src.match(/vimeo\.com\/(\d+)/); + if (vm) return `https://player.vimeo.com/video/${vm[1]}`; + return src; + }); +} diff --git a/apps/docs/src/app/ui/workflow.ts b/apps/docs/src/app/ui/workflow.ts new file mode 100644 index 0000000..62d2613 --- /dev/null +++ b/apps/docs/src/app/ui/workflow.ts @@ -0,0 +1,93 @@ +import { + AfterContentInit, + AfterViewInit, + Component, + ContentChildren, + DestroyRef, + ElementRef, + inject, + input, + QueryList, + signal, +} from '@angular/core'; +import { watchHostAttribute } from '../utils/watch-host-attribute'; + +@Component({ + selector: 'ngmd-step', + template: ` +
+
+ {{ index() + 1 }} +
+
+ @if (title()) { +

+ {{ title() }} +

+ } +
+ +
+
+
+ `, +}) +export class NgmdStep { + readonly title = input(''); + readonly index = signal(0); + + constructor() { + // When this step is used inside a `` rendered from a + // markdown body, the workflow can't see this step via `ContentChildren` + // because each `` is its own Custom Element host. The + // workflow instead sets a `data-step-index` attribute on each child + // element; we read it here on setup and on every later change. + // Component-pages (where ContentChildren works) still call `index.set(i)` + // directly; the attribute path is a no-op for them. + const host = inject>(ElementRef).nativeElement; + const stop = watchHostAttribute(host, 'data-step-index', (value) => { + if (value === null) return; + const n = parseInt(value, 10); + if (!Number.isNaN(n)) this.index.set(n); + }); + inject(DestroyRef).onDestroy(stop); + } +} + +@Component({ + selector: 'ngmd-workflow', + template: ` +
+ +
+ `, +}) +export class NgmdWorkflow implements AfterContentInit, AfterViewInit { + @ContentChildren(NgmdStep) private readonly steps!: QueryList; + private readonly host: ElementRef = inject(ElementRef); + + ngAfterContentInit(): void { + // Component-pages path: ContentChildren finds Angular instances + // directly because the projected children are real Angular components + // (no Custom Element boundary in the way). + this.steps.forEach((step, i) => step.index.set(i)); + } + + ngAfterViewInit(): void { + // Markdown path: child `` elements are Custom Elements that + // ContentChildren can't see through. Walk the DOM and set + // `data-step-index="N"` on each one; the step's MutationObserver picks + // the new value up and updates its signal. Safe to run in both contexts: + // for component pages this is a redundant attribute set that the step + // ignores (its signal is already the right value). + if (typeof document === 'undefined') return; + const els = this.host.nativeElement.querySelectorAll('ngmd-step'); + els.forEach((el, i) => el.setAttribute('data-step-index', String(i))); + } +} diff --git a/apps/docs/src/app/utils/clipboard.ts b/apps/docs/src/app/utils/clipboard.ts new file mode 100644 index 0000000..6f62ad2 --- /dev/null +++ b/apps/docs/src/app/utils/clipboard.ts @@ -0,0 +1,18 @@ +/** + * Shared clipboard helper. Wraps `navigator.clipboard.writeText` with an + * SSR-safety check and a try/catch so callers get a simple `Promise` + * instead of repeating the same five lines at every copy site. + * + * `true` means the text reached the OS clipboard. `false` covers SSR + * (no `navigator`), permission denials, and any browser-side write failure. + * The caller decides what UX to fire (toast, inline flash, silent retry). + */ +export async function writeToClipboard(text: string): Promise { + if (typeof navigator === 'undefined' || !navigator.clipboard) return false; + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + return false; + } +} diff --git a/apps/docs/src/app/utils/enhance-on-navigation.ts b/apps/docs/src/app/utils/enhance-on-navigation.ts new file mode 100644 index 0000000..d3a8ddc --- /dev/null +++ b/apps/docs/src/app/utils/enhance-on-navigation.ts @@ -0,0 +1,64 @@ +import { DestroyRef } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { NavigationEnd, Router } from '@angular/router'; +import { filter } from 'rxjs'; + +/** + * Run a DOM-enhancing function on initial mount and after every router + * navigation, retrying for a short window if the target nodes haven't + * upgraded yet. + * + * Five components shared this exact skeleton (`code-copy`, `code-group`, + * `external-links`, `heading-anchors`, `media-enhancer`): each waited for + * Angular + `analog-markdown` to flush, then walked `document.querySelectorAll` + * for an unprocessed selector and decorated each match. + * + * @param router Inject `Router`. + * @param destroyRef Inject `DestroyRef`. Unsubscribes the router watcher + * when the host component is destroyed. + * @param selector CSS selector for "unprocessed" elements. Marked nodes + * should set their own data attribute so they don't + * match the selector on subsequent runs. + * @param enhanceEach Decorator fn called once per match. + * @param opts.maxAttempts Retry budget; default 20. + * @param opts.delayMs Inter-attempt delay; default 50ms. + */ +export function enhanceOnNavigation( + router: Router, + destroyRef: DestroyRef, + selector: string, + enhanceEach: (el: HTMLElement) => void, + opts: { maxAttempts?: number; delayMs?: number } = {}, +): void { + const maxAttempts = opts.maxAttempts ?? 20; + const delayMs = opts.delayMs ?? 50; + + const run = (attempt = 0): void => { + if (typeof document === 'undefined' || attempt > maxAttempts) return; + const nodes = document.querySelectorAll(selector); + if (nodes.length === 0) { + setTimeout(() => run(attempt + 1), delayMs); + return; + } + nodes.forEach(enhanceEach); + }; + + run(); + onNavigation(router, destroyRef, () => run()); +} + +/** + * Subscribe `fn` to every `NavigationEnd`. Tied to `destroyRef` so the + * subscription is dropped when the host is destroyed. Two-line wrap to + * keep the `filter`-and-typeguard idiom in one place; the TOC and the + * root `App` use it directly (their per-navigation work doesn't fit the + * DOM-walker shape `enhanceOnNavigation` is built around). + */ +export function onNavigation(router: Router, destroyRef: DestroyRef, fn: () => void): void { + router.events + .pipe( + filter((e): e is NavigationEnd => e instanceof NavigationEnd), + takeUntilDestroyed(destroyRef), + ) + .subscribe(fn); +} diff --git a/apps/docs/src/app/utils/watch-host-attribute.ts b/apps/docs/src/app/utils/watch-host-attribute.ts new file mode 100644 index 0000000..c578b7d --- /dev/null +++ b/apps/docs/src/app/utils/watch-host-attribute.ts @@ -0,0 +1,27 @@ +/** + * Watch a single `data-*` (or any) attribute on a host element via + * `MutationObserver`. Calls `onChange` with the attribute's current value + * synchronously on setup, then again on every change. Returns a teardown + * fn — wire it into `DestroyRef.onDestroy(...)`. + * + * Two Custom-Element-wrapped UI components (`NgmdStep`, `NgmdTab`) need + * this pattern. Their parent renders inside markdown via + * `@angular/elements`, so it can't reach into the child via Angular's + * `ContentChildren`; it sets a `data-*` attribute instead and the child + * picks the value up here. + * + * SSR-safe: when `MutationObserver` is missing (server) the function + * still calls `onChange` once with whatever the element's initial value + * is, then returns a noop teardown. + */ +export function watchHostAttribute( + host: HTMLElement, + attribute: string, + onChange: (value: string | null) => void, +): () => void { + onChange(host.getAttribute(attribute)); + if (typeof MutationObserver === 'undefined') return () => {}; + const observer = new MutationObserver(() => onChange(host.getAttribute(attribute))); + observer.observe(host, { attributes: true, attributeFilter: [attribute] }); + return () => observer.disconnect(); +} diff --git a/apps/docs/src/content/agents/mcp-server.md b/apps/docs/src/content/agents/mcp-server.md new file mode 100644 index 0000000..d00b4fc --- /dev/null +++ b/apps/docs/src/content/agents/mcp-server.md @@ -0,0 +1,91 @@ +--- +title: MCP server +description: Connect a coding agent to the devtools over stdio or HTTP. +--- + +# MCP server + +The devtools expose their inspectors to coding agents as *MCP tools and resources. An agent can list your routes, read the live component tree, explain why a form is invalid, or navigate the app. + +There are two ways to connect. Pick one based on the data your agent needs. + +| Transport | Live page data | Setup | +| --------------------------- | ------------------------------------ | ------------------------------------ | +| stdio (`ng-devtools mcp`) | No. Source scan tools only. | A command in your MCP client config. | +| HTTP (`/__devframes/__mcp`) | Yes, with the app open in a browser. | A URL on your app's dev server. | + +## stdio + +The `mcp` command starts a server over stdio in the current directory. It scans your source, but no page ever connects to it, so tools that need the running app answer that no page is attached. + +**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"] + } + } +} +``` + +## HTTP + +When the devtools are embedded in your app's server, the MCP endpoint is also available over HTTP at `/__devframes/__mcp` (or `/__ng-devtools/__mcp` without the hub). This endpoint sees the pages that are connected to that server, so the live tools work. Open the app in a browser with the [overlay](/getting-started/overlay) loaded, then call the tools. + +| Setup | Endpoint | +| --------------------------------------- | ----------------------------------------- | +| [Express hub](/getting-started/express) | `http://localhost:4000/__devframes/__mcp` | +| [Vite plugin](/getting-started/vite) | `http://localhost:5173/__devframes/__mcp` | +| [Standalone CLI](/getting-started/cli) | `http://localhost:9999/__mcp` | + +Use the port your server actually runs on. + + + The HTTP endpoint only answers requests from this machine that carry a local Origin header, such as http://localhost:4000. Requests without one get 403 Forbidden. If your MCP client does not send an Origin header, add it in the client config. + + +For example, in `.vscode/mcp.json`: + +```json +{ + "servers": { + "ng-devtools": { + "type": "http", + "url": "http://localhost:4000/__devframes/__mcp", + "headers": { "Origin": "http://localhost:4000" } + } + } +} +``` + +## Tool names + +Tools are registered with a colon, as `ng-devtools:get-routes`. MCP clients see these with an underscore, as `ng-devtools_get-routes`. Calls with either form work. + +Read-only tools are marked read-only for your client. Tools that act on the app (`highlight`, `navigate`, `form-action`, `fill-form` and `analog-call-api`) are not, so your client can ask before it runs them. + +## Pages + +Each browser tab reports on its own and gets a page id. Tools that read live data use the most recent page by default. Pass `page` (or `pageId`) to pick another tab. Pages that stop reporting are dropped after a short time. + +## Next steps + +- [Tools](/agents/tools): every tool with its inputs. +- [Resources](/agents/resources): live state an agent can read. +- [Security](/security): what leaves the page and what is redacted. diff --git a/apps/docs/src/content/agents/resources.md b/apps/docs/src/content/agents/resources.md new file mode 100644 index 0000000..11b923b --- /dev/null +++ b/apps/docs/src/content/agents/resources.md @@ -0,0 +1,39 @@ +--- +title: Resources +description: Live state an agent can read as MCP resources. +--- + +# Resources + +Resources hold the live data the connected pages reported. They are empty when no page is connected, so read them through the [HTTP endpoint](/agents/mcp-server#http) with the app open in a browser. + +| 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 | + +Clients see each resource at a `devframe://resource/` URI with the id encoded, for example `devframe://resource/ng-devtools%3Acomponent-tree`. Each one returns JSON. + +## What each resource holds + +- **component-tree**: the component instances of each page (instance id, class name, host tag, directives on the host), and the details of the instance selected in the panel. +- **signal-graph**: the signal graph of each page, with nodes, producer to consumer edges, the component it belongs to, and recent value history per node. +- **injector-tree**: the injector hierarchy the page reported, with providers at each level. +- **ngrx-store**: each `@ngrx/signals` store (state, computed values, methods, the component fields that reference it) and the `@ngrx/store` state, plus the change log with a state diff per entry. +- **forms**: every form with each field's value, status, touched, dirty and errors, plus recent changes. When the data is too large, the resource returns a summary and points to `inspect-forms`. +- **router**: the active route tree and recent navigations of each page. When the data is too large, the resource returns a summary and points to `inspect-route`, `explain-navigation` and `list-routes`. + +## Shared state + +Every shared-state key is also listed as a resource at `devframe://state/`, and the `devframe_state_read` tool reads the same keys. This covers data without its own resource: + +| Key | Content | +| ------------------------ | ------------------------------------------------------------------------------ | +| `ng-devtools:http` | The SSR & HTTP timeline, fault rules, hydration data and TransferState payload | +| `ng-devtools:pipe-usage` | Live pipe instances and recorded calls | +| `ng-devtools:analog` | Analog page data and the server call log | +| `ng-devtools:routes` | Routes from the source scan | diff --git a/apps/docs/src/content/agents/tools.md b/apps/docs/src/content/agents/tools.md new file mode 100644 index 0000000..ebc09c7 --- /dev/null +++ b/apps/docs/src/content/agents/tools.md @@ -0,0 +1,99 @@ +--- +title: Tools +description: Every agent tool the devtools expose, with its inputs. +--- + +# Tools + +MCP clients see these with an underscore, as `ng-devtools_get-routes`. Tools marked **live** need a connected page, which means the [HTTP endpoint](/agents/mcp-server#http) with the app open in a browser. The others read your source and also work over stdio. + +Most live tools take an optional `page` input to pick a browser tab. It defaults to the most recent one. + +## Source scan + +These tools take no inputs. + +| Tool | Description | +| ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `ng-devtools:get-routes` | Angular routes from your route files, with full URL path, kind (page, group, redirect or wildcard), guards, resolvers, and file and line. In Analog apps, the file routes. | +| `ng-devtools:get-components` | Components and directives from `@Component` and `@Directive` classes, with selector, kind, inputs, outputs, and file and line. | +| `ng-devtools:get-signals` | `signal()`, `computed()`, `linkedSignal()`, `effect()`, `toSignal()` and resource declarations, plus signal inputs, models and queries. | +| `ng-devtools:get-providers` | DI providers: `@Injectable` services, `inject()` calls and `providers` arrays. | +| `ng-devtools:get-ngrx-store` | NgRx declarations: actions, reducers, effects, selectors, features, store setup, `signalStore` (with its members), `signalState` and `signalMethod`. | +| `ng-devtools:get-pipes` | Custom `@Pipe` classes, and built-in pipes in use in templates, with purity and standalone status. | +| `ng-devtools:build-meta` | Angular and TypeScript versions, the project name and SSR status. | + +## Components, signals and DI + +| Tool | Live | Inputs | Description | +| ------------------------------- | ---- | ------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------- | +| `ng-devtools:highlight` | yes | `selector` (required): instance id, class name, host tag or CSS selector | Highlights a component in the page and makes it the target of `inspect-signals`. | +| `ng-devtools:inspect-signals` | yes | `selector` (required) | The signal graph the page reported, with dependency edges and recent value history per node. | +| `ng-devtools:inspect-providers` | yes | `selector`, `pageId` | The injector hierarchy a page reported, with the providers at each level. | + +## Router + +| Tool | Live | Inputs | Description | +| --------------------------------- | ------ | --------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `ng-devtools:inspect-route` | yes | `selector` | The current route: URL, params, data, guards, resolvers, the navigation in flight and the outlet tree. With `selector`, the route a component was rendered for, or whether a link is active. | +| `ng-devtools:explain-navigation` | yes | `url`, `id`, `limit` (1 to 50, default 5), `perf` | Recent navigations and why each succeeded or not, with timing, redirects and guard verdicts. `perf` lists the slowest navigations and preloads. | +| `ng-devtools:list-routes` | yes | `match`, `audit`, `filter` | The live route 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` | yes | | Route config mistakes, each with a fix. | +| `ng-devtools:router-config` | yes | | Router options, features and strategies in effect. | +| `ng-devtools:export-navigation` | yes | `id` | A markdown repro of a navigation. Defaults to the latest one that did not succeed. | +| `ng-devtools:explain-render-mode` | partly | `url` | The `ServerRoute` and render mode for a URL, read from `*.routes.server.ts`. | +| `ng-devtools:navigate` | yes | `action` (required), `url`, `pattern`, `params`, `id`, `on`, `routeId`, `replaceUrl`, `skipLocationChange`, `waitFor` | Acts on the router, in development only. Actions: `navigate`, `abort`, `replay`, `probe`, `instrument` and `resolve-lazy`. Only same-origin relative URLs are accepted. | + +## Forms + +`form` is a form id (like `Checkout.form@ab12`) or part of its label. `path` is a dotted field path, like `address.city` or `items.0.qty`. + +| Tool | Inputs | Description | +| ------------------------------------ | ----------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | +| `ng-devtools:inspect-forms` | `form`, `path`, `onlyInvalid`, `includeValues` | Forms on the page with every field's state and errors. | +| `ng-devtools:explain-form-invalid` | `form` | Which fields make a form invalid, and why. Without `form`, every invalid or pending form. | +| `ng-devtools:explain-field` | `form`, `path`, `selector` | One field: error sources, skip reasons, binding and source. `selector` starts from a CSS selector instead. | +| `ng-devtools:explain-submit` | `form` | What submit will do, and why it might do nothing. | +| `ng-devtools:form-payload` | `form` | What the form sends: value vs raw value, and unvalidated fields. | +| `ng-devtools:form-history` | `form`, `path`, `type`, `origin`, `since`, `limit` | Change timeline with origin (user, code, devtools). Returns the current marker. | +| `ng-devtools:form-diff` | `form`, `since` | Net change since a marker. | +| `ng-devtools:lint-forms` | `form` | Form bugs and model-aware accessibility checks. | +| `ng-devtools:explain-custom-control` | `form`, `path` | How a field is bound, and what is wrong with the binding. | +| `ng-devtools:export-form` | `form`, `format` (`snapshot` or `fixture`) | JSON snapshot or test fixture. | +| `ng-devtools:wait-for-form` | `form`, `until` (`settled`, `valid`, `not-pending` or `submitted`), `since`, `timeoutMs` | Waits until the condition holds, or reports the state on timeout. | +| `ng-devtools:form-action` | `action` (required), `form` (required), `path`, `value`, `mode`, `confirm`, `force`, `snapshot` | Set, touch, revalidate, reset, submit, focus, snapshot, restore and more. | +| `ng-devtools:fill-form` | `form` (required), `values` (required), `mode`, `submit`, `confirm` | Fills several fields through the inputs, like a user would. | + +All forms tools are live. `form-action` needs a development build. `reset`, `submit` and `restore` need `confirm: true`, and so does `fill-form` with `submit`. Secret, hidden and readonly fields are never written. + +## Pipes + +| Tool | Live | Inputs | Description | +| -------------------------- | ------ | ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | +| `ng-devtools:lint-pipes` | no | | Impure pipes inside `@for`, `\| json` left in templates, and pure pipes that read signals. | +| `ng-devtools:explain-pipe` | partly | `name` (required) | One pipe: where it is declared or used, purity, live counts, last input and output (when recording), a stale-value warning and lint findings. | + +## Analog + +| Tool | Live | Inputs | Description | +| ----------------------------------- | ------ | ---------------------------------------------- | ------------------------------------------------------------------------------------------------- | +| `ng-devtools:analog-routes` | no | `filter` | Analog file routes with their page, layout and server files. | +| `ng-devtools:analog-explain-url` | no | `url` (required) | Which Analog files render a URL, or why nothing matches. | +| `ng-devtools:analog-current-page` | yes | | The open page's files, `load()` data and hydration state. | +| `ng-devtools:analog-server-calls` | yes | `kind`, `route`, `limit` | Page renders, `load()`, server function and API calls. | +| `ng-devtools:analog-api-routes` | no | | Server routes with method, URL and file. | +| `ng-devtools:analog-call-api` | yes | `path` (required), `method`, `body`, `confirm` | Sends a request to a server route. Methods other than GET, HEAD and OPTIONS need `confirm: true`. | +| `ng-devtools:analog-render-modes` | partly | | SSR, prerendered or client only, per page. | +| `ng-devtools:analog-prerender-plan` | no | | `prerender.routes` compared with pages and build output. | +| `ng-devtools:analog-content` | no | `filter` | Markdown content with slug and frontmatter. | +| `ng-devtools:analog-lint` | partly | | Analog routing, server, prerender and content mistakes. | + +`analog-server-calls` and `analog-call-api` need the [Vite plugin](/getting-started/vite), because the plugin records calls and knows the dev server address. + +## Shared state + +| Tool | Inputs | Description | +| --------------------- | ------ | --------------------------------------------------------------------------------------------------------------------- | +| `devframe_state_read` | `key` | Reads the devtools' live shared state. Call it without arguments to list the keys, then with a key to read its value. | + +This is the way to read data that has no dedicated tool, such as the SSR & HTTP timeline (`ng-devtools:http`) or live pipe usage (`ng-devtools:pipe-usage`). See [Resources](/agents/resources). diff --git a/apps/docs/src/content/community.md b/apps/docs/src/content/community.md new file mode 100644 index 0000000..94ffad2 --- /dev/null +++ b/apps/docs/src/content/community.md @@ -0,0 +1,18 @@ +--- +title: Get involved +description: Where to ask questions, report bugs and support the project. +--- + +# Get involved + +## Discord + +Join the conversation, ask questions, and share feedback on [Discord](https://discord.gg/YRTyJd6Qx). + +## Issues and pull requests + +Report bugs and request features in [GitHub issues](https://github.com/santoshyadavdev/angular-devtools/issues). To contribute code, start with the [development setup](/contributing/development). + +## Sponsor + +If the devtools help your work, please consider [sponsoring the project on GitHub](https://github.com/sponsors/santoshyadavdev). See the current [sponsors](/sponsors). diff --git a/apps/docs/src/content/contributing/chrome-extension.md b/apps/docs/src/content/contributing/chrome-extension.md new file mode 100644 index 0000000..cada129 --- /dev/null +++ b/apps/docs/src/content/contributing/chrome-extension.md @@ -0,0 +1,59 @@ +--- +title: Build the extension +description: Build, load and package the Chrome DevTools extension. +--- + +# Build the extension + +The Chrome extension lives in `extension/`. It is a thin shell: it detects Angular pages, creates the panel, and loads the devtools UI from `extension/ui`. + +## Files + +```text +extension/ + manifest.json # Manifest V3, host access to localhost and 127.0.0.1 + background.js # Tracks which tabs run Angular + content-script.js # Relays the detection result to the background worker + detect-angular.js # Runs in the page, looks for ng-version or window.ng + devtools.html + devtools.js # Creates the panel on Angular pages + panel.html + panel-bridge.js # Finds the dev server and connects the UI to it + icons/ + ui/ # The built devtools UI (committed) +``` + +## Build + +```bash +pnpm extension:build +``` + +This builds the devtools UI (`pnpm devtools:build`) and copies `dist/devtools-ui` into `extension/ui`. + +`extension/ui` is committed. If you change `app/`, run `pnpm extension:build` and commit the result. CI fails when it is stale. + +## Load in Chrome + +1. Go to `chrome://extensions` +2. Enable **Developer mode** +3. Click **Load unpacked** and select the `extension/` directory +4. Open DevTools on an Angular app. The panel appears. + +## Package for the Chrome Web Store + +```bash +pnpm extension:zip +``` + +This runs `extension:build`, then writes `dist/ng-devtools-extension.zip`. + +1. Go to the [Chrome Developer Dashboard](https://chrome.google.com/webstore/devconsole) +2. Click **New item** and upload the zip +3. Fill in the listing details and submit for review + +Bump `version` in `extension/manifest.json` before each upload. The privacy policy for the listing is in `docs/privacy-policy.html`. + +## How the panel connects + +`panel-bridge.js` reads the origin of the inspected page. On `localhost` and `127.0.0.1`, it looks for the devtools server at `/__ng-devtools/`, `/__devframes/ng-devtools/`, `/__devframe/` and `/`, and passes the address to the UI. The UI accepts a loopback address only when it runs inside the extension. On other hosts, the panel shows the UI without a connection. diff --git a/apps/docs/src/content/contributing/demo-apps.md b/apps/docs/src/content/contributing/demo-apps.md new file mode 100644 index 0000000..3fc896a --- /dev/null +++ b/apps/docs/src/content/contributing/demo-apps.md @@ -0,0 +1,33 @@ +--- +title: Demo apps +description: The Angular Travel demo and the Analog demo in the repository. +--- + +# Demo apps + +The repository has two demo apps, so every inspector has something to show. + +## Angular Travel + +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, forms, pipes and HTTP +- **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`. + +The demo shows the full setup: the hub in `src/server.ts`, the overlay and `registerNgrxSignals` in `src/main.ts`, and the HTTP providers in `src/app/app.config.ts`. + +## Analog demo + +`examples/analog` is an Analog app wired with the [Vite plugin](/getting-started/vite). + +```bash +pnpm analog:dev +``` + +The script builds the devtools package, then starts the Vite dev server. diff --git a/apps/docs/src/content/contributing/development.md b/apps/docs/src/content/contributing/development.md new file mode 100644 index 0000000..968e27c --- /dev/null +++ b/apps/docs/src/content/contributing/development.md @@ -0,0 +1,106 @@ +--- +title: Development setup +description: Set up the repository, run the devtools UI and the demo app, and run the checks. +--- + +# Development setup + +## Prerequisites + +- Node.js 24 or later +- pnpm 10 or later + +## Setup + +```bash +git clone https://github.com/santoshyadavdev/angular-devtools.git +cd angular-devtools +pnpm install +``` + +## Project structure + +```text +app/ # Devtools UI SPA (Angular + Vite) + src/app.ts # Root component with tab navigation + src/pages/ # One component per tab + vite.config.ts # Vite config with Analog Angular plugin +packages/ + ng-devtools/ # Publishable npm package + src/devframe.ts # defineDevframe(): tool definition + src/overlay.ts # Client script running in user's page + src/rpc/ # Node-side RPC functions +extension/ # Chrome DevTools extension +examples/analog/ # Analog demo app +apps/docs/ # This documentation site +src/ # Angular host app (demo/playground) +``` + +The workspace uses Nx. `pnpm-workspace.yaml` lists `packages/*`, `examples/*` and `apps/*`. + +## Development + +```bash +# 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 +``` + +| Command | Port | Notes | +| ---------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------------------------- | +| `pnpm start` | 4200 | `ng serve` with SSR and hot reload. The popup and live data work without a separate server. | +| `pnpm build --configuration development && node dist/angular-devtools/server/server.mjs` | 4000 | The demo app as an SSR server. | +| `pnpm devtools:dev` | 5173 | The devtools UI with hot reload. It needs the SSR server running for live data. | + +The SSR server serves the UI built into `packages/ng-devtools/dist/public`. Run `pnpm devtools:build-pkg` to refresh it after you change `app/`. + +## Checks + +```bash +pnpm test # host app +pnpm test:devtools # devtools package +pnpm typecheck # host app + specs, devtools UI, devtools package + its tests +pnpm format:check +``` + +CI runs these, then `nx affected -t test build`, builds the Chrome extension and checks that the committed `extension/ui` is current. + +## Making changes + +### Adding a new 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 + +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: { description }` to any RPC function, or use `ctx.agent.registerTool()` in the devframe setup. + +## The docs site + +This site lives in `apps/docs`. It is built with [NgMd](https://github.com/erkamyaman/ngmd) on *Analog. + +```bash +pnpm docs:dev # dev server +pnpm docs:build # production build +``` + +Pages are markdown files under `apps/docs/src/content`. The sidebar comes from `apps/docs/src/ngmd.config.ts`. The build fails on broken internal links, so run `pnpm docs:build` before you open a PR. diff --git a/apps/docs/src/content/contributing/publishing.md b/apps/docs/src/content/contributing/publishing.md new file mode 100644 index 0000000..7b1c317 --- /dev/null +++ b/apps/docs/src/content/contributing/publishing.md @@ -0,0 +1,22 @@ +--- +title: Publishing +description: Publish the npm package. +--- + +# 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`. + +```bash +# Builds on prepack, then publishes +pnpm devtools:publish +``` + +## Steps + +1. Update the version in `packages/ng-devtools/package.json`. +2. Run `pnpm devtools:publish`. The package build bundles the UI. + +To build the package without publishing, run `pnpm devtools:build-pkg`. + +The Chrome extension has its own version in `extension/manifest.json`. See [Build the extension](/contributing/chrome-extension). diff --git a/apps/docs/src/content/getting-started/chrome-extension.md b/apps/docs/src/content/getting-started/chrome-extension.md new file mode 100644 index 0000000..f6f72a2 --- /dev/null +++ b/apps/docs/src/content/getting-started/chrome-extension.md @@ -0,0 +1,33 @@ +--- +title: Chrome extension +description: Open the devtools as a panel inside Chrome DevTools. +--- + +# Chrome extension + +The Chrome extension adds a panel to Chrome DevTools. The panel loads the devtools UI and connects it to the dev server of the page you are inspecting. + +## Install + +The extension lives in the `extension/` folder of the repository. Build it and load it unpacked: + +1. Run `pnpm extension:build` in the repository. +2. Go to `chrome://extensions`. +3. Enable **Developer mode**. +4. Click **Load unpacked** and select the `extension/` directory. +5. Open DevTools on an Angular app. The panel appears. + +[Build the extension](/contributing/chrome-extension) covers the build and the store package in detail. + +## How it works + +- A content script checks each page for Angular (an `ng-version` attribute or `window.ng`). The panel is created only on Angular pages. +- On pages served from `localhost` or `127.0.0.1`, the panel looks for the devtools server on the same origin, at `/__ng-devtools/`, `/__devframes/ng-devtools/`, `/__devframe/` and `/`. When it finds one, it connects the UI to it. +- On other hosts, the panel shows the UI without a connection. +- When the inspected page navigates, the panel looks for the server again. + +The page still needs the devtools mounted on its server and the [overlay](/getting-started/overlay) loaded. The extension replaces the floating button, not the setup. + +## Permissions + +The extension needs Chrome 111 or later. It requests no permissions beyond host access to `localhost` and `127.0.0.1`. It detects Angular on every page, but it only connects to local dev servers. diff --git a/apps/docs/src/content/getting-started/cli.md b/apps/docs/src/content/getting-started/cli.md new file mode 100644 index 0000000..78e24c4 --- /dev/null +++ b/apps/docs/src/content/getting-started/cli.md @@ -0,0 +1,46 @@ +--- +title: Standalone CLI +description: Run the devtools from the command line, build a static report, or start an MCP server. +--- + +# Standalone CLI + +The package installs an `ng-devtools` binary. Run it from the root of your Angular workspace. + +```bash +# 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 +``` + +## Dev server + +The default command starts a local server with the devtools UI. `dev` is optional: `npx @santoshyadavdev/ng-devtools` does the same. + +| Flag | What it does | +| --------------------- | ----------------------------------------------------------------------------------- | +| `--port ` | Port to listen on. The default is 9999. If it is taken, the next free port is used. | +| `--host ` | Host to bind to. The default is `localhost`. | +| `--open`, `--no-open` | Open the browser on start, or not. | +| `--no-auth` | Turn off the one-time code the server asks for. | +| `--mcp`, `--no-mcp` | Mount the MCP endpoint at `/__mcp`, or not. It is on by default. | + +The server scans the source files in the current directory. No page is connected to it, so the tabs show what your source declares: components, routes, signals, providers, NgRx declarations and pipes. For live data, mount the devtools in your app's own server. See [Angular CLI and Express](/getting-started/express) or [Vite and Analog](/getting-started/vite). + +## Static report + +`build` writes a self-contained static copy of the devtools with the source scan baked in. Open it offline or host it anywhere. + +| Flag | What it does | +| ---------------- | ----------------------------------------------------- | +| `--outDir ` | Output directory. The default is `dist-static`. | +| `--pretty` | Pretty-print the data files. They get larger on disk. | + +## MCP server + +`mcp` starts an MCP server over stdio for coding agents. It has no page connected, so only the source scan tools return data. See [MCP server](/agents/mcp-server) for client setup and for the HTTP endpoint that gives agents live data. diff --git a/apps/docs/src/content/getting-started/express.md b/apps/docs/src/content/getting-started/express.md new file mode 100644 index 0000000..b9ab20e --- /dev/null +++ b/apps/docs/src/content/getting-started/express.md @@ -0,0 +1,119 @@ +--- +title: Angular CLI and Express +description: Mount the devtools hub in the Express server of an Angular SSR app. +--- + +# Angular CLI and Express + +In an Angular app with server-side rendering, the devtools run inside your Express server. You add a middleware on the server and load the overlay in the browser. + + + + Add initNgDevtoolsHub() to server.ts, before your other routes. + + + Import the overlay in main.ts, in development only. + + + Start the app and click the amber button in the corner of the page. + + + +## Mount the hub + +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); +``` + +`ws: false` turns the WebSocket off, so the browser connects over server-sent events. Mount the middleware before `express.static` and the Angular SSR handler, so the devtools routes answer first. + +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. + +When you run `ng serve`, the Angular dev server runs `server.ts` too, so the hub also answers on port 4200. + + + The hub protects its connection with a one-time code by default. The server prints the code, and a browser can read data only after it exchanges that code. On a machine only you use, pass auth: false to turn the gate off. + + +[Security](/security) covers the other access options. + +`initNgDevtoolsHub()` accepts the options of `initHub()` from `@devframes/hub`, apart from `devframes` and `ui`. These are the ones you are most likely to set: + +| Option | Default | What it does | +| ---------------- | ----------------- | --------------------------------------------------------------------------------------------- | +| `base` | `'/__devframes/'` | Where the hub is mounted. The devtools panel lives at `ng-devtools/`. | +| `ws` | | `false` uses server-sent events only. `{ sidecar: true }` runs the WebSocket on its own port. | +| `auth` | on | `false` turns off the one-time code. | +| `allowedOrigins` | loopback origins | Extra origins allowed to open the WebSocket. `false` turns the origin check off. | +| `mcp` | `'auto'` | Mounts the MCP endpoint at `__mcp` once agent tools exist. | + +The demo app in this repository mounts the hub like this: + +```ts +// src/server.ts +const auth = process.env['NG_DEVTOOLS_AUTH'] === 'true'; +const devtools = initNgDevtoolsHub({ + ws: { sidecar: true }, + auth, + allowedOrigins: false, +}); +app.use(devtools.nodeMiddleware); +``` + +It turns the origin check off because it runs as a public demo. Keep the check on for your own apps. + +## Load the overlay + +The [overlay](/getting-started/overlay) collects live data from the page. Import it after bootstrap, in development only: + +```ts +// main.ts +bootstrapApplication(App, appConfig) + .then(() => { + if (typeof ngDevMode === 'undefined' || ngDevMode) { + return import('@santoshyadavdev/ng-devtools/overlay'); + } + return undefined; + }) + .catch((err) => console.error(err)); +``` + +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, forms, pipes, and SSR & HTTP | +| 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 | + +## Fill the SSR & HTTP tab + +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(), + ], +}; +``` + +The [SSR & HTTP guide](/guides/ssr-http) covers interceptor order and fault injection. + +## Mount only the panel + +To mount only the devtools panel without the dock, use `initDevframe(ngDevtools, { base: '/__ng-devtools/' })` from `devframe/initiate`. The overlay looks for that path too. diff --git a/apps/docs/src/content/getting-started/installation.md b/apps/docs/src/content/getting-started/installation.md new file mode 100644 index 0000000..349716d --- /dev/null +++ b/apps/docs/src/content/getting-started/installation.md @@ -0,0 +1,44 @@ +--- +title: Installation +description: Install the devtools package and choose where it runs. +--- + +# Installation + +The devtools ship as one npm package, `@santoshyadavdev/ng-devtools`. It contains the Node side, the browser overlay, the in-page popup, the CLI and the built UI. + +```bash group="install" name="npm" active +npm install @santoshyadavdev/ng-devtools devframe +``` + +```bash group="install" name="pnpm" +pnpm add @santoshyadavdev/ng-devtools devframe +``` + +```bash group="install" name="yarn" +yarn add @santoshyadavdev/ng-devtools devframe +``` + +MCP agent support (`@devframes/agentic`) is included. + +## Entry points + +| Import | Use it for | +| --------------------------------------- | ---------------------------------------------------------------- | +| `@santoshyadavdev/ng-devtools/hub` | `initNgDevtoolsHub()`, the server middleware for an Express app. | +| `@santoshyadavdev/ng-devtools/vite` | The Vite plugin for Analog apps. | +| `@santoshyadavdev/ng-devtools/overlay` | The browser script that collects live data from your page. | +| `@santoshyadavdev/ng-devtools/popup` | The floating button and panel on your page. | +| `@santoshyadavdev/ng-devtools/http` | The HTTP interceptor and hydration hooks for the SSR & HTTP tab. | +| `@santoshyadavdev/ng-devtools/devframe` | The devframe definition, for custom hosts. | + +The package also installs an `ng-devtools` binary for the [standalone CLI](/getting-started/cli). + +## Pick a setup + +Every setup has two parts. A server part serves the devtools UI and receives data. A browser part, the [overlay](/getting-started/overlay), runs in your page and sends live data to the server. + +- Your app uses the Angular CLI with SSR (an Express `server.ts`): follow [Angular CLI and Express](/getting-started/express). +- Your app is an Analog app: follow [Vite and Analog](/getting-started/vite). +- You only want the source scan, or a server for agents: use the [standalone CLI](/getting-started/cli). +- You want a panel inside Chrome DevTools: add the [Chrome extension](/getting-started/chrome-extension) on top of one of the setups above. diff --git a/apps/docs/src/content/getting-started/introduction.md b/apps/docs/src/content/getting-started/introduction.md new file mode 100644 index 0000000..d3a512e --- /dev/null +++ b/apps/docs/src/content/getting-started/introduction.md @@ -0,0 +1,55 @@ +--- +title: Introduction +description: What the devtools inspect, and the ways you can run them. +--- + +# Introduction + +The devtools inspect a running Angular app: its components, signals, injectors, routes, forms, pipes, NgRx stores, HTTP calls and hydration. They also scan your source files, so they can answer questions before the app even runs. + +The same tool runs in several places. It is built with *Devframe, so one definition powers every mode. + +| Mode | What you get | +| ---------------- | ------------------------------------------------------------------- | +| Embedded panel | A floating button on your page opens the devtools next to your app. | +| Standalone CLI | A local server that serves the devtools UI. | +| Static report | An offline HTML build of the source scan. | +| MCP server | Every inspector exposed to coding agents as tools and resources. | +| Chrome extension | A panel in Chrome DevTools that connects to your local dev server. | + +## Features + +- **Components**: every component instance on the page, with live inputs, outputs, change detection, encapsulation, DOM listeners, host directives and injected services. Hover a row to highlight the element in the page. +- **Signals**: the live signal graph of one component (signal, computed, linkedSignal and effect nodes with their edges), plus a value history per signal. Needs Angular 19 or later. +- **Injectors**: the element and environment injector hierarchy, the lookup path for a token, and the providers at each level. Needs Angular 17 or later. +- **Routes**: 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, the router setup and a route lint. +- **NgRx Store**: live `@ngrx/signals` stores with a change log, per-change diffs and state restore, plus the `@ngrx/store` state and action log, with time travel through `provideStoreDevtools()`. +- **Forms**: every Signal Form, reactive form and template-driven form on the page, with each field's value, status and readable errors, a change timeline, submit and payload explanations, and a lint. +- **Pipes**: custom and built-in pipes, where they are used, live instances, call recording, async subscriptions and a pipe lint. +- **SSR & HTTP**: an HTTP timeline for SSR and client calls, fault injection, hydration stats and the TransferState payload. +- **Analog**: file routes, server calls, render modes, content and lint for *Analog apps. +- **Dashboard**: the Angular and TypeScript versions, SSR status and a count for each inspector. +- **Agent tools**: the inspectors are exposed as *MCP tools and resources, so a coding agent can read and act on the running app. + +## Requirements + +- The package supports Angular 20 and later. +- Live data needs a development build. The devtools read Angular's debug API (`window.ng`), which production builds remove. +- Node.js 22 or later runs the package. + +## Next steps + + + + Add the package and pick how you want to run it. + + + Mount the devtools in the Express server of an SSR app. + + + Add the Vite plugin next to `analog()`. + + + Give your coding agent access to the inspectors. + + diff --git a/apps/docs/src/content/getting-started/overlay.md b/apps/docs/src/content/getting-started/overlay.md new file mode 100644 index 0000000..f755e87 --- /dev/null +++ b/apps/docs/src/content/getting-started/overlay.md @@ -0,0 +1,40 @@ +--- +title: Browser overlay +description: The script that runs in your page and sends live data to the devtools. +--- + +# Browser overlay + +The overlay runs inside your Angular page. It reads Angular's debug API and sends live data to the devtools server: the component tree, the signal graph, the injector tree, NgRx stores, forms, pipes, the router, HTTP calls and Analog page data. Importing the module starts it, so in most apps that import is all that is needed: + +```ts +import '@santoshyadavdev/ng-devtools/overlay'; +``` + +Load it in development builds only. Production builds remove the debug API, so the overlay has nothing to read. The [Express](/getting-started/express) and [Vite](/getting-started/vite) pages show a dynamic import that only runs in development. + +## How it connects + +It looks for the devframe connection next to the page, then at `/__ng-devtools/` and `/__devframes/ng-devtools/`. It also adds the floating button; with the hub mounted, the button opens the whole hub (every dock in a side rail). + +The overlay sends a fresh snapshot every 3 seconds and skips data that did not change. Router events are sent as they happen. Each browser tab gets its own page id, so the devtools can tell tabs apart. When a tab closes, its data is dropped. + +## A custom mount path + +`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/' }); +``` + +`baseURL` takes one path or a list of paths to try in order. `initOverlay` resolves to a function that stops the overlay and removes its hooks. + +## NgRx signal stores + +The overlay also exports `registerNgrxSignals`. Call it once with `patchState` so that restoring a store's state also notifies `watchState` listeners. See [Restore NgRx signal state](/guides/ngrx-signals-restore). + +## Highlighting + +When you hover a component in the devtools, the overlay draws an amber box around its element in the page. The box follows the element and clears after 2 seconds. diff --git a/apps/docs/src/content/getting-started/popup-and-hub.md b/apps/docs/src/content/getting-started/popup-and-hub.md new file mode 100644 index 0000000..284dd54 --- /dev/null +++ b/apps/docs/src/content/getting-started/popup-and-hub.md @@ -0,0 +1,67 @@ +--- +title: Popup and hub +description: The floating button, the panel and its dock modes, the hub rail and deep links. +--- + +# Popup and hub + +When the overlay loads, a floating button appears in the bottom-right corner of your page. Click it to open the devtools in a panel on top of your app. + +## The in-page popup + +The devtools can appear as a floating popup directly on your page. No browser extension is 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. Importing the overlay already adds the button, so most apps never call `createDevtoolsPopup()` themselves. Calling it again returns the same popup. + +- **Dock modes**: float, bottom (full width, 40% of the height) and right (40% of the width, full height). Only the floating panel can be dragged. +- **Resize** the panel with its resize handle. +- **Move the button**: drag it, or focus it and use the arrow keys (hold Shift for bigger steps). Double-click resets its position. +- **Escape** closes the panel. +- **Persistence**: position, size and dock mode are saved in `localStorage` under `ng-devtools-popup`. + +The button color follows the `--ng-devtools-accent` CSS variable, so you can match it to your app. + +## The hub + +When the page's server mounts the hub (`/__devframes/`), the button opens the whole hub. A side rail shows one dock per tool: + +| Dock | Shows | +| ------------ | ------------------------------------------------------------------------------- | +| Angular | Dashboard, Components, Routes, Signals, Injectors, Forms, Pipes, and SSR & HTTP | +| NgRx | The Store tab | +| Analog | The Analog tab, or a notice in apps that do not use Analog | +| NativeScript | Coming soon | +| Capacitor | Coming soon | + +The full-page viewer is at `/__devframes/` on the same server. The hub is built on [`@devframes/hub`](https://github.com/devframes/devframe), so other devframe tools can join the same rail. + +Without the hub (for example the standalone CLI, or a panel mounted with `initDevframe()`), every tab sits in one tab bar and the Store tab is a regular tab. + +## Deep links + +The URL hash selects a tab. Open `/__devframes/ng-devtools/#tab=signals` to land on the Signals tab. Switching tabs updates the hash, so you can copy the URL at any time. + +| Tab | Hash | +| ---------- | ----------------- | +| Dashboard | `#tab=dashboard` | +| Components | `#tab=components` | +| Routes | `#tab=routes` | +| Signals | `#tab=signals` | +| Injectors | `#tab=injectors` | +| Store | `#tab=store` | +| Forms | `#tab=forms` | +| Pipes | `#tab=pipes` | +| SSR & HTTP | `#tab=network` | +| Analog | `#tab=analog` | + +A hash only works for a tab that exists when the panel opens. The Analog tab appears after the server confirms the app is an Analog app, so `#tab=analog` does not select it on load. Inside the Angular dock, the Store and Analog tabs live in their own docks. + +## Connection status + +The header shows **Live** when the panel is connected, **Connecting…** while it tries, and **Disconnected** when the server is gone. If the panel cannot reach the server, check that the dev server is running, then reload. diff --git a/apps/docs/src/content/getting-started/vite.md b/apps/docs/src/content/getting-started/vite.md new file mode 100644 index 0000000..75aa131 --- /dev/null +++ b/apps/docs/src/content/getting-started/vite.md @@ -0,0 +1,62 @@ +--- +title: Vite and Analog +description: Add the devtools Vite plugin to an Analog app. +--- + +# Vite and Analog + +For *Analog 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`. + +## What the plugin does + +- It runs on the dev server only. `vite build` is not affected. +- It mounts the devtools hub on the Vite dev server and shares its HTTP server for the WebSocket. +- It records Analog page renders, `load()` fetches, server functions and API calls for the [Analog inspector](/inspectors/analog). +- It only answers requests from your machine. See [Security](/security). +- It does not inject the overlay. Your app imports it in `main.ts`, as shown above. + +## Options + +```ts +ngDevtools({ + base: '/__devframes/', + apiPrefix: 'api', + allowedOrigins: ['https://tunnel.example'], +}); +``` + +| Option | Default | What it does | +| ---------------- | -------------------------------- | ------------------------------------------------------------------------ | +| `base` | `'/__devframes/'` | Where the hub is mounted. | +| `apiPrefix` | Analog's `apiPrefix`, or `'api'` | The prefix of your server routes, used to classify API calls. | +| `allowedOrigins` | none | Extra exact origins allowed to reach the devtools, for example a tunnel. | + +## Hostnames other than localhost + +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'] })`. + +## Angular CLI apps + +The Angular CLI dev server does not accept Vite plugins. For an Angular CLI app, mount the hub in your Express server instead. See [Angular CLI and Express](/getting-started/express). + +The [Analog guide](/guides/analog) walks through a full setup, including the demo in this repository. diff --git a/apps/docs/src/content/guides/analog.md b/apps/docs/src/content/guides/analog.md new file mode 100644 index 0000000..827c630 --- /dev/null +++ b/apps/docs/src/content/guides/analog.md @@ -0,0 +1,69 @@ +--- +title: Set up Analog +description: Add the devtools to an Analog app, step by step. +--- + +# Set up Analog + +This guide adds the devtools to an *Analog app. You get the Angular inspectors, the NgRx dock and the Analog dock, with an MCP endpoint on the Vite dev server. + +## 1. Install + +```bash group="install" name="npm" active +npm install @santoshyadavdev/ng-devtools devframe +``` + +```bash group="install" name="pnpm" +pnpm add @santoshyadavdev/ng-devtools devframe +``` + +## 2. Add the Vite plugin + +Add the plugin next to `analog()`: + +```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()], +}); +``` + +The plugin runs on the dev server only. + +## 3. Load the overlay + +```ts +// src/main.ts +bootstrapApplication(App, appConfig).then(() => { + if (import.meta.env.DEV) void import('@santoshyadavdev/ng-devtools/overlay'); +}); +``` + +## 4. Open the devtools + +Start the dev server. 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`. + +Open the **Analog** dock to see file routes, server calls, render modes, content and lint. See [the Analog inspector](/inspectors/analog) for each view. + +## Optional: HTTP timeline + +To record `HttpClient` calls in the SSR & HTTP tab, add `withNgDevtools()` and `provideNgDevtoolsHttp()` to your app config. See [Set up SSR & HTTP](/guides/ssr-http). Analog's own `load()` fetches and API calls show in the Analog dock without it. + +## Custom hostnames + +If you open the dev server through another hostname, add it to Vite's `server.allowedHosts`. See [Security](/security). + +## Try the demo + +The repository has an Analog demo in `examples/analog`. From the repository root: + +```bash +pnpm install +pnpm analog:dev +``` + +It builds the devtools package first, then starts the Vite dev server. diff --git a/apps/docs/src/content/guides/ngrx-signals-restore.md b/apps/docs/src/content/guides/ngrx-signals-restore.md new file mode 100644 index 0000000..e9ba02a --- /dev/null +++ b/apps/docs/src/content/guides/ngrx-signals-restore.md @@ -0,0 +1,55 @@ +--- +title: Restore NgRx signal state +description: Register patchState so that restoring a signal store also notifies watchState listeners. +--- + +# Restore NgRx signal state + +The [NgRx Store tab](/inspectors/ngrx-store) can put a signal store back to its state after any change in the log. By default it writes the state signals directly. That updates your components, but `watchState` listeners do not run. + +Register `patchState` once, and restore goes through it instead. Then `watchState` listeners run as they would for any other change. + +## Register patchState + +Call `registerNgrxSignals({ patchState })` from `@santoshyadavdev/ng-devtools/overlay` once, after the app starts. The demo app in this repository does it in `main.ts`, together with loading the overlay: + +```ts +// main.ts +import { bootstrapApplication } from '@angular/platform-browser'; +import { appConfig } from './app/app.config'; +import { App } from './app/app'; + +bootstrapApplication(App, appConfig) + .then((ref) => { + if (typeof ngDevMode === 'undefined' || ngDevMode) { + return ref + .whenStable() + .then(() => + Promise.all([import('@santoshyadavdev/ng-devtools/overlay'), import('@ngrx/signals')]), + ) + .then(([devtools, { patchState }]) => devtools.registerNgrxSignals({ patchState })); + } + return undefined; + }) + .catch((err) => console.error(err)); +``` + +Both imports are dynamic and run in development only, so production bundles do not include the devtools. + +## Restore a state + +1. Open the Store tab (the **NgRx** dock with the hub). +2. Select a store, then open an entry in its change log. +3. Click **Restore this state**, then **Restore**. + +Every state key of the store goes back to its value right after that change. Components that read the store update at once, and a new **Restore** entry is added to the log. + + + Restore still works, but the log entry says that watchState listeners were not notified. + + +## Limits + +- Restore needs every state key to be writable. +- The log keeps the last 200 entries per page. +- For `@ngrx/store`, restore uses Store DevTools instead. Add `provideStoreDevtools()` to enable it. Without it, the action log is read-only. diff --git a/apps/docs/src/content/guides/ssr-http.md b/apps/docs/src/content/guides/ssr-http.md new file mode 100644 index 0000000..70be5a0 --- /dev/null +++ b/apps/docs/src/content/guides/ssr-http.md @@ -0,0 +1,67 @@ +--- +title: Set up SSR & HTTP +description: Add the interceptor and hydration hooks, in the right order, to fill the SSR & HTTP tab. +--- + +# Set up SSR & HTTP + +The [SSR & HTTP tab](/inspectors/ssr-http) records every `HttpClient` call during server rendering and in the browser. It needs an interceptor, a hydration hook, and SSR running next to the devtools. + +## 1. Add the providers + +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(), + ], +}; +``` + +- `withNgDevtools()` adds the interceptor that records calls and applies fault rules. +- `provideNgDevtoolsHttp()` captures the hydration warnings (NG05xx) Angular logs. + +## 2. Put withNgDevtools first + +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. + +```ts +provideHttpClient(withFetch(), withNgDevtools(), withInterceptors([auth])); +``` + +## 3. Run SSR next to the devtools + +SSR and the devtools middleware must run in the same Express process. The interceptor on the server hands its calls to the devtools through that process. Mount the hub in `server.ts` as shown in [Angular CLI and Express](/getting-started/express). + +It works in development builds only; in production the interceptor passes requests through untouched. + +## 4. Test with server rendering + +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. + +```ts +// app.routes.server.ts +import { RenderMode, ServerRoute } from '@angular/ssr'; + +export const serverRoutes: ServerRoute[] = [ + { path: 'products', renderMode: RenderMode.Server }, + { path: '**', renderMode: RenderMode.Prerender }, +]; +``` + +## Inject a fault + +1. Open the SSR & HTTP tab and go to **Fault injection**. +2. Enter a URL pattern, for example `/api/*`. +3. Pick where it applies: **SSR + client**, **SSR only** or **Client only**. +4. Set a status (for example `500`), a delay, or a mock JSON body, then click **Add rule**. +5. Reload the page. SSR rules apply from the next page load. Client rules apply right away. + +A status of 400 or more fails the request with an `HttpErrorResponse`. A lower status returns the body as a mocked response. SSR mocks are not written to TransferState, so the browser requests the URL again. Apply the rule on SSR + client to mock both. diff --git a/apps/docs/src/content/inspectors/analog.md b/apps/docs/src/content/inspectors/analog.md new file mode 100644 index 0000000..b766b0a --- /dev/null +++ b/apps/docs/src/content/inspectors/analog.md @@ -0,0 +1,45 @@ +--- +title: Analog +description: File routes, server calls, render modes, content and lint for Analog apps. +--- + +# Analog + +The Analog tab shows how an *Analog app is put together and what its dev server does. With the hub mounted, it lives in the **Analog** dock. + +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. + +## Requirements + +Add the Vite plugin next to `analog()` and load the overlay. See [Vite and Analog](/getting-started/vite). The plugin records server calls on the dev server, and the overlay reports the page that is open in the browser. + +## Where the data comes from + +- **Source**: the server scans your pages, layouts, `.server.ts` files, server routes, content files and `vite.config.ts`. +- **Dev server**: the Vite plugin records page renders, `load()` fetches, server functions and API calls. +- **Live**: the overlay reports the open page, the `load()` data it received and its hydration state. + +## Views + +The summary at the top shows the Analog version and the number of pages, API routes, server calls and issues. + +- **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. A prerender plan compares `prerender.routes` with your pages and the build output. +- **Content**: markdown files with title, URL, slug and date. +- **Lint**: checks for duplicate URLs, missing default exports, layouts without ``, orphan `.server.ts` files, API method suffixes, prerender entries and frontmatter. + +## For agents + +The Analog tools read the same data: + +- `ng-devtools:analog-routes`, `ng-devtools:analog-explain-url` and `ng-devtools:analog-current-page` for routes and the open page. +- `ng-devtools:analog-server-calls`, `ng-devtools:analog-api-routes` and `ng-devtools:analog-call-api` for the server. +- `ng-devtools:analog-render-modes` and `ng-devtools:analog-prerender-plan` for rendering. +- `ng-devtools:analog-content` and `ng-devtools:analog-lint` for content and checks. + +`analog-call-api` sends a real request to your dev server. Methods other than GET, HEAD and OPTIONS need `confirm: true`. See [Tools](/agents/tools). + +## Tips + +Tested with Analog 2.7 on Angular 20 (a fresh app from the Analog template, npm and pnpm) and Angular 22. The demo lives in `examples/analog` (`pnpm analog:dev`). diff --git a/apps/docs/src/content/inspectors/components.md b/apps/docs/src/content/inspectors/components.md new file mode 100644 index 0000000..cc7a0df --- /dev/null +++ b/apps/docs/src/content/inspectors/components.md @@ -0,0 +1,53 @@ +--- +title: Components +description: Every component instance on the page, with live inputs, outputs and injected services. +--- + +# Components + +The Components tab lists every component instance on the page, in the order Angular rendered them. Hover a row to highlight its host element in the page. Select it to read its live inputs, outputs and injected services. + +## Where the data comes from + +- **Live**: the [overlay](/getting-started/overlay) walks the page with Angular's debug API and sends the tree every few seconds. +- **Source**: the server scans your files for `@Component` and `@Directive` classes. The tab falls back to this list when no page is connected, or when the page reports no instances (for example a production build). + +## The tree + +Each row shows the class name, the host tag, a chip with the route path for routed components, and **+N** when directives sit on the same host. Filter by class, tag or directive name. + +- **Hover or focus** a row to highlight the element in the page. +- **Click** a row, or press Enter or Space, to select it. +- Use the arrow keys, Home and End to move through the tree. + +The tree shows up to 2000 components. A notice appears when the page has more. + +## Details + +The detail panel for the selected instance shows: + +- The class name, the host tag, and the source file and line. +- **Change detection**, **Encapsulation**, the **Host path**, and for routed components, the route and outlet that rendered it. +- **Inputs** with their live values (and aliases). +- **Outputs**, each marked **listened** or **no listener**. +- **DOM listeners** on the host element. +- One block per directive on the host. +- **Injected**: each token the component injects, with its flags and the injector that provided it, or **not provided**. + +When a form belongs to the selected component, a **Show … in Forms** button opens it in the [Forms tab](/inspectors/forms). + +## Source mode + +Without live data, the tab lists what the source declares. Expand a row to see its class, file, standalone flag, inputs and outputs. Use **Refresh** to scan again. + +## For agents + +- `ng-devtools:get-components` lists components and directives from source. +- `ng-devtools:highlight` highlights a component in the page. +- The `ng-devtools:component-tree` resource holds the live tree. + +See [Tools](/agents/tools) and [Resources](/agents/resources). + +## Requirements + +Live data needs a development build, because it reads `ng.getComponent` from Angular's debug API. Input values are shortened: nested values stop at a few levels, and long strings are cut. Values with secret-looking names are replaced with `[redacted]`. See [Security](/security). diff --git a/apps/docs/src/content/inspectors/dashboard.md b/apps/docs/src/content/inspectors/dashboard.md new file mode 100644 index 0000000..78ab2cb --- /dev/null +++ b/apps/docs/src/content/inspectors/dashboard.md @@ -0,0 +1,41 @@ +--- +title: Dashboard +description: Project metadata and a count for each inspector. +--- + +# Dashboard + +The Dashboard is the first tab. It shows what the project is built with and how much each inspector found. + +## Project + +The top block shows the project name and a chip for each of: + +- **Angular**: the installed Angular version. +- **TypeScript**: the installed TypeScript version. +- **SSR**: On or Off. +- **Analog**: the Analog version, in Analog apps only. + +The server reads these from your workspace. Versions come from the installed packages in `node_modules`, with the range in `package.json` as a fallback. The project name comes from `angular.json`, or from `package.json` when there is none. SSR is on when the build options set `ssr` or `server`. For Analog apps, SSR follows the `ssr` option of `analog()`. + +The same data is available to agents through the `ng-devtools:build-meta` tool, and it is baked into [static reports](/getting-started/cli). + +## Cards + +Each card counts what one inspector found. Click a card to open its tab. + +| Card | Counts | +| ----------------- | ----------------------------------------------------------------------------------------------------------- | +| Components | Components in source, plus the number of directives. | +| Routes | Navigable page paths in source, plus the number of redirects. | +| Signals | Signal nodes live on the page, plus the declarations in source. Without a page, the declarations in source. | +| Injectors | Live injectors on the page, plus their providers. Without a page, the provider declarations in source. | +| NgRx declarations | NgRx declarations in source, broken down by kind. | +| Pipes | Custom pipes in source, plus the built-in pipes in use. | + +A card shows **Counting…** while it loads, and **Count unavailable** when its data could not be read. + +## Tips + +- If the project block says **Project details unavailable**, check that the dev server is running, then reload the panel. +- The NgRx card opens the NgRx dock when the hub is mounted. diff --git a/apps/docs/src/content/inspectors/forms.md b/apps/docs/src/content/inspectors/forms.md new file mode 100644 index 0000000..462570f --- /dev/null +++ b/apps/docs/src/content/inspectors/forms.md @@ -0,0 +1,65 @@ +--- +title: Forms +description: Every form on the page with each field's state and errors, a change timeline, submit explanations and a lint. +--- + +# 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. + +## Where the data comes from + +The [overlay](/getting-started/overlay) finds the forms on the page and sends their state. Form actions (touch, reset, submit and others) are sent back to the page and run there. The server adds the source file and line of each form and its rules. + +## Forms list + +The sidebar lists each form with its label, kind (**Signal Forms**, **Reactive** or **Template-driven**) and error count. Check **All pages** to include forms from other open tabs. + +Select a form to see its status, dirty and touched state, whether it was submitted or is submitting, and an error summary. + +## Fields + +Each field shows its value, status, touched/dirty state and errors, plus: + +- For 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`. + +Filter by path, or with the **Invalid**, **Dirty**, **Touched**, **Disabled** and **Error not shown** chips. Hover a field to highlight its input in the page. Click it for field details, where you can set a value, focus, touch or revalidate the field, or store it as a global (`$form` in the page console). + +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()`. + +## Actions + +The actions bar works on the selected form: + +- **Touch all**, **Revalidate**, **Focus first invalid** and **Pick field on page**. +- **Snapshot** saves the form's values. **Restore** puts them back. +- **Reset** and **Submit**. + +Restore, reset and submit ask for confirmation first. Secret fields are never written. + +## Views + +The Forms tab has four views: + +- **Fields**: the field table with filters and per-field actions. +- **Timeline**: recent changes, each tagged with its origin (user, code or devtools). Check **Record details** to add 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. +- **Submit**: what submit will do and why it might do nothing, plus what the form sends. **Copy test fixture** copies a fixture for your tests. +- **Lint**: form bugs and model-aware accessibility checks. For generic accessibility checks, run axe on the page. + +Pick a field on the page to select it, or open a form from its component in the [Components tab](/inspectors/components). + +## For agents + +`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 `Checkout.form@ab12`, or part of a label like `Checkout.form`) 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. +- `ng-devtools: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`. + +See [Tools](/agents/tools) for the full list. + +## Privacy + +Form values leave the page: they are sent to the devtools server, shown in the Forms tab and returned to agents. Password fields and fields with secret-looking names are replaced with `[redacted]`. See [Security](/security) for the full rules and how to mask or unmask a field. diff --git a/apps/docs/src/content/inspectors/injectors.md b/apps/docs/src/content/inspectors/injectors.md new file mode 100644 index 0000000..15efa17 --- /dev/null +++ b/apps/docs/src/content/inspectors/injectors.md @@ -0,0 +1,41 @@ +--- +title: Injectors +description: The injector hierarchy, token lookup paths and the providers at each level. +--- + +# Injectors + +Every component and directive gets an injector. When it asks for a token, Angular walks up this tree, then through the environment injectors, until something provides it. The Injectors tab shows that tree. + +## Where the data comes from + +- **Live**: the [overlay](/getting-started/overlay) reads the injector tree from Angular's debug API. This needs Angular 17 or later and a development build. +- **Source**: without a live tree, the tab lists DI found in your files, grouped as **Root Providers (provide\*)**, **Injectable Services**, **inject() Calls** and **Component Providers**. + +## The tree + +Switch between **Elements** (component and directive injectors) and **Environment** (the environment injectors, such as the root injector). + +- Search for a token, component or injector. A match shows **Provided by** chips; click one to jump to that injector. +- **Components only** hides directive injectors. It is on by default. +- **With providers** hides injectors that provide nothing. +- Hover an element injector to highlight its element in the page. + +## Details + +Select an injector to see: + +- **Lookup path**: the injectors Angular asks, in order, until one has the token. The path ends at the null injector, which throws `NullInjectorError`. Click any step to open it. +- **Injected here** (element injectors): each token requested at this level, the directive that asked for it, and the injector that answered, or **not provided anywhere**. +- **Provides**: each provider with its kind (`useClass`, `useValue`, `useFactory` or `useExisting`), and whether it is a view provider or a multi provider. Providers that come from imported modules show the import path. + +## For agents + +- `ng-devtools:get-providers` lists DI providers from source. +- `ng-devtools:inspect-providers` returns the injector tree a page reported. +- The `ng-devtools:injector-tree` resource holds the live tree. + +## Tips + +- If a token throws `NullInjectorError`, search for it and read the lookup path of the component that asks for it. +- The tree shows up to 2000 injectors. diff --git a/apps/docs/src/content/inspectors/ngrx-store.md b/apps/docs/src/content/inspectors/ngrx-store.md new file mode 100644 index 0000000..3f00759 --- /dev/null +++ b/apps/docs/src/content/inspectors/ngrx-store.md @@ -0,0 +1,47 @@ +--- +title: NgRx Store +description: Live NgRx signal stores and @ngrx/store state, with change logs, diffs and restore. +--- + +# NgRx Store + +The Store tab shows your *NgRx state as it changes. It covers `@ngrx/signals` stores (`signalStore` and `signalState`) and the classic `@ngrx/store`. With the hub mounted, it lives in the **NgRx** dock. + +## Where the data comes from + +- **Live**: the [overlay](/getting-started/overlay) finds stores in the page's injectors and component fields, and records every change. +- **Source**: the server scans your files for `signalStore` (with its `withState`, `withComputed`, `withMethods`, `withProps`, `withHooks`, `withEntities` and `rxMethod` members), `signalState`, `signalMethod`, and `createAction`, `createReducer`, `createEffect`, `createSelector` and `createFeature`. + +## Live stores + +Each store in the list shows its label, kind (**signalStore**, **signalState** or **@ngrx/store**), scope and change count. The scope is the environment injector that provides it, or the component that owns it. + +Select a store to see: + +- Its kind, scope and declaring file. +- The component fields that reference it (**Referenced by**). +- **State**, **Computed** and **Methods**, with a call count per method. `rxMethod` members are tagged. + +## Change log + +Signal stores get a **Change log**. `@ngrx/store` gets an **Action log**. Each entry shows its number, type, the number of changes and the time. Open an entry to see its arguments and a **State diff** with the value before and after each change. + +Method calls are logged even when they change nothing. The log keeps the last 200 entries. + +## Restore + +Open an entry and click **Restore this state**, then **Restore** to confirm. + +- For a signal store, restore sets every state key back to its value right after that change. Components that read the store update at once, and a new **Restore** entry is added to the log. Call `registerNgrxSignals({ patchState })` once, so restore also notifies `watchState` listeners. See [Restore NgRx signal state](/guides/ngrx-signals-restore). +- For `@ngrx/store`, restore uses Store DevTools to jump to the state right after that action. New actions continue from there. Time travel needs `provideStoreDevtools()`. Without it the log is read-only. + +## For agents + +- `ng-devtools:get-ngrx-store` scans the source for NgRx declarations. +- The `ng-devtools:ngrx-store` resource holds the live stores and the change log. + +## Tips + +- A `signalStore` is created the first time something injects it. Open a page that uses it, and it appears. +- For `@ngrx/store`, the Store must be provided in an environment injector (`provideStore()` or `StoreModule.forRoot()`). +- State keys with secret-looking names are replaced with `[redacted]`. See [Security](/security). diff --git a/apps/docs/src/content/inspectors/pipes.md b/apps/docs/src/content/inspectors/pipes.md new file mode 100644 index 0000000..905c6f5 --- /dev/null +++ b/apps/docs/src/content/inspectors/pipes.md @@ -0,0 +1,52 @@ +--- +title: Pipes +description: Custom and built-in pipes, where they are used, live instances, call recording and a pipe lint. +--- + +# Pipes + +The Pipes tab lists the pipes your templates use: your own `@Pipe` classes and the built-in pipes from `@angular/common`. It shows where each one is declared and used, which components use it on the page, and what it last returned. + +## Where the data comes from + +- **Source**: the server scans your files for `@Pipe` classes (name, class, file, standalone and pure flags). Built-in pipes are listed when a template uses them, with every usage site. +- **Live**: the [overlay](/getting-started/overlay) finds pipe instances in the rendered views. This is read-only until you turn on recording. It needs a development build. +- **Lint**: the server checks your source for common pipe mistakes. + +## Pipe list + +Search by pipe name, class or file, and narrow the list with **Show pipes**: **All pipes**, **Custom**, **Built-in**, **Impure** or **On the page**. Each row shows the pipe name, its class, and chips for **N live**, **built-in**, **NgModule** (not standalone), **pure** or **impure**. Hover a row to highlight the first component that uses it. + +## Details + +Select a pipe to see: + +- **Declaration**: its class, whether it comes from `@angular/common` or your project, its file, whether it is standalone, and whether it is pure. A pure pipe reruns only when an input changes. An impure pipe reruns on every check. +- **Used in templates**: for built-in pipes, every usage site. +- **Live on the page**: the number of instances and the components that use them. Click, hover or focus a component chip to highlight it. + +## Record calls + +Click **Record calls** to count calls and keep the last input and output of each pipe. Recording patches each pipe's `transform` in the inspected page, on every connected tab. Click **Stop recording** when you are done. + +With recording on, the detail panel shows the call count, the last input and output, a per-instance breakdown and the last caller. + +A pure pipe only runs when Angular sees a changed argument. While recording, the tab can warn when a pure pipe got an argument whose contents changed while its reference stayed the same, so it may show a stale value. This warning is experimental. + +## Async subscriptions + +When templates use `| async`, the tab lists each subscription with its component and latest value. Each `| async` subscribes on its own. Two on the same source mean the work runs twice, so those rows are marked **duplicate subscription**. + +## Lint + +| Rule | Severity | Finds | +| -------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------- | +| `impure-pipe-in-for` | warning | An impure pipe inside an `@for` block. It runs on every check, possibly once per row. | +| `json-pipe-in-template` | info | `\| json` left in a template. It is a debugging aid. | +| `signal-read-in-pure-pipe` | warning | A pure pipe whose `transform()` reads a signal. Its memoization only tracks its arguments, not the signals it reads. | + +## For agents + +- `ng-devtools:get-pipes` lists custom pipes and the built-in pipes in use. +- `ng-devtools:lint-pipes` runs the checks above. +- `ng-devtools:explain-pipe` explains one pipe by name: where it is declared or used, purity, live counts, last input and output, the stale warning and lint findings. diff --git a/apps/docs/src/content/inspectors/router.md b/apps/docs/src/content/inspectors/router.md new file mode 100644 index 0000000..5f80cc5 --- /dev/null +++ b/apps/docs/src/content/inspectors/router.md @@ -0,0 +1,41 @@ +--- +title: Router +description: The live route, every navigation as a story, the live route config, router setup and a route lint. +--- + +# 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. + +## Live router + +The live section 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](/inspectors/components). + +## Source route config + +Below the live views, the tab lists the routes declared in your source files: `*.routes.ts` and `*routing.module.ts` files, the files they lazy load, and Analog pages. It shows each path, its component or target, guards and resolvers, title and the file that declares it. Once live config is available, this table is collapsed. + +## Guard verdicts + +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. + +## 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. + +## Privacy + +Secret-looking query, matrix and fragment values, tokens and route params are replaced with `[redacted]` in URLs, params, data and messages. See [Security](/security). diff --git a/apps/docs/src/content/inspectors/signals.md b/apps/docs/src/content/inspectors/signals.md new file mode 100644 index 0000000..56434f8 --- /dev/null +++ b/apps/docs/src/content/inspectors/signals.md @@ -0,0 +1,52 @@ +--- +title: Signals +description: The live signal graph of one component, with a value history per signal. +--- + +# Signals + +The Signals tab shows the live signal graph of one component: its signal, computed, linkedSignal and effect nodes, and the edges between them. Only signals that a template or an effect has read appear. A signal nothing has read yet is not part of the graph. + +## Where the data comes from + +- **Live**: the [overlay](/getting-started/overlay) reads the graph with Angular's debug API. This needs Angular 19 or later and a development build. +- **Source**: without a live graph, the tab lists `signal()`, `computed()`, `linkedSignal()`, `effect()`, `toSignal()` and resource declarations found in your files, with signal inputs, models and queries. + +## Pick a component + +The **Component** picker at the top selects whose graph you see. + +- **Follow the routed component** (the default) shows the component the router rendered. +- Pick any live component to pin the graph to it. Duplicates are numbered, for example `#2`. +- Without a routed component, the tab shows the first component that has signals. + +A notice appears when the picked component is gone or has no graph, and the tab shows another one. The agent tool `ng-devtools:highlight` also switches the graph to the component it highlights. + +## Nodes + +Filter by name, or by kind with the chips. Each node card shows its kind, label, current value, epoch, dependencies and consumers, plus a **N changes** badge. + +Expand a node to see: + +- **Dependencies (producers)**: the nodes it reads. +- **Consumers**: the nodes and effects that read it. +- **Value history**: recent values, newest first, each with a time and a source tag. + +| Source tag | Meaning | +| ---------- | ------------------------------------------- | +| set | The value was written. This entry is exact. | +| sampled | The overlay saw a new value while polling. | +| initial | The first value the overlay saw. | + +The history keeps 50 changes per signal. When values change faster than the overlay polls, an entry says how many earlier values were not captured. + +## For agents + +- `ng-devtools:get-signals` lists signal declarations from source. +- `ng-devtools:inspect-signals` returns the graph the page reported, with history. +- The `ng-devtools:signal-graph` resource holds the live graph. + +## Tips + +- If a signal is missing, check that something reads it. Signals join the graph when a template or effect reads them. +- The graph shows up to 400 nodes. diff --git a/apps/docs/src/content/inspectors/ssr-http.md b/apps/docs/src/content/inspectors/ssr-http.md new file mode 100644 index 0000000..8c48fa5 --- /dev/null +++ b/apps/docs/src/content/inspectors/ssr-http.md @@ -0,0 +1,33 @@ +--- +title: SSR & HTTP +description: An HTTP timeline for SSR and client calls, fault injection, hydration stats and the TransferState payload. +--- + +# SSR & HTTP + +The SSR & HTTP tab shows the HTTP calls your app makes while rendering on the server and in the browser, the hydration result, and the TransferState payload. It can also inject faults into requests. + +## Requirements + +The SSR & HTTP tab needs `withNgDevtools()` and `provideNgDevtoolsHttp()`, 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. The [SSR & HTTP guide](/guides/ssr-http) shows the setup. + +## Sections + +- **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. + +## Limits + +The timeline keeps the last 200 calls. You can add up to 50 fault rules. Client rules are kept in `sessionStorage`, so they survive a reload of the same tab. + +## For agents + +There is no dedicated tool for this tab. Agents can read its data through the `devframe_state_read` tool with the `ng-devtools:http` key. See [Resources](/agents/resources). + +## Privacy + +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. diff --git a/apps/docs/src/content/security.md b/apps/docs/src/content/security.md new file mode 100644 index 0000000..08eaaa1 --- /dev/null +++ b/apps/docs/src/content/security.md @@ -0,0 +1,83 @@ +--- +title: Access and redaction +description: Who can reach the devtools, and which values are redacted before they leave the page. +--- + +# Access and redaction + +The devtools read your running app and send what they find to a server on your machine. This page covers who can reach that server, and what is redacted on the way. + +## Local-only access + +### Vite plugin + +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'] })`. + +In detail, a request to the devtools must: + +- come from a loopback address (`127.0.0.1` or `::1`), and +- have no `Origin` header, or an origin that is a loopback host, a Chrome extension, an entry in `allowedOrigins`, or a host that Vite's `server.allowedHosts` accepts. + +Other requests get `403` with the message "ng-devtools only answers requests from this machine." WebSocket upgrades follow the same rules. + +```ts +// vite.config.ts +export default defineConfig({ + server: { allowedHosts: ['myapp.test'] }, + plugins: [analog(), ngDevtools({ allowedOrigins: ['https://tunnel.example'] })], +}); +``` + +### Express hub + +`initNgDevtoolsHub()` has two checks, both on by default: + +- **One-time code** (`auth`): the server prints a code, and a browser can read data only after it exchanges that code. Pass `auth: false` to turn it off on a machine only you use. +- **Origin check** (`allowedOrigins`): only loopback origins can open the WebSocket. Pass a list to allow more origins. `false` turns the check off. + +```ts +// server.ts +const devtools = initNgDevtoolsHub({ + auth: false, + allowedOrigins: ['https://tunnel.example'], +}); +app.use(devtools.nodeMiddleware); +``` + +The demo app in this repository sets `allowedOrigins: false` because it runs as a public demo. Keep the check on for your own apps. + +### MCP endpoint + +The HTTP MCP endpoint answers only requests from a loopback address that carry a loopback `Origin` header. See [MCP server](/agents/mcp-server#http). + +### Chrome extension + +The extension connects only to pages served from `localhost` or `127.0.0.1`. On other hosts the panel shows the UI without data. + +## What is redacted + +Live values leave the page: they are sent to the devtools server, shown in the panel and returned to agents. Redacted values are replaced with `[redacted]`. + +### Forms + +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. + +The secret words are: password, passwd, passphrase, passcode, pass, pwd, secret, token, otp, totp, pin, cvv, cvc, csc, ssn, iban, card, cc, credential and credentials. Names are split on camelCase and punctuation, so `userPassword` and `card_number` both match. The pairs apiKey, privateKey, secretKey, accessKey, ccNum, ccNumber and securityCode match as well. + +### Router + +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. + +A navigation whose URL was redacted cannot be replayed. + +### Components, signals and NgRx + +Component inputs, signal values and NgRx state use the same secret names as forms. A value whose name looks secret is replaced with `[redacted]`. JWTs and bearer tokens inside strings and error messages are replaced too. + +### Analog + +Server call previews and URLs are redacted: secret-looking keys in JSON bodies, secret query parameters, JWTs and bearer tokens. Only JSON and plain text responses get a preview, and it is cut at 1000 characters. The `load()` data preview on the open page redacts secret-looking keys too. + +### Not redacted + +Response previews and TransferState values in the [SSR & HTTP tab](/inspectors/ssr-http) are not redacted: they are sent to the devtools server as they are, so don't expose the dev server beyond localhost. diff --git a/apps/docs/src/main.server.ts b/apps/docs/src/main.server.ts new file mode 100644 index 0000000..c054210 --- /dev/null +++ b/apps/docs/src/main.server.ts @@ -0,0 +1,7 @@ +import '@angular/platform-server/init'; +import { render } from '@analogjs/router/server'; + +import { App } from './app/app'; +import { config } from './app/app.config.server'; + +export default render(App, config); diff --git a/apps/docs/src/main.ts b/apps/docs/src/main.ts new file mode 100644 index 0000000..f75ff45 --- /dev/null +++ b/apps/docs/src/main.ts @@ -0,0 +1,6 @@ +import { bootstrapApplication } from '@angular/platform-browser'; + +import { App } from './app/app'; +import { appConfig } from './app/app.config'; + +bootstrapApplication(App, appConfig); diff --git a/apps/docs/src/marked-extensions/index.ts b/apps/docs/src/marked-extensions/index.ts new file mode 100644 index 0000000..30cdc15 --- /dev/null +++ b/apps/docs/src/marked-extensions/index.ts @@ -0,0 +1,37 @@ +import type { MarkedExtension } from 'marked'; +import { ngmdRuntimeExtensions } from './runtime.ts'; + +export { ngmdRuntimeExtensions }; + +/** + * Marked extensions are split into two arrays. + * + * `ngmdRuntimeExtensions` — safe to register on the browser-side marked + * instance via `app.config.ts > provideAppInitializer`. Only token-level + * extensions with no Node deps (currently `` and ``). + * + * `ngmdBuildExtensions` — used at build time by `vite.config.ts > + * markedOptions.extensions`. Adds the build-only extensions that touch + * `node:fs` (code-import) or load a shiki highlighter (code-group), which + * would crash if pulled into the client bundle. + * + * Chrome (cards, tabs, callouts, alerts, pill rows, workflows, hero, code + * blocks) lives as Angular components under `src/app/ui/`, not here. + */ +// Build-time-only extensions are imported lazily below so the runtime bundle +// never resolves their `node:fs` / `shiki` imports. The async getter is +// called by `vite.config.ts` (Node context) only. +export async function getBuildExtensions(): Promise { + const [{ ngmdCodeImportExtension }, { ngmdCodeGroupExtension }, { ngmdCodeHighlightExtension }] = + await Promise.all([ + import('./ngmd-code-import.ts'), + import('./ngmd-code-group.ts'), + import('./ngmd-code-highlight.ts'), + ]); + return [ + ...ngmdRuntimeExtensions, + ngmdCodeImportExtension, + ngmdCodeGroupExtension, + ngmdCodeHighlightExtension, + ]; +} diff --git a/apps/docs/src/marked-extensions/ngmd-code-group.ts b/apps/docs/src/marked-extensions/ngmd-code-group.ts new file mode 100644 index 0000000..39d3d29 --- /dev/null +++ b/apps/docs/src/marked-extensions/ngmd-code-group.ts @@ -0,0 +1,130 @@ +import type { MarkedExtension } from 'marked'; +import { getHighlighter, LANGS, escapeHtml } from './shiki-shared.ts'; + +/** + * Adjacent fenced code blocks tagged with `group="..."` merge into a tabbed + * UI. Tab labels default to the language; pass `name="pnpm"` to override. + * Mark the initial tab with the `active` flag. + * + * ```bash group="install" name="pnpm" active + * pnpm create ngmd@latest my-docs + * ``` + * + * ```bash group="install" name="npm" + * npm create ngmd@latest my-docs + * ``` + * + * Implementation: preprocess runs before marked tokenizes. It finds + * consecutive group fences, pre-renders each body through a cached shiki + * highlighter, and emits a single self-contained HTML wrapper. By the time + * marked sees it, it's a finished `
` block with `
` children — no
+ * fenced-code re-parsing, no tokenizer race with marked-shiki, no marked
+ * HTML-block quirks.
+ */
+
+let groupCounter = 0;
+
+const FENCE_WITH_GROUP_RE =
+  /^```([\w-]+)?[\t ]+([^\n]*?\bgroup="([^"]+)"[^\n]*)\n([\s\S]*?)\n```$/gm;
+
+interface Fence {
+  start: number;
+  end: number;
+  lang: string;
+  attrs: string;
+  group: string;
+  body: string;
+}
+
+function getAttr(attrs: string, name: string): string | undefined {
+  return new RegExp(`${name}="([^"]*)"`).exec(attrs)?.[1];
+}
+
+function hasFlag(attrs: string, name: string): boolean {
+  return new RegExp(`(^|\\s)${name}(\\s|$)`).test(attrs);
+}
+
+async function renderCode(body: string, lang: string): Promise {
+  const safeLang = LANGS.includes(lang) ? lang : 'text';
+  const highlighter = await getHighlighter();
+  return highlighter.codeToHtml(body, {
+    lang: safeLang,
+    themes: { light: 'github-light', dark: 'github-dark' },
+    defaultColor: false,
+  });
+}
+
+export const ngmdCodeGroupExtension: MarkedExtension = {
+  hooks: {
+    async preprocess(markdown: string): Promise {
+      const fences: Fence[] = [];
+      const re = new RegExp(FENCE_WITH_GROUP_RE.source, FENCE_WITH_GROUP_RE.flags);
+      let m: RegExpExecArray | null;
+      while ((m = re.exec(markdown)) !== null) {
+        fences.push({
+          start: m.index,
+          end: m.index + m[0].length,
+          lang: m[1] ?? '',
+          attrs: m[2],
+          group: m[3],
+          body: m[4],
+        });
+      }
+      if (fences.length === 0) return markdown;
+
+      // Cluster consecutive same-group fences (whitespace-only between).
+      const clusters: Fence[][] = [];
+      let current: Fence[] = [];
+      for (const f of fences) {
+        if (
+          current.length > 0 &&
+          current[0].group === f.group &&
+          /^\s*$/.test(markdown.slice(current.at(-1)!.end, f.start))
+        ) {
+          current.push(f);
+        } else {
+          if (current.length > 0) clusters.push(current);
+          current = [f];
+        }
+      }
+      if (current.length > 0) clusters.push(current);
+
+      // Replace from end to start so indices stay valid.
+      let result = markdown;
+      for (let i = clusters.length - 1; i >= 0; i--) {
+        const c = clusters[i];
+        if (c.length < 2) continue;
+
+        const groupId = `cg-${++groupCounter}`;
+        let activeIdx = c.findIndex((f) => hasFlag(f.attrs, 'active'));
+        if (activeIdx === -1) activeIdx = 0;
+
+        const tabs = c
+          .map((f, idx) => {
+            const name = getAttr(f.attrs, 'name') ?? (f.lang || `tab ${idx + 1}`);
+            const image = getAttr(f.attrs, 'image');
+            const imgHtml = image
+              ? ``
+              : '';
+            return ``;
+          })
+          .join('');
+
+        const panels = (
+          await Promise.all(
+            c.map(async (f, idx) => {
+              const html = await renderCode(f.body, f.lang);
+              return `
${html}
`; + }), + ) + ).join(''); + + const wrapper = `\n\n
${tabs}
${panels}
\n\n`; + + result = result.slice(0, c[0].start) + wrapper + result.slice(c.at(-1)!.end); + } + + return result; + }, + }, +}; diff --git a/apps/docs/src/marked-extensions/ngmd-code-highlight.ts b/apps/docs/src/marked-extensions/ngmd-code-highlight.ts new file mode 100644 index 0000000..72ba594 --- /dev/null +++ b/apps/docs/src/marked-extensions/ngmd-code-highlight.ts @@ -0,0 +1,104 @@ +import type { MarkedExtension } from 'marked'; +import { getHighlighter, LANGS } from './shiki-shared.ts'; + +/** + * Fenced code blocks tagged with `{1,3-5}` get the matching lines visually + * highlighted. Comma-separated ranges, GitHub-style: `{1}`, `{3-5}`, `{1,3-5,8}`. + * + * ```ts {3-5} + * import { Component } from '@angular/core'; + * + * @Component({ + * selector: 'app-hello', + * template: '

Hello

', + * }) + * export class Hello {} + * ``` + * + * Implementation: preprocess detects the meta, pre-renders the fence via a + * cached shiki highlighter, then post-processes the rendered HTML to add + * `class="highlighted"` to matching `` elements. CSS in + * styles.css tints those lines. + * + * Scope: skips fences with `group="..."` (handled by ngmd-code-group) or + * `file="..."` (handled by ngmd-code-import). One fence, one treatment. + */ + +// Capture: lang, line ranges in {}, body. Skips fences whose info string +// contains `group=` or `file=` so those routes own the fence. +const FENCE_RE = /^```([\w-]+)?[\t ]+\{([0-9,\-\s]+)\}[\t ]*\n([\s\S]*?)\n```$/gm; + +function parseRanges(spec: string): Set { + const lines = new Set(); + for (const part of spec + .split(',') + .map((s) => s.trim()) + .filter(Boolean)) { + const m = part.match(/^(\d+)(?:-(\d+))?$/); + if (!m) continue; + const start = parseInt(m[1], 10); + const end = m[2] ? parseInt(m[2], 10) : start; + for (let i = start; i <= end; i++) lines.add(i); + } + return lines; +} + +/** + * Walks the shiki output's `` elements, adds the + * `highlighted` class to lines whose 1-indexed position is in `set`. + */ +function applyHighlights(html: string, set: Set): string { + let lineNum = 0; + return html.replace(/ { + lineNum++; + return set.has(lineNum) ? ' { + // Quick negative check before scanning. + if (!/^```[\w-]*[\t ]+\{[0-9,\-\s]+\}/m.test(markdown)) return markdown; + + const matches: { start: number; end: number; lang: string; spec: string; body: string }[] = + []; + const re = new RegExp(FENCE_RE.source, FENCE_RE.flags); + let m: RegExpExecArray | null; + while ((m = re.exec(markdown)) !== null) { + // Skip if the fence also carries `group=` or `file=` (other ext owns). + const infoLineEnd = markdown.indexOf('\n', m.index); + const infoLine = markdown.slice(m.index, infoLineEnd); + if (/\b(?:group|file)="/.test(infoLine)) continue; + matches.push({ + start: m.index, + end: m.index + m[0].length, + lang: m[1] ?? '', + spec: m[2], + body: m[3], + }); + } + if (matches.length === 0) return markdown; + + const highlighter = await getHighlighter(); + const renders = await Promise.all( + matches.map(async (mt) => { + const safeLang = LANGS.includes(mt.lang) ? mt.lang : 'text'; + const raw = highlighter.codeToHtml(mt.body, { + lang: safeLang, + themes: { light: 'github-light', dark: 'github-dark' }, + defaultColor: false, + }); + return applyHighlights(raw, parseRanges(mt.spec)); + }), + ); + + let result = markdown; + for (let i = matches.length - 1; i >= 0; i--) { + const mt = matches[i]; + result = result.slice(0, mt.start) + `\n\n${renders[i]}\n\n` + result.slice(mt.end); + } + return result; + }, + }, +}; diff --git a/apps/docs/src/marked-extensions/ngmd-code-import.ts b/apps/docs/src/marked-extensions/ngmd-code-import.ts new file mode 100644 index 0000000..35231ac --- /dev/null +++ b/apps/docs/src/marked-extensions/ngmd-code-import.ts @@ -0,0 +1,119 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import type { MarkedExtension } from 'marked'; +import { getHighlighter, LANGS, escapeHtml } from './shiki-shared.ts'; +import config from '../ngmd.config.ts'; + +/** + * Fenced code blocks can import their content from a source file by adding + * `file="..."` to the info string. Supports GitHub-style `#L5-L20` line + * ranges so docs reference the *real* code instead of a hand-typed copy + * that rots out of sync. + * + * ```ts file="src/app/hello.ts" + * ``` + * + * ```ts file="src/app/hello.ts#L5-L20" + * ``` + * + * Renders as a `
` wrapper with a header bar + * linking to the file on GitHub (via `ngmd.config.ts > site.githubUrl`). + * Lines marked `// ngmd-ignore-line` are stripped from the imported snippet. + * + * Pre-rendered through the shared shiki highlighter so the output is one + * self-contained HTML block — marked never sees the inner fence. + */ + +const FENCE_RE = /^```([\w-]+)?[\t ]+file="([^"]+)"[^\n]*\n(?:([\s\S]*?)\n)?```$/gm; +const IGNORE_LINE_RE = /^.*\/\/\s*ngmd-ignore-line\s*$/; + +function loadFile(spec: string): { code: string; rangeFragment: string } { + const [path, range] = spec.split('#'); + const full = resolve(process.cwd(), path); + let content = readFileSync(full, 'utf8'); + + let rangeFragment = ''; + if (range) { + const m = range.match(/^L(\d+)(?:-L?(\d+))?$/); + if (m) { + const start = parseInt(m[1], 10); + const end = m[2] ? parseInt(m[2], 10) : start; + const lines = content.split('\n'); + content = lines.slice(start - 1, end).join('\n'); + rangeFragment = m[2] ? `#L${start}-L${end}` : `#L${start}`; + } + } + + const filtered = content + .split('\n') + .filter((l) => !IGNORE_LINE_RE.test(l)) + .join('\n'); + + return { code: filtered.replace(/\n+$/, ''), rangeFragment }; +} + +function githubBlobUrl(filePath: string, rangeFragment: string): string { + const repo = config.site.githubUrl.replace(/\.git$/, ''); + // encodeURI keeps `/` and `.` as-is but escapes brackets, so paths like + // `src/app/pages/[...slug].page.ts` resolve on GitHub instead of breaking. + return `${repo}/blob/main/${encodeURI(filePath)}${rangeFragment}`; +} + +export const ngmdCodeImportExtension: MarkedExtension = { + hooks: { + async preprocess(markdown: string): Promise { + if (!/^```[\w-]*[\t ]+file="/m.test(markdown)) return markdown; + + const matches: { + start: number; + end: number; + lang: string; + filePath: string; + rangeFragment: string; + code: string; + }[] = []; + + const re = new RegExp(FENCE_RE.source, FENCE_RE.flags); + let m: RegExpExecArray | null; + while ((m = re.exec(markdown)) !== null) { + const lang = m[1] ?? ''; + const spec = m[2]; + try { + const { code, rangeFragment } = loadFile(spec); + matches.push({ + start: m.index, + end: m.index + m[0].length, + lang, + filePath: spec.split('#')[0], + rangeFragment, + code, + }); + } catch (e) { + const msg = (e as Error).message ?? String(e); + console.warn(`[ngmd-code-import] failed to load "${spec}": ${msg}`); + } + } + if (matches.length === 0) return markdown; + + const highlighter = await getHighlighter(); + const renders = matches.map((mt) => { + const safeLang = LANGS.includes(mt.lang) ? mt.lang : 'text'; + const codeHtml = highlighter.codeToHtml(mt.code, { + lang: safeLang, + themes: { light: 'github-light', dark: 'github-dark' }, + defaultColor: false, + }); + const headerLabel = mt.filePath + (mt.rangeFragment || ''); + const headerHtml = `${escapeHtml(headerLabel)}`; + return `
${headerHtml}${codeHtml}
`; + }); + + let result = markdown; + for (let i = matches.length - 1; i >= 0; i--) { + const mt = matches[i]; + result = result.slice(0, mt.start) + `\n\n${renders[i]}\n\n` + result.slice(mt.end); + } + return result; + }, + }, +}; diff --git a/apps/docs/src/marked-extensions/ngmd-image.ts b/apps/docs/src/marked-extensions/ngmd-image.ts new file mode 100644 index 0000000..d7a4185 --- /dev/null +++ b/apps/docs/src/marked-extensions/ngmd-image.ts @@ -0,0 +1,45 @@ +import type { Tokens } from 'marked'; + +interface NgmdImageToken extends Tokens.Generic { + type: 'ngmd-image'; + src: string; + alt: string; + caption?: string; + width?: string; +} + +// Accepts both self-closing `` and paired +// `` (HTML5 parsers don't honour the +// self-closing form for custom elements, so authoring docs use the +// paired form). `s` flag lets attributes span multiple lines. +const tagRule = /^]*?)(?:\/>|>\s*<\/ngmd-image>)/s; +const attrRule = (name: string) => new RegExp(`${name}="([^"]*)"`); + +export const ngmdImageExtension = { + name: 'ngmd-image', + level: 'block' as const, + start(src: string) { + return src.match(/^\s*
`; + }, +}; diff --git a/apps/docs/src/marked-extensions/ngmd-keywords.ts b/apps/docs/src/marked-extensions/ngmd-keywords.ts new file mode 100644 index 0000000..db2e351 --- /dev/null +++ b/apps/docs/src/marked-extensions/ngmd-keywords.ts @@ -0,0 +1,76 @@ +import type { MarkedExtension, Tokens } from 'marked'; +import config from '../ngmd.config.ts'; + +/** + * Inline keyword auto-linking. Any `*Keyword` token (where `Keyword` is + * defined in `ngmd.config.ts > keywords`) becomes a link. + * + * *AnalogJS → AnalogJS + * *NgMd → NgMd + * + * Unknown keywords log a one-line warning and fall through to the default + * inline tokenizer — they render as literal `*Keyword` text. External URLs + * get `target="_blank" rel="noopener noreferrer"` automatically. + * + * Lives in the inline tokenizer chain, so it never fires inside fenced code + * blocks or inline code (those are block-level and tokenized first). + */ + +interface NgmdKeywordToken extends Tokens.Generic { + type: 'ngmdKeyword'; + keyword: string; + url: string; +} + +// `(?!\*)` after the leading `*` prevents matching the second `*` of a +// `**bold**` pair. `(?!\*)` after the keyword prevents matching the inside +// of `**Keyword**` (which would leave one stray `*` and one stray `**`). +const KEYWORD_RE = /^\*(?!\*)([A-Z][a-zA-Z0-9]+)\b(?!\*)/; +const HINT_RE = /\*(?!\*)[A-Z]/; +const warned = new Set(); + +function lookup(keyword: string): string | undefined { + return config.keywords?.[keyword]; +} + +function escapeAttr(s: string): string { + return s.replace(/"/g, '"'); +} + +export const ngmdKeywordsExtension: MarkedExtension = { + extensions: [ + { + name: 'ngmdKeyword', + level: 'inline', + start(src: string) { + return src.match(HINT_RE)?.index; + }, + tokenizer(src: string): NgmdKeywordToken | undefined { + const m = KEYWORD_RE.exec(src); + if (!m) return undefined; + const url = lookup(m[1]); + if (!url) { + if (!warned.has(m[1])) { + warned.add(m[1]); + console.warn( + `[ngmd-keywords] unknown keyword "${m[1]}" — add it to ngmd.config.ts > keywords or escape the asterisk.`, + ); + } + return undefined; + } + return { + type: 'ngmdKeyword', + raw: m[0], + keyword: m[1], + url, + }; + }, + renderer(token: Tokens.Generic) { + const t = token as NgmdKeywordToken; + const isExternal = /^https?:\/\//.test(t.url); + const targetAttrs = isExternal ? ' target="_blank" rel="noopener noreferrer"' : ''; + return `${t.keyword}`; + }, + }, + ], +}; diff --git a/apps/docs/src/marked-extensions/ngmd-video.ts b/apps/docs/src/marked-extensions/ngmd-video.ts new file mode 100644 index 0000000..35329dd --- /dev/null +++ b/apps/docs/src/marked-extensions/ngmd-video.ts @@ -0,0 +1,52 @@ +import type { Tokens } from 'marked'; + +interface NgmdVideoToken extends Tokens.Generic { + type: 'ngmd-video'; + src: string; + title?: string; +} + +// Accepts both self-closing `` and paired +// `` (HTML5 parsers don't honour the +// self-closing form for custom elements, so authoring docs use the +// paired form). `s` flag lets attributes span multiple lines. +const tagRule = /^]*?)(?:\/>|>\s*<\/ngmd-video>)/s; +const srcRule = /src="([^"]*)"/; +const titleRule = /title="([^"]*)"/; + +function buildEmbedUrl(src: string): string { + if (src.startsWith('https://www.youtube.com/embed/')) return src; + const yt = src.match(/youtube\.com\/watch\?v=([\w-]+)/); + if (yt) return `https://www.youtube.com/embed/${yt[1]}`; + const ytShort = src.match(/youtu\.be\/([\w-]+)/); + if (ytShort) return `https://www.youtube.com/embed/${ytShort[1]}`; + const vm = src.match(/vimeo\.com\/(\d+)/); + if (vm) return `https://player.vimeo.com/video/${vm[1]}`; + return src; +} + +export const ngmdVideoExtension = { + name: 'ngmd-video', + level: 'block' as const, + start(src: string) { + return src.match(/^\s*
`; + }, +}; diff --git a/apps/docs/src/marked-extensions/runtime.ts b/apps/docs/src/marked-extensions/runtime.ts new file mode 100644 index 0000000..9befe76 --- /dev/null +++ b/apps/docs/src/marked-extensions/runtime.ts @@ -0,0 +1,11 @@ +import type { MarkedExtension } from 'marked'; +import { ngmdVideoExtension } from './ngmd-video.ts'; +import { ngmdImageExtension } from './ngmd-image.ts'; +import { ngmdKeywordsExtension } from './ngmd-keywords.ts'; + +export const ngmdRuntimeExtensions: MarkedExtension[] = [ + { + extensions: [ngmdVideoExtension, ngmdImageExtension], + }, + ngmdKeywordsExtension, +]; diff --git a/apps/docs/src/marked-extensions/shiki-shared.ts b/apps/docs/src/marked-extensions/shiki-shared.ts new file mode 100644 index 0000000..193eafa --- /dev/null +++ b/apps/docs/src/marked-extensions/shiki-shared.ts @@ -0,0 +1,42 @@ +import { createHighlighter, type Highlighter } from 'shiki'; + +/** + * Shared shiki highlighter instance used by every build-time fence extension + * that pre-renders code (code-group, code-import, code-highlight). Loading + * the highlighter is expensive (parses tmGrammar files for every language), + * so we keep a single promise per process. + */ + +let highlighterPromise: Promise | null = null; + +export const LANGS = [ + 'bash', + 'json', + 'ts', + 'tsx', + 'js', + 'jsx', + 'html', + 'css', + 'md', + 'angular-html', + 'angular-ts', +]; + +export function getHighlighter(): Promise { + if (!highlighterPromise) { + highlighterPromise = createHighlighter({ + themes: ['github-light', 'github-dark'], + langs: LANGS, + }); + } + return highlighterPromise; +} + +export function escapeHtml(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} diff --git a/apps/docs/src/ngmd.config.ts b/apps/docs/src/ngmd.config.ts new file mode 100644 index 0000000..ab80d26 --- /dev/null +++ b/apps/docs/src/ngmd.config.ts @@ -0,0 +1,234 @@ +/** + * NgMd site configuration. + * + * Edit this file to customise navigation, site metadata, and external links. + * Sidebar, command palette, breadcrumb, and header all read from here. + */ + +import type { BadgeVariant } from './types/badge.ts'; + +export interface NavItem { + label: string; + href: string; + /** Optional lifecycle marker rendered as a coloured chip beside the + * sidebar label. Accepts any value from the shared `BadgeVariant` set + * (`new`, `updated`, `alpha`, `beta`, `stable`, `deprecated`), so the + * sidebar chip and inline `` always stay in sync. */ + status?: BadgeVariant; +} + +export interface NavSection { + label: string; + items: NavItem[]; +} + +/** + * Lifecycle marker for a documentation version. Drives the chip rendered + * beside the version label in the switcher and the banner shown above + * content when this deployment isn't the current stable release. + * + * - `current`: the production stable. Most visitors should land here. + * - `next`: the upcoming release, served from a `next.*` subdomain. + * - `rc`: release candidate, served from an `rc.*` subdomain. + * - `deprecated`: older stable that's been superseded. + */ +export type VersionStatus = 'current' | 'next' | 'rc' | 'deprecated'; + +export interface VersionEntry { + /** Switcher label, e.g. `v17`, `v18`, `next`. */ + label: string; + /** External deployment URL. NgMd follows the adev / PrimeNG model of + * per-version subdomains (`v17.example.com`, `next.example.com`). The + * live deployment renders one version of the docs; other entries link + * out via ``. */ + url: string; + /** Lifecycle marker. */ + status: VersionStatus; +} + +export interface VersionsConfig { + /** Label of the entry that represents THIS deployment. The switcher + * marks it as the active row (no external link), and the content + * banner reads its status to decide whether to nudge visitors toward + * the current stable. */ + self: string; + /** Ordered list rendered in the version switcher dropdown. Newest at + * the top is the convention adev and PrimeNG both follow. */ + list: VersionEntry[]; +} + +export interface SiteConfig { + /** Brand name shown in the header next to the logo. */ + name: string; + /** One-liner description used in meta tags + social previews. */ + description: string; + /** Short tagline shown after the brand in the homepage ``. */ + tagline?: string; + /** Public origin (no trailing slash). Used by sitemap.xml + robots.txt. */ + url: string; + /** Repository URL. Powers the GitHub icon in the header. */ + githubUrl: string; + /** Default branch used to build GitHub blob/edit links (e.g. the + * "view source" link on API symbol pages). Defaults to `main` when + * omitted. Set this if the repo's default branch isn't `main`. */ + githubBranch?: string; + /** Optional social / community links rendered in the header. */ + links?: { + twitter?: string; + discord?: string; + sponsor?: string; + }; + /** + * Optional Algolia DocSearch credentials. When all three are set, the + * command palette queries Algolia instead of the bundled Orama index. + * Requires `algoliasearch` as a runtime dep: `pnpm add algoliasearch`. + * Leave undefined to keep the default local search. + */ + algolia?: { + appId: string; + apiKey: string; + indexName: string; + }; +} + +export interface Sponsor { + name: string; + login: string; +} + +export interface NgmdConfig { + site: SiteConfig; + sponsors?: Sponsor[]; + /** Sidebar sections, in render order. */ + nav: NavSection[]; + /** + * Inline-link keywords. In any `.md` body, `*Keyword` resolves to a link + * pointing at the configured URL. Unknown keywords log a warning and fall + * back to literal `*Keyword` text. Change the URL here once, every doc + * follows. + */ + keywords?: Record<string, string>; + /** + * Documentation version registry. When set with more than one entry, the + * version switcher renders in the header. Each entry is a separate + * deployment (its own URL); the live site renders one version and the + * switcher links out to the others — the adev / PrimeNG model, no in-repo + * historical content. Leave undefined for single-version sites. + */ + versions?: VersionsConfig; +} + +const config: NgmdConfig = { + site: { + name: 'Angular DevTools', + description: + 'Inspect Angular components, signals, dependency injection, routes, forms and stores. In the page, from the CLI, or through a coding agent over MCP.', + tagline: 'Devtools for Angular apps and coding agents', + url: 'https://santoshyadavdev.github.io/angular-devtools', + githubUrl: 'https://github.com/santoshyadavdev/angular-devtools', + links: { + discord: 'https://discord.gg/YRTyJd6Qx', + sponsor: 'https://github.com/sponsors/santoshyadavdev', + }, + }, + + sponsors: [ + { name: 'CodeRabbit', login: 'coderabbitai' }, + { name: 'umairhm', login: 'umairhm' }, + { name: 'Sonichigo', login: 'Sonichigo' }, + ], + + keywords: { + Angular: 'https://angular.dev', + Analog: 'https://analogjs.org', + Devframe: 'https://devfra.me', + NgRx: 'https://ngrx.io', + MCP: 'https://modelcontextprotocol.io', + Vite: 'https://vite.dev', + }, + + nav: [ + { + label: 'Getting Started', + items: [ + { label: 'Introduction', href: '/getting-started/introduction' }, + { label: 'Installation', href: '/getting-started/installation' }, + { label: 'Angular CLI and Express', href: '/getting-started/express' }, + { label: 'Vite and Analog', href: '/getting-started/vite' }, + { label: 'Standalone CLI', href: '/getting-started/cli' }, + { label: 'Popup and hub', href: '/getting-started/popup-and-hub' }, + { label: 'Browser overlay', href: '/getting-started/overlay' }, + { label: 'Chrome extension', href: '/getting-started/chrome-extension' }, + ], + }, + { + label: 'Inspectors', + items: [ + { label: 'Dashboard', href: '/inspectors/dashboard' }, + { label: 'Components', href: '/inspectors/components' }, + { label: 'Injectors', href: '/inspectors/injectors' }, + { label: 'Signals', href: '/inspectors/signals' }, + { label: 'NgRx Store', href: '/inspectors/ngrx-store' }, + { label: 'Forms', href: '/inspectors/forms' }, + { label: 'Router', href: '/inspectors/router' }, + { label: 'Pipes', href: '/inspectors/pipes' }, + { label: 'SSR & HTTP', href: '/inspectors/ssr-http' }, + { label: 'Analog', href: '/inspectors/analog' }, + ], + }, + { + label: 'Agent Tools', + items: [ + { label: 'MCP server', href: '/agents/mcp-server' }, + { label: 'Tools', href: '/agents/tools' }, + { label: 'Resources', href: '/agents/resources' }, + ], + }, + { + label: 'Guides', + items: [ + { label: 'Restore NgRx signal state', href: '/guides/ngrx-signals-restore' }, + { label: 'Set up SSR & HTTP', href: '/guides/ssr-http' }, + { label: 'Set up Analog', href: '/guides/analog' }, + ], + }, + { + label: 'Security', + items: [{ label: 'Access and redaction', href: '/security' }], + }, + { + label: 'Community', + items: [ + { label: 'Get involved', href: '/community' }, + { label: 'Sponsors', href: '/sponsors' }, + ], + }, + { + label: 'Contributing', + items: [ + { label: 'Development setup', href: '/contributing/development' }, + { label: 'Demo apps', href: '/contributing/demo-apps' }, + { label: 'Build the extension', href: '/contributing/chrome-extension' }, + { label: 'Publishing', href: '/contributing/publishing' }, + ], + }, + ], +}; + +export default config; + +/** Flattened list of all nav items, useful for command palette / search. */ +export const navItems = config.nav.flatMap((section) => + section.items.map((item) => ({ + label: item.label, + href: item.href, + section: section.label, + })), +); + +/** Map of last URL segment to its human label, useful for breadcrumb. */ +export const navLabels = Object.fromEntries( + config.nav.flatMap((section) => + section.items.map((item) => [item.href.split('/').pop() ?? '', item.label]), + ), +); diff --git a/apps/docs/src/styles.css b/apps/docs/src/styles.css new file mode 100644 index 0000000..0a4d9a2 --- /dev/null +++ b/apps/docs/src/styles.css @@ -0,0 +1,819 @@ +@import 'tailwindcss'; + +/* Tailwind v4 doesn't scan `.md` by default. Tell it to, so authoring + * components can take Tailwind classes (`<ngmd-accordion class="my-8">`, + * etc.) and have those utilities actually appear in the generated CSS. */ +@source "./content/**/*.md"; +@source not inline("bg-[image:var(...)]"); + +/* View Transitions API tuning. Angular's withViewTransitions() crossfades + * route changes via these pseudo-elements. Shorten the default ~250ms to + * 150ms so it stays snappy. */ +::view-transition-old(root), +::view-transition-new(root) { + animation-duration: 150ms; + animation-timing-function: ease; +} + +/* Hero title animation. The gradient span slowly shifts horizontally so the + * amber core appears to pulse through the text. Whole h1 fades up on + * first paint. Honours prefers-reduced-motion. */ +@keyframes ngmd-hero-fade-in { + from { + opacity: 0; + transform: translateY(8px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes ngmd-hero-gradient-flow { + from { + background-position: 200% 50%; + } + + to { + background-position: -100% 50%; + } +} + +.ngmd-hero-fade { + animation: ngmd-hero-fade-in 600ms cubic-bezier(0.22, 1, 0.36, 1) both; +} + +/* Hide hero words and the gradient line at first paint so SSR HTML doesn't + * flash before motion takes over. Motion sets opacity:1 + translateY(0) + * via inline styles, which override these defaults. */ +.ngmd-hero-anim { + opacity: 0; + transform: translateY(0.5em); +} + +@media (prefers-reduced-motion: reduce) { + .ngmd-hero-anim { + opacity: 1; + transform: none; + } +} + +.ngmd-hero-gradient { + background-size: 300% auto; + animation: ngmd-hero-gradient-flow 5s linear infinite; +} + +/* Toast slide-in. Each toast in `<app-toaster>` starts off-screen right and + * eases in. Reduced-motion users get a plain fade so nothing flies across + * the viewport. */ +@keyframes ngmd-toast-slide-in { + from { + opacity: 0; + transform: translateX(110%); + } + + to { + opacity: 1; + transform: translateX(0); + } +} + +@keyframes ngmd-toast-slide-out { + from { + opacity: 1; + transform: translateX(0); + } + + to { + opacity: 0; + transform: translateX(110%); + } +} + +@keyframes ngmd-toast-fade-in { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +@keyframes ngmd-toast-fade-out { + from { + opacity: 1; + } + + to { + opacity: 0; + } +} + +.ngmd-toast-slide { + animation: ngmd-toast-slide-in 260ms cubic-bezier(0.22, 1, 0.36, 1) both; +} + +.ngmd-toast-slide-out { + animation: ngmd-toast-slide-out 220ms cubic-bezier(0.55, 0, 0.78, 0.2) both; +} + +@media (prefers-reduced-motion: reduce) { + .ngmd-toast-slide { + animation: ngmd-toast-fade-in 200ms ease-out both; + } + + .ngmd-toast-slide-out { + animation: ngmd-toast-fade-out 180ms ease-in both; + } +} + +@media (prefers-reduced-motion: reduce) { + .ngmd-hero-fade, + .ngmd-hero-gradient { + animation: none; + } +} + +@plugin '@tailwindcss/typography'; + +@variant dark (&:where(.dark, .dark *)); + +/* Hide NgmdUi Custom Elements inside markdown until @angular/elements has + * registered them (Angular-rendered hosts in .page.ts are already complete). + * Without this, raw children (often plain <code> spans projected via + * ng-content) render with default inline-code styling for a few ms during + * the bundle load + provideAppInitializer step — looks like the title sits + * inside a code pill until the element upgrades. `:not(:defined)` ceases + * matching the moment customElements.define() runs. */ +:is(analog-markdown, analog-markdown-route) ngmd-accordion:not(:defined), +:is(analog-markdown, analog-markdown-route) ngmd-accordion-item:not(:defined), +:is(analog-markdown, analog-markdown-route) ngmd-alert:not(:defined), +:is(analog-markdown, analog-markdown-route) ngmd-badge:not(:defined), +:is(analog-markdown, analog-markdown-route) ngmd-callout:not(:defined), +:is(analog-markdown, analog-markdown-route) ngmd-card:not(:defined), +:is(analog-markdown, analog-markdown-route) ngmd-card-grid:not(:defined), +:is(analog-markdown, analog-markdown-route) ngmd-hero:not(:defined), +:is(analog-markdown, analog-markdown-route) ngmd-image:not(:defined), +:is(analog-markdown, analog-markdown-route) ngmd-pill:not(:defined), +:is(analog-markdown, analog-markdown-route) ngmd-pill-row:not(:defined), +:is(analog-markdown, analog-markdown-route) ngmd-step:not(:defined), +:is(analog-markdown, analog-markdown-route) ngmd-tabs:not(:defined), +:is(analog-markdown, analog-markdown-route) ngmd-video:not(:defined), +:is(analog-markdown, analog-markdown-route) ngmd-workflow:not(:defined) { + visibility: hidden; +} + +@layer base { + /* Host margin lives here (not on the inner template div) so a class + * on the markdown tag overrides it. Inner-div margins are trapped by + * the flex/grid formatting context they sit in, so `class="my-8"` + * written in .md cannot reach them. With margin on the host + + * `display: block`, `<ngmd-callout class="my-8">` works as expected. + * Lives inside `@layer base` so Tailwind utilities (which sit in the + * later `@layer utilities`) win on cascade and the `class=` override + * actually applies. Unlayered rules would otherwise always beat + * layered ones regardless of selector specificity. */ + ngmd-accordion, + ngmd-callout, + ngmd-card-grid, + ngmd-code-block, + ngmd-image, + ngmd-tabs, + ngmd-video, + ngmd-hero, + ngmd-workflow, + ngmd-alert, + ngmd-pill-row { + display: block; + margin: 1.5rem 0; + } +} + +@layer base { + :root { + /* Surface */ + --bg: #ffffff; + --bg-muted: #f4f4f5; + --fg: #0a0a0a; + --muted: #71717a; + --border: #e4e4e7; + --border-strong: #d4d4d8; + + /* Brand (used by accent-aware components) */ + --primary: #18181b; + --primary-fg: #fafafa; + --accent: #b45309; + --accent-strong: #92400e; + --accent-fg: #ffffff; + --accent-soft: rgba(245, 165, 36, 0.16); + --accent-gradient: linear-gradient(to right, #b45309 0%, #d97706 50%, #92400e 100%); + --accent-gradient-soft: linear-gradient( + to bottom right, + rgba(251, 191, 36, 0.12) 0%, + rgba(245, 165, 36, 0.1) 50%, + rgba(217, 119, 6, 0.08) 100% + ); + --code-border-gradient: linear-gradient(135deg, #fcd34d, #f5a524, #b45309); + --line-highlight: rgba(245, 165, 36, 0.16); + + /* Geometry */ + --radius-sm: 0.25rem; + --radius: 0.5rem; + --radius-lg: 0.75rem; + --radius-xl: 1rem; + + /* Typography */ + --font-sans: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, sans-serif; + --font-display: 'Geist Mono', ui-monospace, SFMono-Regular, Menlo, monospace; + --font-mono: ui-monospace, SFMono-Regular, Menlo, monospace; + + font-family: var(--font-sans); + color-scheme: light; + } + + .dark { + --bg: #0a0a0a; + --bg-muted: #18181b; + --fg: #fafafa; + --muted: #a1a1aa; + --border: #27272a; + --border-strong: #3f3f46; + + --primary: #fafafa; + --primary-fg: #18181b; + --accent: #f5a524; + --accent-strong: #fcd34d; + --accent-fg: #18181b; + --accent-soft: rgba(245, 165, 36, 0.15); + --accent-gradient: linear-gradient(to right, #fbbf24 0%, #f5a524 50%, #fcd34d 100%); + --accent-gradient-soft: linear-gradient( + to bottom right, + rgba(251, 191, 36, 0.1) 0%, + rgba(245, 165, 36, 0.1) 50%, + rgba(217, 119, 6, 0.1) 100% + ); + --code-border-gradient: linear-gradient(135deg, #fde68a, #f5a524, #d97706); + --line-highlight: rgba(245, 165, 36, 0.14); + + color-scheme: dark; + } + + html, + body { + margin: 0; + background: var(--bg); + color: var(--fg); + min-height: 100vh; + } + + a { + text-decoration: inherit; + } + + /* `<mark>` is emitted by both search providers around the matched + * substring. Match-on-accent so users see why each row hit without the + * default browser yellow flash. */ + mark { + background: var(--accent-soft); + color: var(--accent-strong); + border-radius: 0.2rem; + padding: 0 0.1em; + } + + /* Scrollbar styling, ported from angular.dev (shared-docs + * `_scroll-track.scss`). Opt in by adding the class to a scrollable + * element. Global / native scrollbars stay browser-default. + * + * - `.ngmd-scroll-track` — 8px, large surfaces (main page) + * - `.ngmd-scroll-track-mini` — 6px, side surfaces (sidebar, TOC, code) + * + * Both: transparent track, rounded thumb, colour shift on hover. */ + .ngmd-scroll-track::-webkit-scrollbar-track, + .ngmd-scroll-track-mini::-webkit-scrollbar-track, + .ngmd-scroll-track::-webkit-scrollbar-corner, + .ngmd-scroll-track-mini::-webkit-scrollbar-corner { + background: transparent; + } + + .ngmd-scroll-track::-webkit-scrollbar { + width: 8px; + height: 8px; + } + + .ngmd-scroll-track-mini::-webkit-scrollbar { + width: 6px; + height: 6px; + } + + /* Main page (body) — adev pattern: base is brighter (medium gray) and + * hover goes a notch darker. Different palette step from the side + * scroll so they don't read identical. */ + .ngmd-scroll-track::-webkit-scrollbar-thumb { + background-color: color-mix(in srgb, var(--muted) 45%, transparent); + border-radius: 10px; + transition: background-color 0.3s ease; + } + + .ngmd-scroll-track::-webkit-scrollbar-thumb:hover { + background-color: var(--muted); + } + + /* Side surfaces — pick up colour on hover so the user knows they can + * grab the small thumb. */ + .ngmd-scroll-track-mini::-webkit-scrollbar-thumb { + background-color: var(--border-strong); + border-radius: 10px; + transition: background-color 0.3s ease; + } + + .ngmd-scroll-track-mini::-webkit-scrollbar-thumb:hover { + background-color: var(--muted); + } +} + +@layer components { + /* Suppress pointer-driven focus rings on <details>/<summary> (the cyan + * flash on click is the browser default outline). Keyboard focus is left + * untouched — `:focus-visible` only fires for keyboard / tab navigation + * and gets a proper accent outline below. */ + details:focus:not(:focus-visible), + summary:focus:not(:focus-visible) { + outline: none; + box-shadow: none; + } + + /* Same treatment for the accordion-item button. */ + .ngmd-accordion-item > button:focus:not(:focus-visible) { + outline: none; + box-shadow: none; + } + + /* Visible keyboard focus for accordion buttons. Accent ring so the focused + * row reads at a glance during keyboard navigation. */ + .ngmd-accordion-item > button:focus-visible { + outline: 2px solid var(--accent); + outline-offset: -2px; + } + + analog-markdown, + analog-markdown-route, + .ngmd-prose { + display: block; + color: var(--fg); + line-height: 1.7; + overflow-wrap: break-word; + /* Widen inter-word gaps slightly so inline-code chips don't kiss the + * neighbouring word. Applies to all prose so the rhythm stays even. */ + word-spacing: 0.05em; + } + + analog-markdown-route { + max-width: 48rem; + margin-inline: auto; + padding: 0 2rem 0 2rem; + } + + analog-markdown h1, + analog-markdown-route h1 { + font-size: 2.25rem; + font-weight: 700; + line-height: 1.2; + margin-block: 0 1rem; + letter-spacing: -0.02em; + scroll-margin-top: 6rem; + } + + analog-markdown h2, + analog-markdown-route h2 { + font-size: 1.5rem; + font-weight: 600; + line-height: 1.3; + margin-block: 2.5rem 1rem; + padding-block-end: 0.5rem; + border-block-end: 1px solid var(--border); + letter-spacing: -0.01em; + scroll-margin-top: 6rem; + } + + analog-markdown h3, + analog-markdown-route h3 { + font-size: 1.25rem; + font-weight: 600; + margin-block: 2rem 0.75rem; + scroll-margin-top: 6rem; + } + + /* When a badge chip sits inline next to a heading (`## 0.1.2 <ngmd-badge ...>`) + * the chip's default `vertical-align: middle` aligns to the x-height + * baseline, which sits low against uppercase / numeric heading glyphs that + * have no x-height. Nudge the chip up so it reads as visually centred. */ + analog-markdown :where(h1, h2, h3, h4) ngmd-badge, + analog-markdown-route :where(h1, h2, h3, h4) ngmd-badge, + .ngmd-prose :where(h1, h2, h3, h4) ngmd-badge { + position: relative; + top: -0.05em; + } + + /* Inline code inside headings: strip the heavy code-span box and lean on + * the brand accent instead. Lets identifiers like skill names, hook names, + * or symbol references read as accent-coloured monospace titles rather + * than the dense gray pill the inline-code rule applies in body prose. */ + analog-markdown h1 code, + analog-markdown-route h1 code, + analog-markdown h2 code, + analog-markdown-route h2 code, + analog-markdown h3 code, + analog-markdown-route h3 code { + color: var(--accent); + background: transparent; + padding: 0; + font-size: 0.95em; + } + + analog-markdown p, + analog-markdown-route p { + margin-block: 1rem; + } + + analog-markdown ul, + analog-markdown-route ul, + analog-markdown ol, + analog-markdown-route ol { + margin-block: 1rem; + padding-inline-start: 1.5rem; + } + + analog-markdown ul, + analog-markdown-route ul { + list-style: disc; + } + + analog-markdown ol, + analog-markdown-route ol { + list-style: decimal; + } + + analog-markdown li, + analog-markdown-route li { + margin-block: 0.25rem; + } + + analog-markdown a, + analog-markdown-route a { + color: inherit; + text-decoration: underline; + text-underline-offset: 3px; + transition: color 0.15s ease; + } + + analog-markdown a:hover, + analog-markdown-route a:hover { + color: var(--accent); + } + + analog-markdown a:focus-visible, + analog-markdown-route a:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + border-radius: 2px; + } + + analog-markdown code:not(pre code), + analog-markdown-route code:not(pre code), + .ngmd-prose code:not(pre code) { + background: + linear-gradient(var(--bg), var(--bg)) padding-box, + var(--code-border-gradient) border-box; + color: var(--fg); + padding: 0.15em 0.45em; + border-radius: 0.3rem; + font-size: 0.875em; + font-weight: 500; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + border: 1.5px solid transparent; + /* When a long chip wraps across two lines, each fragment gets a + * full border + padding instead of the default split where the + * right side is open on line 1 and the left side is open on line 2. */ + box-decoration-break: clone; + -webkit-box-decoration-break: clone; + } + + .dark analog-markdown code:not(pre code), + .dark analog-markdown-route code:not(pre code), + .dark .ngmd-prose code:not(pre code) { + border-width: 1px; + } + + analog-markdown pre, + analog-markdown-route pre { + margin: 1.5rem 0; + padding: 1rem 1.25rem; + border-radius: 0.5rem; + overflow-x: auto; + font-size: 0.875rem; + line-height: 1.6; + border: 1px solid var(--border); + } + + analog-markdown pre code, + analog-markdown-route pre code { + background: transparent; + padding: 0; + } + + /* Markdown tables. Mirrors adev's `_table.scss` shape: full-width, thin + * row dividers, header underline, vertical-align top so multi-line cells + * and inline-code chips don't push their row off-balance. Wrapped in an + * `overflow-x: auto` shell so wide tables scroll horizontally on mobile + * instead of breaking the layout. */ + analog-markdown table, + analog-markdown-route table { + width: 100%; + border-collapse: collapse; + margin: 1.5rem 0; + font-size: 0.875rem; + line-height: 1.6; + display: block; + overflow-x: auto; + } + + analog-markdown thead, + analog-markdown-route thead { + border-block-end: 1px solid var(--border); + } + + analog-markdown th, + analog-markdown-route th { + text-align: left; + padding: 0.5rem 1rem 0.5rem 0; + font-size: 0.75rem; + font-weight: 600; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.05em; + vertical-align: bottom; + } + + analog-markdown tbody tr, + analog-markdown-route tbody tr { + border-block-end: 1px solid var(--border); + } + + analog-markdown tbody tr:last-child, + analog-markdown-route tbody tr:last-child { + border-block-end: none; + } + + analog-markdown td, + analog-markdown-route td { + padding: 0.85rem 1rem 0.85rem 0; + vertical-align: top; + } + + analog-markdown td:first-child, + analog-markdown-route td:first-child { + min-width: 22ch; + padding-inline-end: 1.5rem; + } + + analog-markdown td:last-child, + analog-markdown-route td:last-child { + padding-inline-end: 0; + } + + /* Small brand icon for package-name table cells. Inline-block with the + * baseline so it doesn't bump the row height; sized to 1em so it scales + * with the surrounding font automatically. */ + analog-markdown img.pkg-icon, + analog-markdown-route img.pkg-icon { + display: inline-block; + width: 1em; + height: 1em; + margin-inline-end: 0.5em; + vertical-align: -0.15em; + object-fit: contain; + } + + analog-markdown blockquote, + analog-markdown-route blockquote { + border-inline-start: 3px solid var(--border); + padding-inline-start: 1rem; + margin-inline: 0; + color: var(--muted); + font-style: italic; + } + + .ngmd-video { + position: relative; + width: 100%; + aspect-ratio: 16 / 9; + margin: 1.5rem 0; + border-radius: var(--radius-lg); + overflow: hidden; + background: var(--bg-muted); + } + + .ngmd-video iframe { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + border: 0; + } + + .ngmd-image { + margin: 1.5rem 0; + margin-inline: 0; + } + + .ngmd-image img { + width: 100%; + height: auto; + border-radius: var(--radius); + border: 1px solid var(--border); + } + + .ngmd-image figcaption { + margin-top: 0.5rem; + text-align: center; + font-size: 0.875rem; + color: var(--muted); + } + + .ngmd-code-group { + margin: 1.5rem 0; + border: 1px solid var(--border); + border-radius: var(--radius-lg); + overflow: hidden; + background: var(--bg); + } + + .ngmd-code-group__tabs { + display: flex; + flex-wrap: wrap; + border-bottom: 1px solid var(--border); + background: var(--bg-muted); + } + + .ngmd-code-group__tab { + appearance: none; + background: transparent; + border: 0; + border-bottom: 2px solid transparent; + padding: 0.625rem 1rem; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + color: var(--muted); + font-family: var(--font-mono); + transition: + color 0.15s ease, + border-color 0.15s ease; + display: inline-flex; + align-items: center; + gap: 0.375rem; + } + + .ngmd-code-group__icon { + width: 1rem; + height: 1rem; + object-fit: contain; + flex-shrink: 0; + } + + .ngmd-code-group__tab:hover { + color: var(--fg); + } + + .ngmd-code-group__tab[data-active='true'] { + color: var(--accent); + border-bottom-color: var(--accent); + } + + .ngmd-code-group__panel { + display: none; + } + + .ngmd-code-group__panel[data-active='true'] { + display: block; + } + + .ngmd-code-group__panel pre { + margin: 0; + border-radius: 0; + border: 0; + } + + /* Shiki dual-theme: github-light by default, github-dark under `.dark`. + * Shiki emits both --shiki-light and --shiki-dark CSS vars on every span + * but never picks a default color, so we have to wire both branches. */ + /* Scoped to `.shiki-themes` (only present on dual-theme output, never on + * single-theme build-time output) so we don't override the inline colours + * shiki emits for the build-time `.md` code blocks. */ + .shiki-themes, + .shiki-themes span { + color: var(--shiki-light); + background-color: var(--shiki-light-bg); + } + + .dark .shiki-themes, + .dark .shiki-themes span { + color: var(--shiki-dark) !important; + background-color: var(--shiki-dark-bg) !important; + } + + /* Highlighted lines (from `{1,3-5}` meta on a fenced block). + * Shiki wraps each line in `<span class="line">`; the ngmd-code-highlight + * marked extension adds `highlighted` to lines in the spec. We render a + * full-bleed tinted background plus a left-edge accent stripe. */ + .shiki .line.highlighted { + display: inline-block; + width: 100%; + background-color: var(--line-highlight); + box-shadow: inset 2px 0 var(--accent); + } + + /* Accordion item (NgmdAccordionItem) animations. + * + * Two pieces: + * 1. Chevron rotates from -180deg (closed, points up) to 0deg (open, points + * down). Single endpoint pair so the browser cannot take the long way + * around — what tripped the earlier `rotate-180` attempt. + * 2. Body collapses via CSS grid-rows: the outer `.ngmd-accordion-body` + * animates `grid-template-rows` between 0fr and 1fr; the inner wrapper + * has `overflow: hidden` and `min-height: 0` so the row fr basis is the + * real animation driver. This trick has the widest evergreen support + * right now — Chrome 117+, Edge 117+, Safari 17.4+, Firefox 121+ — and + * avoids `interpolate-size: allow-keywords` which is still landing + * (Firefox shipped only in 145, late 2025). See: + * https://developer.mozilla.org/en-US/docs/Web/CSS/grid-template-rows + */ + /* Open-state button background. Token-driven so it follows the surface + * palette in both modes (zinc-100 in light, zinc-900 in dark). */ + .ngmd-accordion-item.is-open > button { + background: var(--bg-muted); + } + + /* Body reveal: grid-template-rows from minmax(0,0fr) to minmax(0,1fr). + * Symmetric in both directions and crucially clamps the row to 0 when + * closed regardless of the inner wrapper's intrinsic size. */ + .ngmd-accordion-body { + display: grid; + grid-template-rows: minmax(0, 0fr); + min-height: 0; + opacity: 0; + transition: + grid-template-rows 300ms cubic-bezier(0.22, 1, 0.36, 1), + opacity 200ms ease-out; + } + + .ngmd-accordion-item.is-open .ngmd-accordion-body { + grid-template-rows: minmax(0, 1fr); + opacity: 1; + } + + .ngmd-accordion-body-inner { + overflow: hidden; + min-height: 0; + } + + @media (prefers-reduced-motion: reduce) { + .ngmd-accordion-body { + transition: none; + } + } + + /* `<div class="ngmd-code-import">` wraps a file= imported code block with + * a header bar that links the file path to its GitHub source. */ + .ngmd-code-import { + margin: 1.5rem 0; + border: 1px solid var(--border); + border-radius: var(--radius-lg); + overflow: hidden; + background: var(--bg); + } + + .ngmd-code-import__header { + display: block; + padding: 0.5rem 1rem; + border-bottom: 1px solid var(--border); + background: var(--bg-muted); + font-family: var(--font-mono); + font-size: 0.8125rem; + color: var(--muted); + text-decoration: none; + transition: color 0.15s ease; + } + + .ngmd-code-import__header:hover { + color: var(--accent); + } + + .ngmd-code-import pre { + margin: 0; + border: 0; + border-radius: 0; + } +} diff --git a/apps/docs/src/test-setup.ts b/apps/docs/src/test-setup.ts new file mode 100644 index 0000000..b0f7cdc --- /dev/null +++ b/apps/docs/src/test-setup.ts @@ -0,0 +1,6 @@ +import '@angular/compiler'; +import '@analogjs/vitest-angular/setup-snapshots'; +import '@analogjs/vitest-angular/setup-serializers'; +import { setupTestBed } from '@analogjs/vitest-angular/setup-testbed'; + +setupTestBed(); diff --git a/apps/docs/src/types/api.ts b/apps/docs/src/types/api.ts new file mode 100644 index 0000000..a52ee34 --- /dev/null +++ b/apps/docs/src/types/api.ts @@ -0,0 +1,52 @@ +/** + * Public shape of the API-reference scope file (`ngmd.api.ts` at repo root). + * Authors `import {defineApi} from 'ngmd/api'` and export the result as + * default so the api-gen plugin can pick it up via Vite's module loader. + */ + +export type SymbolKind = + | 'class' + | 'interface' + | 'function' + | 'const' + | 'type' + | 'enum' + | 'signal-input' + | 'standalone-component'; + +export interface ApiConfig { + /** Glob patterns (Vite-style) for source files to parse. */ + scope: string[]; + /** Glob patterns to exclude (matched against the same set as `scope`). */ + exclude?: string[]; + /** Base URL path under which symbol pages render. Default: `/api`. */ + basePath?: string; + /** Sidebar grouping strategy. `'package'` reads `package.json` boundaries; + * `'directory'` groups by source folder; `'kind'` groups by symbol kind. */ + groupBy?: 'package' | 'directory' | 'kind'; + /** JSDoc tag names that translate to status badges on the rendered page. */ + badgesFromJsDoc?: readonly string[]; +} + +/** + * Identity wrapper. Exists so users can declare the config with type + * inference and IDE autocomplete without manually importing the type. + */ +export function defineApi(config: ApiConfig): ApiConfig { + return config; +} + +/** + * Internal record emitted by the parser, one per discovered exported symbol. + * Consumed by the page-template renderer; not part of the user-facing API. + */ +export interface SymbolRecord { + kind: SymbolKind; + name: string; + filePath: string; + line: number; + signature: string; + description: string; + badges: string[]; + group: string; +} diff --git a/apps/docs/src/types/badge.ts b/apps/docs/src/types/badge.ts new file mode 100644 index 0000000..edee8f9 --- /dev/null +++ b/apps/docs/src/types/badge.ts @@ -0,0 +1,21 @@ +/** + * Single source of truth for every badge variant used across NgMd. + * + * One map, two consumers: + * - `<ngmd-badge variant="...">` (`src/app/ui/badge.ts`): inline status pill + * - sidebar status chip (`src/app/components/sidebar.ts`), driven by + * `NavItem.status` declared in `ngmd.config.ts` + * + * Add a row here to define a new variant. Both the inline component and + * the sidebar pick it up without further edits. + */ +export const BADGE_VARIANTS = { + new: 'bg-sky-100 text-sky-700 dark:bg-sky-500/15 dark:text-sky-300', + updated: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-500/15 dark:text-yellow-300', + alpha: 'bg-red-100 text-red-700 dark:bg-red-500/15 dark:text-red-300', + beta: 'bg-amber-100 text-amber-700 dark:bg-amber-500/15 dark:text-amber-300', + stable: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300', + deprecated: 'bg-zinc-100 text-zinc-500 dark:bg-zinc-800 dark:text-zinc-400 line-through', +} as const satisfies Record<string, string>; + +export type BadgeVariant = keyof typeof BADGE_VARIANTS; diff --git a/apps/docs/src/types/search.ts b/apps/docs/src/types/search.ts new file mode 100644 index 0000000..a7e413e --- /dev/null +++ b/apps/docs/src/types/search.ts @@ -0,0 +1,55 @@ +/** + * Search abstraction. Two implementations live behind it: + * + * - `OramaSearchProvider` — default. Queries a local Orama index built + * at vite-build time and shipped as JSON. + * - `AlgoliaSearchProvider` — opt-in. Wired when `ngmd.config.ts > site.algolia` + * provides `appId` + `apiKey` + `indexName`. Talks to Algolia DocSearch. + * + * Both implementations return the same `SearchHit` shape so the command + * palette UI does not need to know which backend is active. + */ + +/** Hierarchy level. Mirrors Algolia DocSearch's `lvl0`-`lvl6` so the same + * UI works against either backend. `page` is the doc title, `section` is a + * heading, `snippet` is a body excerpt, `symbol` is an API-reference entry + * surfaced from the build-time `virtual:ngmd/api-index` virtual module. */ +export type SearchHitKind = 'page' | 'section' | 'snippet' | 'symbol'; + +export interface SearchHit { + /** Stable identifier for keying / dedup. */ + id: string; + /** Hit category for icon / grouping. */ + kind: SearchHitKind; + /** Final navigation target (route + optional fragment). */ + url: string; + /** Primary label, may contain `<mark>` highlight tags. */ + labelHtml: string; + /** Secondary label (the page this hit lives in), may contain `<mark>`. */ + subLabelHtml: string; + /** Body excerpt for snippet hits, may contain `<mark>`. */ + contentHtml?: string; + /** Optional ranking score for backends that expose one. */ + score?: number; +} + +/** Single record in the build-time index, before Orama digests it. */ +export interface IndexDoc { + id: string; + /** Page route, e.g. `/concepts/theming`. */ + url: string; + /** Anchor slug for `section` records, empty for `page` / `snippet`. */ + anchor: string; + kind: SearchHitKind; + /** Page title (frontmatter `title:` or nav label). */ + pageTitle: string; + /** Heading text for `section`, page title for `page`, empty for `snippet`. */ + heading: string; + /** Body slice (full page for `page`, paragraph chunk for `snippet`). */ + body: string; +} + +export interface SearchProvider { + /** Run a query. Returns up to 20 hits. */ + search(query: string): Promise<SearchHit[]>; +} diff --git a/apps/docs/src/vite-env.d.ts b/apps/docs/src/vite-env.d.ts new file mode 100644 index 0000000..c9c6f39 --- /dev/null +++ b/apps/docs/src/vite-env.d.ts @@ -0,0 +1,21 @@ +/// <reference types="vite/client" /> + +declare module 'virtual:ngmd/page-meta' { + export interface PageMeta { + editUrl: string; + lastUpdated: string; + } + export const pageMeta: Record<string, PageMeta>; +} + +declare module 'virtual:ngmd/search-index' { + import type { IndexDoc } from './types/search'; + export const searchIndex: IndexDoc[]; +} + +declare module 'virtual:ngmd/api-index' { + import type { SymbolRecord } from './types/api'; + /** Every exported symbol discovered by the api-gen plugin from sources + * matched by `ngmd.api.ts`. Empty array when `ngmd.api.ts` is absent. */ + export const apiIndex: SymbolRecord[]; +} diff --git a/apps/docs/tsconfig.app.json b/apps/docs/tsconfig.app.json new file mode 100644 index 0000000..03a3f71 --- /dev/null +++ b/apps/docs/tsconfig.app.json @@ -0,0 +1,10 @@ +/* To learn more about this file see: https://angular.io/config/tsconfig. */ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/app", + "types": [] + }, + "files": ["src/main.ts", "src/main.server.ts"], + "include": ["src/**/*.d.ts", "src/app/pages/**/*.page.ts"] +} diff --git a/apps/docs/tsconfig.json b/apps/docs/tsconfig.json new file mode 100644 index 0000000..ea8f635 --- /dev/null +++ b/apps/docs/tsconfig.json @@ -0,0 +1,31 @@ +{ + "compileOnSave": false, + "compilerOptions": { + "outDir": "./dist/out-tsc", + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "sourceMap": true, + "declaration": false, + "experimentalDecorators": true, + "moduleResolution": "bundler", + "rewriteRelativeImportExtensions": true, + "isolatedModules": true, + "importHelpers": true, + "target": "ES2022", + "module": "ES2022", + "lib": ["ES2022", "dom"], + "useDefineForClassFields": false, + "skipLibCheck": true + }, + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + }, + "references": [{ "path": "tsconfig.spec.json" }] +} diff --git a/apps/docs/tsconfig.spec.json b/apps/docs/tsconfig.spec.json new file mode 100644 index 0000000..69e08ce --- /dev/null +++ b/apps/docs/tsconfig.spec.json @@ -0,0 +1,11 @@ +/* To learn more about this file see: https://angular.io/config/tsconfig. */ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/spec", + "target": "es2022", + "types": ["vitest/globals"] + }, + "files": ["src/test-setup.ts"], + "include": ["src/**/*.spec.ts", "src/**/*.d.ts"] +} diff --git a/apps/docs/vars.plugin.ts b/apps/docs/vars.plugin.ts new file mode 100644 index 0000000..8348d66 --- /dev/null +++ b/apps/docs/vars.plugin.ts @@ -0,0 +1,67 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import type { Plugin } from 'vite'; + +/** + * Single source of truth for "the current published version" in markdown + * content. Reads `create-ngmd/package.json` at build time (or the project's + * own `package.json` in a scaffolded site, where `create-ngmd/` doesn't + * exist), exposes its `version` as the `{{ngmd-version}}` token, and + * substitutes it into every `.md` source before AnalogJS hands the body + * to marked. + * + * Saves the two-place hand-update in changelog + technologies pages + * after every npm publish. Extend the `vars` map if you need more. + * + * Shared with `rawMdPlugin` via `substituteMdVars()` so the "Copy + * Markdown" / "Open in LLM" downloads see the same substituted text + * as the rendered page. + */ + +let memo: Record<string, string> | null = null; + +function readVars(root: string): Record<string, string> { + if (memo) return memo; + let version = ''; + for (const file of ['create-ngmd/package.json', 'package.json']) { + try { + const pkg = JSON.parse(readFileSync(join(root, file), 'utf8')); + if (typeof pkg.version === 'string') { + version = pkg.version; + break; + } + } catch {} + } + memo = { 'ngmd-version': version }; + return memo; +} + +/** + * Apply `{{token}}` substitutions to a markdown body. Unknown tokens + * are left in place so an unrecognised marker survives to the rendered + * page rather than silently disappearing. + */ +export function substituteMdVars(body: string, root: string): string { + const vars = readVars(root); + return body.replace(/\{\{\s*([\w-]+)\s*\}\}/g, (match, key) => { + return key in vars ? vars[key] : match; + }); +} + +export function varsPlugin(): Plugin { + let root = process.cwd(); + return { + name: 'ngmd-vars', + enforce: 'pre', + configResolved(cfg) { + root = cfg.root; + memo = null; + }, + transform(code, id) { + if (!id.endsWith('.md')) return null; + const out = substituteMdVars(code, root); + if (out === code) return null; + return { code: out, map: null }; + }, + }; +} diff --git a/apps/docs/vite.config.ts b/apps/docs/vite.config.ts new file mode 100644 index 0000000..d408f0d --- /dev/null +++ b/apps/docs/vite.config.ts @@ -0,0 +1,111 @@ +/// <reference types="vitest" /> + +import { defineConfig, type Plugin } from 'vite'; +import analog from '@analogjs/platform'; +import tailwindcss from '@tailwindcss/vite'; +import { readFileSync } from 'node:fs'; +import { getBuildExtensions } from './src/marked-extensions/index.ts'; +import { pageMetaPlugin } from './page-meta.plugin.ts'; +import { internalLinkGuard } from './link-guard.plugin.ts'; +import { sitemapPlugin } from './sitemap.plugin.ts'; +import { searchIndexPlugin } from './search-index.plugin.ts'; +import { rawMdPlugin } from './raw-md.plugin.ts'; +import { varsPlugin } from './vars.plugin.ts'; +import { apiGenPlugin } from './api-gen.plugin.ts'; +import config from './src/ngmd.config.ts'; + +/** + * Build-time guard: errors when a markdown file in `src/content/` contains + * a raw HTML `<a href="http(s)://...">` without `target="_blank"`. Raw HTML + * anchors bypass the marked link renderer (which would add target=_blank + * automatically), so this catches external links that would silently open + * in the same tab. + * + * Lifted from the adev docs pipeline pattern. + */ +function externalLinkGuard(): Plugin { + return { + name: 'ngmd-external-link-guard', + enforce: 'pre', + transform(_code, id) { + if (!id.endsWith('.md')) return null; + const content = readFileSync(id.split('?')[0], 'utf8'); + const anchorRe = /<a\b[^>]*href=["']https?:\/\/[^"']+["'][^>]*>/g; + const matches = content.match(anchorRe) ?? []; + for (const m of matches) { + if (!/target=["']_blank["']/.test(m)) { + this.error( + `[ngmd] External anchor in ${id} is missing target="_blank":\n ${m}\n` + + `Add target="_blank" rel="noopener noreferrer" so external links open in a new tab.`, + ); + } + } + return null; + }, + }; +} + +function siteHtml(): Plugin { + const escape = (value: string) => + value.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<'); + const values: Record<string, string> = { + '%SITE_NAME%': config.site.name, + '%SITE_DESCRIPTION%': config.site.description, + '%SITE_URL%': config.site.url.replace(/\/+$/, ''), + }; + return { + name: 'site-html', + transformIndexHtml(html) { + return Object.entries(values).reduce( + (out, [token, value]) => out.replaceAll(token, escape(value)), + html, + ); + }, + }; +} + +export default defineConfig(async () => ({ + build: { + target: ['es2020'], + }, + resolve: { + mainFields: ['module'], + }, + plugins: [ + siteHtml(), + varsPlugin(), + externalLinkGuard(), + internalLinkGuard(), + pageMetaPlugin({ repoUrl: config.site.githubUrl, branch: 'main', dir: 'apps/docs' }), + sitemapPlugin({ siteUrl: config.site.url }), + rawMdPlugin(), + searchIndexPlugin(), + apiGenPlugin(), + analog({ + apiPrefix: '_server', + content: { + highlighter: 'shiki', + markedOptions: { + extensions: await getBuildExtensions(), + }, + shikiOptions: { + highlight: { + themes: { light: 'github-light', dark: 'github-dark' }, + defaultColor: false, + }, + highlighter: { + additionalLangs: ['bash', 'md', 'json'], + }, + }, + }, + }), + tailwindcss(), + ], + test: { + globals: true, + environment: 'jsdom', + setupFiles: ['src/test-setup.ts'], + include: ['**/*.spec.ts'], + reporters: ['default'], + }, +})); diff --git a/package.json b/package.json index f5497fd..dc4ba53 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,9 @@ "devtools:publish": "pnpm --filter @santoshyadavdev/ng-devtools publish --access public", "extension:build": "pnpm devtools:build && rm -rf extension/ui && cp -r dist/devtools-ui extension/ui", "extension:zip": "pnpm extension:build && rm -f dist/ng-devtools-extension.zip && cd extension && zip -r ../dist/ng-devtools-extension.zip . -x '*.DS_Store'", - "analog:dev": "pnpm --filter analog-demo dev" + "analog:dev": "pnpm --filter analog-demo dev", + "docs:dev": "pnpm --filter angular-devtools-docs dev", + "docs:build": "pnpm --filter angular-devtools-docs build" }, "private": true, "packageManager": "pnpm@10.33.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0f2b53e..2da2a40 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,19 +21,19 @@ importers: version: 22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3) '@angular/forms': specifier: ^22.1.0 - version: 22.1.7(@angular/common@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(@angular/platform-browser@22.1.7(@angular/common@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3)))(rxjs@7.8.2) + version: 22.1.7(@angular/common@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(@angular/platform-browser@22.1.7(@angular/animations@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3)))(@angular/common@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3)))(rxjs@7.8.2) '@angular/platform-browser': specifier: ^22.1.0 - version: 22.1.7(@angular/common@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3)) + version: 22.1.7(@angular/animations@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3)))(@angular/common@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3)) '@angular/platform-server': specifier: ^22.1.0 - version: 22.1.7(@angular/common@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/compiler@22.1.7)(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(@angular/platform-browser@22.1.7(@angular/common@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3)))(rxjs@7.8.2) + version: 22.1.7(@angular/common@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/compiler@22.1.7)(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(@angular/platform-browser@22.1.7(@angular/animations@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3)))(@angular/common@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3)))(rxjs@7.8.2) '@angular/router': specifier: ^22.1.0 - version: 22.1.7(@angular/common@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(@angular/platform-browser@22.1.7(@angular/common@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3)))(rxjs@7.8.2) + version: 22.1.7(@angular/common@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(@angular/platform-browser@22.1.7(@angular/animations@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3)))(@angular/common@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3)))(rxjs@7.8.2) '@angular/ssr': specifier: ^22.1.8 - version: 22.1.8(ea98ef3ad402c646289083575c811780) + version: 22.1.8(26f1d65167ee0a7588ca833b67e4c299) '@ngrx/signals': specifier: ^22.0.1 version: 22.0.1(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2) @@ -58,7 +58,7 @@ importers: devDependencies: '@analogjs/vite-plugin-angular': specifier: ^2.7.2 - version: 2.7.2(@angular/build@22.1.8(e4e5819de5155796ef25fd39ddbcaaa2))(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0)) + version: 2.7.2(@angular/build@22.1.8(7bf82a545fa1df14b49b61d6ca208405))(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0)) '@angular-devkit/core': specifier: ^22.1.8 version: 22.1.8(chokidar@5.0.0) @@ -67,7 +67,7 @@ importers: version: 22.1.8(chokidar@5.0.0) '@angular/build': specifier: ^22.1.8 - version: 22.1.8(e4e5819de5155796ef25fd39ddbcaaa2) + version: 22.1.8(7bf82a545fa1df14b49b61d6ca208405) '@angular/cli': specifier: ^22.1.8 version: 22.1.8(@types/node@24.13.6)(chokidar@5.0.0) @@ -82,7 +82,7 @@ importers: version: 1.0.0(devframe@1.1.0)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0)) '@nx/angular': specifier: 23.2.1 - version: 23.2.1(@angular-devkit/core@22.1.8(chokidar@5.0.0))(@angular-devkit/schematics@22.1.8(chokidar@5.0.0))(@angular/build@22.1.8(e4e5819de5155796ef25fd39ddbcaaa2))(@babel/traverse@7.29.8)(@schematics/angular@22.1.8(chokidar@5.0.0))(@zkochan/js-yaml@0.0.7)(eslint@10.11.0(jiti@2.7.0))(nx@23.2.1)(rxjs@7.8.2)(typescript@6.0.3) + version: 23.2.1(@angular-devkit/core@22.1.8(chokidar@5.0.0))(@angular-devkit/schematics@22.1.8(chokidar@5.0.0))(@angular/build@22.1.8(7bf82a545fa1df14b49b61d6ca208405))(@babel/traverse@7.29.8)(@schematics/angular@22.1.8(chokidar@5.0.0))(@zkochan/js-yaml@0.0.7)(eslint@10.11.0(jiti@2.7.0))(nx@23.2.1)(rxjs@7.8.2)(typescript@6.0.3) '@nx/workspace': specifier: 23.2.1 version: 23.2.1 @@ -117,14 +117,138 @@ importers: specifier: ^4.0.8 version: 4.1.11(@types/node@24.13.6)(jsdom@28.1.0)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0)) + apps/docs: + dependencies: + '@analogjs/content': + specifier: ^2.7.5 + version: 2.7.5(24eddf7a65aa2653ec559a78e283ee11) + '@analogjs/router': + specifier: ^2.7.5 + version: 2.7.5(@analogjs/content@2.7.5(24eddf7a65aa2653ec559a78e283ee11))(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(@angular/router@22.1.7(@angular/common@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(@angular/platform-browser@22.1.7(@angular/animations@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3)))(@angular/common@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3)))(rxjs@7.8.2)) + '@angular/common': + specifier: 22.1.7 + version: 22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2) + '@angular/compiler': + specifier: 22.1.7 + version: 22.1.7 + '@angular/core': + specifier: 22.1.7 + version: 22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3) + '@angular/elements': + specifier: 22.1.7 + version: 22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2) + '@angular/forms': + specifier: 22.1.7 + version: 22.1.7(@angular/common@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(@angular/platform-browser@22.1.7(@angular/animations@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3)))(@angular/common@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3)))(rxjs@7.8.2) + '@angular/platform-browser': + specifier: 22.1.7 + version: 22.1.7(@angular/animations@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3)))(@angular/common@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3)) + '@angular/platform-server': + specifier: 22.1.7 + version: 22.1.7(@angular/common@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/compiler@22.1.7)(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(@angular/platform-browser@22.1.7(@angular/animations@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3)))(@angular/common@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3)))(rxjs@7.8.2) + '@angular/router': + specifier: 22.1.7 + version: 22.1.7(@angular/common@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(@angular/platform-browser@22.1.7(@angular/animations@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3)))(@angular/common@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3)))(rxjs@7.8.2) + '@lucide/angular': + specifier: ^1.48.0 + version: 1.48.0(@angular/common@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3)) + '@orama/orama': + specifier: ^3.1.18 + version: 3.1.18 + '@tailwindcss/typography': + specifier: ^0.5.20 + version: 0.5.20(tailwindcss@4.3.3) + '@tailwindcss/vite': + specifier: ^4.3.3 + version: 4.3.3(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0)) + front-matter: + specifier: ^4.0.2 + version: 4.0.2 + h3: + specifier: ^1.13.0 + version: 1.15.11 + marked: + specifier: ^15.0.7 + version: 15.0.12 + marked-gfm-heading-id: + specifier: ^4.1.3 + version: 4.1.4(marked@15.0.12) + marked-highlight: + specifier: ^2.2.3 + version: 2.2.4(marked@15.0.12) + marked-mangle: + specifier: ^1.1.14 + version: 1.1.14(marked@15.0.12) + marked-shiki: + specifier: ^1.2.1 + version: 1.2.1(marked@15.0.12)(shiki@1.29.2) + motion: + specifier: ^13.4.4 + version: 13.4.4 + postcss: + specifier: ^8.5.28 + version: 8.5.28 + prismjs: + specifier: ^1.29.0 + version: 1.30.0 + rxjs: + specifier: ~7.8.0 + version: 7.8.2 + shiki: + specifier: ^1.29.2 + version: 1.29.2 + tailwindcss: + specifier: ^4.3.3 + version: 4.3.3 + ts-morph: + specifier: ^28.0.0 + version: 28.0.0 + tslib: + specifier: ^2.3.0 + version: 2.8.1 + devDependencies: + '@analogjs/platform': + specifier: ^2.7.5 + version: 2.7.5(@angular/build@22.1.8(7bf82a545fa1df14b49b61d6ca208405))(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@nx/angular@23.2.1(@angular-devkit/core@22.1.8(chokidar@5.0.0))(@angular-devkit/schematics@22.1.8(chokidar@5.0.0))(@angular/build@22.1.8(7bf82a545fa1df14b49b61d6ca208405))(@babel/traverse@7.29.8)(@schematics/angular@22.1.8(chokidar@5.0.0))(@zkochan/js-yaml@0.0.7)(eslint@10.11.0(jiti@2.7.0))(nx@23.2.1)(rxjs@7.8.2)(typescript@6.0.3))(@nx/devkit@23.2.1(nx@23.2.1))(@parcel/watcher@2.6.0)(marked-gfm-heading-id@4.1.4(marked@15.0.12))(marked-highlight@2.2.4(marked@15.0.12))(marked-mangle@1.1.14(marked@15.0.12))(marked-shiki@1.2.1(marked@15.0.12)(shiki@1.29.2))(marked@15.0.12)(prismjs@1.30.0)(rolldown@1.2.9)(shiki@1.29.2)(srvx@1.0.5)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0)) + '@analogjs/vite-plugin-angular': + specifier: ^2.7.5 + version: 2.7.5(@angular/build@22.1.8(7bf82a545fa1df14b49b61d6ca208405))(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0)) + '@analogjs/vitest-angular': + specifier: ^2.7.5 + version: 2.7.5(@analogjs/vite-plugin-angular@2.7.5(@angular/build@22.1.8(7bf82a545fa1df14b49b61d6ca208405))(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0)))(@angular-devkit/architect@0.2201.8(chokidar@5.0.0))(@angular-devkit/schematics@22.1.8(chokidar@5.0.0))(vitest@4.1.11(@types/node@24.13.6)(jsdom@28.1.0)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0)))(zone.js@0.16.3) + '@angular/build': + specifier: ^22.1.8 + version: 22.1.8(7bf82a545fa1df14b49b61d6ca208405) + '@angular/cli': + specifier: ^22.1.8 + version: 22.1.8(@types/node@24.13.6)(chokidar@5.0.0) + '@angular/compiler-cli': + specifier: 22.1.7 + version: 22.1.7(@angular/compiler@22.1.7)(typescript@6.0.3) + jsdom: + specifier: ^28.0.0 + version: 28.1.0 + prettier: + specifier: ^3.8.1 + version: 3.9.8 + typescript: + specifier: ~6.0.2 + version: 6.0.3 + vite: + specifier: ^8.3.0 + version: 8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0) + vitest: + specifier: ^4.0.8 + version: 4.1.11(@types/node@24.13.6)(jsdom@28.1.0)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0)) + examples/analog: dependencies: '@analogjs/content': specifier: 2.7.5 - version: 2.7.5(1e58ba138dc745395de3261acccd674a) + version: 2.7.5(24eddf7a65aa2653ec559a78e283ee11) '@analogjs/router': specifier: 2.7.5 - version: 2.7.5(@analogjs/content@2.7.5(1e58ba138dc745395de3261acccd674a))(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(@angular/router@22.1.7(@angular/common@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(@angular/platform-browser@22.1.7(@angular/common@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3)))(rxjs@7.8.2)) + version: 2.7.5(@analogjs/content@2.7.5(24eddf7a65aa2653ec559a78e283ee11))(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(@angular/router@22.1.7(@angular/common@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(@angular/platform-browser@22.1.7(@angular/animations@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3)))(@angular/common@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3)))(rxjs@7.8.2)) '@angular/common': specifier: ^22.1.0 version: 22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2) @@ -136,16 +260,16 @@ importers: version: 22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3) '@angular/forms': specifier: ^22.1.0 - version: 22.1.7(@angular/common@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(@angular/platform-browser@22.1.7(@angular/common@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3)))(rxjs@7.8.2) + version: 22.1.7(@angular/common@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(@angular/platform-browser@22.1.7(@angular/animations@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3)))(@angular/common@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3)))(rxjs@7.8.2) '@angular/platform-browser': specifier: ^22.1.0 - version: 22.1.7(@angular/common@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3)) + version: 22.1.7(@angular/animations@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3)))(@angular/common@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3)) '@angular/platform-server': specifier: ^22.1.0 - version: 22.1.7(@angular/common@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/compiler@22.1.7)(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(@angular/platform-browser@22.1.7(@angular/common@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3)))(rxjs@7.8.2) + version: 22.1.7(@angular/common@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/compiler@22.1.7)(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(@angular/platform-browser@22.1.7(@angular/animations@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3)))(@angular/common@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3)))(rxjs@7.8.2) '@angular/router': specifier: ^22.1.0 - version: 22.1.7(@angular/common@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(@angular/platform-browser@22.1.7(@angular/common@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3)))(rxjs@7.8.2) + version: 22.1.7(@angular/common@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(@angular/platform-browser@22.1.7(@angular/animations@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3)))(@angular/common@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3)))(rxjs@7.8.2) front-matter: specifier: ^4.0.2 version: 4.0.2 @@ -176,13 +300,13 @@ importers: devDependencies: '@analogjs/platform': specifier: 2.7.5 - version: 2.7.5(@angular/build@22.1.8(e4e5819de5155796ef25fd39ddbcaaa2))(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@nx/angular@23.2.1(@angular-devkit/core@22.1.8(chokidar@5.0.0))(@angular-devkit/schematics@22.1.8(chokidar@5.0.0))(@angular/build@22.1.8(e4e5819de5155796ef25fd39ddbcaaa2))(@babel/traverse@7.29.8)(@schematics/angular@22.1.8(chokidar@5.0.0))(@zkochan/js-yaml@0.0.7)(eslint@10.11.0(jiti@2.7.0))(nx@23.2.1)(rxjs@7.8.2)(typescript@6.0.3))(@nx/devkit@23.2.1(nx@23.2.1))(@parcel/watcher@2.6.0)(marked-gfm-heading-id@4.1.4(marked@15.0.12))(marked-highlight@2.2.4(marked@15.0.12))(marked-mangle@1.1.14(marked@15.0.12))(marked@15.0.12)(prismjs@1.30.0)(rolldown@1.2.9)(srvx@1.0.5)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0)) + version: 2.7.5(@angular/build@22.1.8(7bf82a545fa1df14b49b61d6ca208405))(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@nx/angular@23.2.1(@angular-devkit/core@22.1.8(chokidar@5.0.0))(@angular-devkit/schematics@22.1.8(chokidar@5.0.0))(@angular/build@22.1.8(7bf82a545fa1df14b49b61d6ca208405))(@babel/traverse@7.29.8)(@schematics/angular@22.1.8(chokidar@5.0.0))(@zkochan/js-yaml@0.0.7)(eslint@10.11.0(jiti@2.7.0))(nx@23.2.1)(rxjs@7.8.2)(typescript@6.0.3))(@nx/devkit@23.2.1(nx@23.2.1))(@parcel/watcher@2.6.0)(marked-gfm-heading-id@4.1.4(marked@15.0.12))(marked-highlight@2.2.4(marked@15.0.12))(marked-mangle@1.1.14(marked@15.0.12))(marked-shiki@1.2.1(marked@15.0.12)(shiki@1.29.2))(marked@15.0.12)(prismjs@1.30.0)(rolldown@1.2.9)(shiki@1.29.2)(srvx@1.0.5)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0)) '@analogjs/vite-plugin-angular': specifier: 2.7.5 - version: 2.7.5(@angular/build@22.1.8(e4e5819de5155796ef25fd39ddbcaaa2))(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0)) + version: 2.7.5(@angular/build@22.1.8(7bf82a545fa1df14b49b61d6ca208405))(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0)) '@angular/build': specifier: ^22.1.8 - version: 22.1.8(e4e5819de5155796ef25fd39ddbcaaa2) + version: 22.1.8(7bf82a545fa1df14b49b61d6ca208405) '@angular/compiler-cli': specifier: ^22.1.0 version: 22.1.7(@angular/compiler@22.1.7)(typescript@6.0.3) @@ -344,6 +468,18 @@ packages: '@analogjs/vite-plugin-nitro@2.7.5': resolution: {integrity: sha512-qzx8edOHJaXrWMSiKEb4YSCipSJjQCdgnRnb3Z0sOGC5BwnQQsNUWnnxxTU6rH0GpBSE33MJBriErrcV2RpfuA==} + '@analogjs/vitest-angular@2.7.5': + resolution: {integrity: sha512-HCfOQ9AvsFVsEbRHF8c99B4TR1aaI76lzna9MZB+nkueTJm0Ru9JiQmqo1eQ5M8Yi8hdEhMv1Wu24Rlm6YjxTw==} + peerDependencies: + '@analogjs/vite-plugin-angular': '*' + '@angular-devkit/architect': '>=0.1700.0 < 0.2300.0 || >=0.2200.0 < 0.2300.0' + '@angular-devkit/schematics': '>=17.0.0' + vitest: ^1.3.1 || ^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 + zone.js: '>=0.14.0' + peerDependenciesMeta: + zone.js: + optional: true + '@angular-devkit/architect@0.2201.8': resolution: {integrity: sha512-EUQo8RDS1my2Bo5FRS+gBYgz1/klfIp9XESfMpTBO04nBkjF6DkPCeOxeAf1CYjWy9nCXcWn968eSdEhV5jXiA==} engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} @@ -362,6 +498,13 @@ packages: resolution: {integrity: sha512-Pv3cPa/44kvEwYcqwx4Ns5dhIDmcFNSHhbpheqiEG1Z/u4e2t4zHKDtE3eHZB+8o+IcC7xJj+d+AqGR44RoZDA==} engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + '@angular/animations@22.1.7': + resolution: {integrity: sha512-ssfo40eCLYNdD1rmN1YKJFpBOF4jR7PwJoCLqp5WtD9KzrXaE+/LPpWAgfVdpCB1vwgON0j6HOV+T0e/0uuPYQ==} + engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0} + deprecated: '@angular/animations is deprecated. Use `animate.enter` and `animate.leave` instead. For more information see: https://v22.angular.dev/guide/animations.' + peerDependencies: + '@angular/core': 22.1.7 + '@angular/build@22.1.8': resolution: {integrity: sha512-tw+Evk0EITb8p8dTu743ONoFztCEiyDkdRbtZsh7C0vBS7Q9ElsOcFeg2Z9OAAwrmtZMIBuW3jrdPRQp7OCSdw==} engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} @@ -454,6 +597,13 @@ packages: zone.js: optional: true + '@angular/elements@22.1.7': + resolution: {integrity: sha512-m1TrrXm7fOGLRctMlQ4YfWieNo2TV6cAvIcWulAbYQf4LLOImy6x0eHGfeTiAlc7ngSWTbzqYKgWIyGy2YPOAA==} + engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0} + peerDependencies: + '@angular/core': 22.1.7 + rxjs: ^6.5.3 || ^7.4.0 + '@angular/forms@22.1.7': resolution: {integrity: sha512-oc0DT39C3ZboJpDx8xKUCltgn56LHi0kbv+ThNK/dCSZLgjF2nd+muMMRYF+amdljQp2q7+hm2ORmhON3CNFog==} engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0} @@ -1867,6 +2017,12 @@ packages: cpu: [x64] os: [win32] + '@lucide/angular@1.48.0': + resolution: {integrity: sha512-CeblytfN5ThHYnFy6nV2KVmGQx/MVh7JK2IMnh+5+mQhB0NAFFHVE/WjfxsGoUiOTaSa1GRC9z+Kbm0OhL2rsA==} + peerDependencies: + '@angular/common': '>=17.0.0' + '@angular/core': '>=17.0.0' + '@mapbox/node-pre-gyp@2.0.3': resolution: {integrity: sha512-uwPAhccfFJlsfCxMYTwOdVfOz3xqyj8xYL3zJj8f0pb30tLohnnFPhLuqp4/qoEz8sNxe4SESZedcBojRefIzg==} engines: {node: '>=18'} @@ -2235,6 +2391,10 @@ packages: resolution: {integrity: sha512-hAX0pT/73190NLqBPPWSdBVGtbY6VOhWYK3qqHqtXQ1gK7kS2yz4+ivsN07hpJ6I3aeMtKP6J6npsEKOAzuTLA==} engines: {node: '>=20.0'} + '@orama/orama@3.1.18': + resolution: {integrity: sha512-a61ljmRVVyG5MC/698C8/FfFDw5a8LOIvyOLW5fztgUXqUpc1jOfQzOitSCbge657OgXXThmY3Tk8fpiDb4UcA==} + engines: {node: '>= 20.0.0'} + '@oxc-parser/binding-android-arm-eabi@0.121.0': resolution: {integrity: sha512-n07FQcySwOlzap424/PLMtOkbS7xOu8nsJduKL8P3COGHKgKoDYXwoAHCbChfgFpHnviehrLWIPX0lKGtbEk/A==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3116,6 +3276,27 @@ packages: resolution: {integrity: sha512-V37T9uHOQVHyxxOqwcJ9xjSIW/mW9UuSfjOc7WJE4V8+3zj0abDJLHuoxDZKe0icYGapgOcvHyYjtNOjSeSivw==} engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + '@shikijs/core@1.29.2': + resolution: {integrity: sha512-vju0lY9r27jJfOY4Z7+Rt/nIOjzJpZ3y+nYpqtUZInVoXQ/TJZcfGnNOGnKjFdVZb8qexiCuSlZRKcGfhhTTZQ==} + + '@shikijs/engine-javascript@1.29.2': + resolution: {integrity: sha512-iNEZv4IrLYPv64Q6k7EPpOCE/nuvGiKl7zxdq0WFuRPF5PAE9PRo2JGq/d8crLusM59BRemJ4eOqrFrC4wiQ+A==} + + '@shikijs/engine-oniguruma@1.29.2': + resolution: {integrity: sha512-7iiOx3SG8+g1MnlzZVDYiaeHe7Ez2Kf2HrJzdmGwkRisT7r4rak0e655AcM/tF9JG/kg5fMNYlLLKglbN7gBqA==} + + '@shikijs/langs@1.29.2': + resolution: {integrity: sha512-FIBA7N3LZ+223U7cJDUYd5shmciFQlYkFXlkKVaHsCPgfVLiO+e12FmQE6Tf9vuyEsFe3dIl8qGWKXgEHL9wmQ==} + + '@shikijs/themes@1.29.2': + resolution: {integrity: sha512-i9TNZlsq4uoyqSbluIcZkmPL9Bfi3djVxRnofUHwvx/h6SRW3cwgBC5SML7vsDcWyukY0eCzVN980rqP6qNl9g==} + + '@shikijs/types@1.29.2': + resolution: {integrity: sha512-VJjK0eIijTZf0QSTODEXCqinjBn0joAHQ+aPSBzrv4O2d/QSbsMw+ZeSRx03kV34Hy7NzUvV/7NqfYGRLrASmw==} + + '@shikijs/vscode-textmate@10.0.2': + resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + '@sindresorhus/is@7.2.0': resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==} engines: {node: '>=18'} @@ -3130,6 +3311,108 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@tailwindcss/node@4.3.3': + resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==} + + '@tailwindcss/oxide-android-arm64@4.3.3': + resolution: {integrity: sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.3.3': + resolution: {integrity: sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.3.3': + resolution: {integrity: sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.3.3': + resolution: {integrity: sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3': + resolution: {integrity: sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.3': + resolution: {integrity: sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-arm64-musl@4.3.3': + resolution: {integrity: sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-linux-x64-gnu@4.3.3': + resolution: {integrity: sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-x64-musl@4.3.3': + resolution: {integrity: sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-wasm32-wasi@4.3.3': + resolution: {integrity: sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3': + resolution: {integrity: sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.3.3': + resolution: {integrity: sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.3.3': + resolution: {integrity: sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==} + engines: {node: '>= 20'} + + '@tailwindcss/typography@0.5.20': + resolution: {integrity: sha512-hwbzQuNUfcPvbegQFatVPl/MY/tcM9KLl963hQ5laJKPh81TEZ1+dNG9PirGvcaDBkp+BCshExAyKVPW91dozw==} + peerDependencies: + tailwindcss: '>=3.0.0 || >=4.0.0 || insiders' + + '@tailwindcss/vite@4.3.3': + resolution: {integrity: sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==} + peerDependencies: + vite: ^5.2.0 || ^6 || ^7 || ^8 + + '@ts-morph/common@0.29.0': + resolution: {integrity: sha512-35oUmphHbJvQ/+UTwFNme/t2p3FoKiGJ5auTjjpNTop2dyREspirjMy82PLSC1pnDJ8ah1GU98hwpVt64YXQsg==} + '@tybys/wasm-util@0.10.4': resolution: {integrity: sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==} @@ -3166,6 +3449,9 @@ packages: '@types/gensync@1.0.5': resolution: {integrity: sha512-MbsRCT7mTikHwKZ0X+LVUTLRrZZRLipTuXEO9qOYO+zmjMVk81axyClMROf6uoPD9MRVu46bx8zoR0Ad9q3NAg==} + '@types/hast@3.0.5': + resolution: {integrity: sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==} + '@types/http-errors@2.0.5': resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} @@ -3175,6 +3461,9 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/mdast@4.0.4': + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + '@types/node@24.13.6': resolution: {integrity: sha512-SGrw/h3KPFshy3OE6ZL53LMBG5vGQQ8/gIpiqz/kRZhPJ7HgwCEs8LBuNtWLa8dvGZVpSF7+Bf+c11HUrCb/yg==} @@ -3196,6 +3485,9 @@ packages: '@types/serve-static@2.2.0': resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==} + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@typescript-eslint/project-service@8.71.0': resolution: {integrity: sha512-aABjw5rjBacYONVPaPiWOCjJu0vEF4a25iQuodlmQYL1trtLZ0X/y+2Vzl3BKI1odM4LnwLE1oUDXYp1wzx1TQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -3240,6 +3532,9 @@ packages: resolution: {integrity: sha512-8eQ9R218XORK+KLosnf4bu/QsUXvUyVwTbArg7/0NMB1Pu87OJKvj4nhFblkYE8gQV73mW1dx1ptlPCkwRGa7A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@ungap/structured-clone@1.4.0': + resolution: {integrity: sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==} + '@valibot/to-json-schema@1.8.0': resolution: {integrity: sha512-a0M+uwCuQZEPAo65NYkFSJ14O5c213KoSZmPdoCfuFvUhfULi4T2Z6Tpjv6lTVMW9RoLm74RpRAjNEa43KcN+g==} peerDependencies: @@ -3739,6 +4034,9 @@ packages: caniuse-lite@1.0.30001810: resolution: {integrity: sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==} + ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} @@ -3751,6 +4049,12 @@ packages: resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + character-entities-html4@2.1.0: + resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} + + character-entities-legacy@3.0.0: + resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + chardet@2.2.0: resolution: {integrity: sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==} @@ -3808,6 +4112,9 @@ packages: resolution: {integrity: sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==} engines: {node: '>=0.10.0'} + code-block-writer@13.0.3: + resolution: {integrity: sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -3823,6 +4130,9 @@ packages: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} + comma-separated-tokens@2.0.3: + resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + commander@2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} @@ -3942,6 +4252,11 @@ packages: resolution: {integrity: sha512-wD5oz5xibMOPHzy13CyGmogB3phdvcDaB5t0W/Nr5Z2O/agcB8YwOz6e2Lsp10pNDzBoDO9nVa3RGs/2BttpHQ==} engines: {node: '>= 6'} + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + cssstyle@6.2.0: resolution: {integrity: sha512-Fm5NvhYathRnXNVndkUsCCuR63DCLVVwGOOwQw782coXFi5HhkXdu289l59HlXZBawsyNccXfWRYvLzcDCdDig==} engines: {node: '>=20'} @@ -4030,6 +4345,10 @@ packages: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + destr@2.0.5: resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} @@ -4054,6 +4373,9 @@ packages: cac: optional: true + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + dom-serializer@2.0.0: resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} @@ -4113,6 +4435,9 @@ packages: electron-to-chromium@1.5.433: resolution: {integrity: sha512-5lCAbyZBjtmUt/RAGHRqrL2q0oEFRThDAsZHHDn9XHa89Qw7gMYOeSicBTy+AHfvo0r6vwsZvqNJTQIQy1BLzA==} + emoji-regex-xs@1.0.0: + resolution: {integrity: sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==} + emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} @@ -4133,6 +4458,10 @@ packages: end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + enhanced-resolve@5.25.1: + resolution: {integrity: sha512-nGXts5znJzmWPu+mIE9izCOzdg63oJca2mDzGWWTth7sr4aCToKcoyFVBQwN75Ij5Pf6p510EwkTqViTRzDV+w==} + engines: {node: '>=10.13.0'} + entities@4.5.0: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} @@ -4403,6 +4732,17 @@ packages: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} + framer-motion@13.4.4: + resolution: {integrity: sha512-lbsZO95NGbulo6apz70zEt6Vxf/anoRwQI2ECEc/EQYWO5CGbITtl7plDW8P/VXgXw6e8wrbhH4a9i+ygke0Mg==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + react: + optional: true + react-dom: + optional: true + fresh@2.0.0: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} @@ -4523,6 +4863,12 @@ packages: resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} engines: {node: '>= 0.4'} + hast-util-to-html@9.0.5: + resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} + + hast-util-whitespace@3.0.0: + resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + he@1.2.0: resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} hasBin: true @@ -4555,6 +4901,9 @@ packages: resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + html-void-elements@3.0.0: + resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + htmlparser2@10.1.0: resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==} @@ -4827,36 +5176,73 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + lightningcss-android-arm64@1.33.0: resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [android] + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + lightningcss-darwin-arm64@1.33.0: resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [darwin] + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + lightningcss-darwin-x64@1.33.0: resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [darwin] + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + lightningcss-freebsd-x64@1.33.0: resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [freebsd] + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + lightningcss-linux-arm-gnueabihf@1.33.0: resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} engines: {node: '>= 12.0.0'} cpu: [arm] os: [linux] + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + lightningcss-linux-arm64-gnu@1.33.0: resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} engines: {node: '>= 12.0.0'} @@ -4864,6 +5250,13 @@ packages: os: [linux] libc: [glibc] + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + lightningcss-linux-arm64-musl@1.33.0: resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} engines: {node: '>= 12.0.0'} @@ -4871,6 +5264,13 @@ packages: os: [linux] libc: [musl] + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + lightningcss-linux-x64-gnu@1.33.0: resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} engines: {node: '>= 12.0.0'} @@ -4878,6 +5278,13 @@ packages: os: [linux] libc: [glibc] + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + lightningcss-linux-x64-musl@1.33.0: resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} engines: {node: '>= 12.0.0'} @@ -4885,18 +5292,34 @@ packages: os: [linux] libc: [musl] + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + lightningcss-win32-arm64-msvc@1.33.0: resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [win32] + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + lightningcss-win32-x64-msvc@1.33.0: resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [win32] + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + lightningcss@1.33.0: resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} engines: {node: '>= 12.0.0'} @@ -4988,6 +5411,12 @@ packages: peerDependencies: marked: '>=4 <19' + marked-shiki@1.2.1: + resolution: {integrity: sha512-yHxYQhPY5oYaIRnROn98foKhuClark7M373/VpLxiy5TrDu9Jd/LsMwo8w+U91Up4oDb9IXFrP0N1MFRz8W/DQ==} + peerDependencies: + marked: '>=7.0.0' + shiki: '>=1.0.0' + marked@15.0.12: resolution: {integrity: sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==} engines: {node: '>= 18'} @@ -4997,6 +5426,9 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + mdast-util-to-hast@13.2.1: + resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} + mdn-data@2.27.1: resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} @@ -5012,6 +5444,21 @@ packages: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} + micromark-util-character@2.1.1: + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} + + micromark-util-encode@2.0.1: + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + + micromark-util-sanitize-uri@2.0.1: + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} + + micromark-util-symbol@2.0.1: + resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} + + micromark-util-types@2.0.3: + resolution: {integrity: sha512-oxB2Ik03hI0gv+VNn9tnh1t1YEe9MDPptViAEgfdf3YQHsn0pzGTgCdlSCJXcwhqm8phaEuM7zeEu3QQzVBrPg==} + micromatch@4.0.8: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} @@ -5080,6 +5527,23 @@ packages: mlly@1.8.2: resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} + motion-dom@13.4.4: + resolution: {integrity: sha512-z2qN3RUABSci4G7cr5aHTFhqPNCWJsEMMfRlzxqtANQsSCIbVJmHvMV288m5x7doEQBTYbRTWbKdztmquCn4Sw==} + + motion-utils@13.3.0: + resolution: {integrity: sha512-sgSschQp7EseHInIlR7hBbMuvet3RA0bs28KPZAXJcGKGdxHGvh1ogpYDilY3bOMtl73EqPNmp75sAKHYPU5sg==} + + motion@13.4.4: + resolution: {integrity: sha512-lyX5kpAum2MmigecKWrFdIj3Sjn7pHek2EUrXY0WGZbOjJ1f5NfX0g4KQAb2Bp2EqL0a6dUzse11s1n7OYr5jg==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + react: + optional: true + react-dom: + optional: true + mrmime@2.0.1: resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} engines: {node: '>=10'} @@ -5229,6 +5693,9 @@ packages: resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} engines: {node: '>=18'} + oniguruma-to-es@2.3.0: + resolution: {integrity: sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g==} + open@10.1.0: resolution: {integrity: sha512-mnkeQ1qP5Ue2wd+aivTD3NHd/lZ96Lu0jgf0pwktLPtx6cTZiH7tyeGRRHs0zX0rbrahXPnXlUnbeXyaBBuIaw==} engines: {node: '>=18'} @@ -5296,6 +5763,9 @@ packages: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} + path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -5374,6 +5844,10 @@ packages: peerDependencies: postcss: ^8.4.31 + postcss-selector-parser@6.0.10: + resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==} + engines: {node: '>=4'} + postcss@8.5.28: resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} engines: {node: ^10 || ^12 || >=14} @@ -5414,6 +5888,9 @@ packages: resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} engines: {node: '>= 0.6.0'} + property-information@7.2.0: + resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} + proxy-addr@2.0.8: resolution: {integrity: sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==} engines: {node: '>= 0.10'} @@ -5502,6 +5979,15 @@ packages: regenerate@1.4.2: resolution: {integrity: sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==} + regex-recursion@5.1.1: + resolution: {integrity: sha512-ae7SBCbzVNrIjgSbh7wMznPcQel1DNlDtzensnFxpiNpXt1U2ju/bHugH422r+4LAVS1FpW1YCwilmnNsjum9w==} + + regex-utilities@2.3.0: + resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==} + + regex@5.1.1: + resolution: {integrity: sha512-dN5I359AVGPnwzJm2jN1k0W9LPZ+ePvoOeVMMfqIMFz53sSwXkxaJoxr50ptnsC771lK95BnTrVSZxq0b9yCGw==} + regexpu-core@6.4.0: resolution: {integrity: sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==} engines: {node: '>=4'} @@ -5701,6 +6187,9 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + shiki@1.29.2: + resolution: {integrity: sha512-njXuliz/cP+67jU2hukkxCNuH1yUi4QfdZZY+sMr5PPrIyXSu5iTb/qYC4BiWWB0vZ+7TbdvYUCeL23zpwCfbg==} + side-channel-list@1.0.1: resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} engines: {node: '>= 0.4'} @@ -5768,6 +6257,9 @@ packages: resolution: {integrity: sha512-d8EqvL+k/SOXCreS/SUzg2ciyHqBBLcN/yuRjFsbvVhHTE2pgei7oAhmPM7kWFbkX6OSMQfUq4KbkF3au9lhYQ==} engines: {node: '>= 12'} + space-separated-tokens@2.0.2: + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} @@ -5818,6 +6310,9 @@ packages: string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + stringify-entities@4.0.4: + resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} @@ -5852,6 +6347,13 @@ packages: resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} engines: {node: '>=20'} + tailwindcss@4.3.3: + resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==} + + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} + engines: {node: '>=6'} + tar-stream@2.2.0: resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} engines: {node: '>=6'} @@ -5927,12 +6429,18 @@ packages: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true + trim-lines@3.0.1: + resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + ts-api-utils@2.5.0: resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} engines: {node: '>=18.12'} peerDependencies: typescript: '>=4.8.4' + ts-morph@28.0.0: + resolution: {integrity: sha512-Wp3tnZ2bzwxyTZMtgWVzXDfm7lB1Drz+y9DmmYH/L702PQhPyVrp3pkou3yIz4qjS14GY9kcpmLiOOMvl8oG1g==} + tsconfig-paths@4.2.0: resolution: {integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==} engines: {node: '>=6'} @@ -6052,6 +6560,21 @@ packages: resolution: {integrity: sha512-N6uOhuW6zO95P3Mel2I2zMsbsanvvtgn6jVqJv4vbVcz/JN0OkL9suomjQGmWtxJQXOCqUJvquc1sMeNz/IwlA==} engines: {node: '>= 0.8.0'} + unist-util-is@6.0.1: + resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} + + unist-util-position@5.0.0: + resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + + unist-util-stringify-position@4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + + unist-util-visit-parents@6.0.2: + resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} + + unist-util-visit@5.1.0: + resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + unpipe@1.0.0: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} @@ -6215,6 +6738,12 @@ packages: resolution: {integrity: sha512-mMVzj0TXExtVdlDEq+Mzp0eyOuyznNpFobNM3uAqe3HVm/Ps2c+u17BligMkZjZCA6EiXpoon8iRTer3iu40SQ==} engines: {node: '>=18.12.0'} + vfile-message@4.0.3: + resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} + + vfile@6.0.3: + resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + vite@8.1.5: resolution: {integrity: sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -6527,6 +7056,9 @@ packages: zone.js@0.16.3: resolution: {integrity: sha512-ihXL9+vhYyEhXz4TDNpHeAOZN9FVrbog0Il64OIEI28UP/n5AaI6gsccRuOHBfx+206agzyoK527bYIO0Foy6A==} + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + snapshots: '@acemir/cssom@0.9.31': {} @@ -6536,12 +7068,12 @@ snapshots: '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 - '@analogjs/content@2.7.5(1e58ba138dc745395de3261acccd674a)': + '@analogjs/content@2.7.5(24eddf7a65aa2653ec559a78e283ee11)': dependencies: '@angular/common': 22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2) '@angular/core': 22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3) - '@angular/platform-browser': 22.1.7(@angular/common@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3)) - '@angular/router': 22.1.7(@angular/common@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(@angular/platform-browser@22.1.7(@angular/common@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3)))(rxjs@7.8.2) + '@angular/platform-browser': 22.1.7(@angular/animations@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3)))(@angular/common@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3)) + '@angular/router': 22.1.7(@angular/common@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(@angular/platform-browser@22.1.7(@angular/animations@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3)))(@angular/common@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3)))(rxjs@7.8.2) front-matter: 4.0.2 marked: 15.0.12 marked-gfm-heading-id: 4.1.4(marked@15.0.12) @@ -6553,9 +7085,9 @@ snapshots: optionalDependencies: '@nx/devkit': 23.2.1(nx@23.2.1) - '@analogjs/platform@2.7.5(@angular/build@22.1.8(e4e5819de5155796ef25fd39ddbcaaa2))(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@nx/angular@23.2.1(@angular-devkit/core@22.1.8(chokidar@5.0.0))(@angular-devkit/schematics@22.1.8(chokidar@5.0.0))(@angular/build@22.1.8(e4e5819de5155796ef25fd39ddbcaaa2))(@babel/traverse@7.29.8)(@schematics/angular@22.1.8(chokidar@5.0.0))(@zkochan/js-yaml@0.0.7)(eslint@10.11.0(jiti@2.7.0))(nx@23.2.1)(rxjs@7.8.2)(typescript@6.0.3))(@nx/devkit@23.2.1(nx@23.2.1))(@parcel/watcher@2.6.0)(marked-gfm-heading-id@4.1.4(marked@15.0.12))(marked-highlight@2.2.4(marked@15.0.12))(marked-mangle@1.1.14(marked@15.0.12))(marked@15.0.12)(prismjs@1.30.0)(rolldown@1.2.9)(srvx@1.0.5)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0))': + '@analogjs/platform@2.7.5(@angular/build@22.1.8(7bf82a545fa1df14b49b61d6ca208405))(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@nx/angular@23.2.1(@angular-devkit/core@22.1.8(chokidar@5.0.0))(@angular-devkit/schematics@22.1.8(chokidar@5.0.0))(@angular/build@22.1.8(7bf82a545fa1df14b49b61d6ca208405))(@babel/traverse@7.29.8)(@schematics/angular@22.1.8(chokidar@5.0.0))(@zkochan/js-yaml@0.0.7)(eslint@10.11.0(jiti@2.7.0))(nx@23.2.1)(rxjs@7.8.2)(typescript@6.0.3))(@nx/devkit@23.2.1(nx@23.2.1))(@parcel/watcher@2.6.0)(marked-gfm-heading-id@4.1.4(marked@15.0.12))(marked-highlight@2.2.4(marked@15.0.12))(marked-mangle@1.1.14(marked@15.0.12))(marked-shiki@1.2.1(marked@15.0.12)(shiki@1.29.2))(marked@15.0.12)(prismjs@1.30.0)(rolldown@1.2.9)(shiki@1.29.2)(srvx@1.0.5)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0))': dependencies: - '@analogjs/vite-plugin-angular': 2.7.5(@angular/build@22.1.8(e4e5819de5155796ef25fd39ddbcaaa2))(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0)) + '@analogjs/vite-plugin-angular': 2.7.5(@angular/build@22.1.8(7bf82a545fa1df14b49b61d6ca208405))(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0)) '@analogjs/vite-plugin-nitro': 2.7.5(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@parcel/watcher@2.6.0)(rolldown@1.2.9)(srvx@1.0.5)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0)) marked: 15.0.12 marked-gfm-heading-id: 4.1.4(marked@15.0.12) @@ -6565,10 +7097,12 @@ snapshots: vite: 8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0) vitefu: 1.1.3(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0)) optionalDependencies: - '@nx/angular': 23.2.1(@angular-devkit/core@22.1.8(chokidar@5.0.0))(@angular-devkit/schematics@22.1.8(chokidar@5.0.0))(@angular/build@22.1.8(e4e5819de5155796ef25fd39ddbcaaa2))(@babel/traverse@7.29.8)(@schematics/angular@22.1.8(chokidar@5.0.0))(@zkochan/js-yaml@0.0.7)(eslint@10.11.0(jiti@2.7.0))(nx@23.2.1)(rxjs@7.8.2)(typescript@6.0.3) + '@nx/angular': 23.2.1(@angular-devkit/core@22.1.8(chokidar@5.0.0))(@angular-devkit/schematics@22.1.8(chokidar@5.0.0))(@angular/build@22.1.8(7bf82a545fa1df14b49b61d6ca208405))(@babel/traverse@7.29.8)(@schematics/angular@22.1.8(chokidar@5.0.0))(@zkochan/js-yaml@0.0.7)(eslint@10.11.0(jiti@2.7.0))(nx@23.2.1)(rxjs@7.8.2)(typescript@6.0.3) '@nx/devkit': 23.2.1(nx@23.2.1) marked-highlight: 2.2.4(marked@15.0.12) + marked-shiki: 1.2.1(marked@15.0.12)(shiki@1.29.2) prismjs: 1.30.0 + shiki: 1.29.2 transitivePeerDependencies: - '@angular-devkit/build-angular' - '@angular/build' @@ -6613,34 +7147,34 @@ snapshots: - webpack - xml2js - '@analogjs/router@2.7.5(@analogjs/content@2.7.5(1e58ba138dc745395de3261acccd674a))(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(@angular/router@22.1.7(@angular/common@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(@angular/platform-browser@22.1.7(@angular/common@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3)))(rxjs@7.8.2))': + '@analogjs/router@2.7.5(@analogjs/content@2.7.5(24eddf7a65aa2653ec559a78e283ee11))(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(@angular/router@22.1.7(@angular/common@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(@angular/platform-browser@22.1.7(@angular/animations@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3)))(@angular/common@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3)))(rxjs@7.8.2))': dependencies: - '@analogjs/content': 2.7.5(1e58ba138dc745395de3261acccd674a) + '@analogjs/content': 2.7.5(24eddf7a65aa2653ec559a78e283ee11) '@angular/core': 22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3) - '@angular/router': 22.1.7(@angular/common@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(@angular/platform-browser@22.1.7(@angular/common@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3)))(rxjs@7.8.2) + '@angular/router': 22.1.7(@angular/common@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(@angular/platform-browser@22.1.7(@angular/animations@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3)))(@angular/common@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3)))(rxjs@7.8.2) tslib: 2.8.1 - '@analogjs/vite-plugin-angular@2.7.2(@angular/build@22.1.8(e4e5819de5155796ef25fd39ddbcaaa2))(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0))': + '@analogjs/vite-plugin-angular@2.7.2(@angular/build@22.1.8(7bf82a545fa1df14b49b61d6ca208405))(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0))': dependencies: magic-string: 0.30.21 obug: 2.2.1 oxc-parser: 0.121.0(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2) tinyglobby: 0.2.17 optionalDependencies: - '@angular/build': 22.1.8(e4e5819de5155796ef25fd39ddbcaaa2) + '@angular/build': 22.1.8(7bf82a545fa1df14b49b61d6ca208405) vite: 8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0) transitivePeerDependencies: - '@emnapi/core' - '@emnapi/runtime' - '@analogjs/vite-plugin-angular@2.7.5(@angular/build@22.1.8(e4e5819de5155796ef25fd39ddbcaaa2))(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0))': + '@analogjs/vite-plugin-angular@2.7.5(@angular/build@22.1.8(7bf82a545fa1df14b49b61d6ca208405))(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0))': dependencies: magic-string: 0.30.21 obug: 2.2.1 oxc-parser: 0.121.0(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2) tinyglobby: 0.2.17 optionalDependencies: - '@angular/build': 22.1.8(e4e5819de5155796ef25fd39ddbcaaa2) + '@angular/build': 22.1.8(7bf82a545fa1df14b49b61d6ca208405) vite: 8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0) transitivePeerDependencies: - '@emnapi/core' @@ -6699,6 +7233,15 @@ snapshots: - webpack - xml2js + '@analogjs/vitest-angular@2.7.5(@analogjs/vite-plugin-angular@2.7.5(@angular/build@22.1.8(7bf82a545fa1df14b49b61d6ca208405))(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0)))(@angular-devkit/architect@0.2201.8(chokidar@5.0.0))(@angular-devkit/schematics@22.1.8(chokidar@5.0.0))(vitest@4.1.11(@types/node@24.13.6)(jsdom@28.1.0)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0)))(zone.js@0.16.3)': + dependencies: + '@analogjs/vite-plugin-angular': 2.7.5(@angular/build@22.1.8(7bf82a545fa1df14b49b61d6ca208405))(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0)) + '@angular-devkit/architect': 0.2201.8(chokidar@5.0.0) + '@angular-devkit/schematics': 22.1.8(chokidar@5.0.0) + vitest: 4.1.11(@types/node@24.13.6)(jsdom@28.1.0)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0)) + optionalDependencies: + zone.js: 0.16.3 + '@angular-devkit/architect@0.2201.8(chokidar@5.0.0)': dependencies: '@angular-devkit/core': 22.1.8(chokidar@5.0.0) @@ -6727,7 +7270,13 @@ snapshots: transitivePeerDependencies: - chokidar - '@angular/build@22.1.8(e4e5819de5155796ef25fd39ddbcaaa2)': + '@angular/animations@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))': + dependencies: + '@angular/core': 22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3) + tslib: 2.8.1 + optional: true + + '@angular/build@22.1.8(7bf82a545fa1df14b49b61d6ca208405)': dependencies: '@ampproject/remapping': 2.3.0 '@angular-devkit/architect': 0.2201.8(chokidar@5.0.0) @@ -6761,12 +7310,13 @@ snapshots: watchpack: 2.5.2 optionalDependencies: '@angular/core': 22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3) - '@angular/platform-browser': 22.1.7(@angular/common@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3)) - '@angular/platform-server': 22.1.7(@angular/common@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/compiler@22.1.7)(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(@angular/platform-browser@22.1.7(@angular/common@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3)))(rxjs@7.8.2) - '@angular/ssr': 22.1.8(ea98ef3ad402c646289083575c811780) + '@angular/platform-browser': 22.1.7(@angular/animations@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3)))(@angular/common@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3)) + '@angular/platform-server': 22.1.7(@angular/common@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/compiler@22.1.7)(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(@angular/platform-browser@22.1.7(@angular/animations@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3)))(@angular/common@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3)))(rxjs@7.8.2) + '@angular/ssr': 22.1.8(26f1d65167ee0a7588ca833b67e4c299) lmdb: 3.5.6 postcss: 8.5.28 rollup: 4.63.5 + tailwindcss: 4.3.3 vitest: 4.1.11(@types/node@24.13.6)(jsdom@28.1.0)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0)) transitivePeerDependencies: - '@types/node' @@ -6836,48 +7386,56 @@ snapshots: '@angular/compiler': 22.1.7 zone.js: 0.16.3 - '@angular/forms@22.1.7(@angular/common@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(@angular/platform-browser@22.1.7(@angular/common@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3)))(rxjs@7.8.2)': + '@angular/elements@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2)': + dependencies: + '@angular/core': 22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3) + rxjs: 7.8.2 + tslib: 2.8.1 + + '@angular/forms@22.1.7(@angular/common@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(@angular/platform-browser@22.1.7(@angular/animations@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3)))(@angular/common@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3)))(rxjs@7.8.2)': dependencies: '@angular/common': 22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2) '@angular/core': 22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3) - '@angular/platform-browser': 22.1.7(@angular/common@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3)) + '@angular/platform-browser': 22.1.7(@angular/animations@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3)))(@angular/common@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3)) '@standard-schema/spec': 1.1.0 rxjs: 7.8.2 tslib: 2.8.1 zod: 4.6.5 - '@angular/platform-browser@22.1.7(@angular/common@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))': + '@angular/platform-browser@22.1.7(@angular/animations@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3)))(@angular/common@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))': dependencies: '@angular/common': 22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2) '@angular/core': 22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3) tslib: 2.8.1 + optionalDependencies: + '@angular/animations': 22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3)) - '@angular/platform-server@22.1.7(@angular/common@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/compiler@22.1.7)(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(@angular/platform-browser@22.1.7(@angular/common@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3)))(rxjs@7.8.2)': + '@angular/platform-server@22.1.7(@angular/common@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/compiler@22.1.7)(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(@angular/platform-browser@22.1.7(@angular/animations@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3)))(@angular/common@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3)))(rxjs@7.8.2)': dependencies: '@angular/common': 22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2) '@angular/compiler': 22.1.7 '@angular/core': 22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3) - '@angular/platform-browser': 22.1.7(@angular/common@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3)) + '@angular/platform-browser': 22.1.7(@angular/animations@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3)))(@angular/common@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3)) rxjs: 7.8.2 tslib: 2.8.1 xhr2: 0.2.1 - '@angular/router@22.1.7(@angular/common@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(@angular/platform-browser@22.1.7(@angular/common@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3)))(rxjs@7.8.2)': + '@angular/router@22.1.7(@angular/common@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(@angular/platform-browser@22.1.7(@angular/animations@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3)))(@angular/common@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3)))(rxjs@7.8.2)': dependencies: '@angular/common': 22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2) '@angular/core': 22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3) - '@angular/platform-browser': 22.1.7(@angular/common@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3)) + '@angular/platform-browser': 22.1.7(@angular/animations@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3)))(@angular/common@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3)) rxjs: 7.8.2 tslib: 2.8.1 - '@angular/ssr@22.1.8(ea98ef3ad402c646289083575c811780)': + '@angular/ssr@22.1.8(26f1d65167ee0a7588ca833b67e4c299)': dependencies: '@angular/common': 22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2) '@angular/core': 22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3) - '@angular/router': 22.1.7(@angular/common@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(@angular/platform-browser@22.1.7(@angular/common@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3)))(rxjs@7.8.2) + '@angular/router': 22.1.7(@angular/common@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(@angular/platform-browser@22.1.7(@angular/animations@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3)))(@angular/common@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3)))(rxjs@7.8.2) tslib: 2.8.1 optionalDependencies: - '@angular/platform-server': 22.1.7(@angular/common@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/compiler@22.1.7)(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(@angular/platform-browser@22.1.7(@angular/common@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3)))(rxjs@7.8.2) + '@angular/platform-server': 22.1.7(@angular/common@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/compiler@22.1.7)(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(@angular/platform-browser@22.1.7(@angular/animations@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3)))(@angular/common@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3)))(rxjs@7.8.2) '@asamuzakjp/css-color@5.1.11': dependencies: @@ -8269,6 +8827,12 @@ snapshots: '@lmdb/lmdb-win32-x64@3.5.6': optional: true + '@lucide/angular@1.48.0(@angular/common@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))': + dependencies: + '@angular/common': 22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2) + '@angular/core': 22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3) + tslib: 2.8.1 + '@mapbox/node-pre-gyp@2.0.3': dependencies: consola: 3.4.2 @@ -8455,7 +9019,7 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.3 - '@nx/angular@23.2.1(@angular-devkit/core@22.1.8(chokidar@5.0.0))(@angular-devkit/schematics@22.1.8(chokidar@5.0.0))(@angular/build@22.1.8(e4e5819de5155796ef25fd39ddbcaaa2))(@babel/traverse@7.29.8)(@schematics/angular@22.1.8(chokidar@5.0.0))(@zkochan/js-yaml@0.0.7)(eslint@10.11.0(jiti@2.7.0))(nx@23.2.1)(rxjs@7.8.2)(typescript@6.0.3)': + '@nx/angular@23.2.1(@angular-devkit/core@22.1.8(chokidar@5.0.0))(@angular-devkit/schematics@22.1.8(chokidar@5.0.0))(@angular/build@22.1.8(7bf82a545fa1df14b49b61d6ca208405))(@babel/traverse@7.29.8)(@schematics/angular@22.1.8(chokidar@5.0.0))(@zkochan/js-yaml@0.0.7)(eslint@10.11.0(jiti@2.7.0))(nx@23.2.1)(rxjs@7.8.2)(typescript@6.0.3)': dependencies: '@angular-devkit/core': 22.1.8(chokidar@5.0.0) '@angular-devkit/schematics': 22.1.8(chokidar@5.0.0) @@ -8474,7 +9038,7 @@ snapshots: semver: 7.8.5 tslib: 2.8.1 optionalDependencies: - '@angular/build': 22.1.8(e4e5819de5155796ef25fd39ddbcaaa2) + '@angular/build': 22.1.8(7bf82a545fa1df14b49b61d6ca208405) transitivePeerDependencies: - '@babel/traverse' - '@nx/jest' @@ -8637,6 +9201,8 @@ snapshots: '@oozcitak/util@10.0.0': {} + '@orama/orama@3.1.18': {} + '@oxc-parser/binding-android-arm-eabi@0.121.0': optional: true @@ -9155,6 +9721,41 @@ snapshots: transitivePeerDependencies: - chokidar + '@shikijs/core@1.29.2': + dependencies: + '@shikijs/engine-javascript': 1.29.2 + '@shikijs/engine-oniguruma': 1.29.2 + '@shikijs/types': 1.29.2 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.5 + hast-util-to-html: 9.0.5 + + '@shikijs/engine-javascript@1.29.2': + dependencies: + '@shikijs/types': 1.29.2 + '@shikijs/vscode-textmate': 10.0.2 + oniguruma-to-es: 2.3.0 + + '@shikijs/engine-oniguruma@1.29.2': + dependencies: + '@shikijs/types': 1.29.2 + '@shikijs/vscode-textmate': 10.0.2 + + '@shikijs/langs@1.29.2': + dependencies: + '@shikijs/types': 1.29.2 + + '@shikijs/themes@1.29.2': + dependencies: + '@shikijs/types': 1.29.2 + + '@shikijs/types@1.29.2': + dependencies: + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.5 + + '@shikijs/vscode-textmate@10.0.2': {} + '@sindresorhus/is@7.2.0': {} '@sindresorhus/merge-streams@4.0.0': {} @@ -9163,6 +9764,85 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@tailwindcss/node@4.3.3': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.25.1 + jiti: 2.7.0 + lightningcss: 1.32.0 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.3.3 + + '@tailwindcss/oxide-android-arm64@4.3.3': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.3.3': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.3.3': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.3.3': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.3.3': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.3.3': + optional: true + + '@tailwindcss/oxide@4.3.3': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.3.3 + '@tailwindcss/oxide-darwin-arm64': 4.3.3 + '@tailwindcss/oxide-darwin-x64': 4.3.3 + '@tailwindcss/oxide-freebsd-x64': 4.3.3 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.3 + '@tailwindcss/oxide-linux-arm64-gnu': 4.3.3 + '@tailwindcss/oxide-linux-arm64-musl': 4.3.3 + '@tailwindcss/oxide-linux-x64-gnu': 4.3.3 + '@tailwindcss/oxide-linux-x64-musl': 4.3.3 + '@tailwindcss/oxide-wasm32-wasi': 4.3.3 + '@tailwindcss/oxide-win32-arm64-msvc': 4.3.3 + '@tailwindcss/oxide-win32-x64-msvc': 4.3.3 + + '@tailwindcss/typography@0.5.20(tailwindcss@4.3.3)': + dependencies: + postcss-selector-parser: 6.0.10 + tailwindcss: 4.3.3 + + '@tailwindcss/vite@4.3.3(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0))': + dependencies: + '@tailwindcss/node': 4.3.3 + '@tailwindcss/oxide': 4.3.3 + tailwindcss: 4.3.3 + vite: 8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0) + + '@ts-morph/common@0.29.0': + dependencies: + minimatch: 10.2.6 + path-browserify: 1.0.1 + tinyglobby: 0.2.17 + '@tybys/wasm-util@0.10.4': dependencies: tslib: 2.8.1 @@ -9211,12 +9891,20 @@ snapshots: '@types/gensync@1.0.5': {} + '@types/hast@3.0.5': + dependencies: + '@types/unist': 3.0.3 + '@types/http-errors@2.0.5': {} '@types/jsesc@2.5.1': {} '@types/json-schema@7.0.15': {} + '@types/mdast@4.0.4': + dependencies: + '@types/unist': 3.0.3 + '@types/node@24.13.6': dependencies: undici-types: 7.18.2 @@ -9238,6 +9926,8 @@ snapshots: '@types/http-errors': 2.0.5 '@types/node': 24.13.6 + '@types/unist@3.0.3': {} + '@typescript-eslint/project-service@8.71.0(typescript@6.0.3)': dependencies: '@typescript-eslint/tsconfig-utils': 8.71.0(typescript@6.0.3) @@ -9301,6 +9991,8 @@ snapshots: '@typescript-eslint/types': 8.71.0 eslint-visitor-keys: 5.0.1 + '@ungap/structured-clone@1.4.0': {} + '@valibot/to-json-schema@1.8.0(valibot@1.5.0(typescript@6.0.3))': dependencies: valibot: 1.5.0(typescript@6.0.3) @@ -9797,6 +10489,8 @@ snapshots: caniuse-lite@1.0.30001810: {} + ccount@2.0.1: {} + chai@6.2.2: {} chalk@4.1.2: @@ -9806,6 +10500,10 @@ snapshots: chalk@5.6.2: {} + character-entities-html4@2.1.0: {} + + character-entities-legacy@3.0.0: {} + chardet@2.2.0: {} chokidar@5.0.0: @@ -9855,6 +10553,8 @@ snapshots: cluster-key-slot@1.1.1: {} + code-block-writer@13.0.3: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -9870,6 +10570,8 @@ snapshots: dependencies: delayed-stream: 1.0.0 + comma-separated-tokens@2.0.3: {} + commander@2.20.3: {} commondir@1.0.1: {} @@ -9971,6 +10673,8 @@ snapshots: css-what@7.0.0: {} + cssesc@3.0.0: {} + cssstyle@6.2.0: dependencies: '@asamuzakjp/css-color': 5.1.11 @@ -10027,6 +10731,8 @@ snapshots: depd@2.0.0: {} + dequal@2.0.3: {} + destr@2.0.5: {} detect-libc@2.1.2: {} @@ -10048,6 +10754,10 @@ snapshots: - ocache - srvx + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + dom-serializer@2.0.0: dependencies: domelementtype: 2.3.0 @@ -10096,6 +10806,8 @@ snapshots: electron-to-chromium@1.5.433: {} + emoji-regex-xs@1.0.0: {} + emoji-regex@10.6.0: {} emoji-regex@8.0.0: {} @@ -10110,6 +10822,11 @@ snapshots: dependencies: once: 1.4.0 + enhanced-resolve@5.25.1: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.3 + entities@4.5.0: {} entities@7.0.1: {} @@ -10453,6 +11170,12 @@ snapshots: forwarded@0.2.0: {} + framer-motion@13.4.4: + dependencies: + motion-dom: 13.4.4 + motion-utils: 13.3.0 + tslib: 2.8.1 + fresh@2.0.0: {} front-matter@4.0.2: @@ -10576,6 +11299,24 @@ snapshots: dependencies: function-bind: 1.1.2 + hast-util-to-html@9.0.5: + dependencies: + '@types/hast': 3.0.5 + '@types/unist': 3.0.3 + ccount: 2.0.1 + comma-separated-tokens: 2.0.3 + hast-util-whitespace: 3.0.0 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.1 + property-information: 7.2.0 + space-separated-tokens: 2.0.2 + stringify-entities: 4.0.4 + zwitch: 2.0.4 + + hast-util-whitespace@3.0.0: + dependencies: + '@types/hast': 3.0.5 + he@1.2.0: {} hono@4.13.8: {} @@ -10602,6 +11343,8 @@ snapshots: transitivePeerDependencies: - '@noble/hashes' + html-void-elements@3.0.0: {} + htmlparser2@10.1.0: dependencies: domelementtype: 2.3.0 @@ -10874,39 +11617,88 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + lightningcss-android-arm64@1.32.0: + optional: true + lightningcss-android-arm64@1.33.0: optional: true + lightningcss-darwin-arm64@1.32.0: + optional: true + lightningcss-darwin-arm64@1.33.0: optional: true + lightningcss-darwin-x64@1.32.0: + optional: true + lightningcss-darwin-x64@1.33.0: optional: true + lightningcss-freebsd-x64@1.32.0: + optional: true + lightningcss-freebsd-x64@1.33.0: optional: true + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + lightningcss-linux-arm-gnueabihf@1.33.0: optional: true + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + lightningcss-linux-arm64-gnu@1.33.0: optional: true + lightningcss-linux-arm64-musl@1.32.0: + optional: true + lightningcss-linux-arm64-musl@1.33.0: optional: true + lightningcss-linux-x64-gnu@1.32.0: + optional: true + lightningcss-linux-x64-gnu@1.33.0: optional: true + lightningcss-linux-x64-musl@1.32.0: + optional: true + lightningcss-linux-x64-musl@1.33.0: optional: true + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + lightningcss-win32-arm64-msvc@1.33.0: optional: true + lightningcss-win32-x64-msvc@1.32.0: + optional: true + lightningcss-win32-x64-msvc@1.33.0: optional: true + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + lightningcss@1.33.0: dependencies: detect-libc: 2.1.2 @@ -11046,10 +11838,27 @@ snapshots: dependencies: marked: 15.0.12 + marked-shiki@1.2.1(marked@15.0.12)(shiki@1.29.2): + dependencies: + marked: 15.0.12 + shiki: 1.29.2 + marked@15.0.12: {} math-intrinsics@1.1.0: {} + mdast-util-to-hast@13.2.1: + dependencies: + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + '@ungap/structured-clone': 1.4.0 + devlop: 1.1.0 + micromark-util-sanitize-uri: 2.0.1 + trim-lines: 3.0.1 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + mdn-data@2.27.1: {} media-typer@1.1.1: {} @@ -11058,6 +11867,23 @@ snapshots: merge2@1.4.1: {} + micromark-util-character@2.1.1: + dependencies: + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-util-encode@2.0.1: {} + + micromark-util-sanitize-uri@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-encode: 2.0.1 + micromark-util-symbol: 2.0.1 + + micromark-util-symbol@2.0.1: {} + + micromark-util-types@2.0.3: {} + micromatch@4.0.8: dependencies: braces: 3.0.3 @@ -11114,6 +11940,17 @@ snapshots: pkg-types: 1.3.1 ufo: 1.6.4 + motion-dom@13.4.4: + dependencies: + motion-utils: 13.3.0 + + motion-utils@13.3.0: {} + + motion@13.4.4: + dependencies: + framer-motion: 13.4.4 + tslib: 2.8.1 + mrmime@2.0.1: {} ms@2.1.3: {} @@ -11476,6 +12313,12 @@ snapshots: dependencies: mimic-function: 5.0.1 + oniguruma-to-es@2.3.0: + dependencies: + emoji-regex-xs: 1.0.0 + regex: 5.1.1 + regex-recursion: 5.1.1 + open@10.1.0: dependencies: default-browser: 5.5.1 @@ -11619,6 +12462,8 @@ snapshots: parseurl@1.3.3: {} + path-browserify@1.0.1: {} + path-exists@4.0.0: {} path-key@3.1.1: {} @@ -11684,6 +12529,11 @@ snapshots: dependencies: postcss: 8.5.28 + postcss-selector-parser@6.0.10: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + postcss@8.5.28: dependencies: nanoid: 3.3.19 @@ -11708,6 +12558,8 @@ snapshots: process@0.11.10: {} + property-information@7.2.0: {} + proxy-addr@2.0.8: dependencies: forwarded: 0.2.0 @@ -11794,6 +12646,17 @@ snapshots: regenerate@1.4.2: {} + regex-recursion@5.1.1: + dependencies: + regex: 5.1.1 + regex-utilities: 2.3.0 + + regex-utilities@2.3.0: {} + + regex@5.1.1: + dependencies: + regex-utilities: 2.3.0 + regexpu-core@6.4.0: dependencies: regenerate: 1.4.2 @@ -12060,6 +12923,17 @@ snapshots: shebang-regex@3.0.0: {} + shiki@1.29.2: + dependencies: + '@shikijs/core': 1.29.2 + '@shikijs/engine-javascript': 1.29.2 + '@shikijs/engine-oniguruma': 1.29.2 + '@shikijs/langs': 1.29.2 + '@shikijs/themes': 1.29.2 + '@shikijs/types': 1.29.2 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.5 + side-channel-list@1.0.1: dependencies: es-errors: 1.3.0 @@ -12125,6 +12999,8 @@ snapshots: source-map@0.8.0: {} + space-separated-tokens@2.0.2: {} + sprintf-js@1.0.3: {} srvx@1.0.5: {} @@ -12179,6 +13055,11 @@ snapshots: dependencies: safe-buffer: 5.2.1 + stringify-entities@4.0.4: + dependencies: + character-entities-html4: 2.1.0 + character-entities-legacy: 3.0.0 + strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 @@ -12205,6 +13086,10 @@ snapshots: tagged-tag@1.0.0: {} + tailwindcss@4.3.3: {} + + tapable@2.3.3: {} + tar-stream@2.2.0: dependencies: bl: 4.1.0 @@ -12291,10 +13176,17 @@ snapshots: tree-kill@1.2.2: {} + trim-lines@3.0.1: {} + ts-api-utils@2.5.0(typescript@6.0.3): dependencies: typescript: 6.0.3 + ts-morph@28.0.0: + dependencies: + '@ts-morph/common': 0.29.0 + code-block-writer: 13.0.3 + tsconfig-paths@4.2.0: dependencies: json5: 2.2.3 @@ -12417,6 +13309,29 @@ snapshots: dependencies: qs: 6.16.0 + unist-util-is@6.0.1: + dependencies: + '@types/unist': 3.0.3 + + unist-util-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-stringify-position@4.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-parents@6.0.2: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-visit@5.1.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + unpipe@1.0.0: {} unplugin-utils@0.3.2: @@ -12503,6 +13418,16 @@ snapshots: verkit@0.4.1: {} + vfile-message@4.0.3: + dependencies: + '@types/unist': 3.0.3 + unist-util-stringify-position: 4.0.0 + + vfile@6.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile-message: 4.0.3 + vite@8.1.5(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.101.0)(terser@5.51.2)(yaml@2.9.0): dependencies: lightningcss: 1.33.0 @@ -12771,3 +13696,5 @@ snapshots: zod@4.6.5: {} zone.js@0.16.3: {} + + zwitch@2.0.4: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c8c7160..b75eccd 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,7 @@ packages: - packages/* - examples/* + - apps/* packageExtensions: '@analogjs/vite-plugin-nitro': From f17b5709a5461913eb870318842e71699f7fec61 Mon Sep 17 00:00:00 2001 From: Kam <erkamyaman35@gmail.com> Date: Tue, 29 Sep 2026 00:39:48 +0300 Subject: [PATCH 02/13] feat(docs): enhance security documentation with access details and redaction guidelines style(docs): apply consistent formatting and spacing in TypeScript imports and configurations fix(docs): update navigation items and statuses in ngmd.config.ts for clarity and accuracy chore(docs): improve overall code readability by standardizing import statements and object formatting --- apps/docs/.prettierignore | 18 + apps/docs/.prettierrc.json | 25 + apps/docs/api-gen.plugin.ts | 24 +- apps/docs/link-guard.plugin.ts | 8 +- apps/docs/page-meta.plugin.ts | 10 +- apps/docs/plugin-utils.ts | 10 +- apps/docs/public/logos/angular.svg | 23 + apps/docs/public/logos/devframe.svg | 23 + apps/docs/public/logos/vite.svg | 132 ++++ apps/docs/raw-md.plugin.ts | 10 +- apps/docs/search-index.plugin.ts | 26 +- apps/docs/sitemap.plugin.ts | 10 +- apps/docs/src/app/app.config.server.ts | 6 +- apps/docs/src/app/app.config.ts | 22 +- apps/docs/src/app/app.spec.ts | 8 +- apps/docs/src/app/app.ts | 46 +- apps/docs/src/app/components/breadcrumb.ts | 8 +- apps/docs/src/app/components/code-copy.ts | 10 +- apps/docs/src/app/components/code-group.ts | 6 +- .../src/app/components/command-palette.ts | 18 +- .../src/app/components/content-banners.ts | 4 +- .../docs/src/app/components/external-links.ts | 6 +- .../src/app/components/heading-anchors.ts | 8 +- apps/docs/src/app/components/llm-actions.ts | 63 +- .../docs/src/app/components/media-enhancer.ts | 6 +- apps/docs/src/app/components/page-footer.ts | 10 +- apps/docs/src/app/components/sidebar.ts | 14 +- apps/docs/src/app/components/site-footer.ts | 8 +- .../docs/src/app/components/source-actions.ts | 10 +- apps/docs/src/app/components/sponsor-list.ts | 2 +- apps/docs/src/app/components/toaster.ts | 4 +- apps/docs/src/app/components/toc.ts | 14 +- .../app/components/version-switcher.spec.ts | 18 +- .../src/app/components/version-switcher.ts | 6 +- apps/docs/src/app/layout-mode.service.ts | 4 +- apps/docs/src/app/pages/[...slug].page.ts | 22 +- apps/docs/src/app/pages/index.page.ts | 721 ++++++++++++------ apps/docs/src/app/pages/sponsors.page.ts | 8 +- apps/docs/src/app/register-elements.spec.ts | 6 +- apps/docs/src/app/register-elements.ts | 28 +- .../services/route-url/route-url.service.ts | 12 +- .../app/services/search/algolia-provider.ts | 24 +- .../src/app/services/search/orama-provider.ts | 12 +- .../src/app/services/search/search.service.ts | 12 +- .../src/app/services/toast/toast.service.ts | 6 +- .../app/services/version/version.service.ts | 6 +- apps/docs/src/app/theme.ts | 6 +- apps/docs/src/app/title-strategy.ts | 12 +- apps/docs/src/app/ui/accordion.spec.ts | 4 +- apps/docs/src/app/ui/accordion.ts | 4 +- apps/docs/src/app/ui/alert.ts | 2 +- apps/docs/src/app/ui/badge.ts | 4 +- apps/docs/src/app/ui/brand-icons.ts | 33 + apps/docs/src/app/ui/callout.ts | 4 +- apps/docs/src/app/ui/card-grid.ts | 2 +- apps/docs/src/app/ui/card.ts | 20 +- apps/docs/src/app/ui/code-block.ts | 10 +- apps/docs/src/app/ui/discord-icon.ts | 2 +- apps/docs/src/app/ui/github-icon.ts | 2 +- apps/docs/src/app/ui/hero.ts | 22 +- apps/docs/src/app/ui/image.ts | 2 +- apps/docs/src/app/ui/index.ts | 52 +- apps/docs/src/app/ui/pill.ts | 6 +- apps/docs/src/app/ui/tabs.ts | 2 +- apps/docs/src/app/ui/video.ts | 4 +- apps/docs/src/app/ui/workflow.ts | 2 +- .../src/app/utils/enhance-on-navigation.ts | 10 +- .../src/app/utils/watch-host-attribute.ts | 2 +- apps/docs/src/content/agents/mcp-server.md | 146 +++- apps/docs/src/content/agents/resources.md | 112 ++- apps/docs/src/content/agents/tools.md | 301 ++++++-- apps/docs/src/content/community.md | 82 +- .../content/contributing/chrome-extension.md | 84 +- .../src/content/contributing/demo-apps.md | 109 ++- .../src/content/contributing/development.md | 171 ++++- .../src/content/contributing/publishing.md | 88 ++- .../getting-started/chrome-extension.md | 118 ++- apps/docs/src/content/getting-started/cli.md | 125 ++- .../src/content/getting-started/express.md | 186 ++++- .../content/getting-started/installation.md | 151 +++- .../content/getting-started/introduction.md | 164 +++- .../src/content/getting-started/overlay.md | 131 +++- .../content/getting-started/popup-and-hub.md | 121 ++- apps/docs/src/content/getting-started/vite.md | 140 +++- apps/docs/src/content/guides/analog.md | 141 +++- .../content/guides/ngrx-signals-restore.md | 110 ++- apps/docs/src/content/guides/ssr-http.md | 173 ++++- apps/docs/src/content/inspectors/analog.md | 197 ++++- .../docs/src/content/inspectors/components.md | 190 ++++- apps/docs/src/content/inspectors/dashboard.md | 116 ++- apps/docs/src/content/inspectors/forms.md | 226 +++++- apps/docs/src/content/inspectors/injectors.md | 148 +++- .../docs/src/content/inspectors/ngrx-store.md | 175 ++++- apps/docs/src/content/inspectors/pipes.md | 149 +++- apps/docs/src/content/inspectors/router.md | 217 +++++- apps/docs/src/content/inspectors/signals.md | 166 +++- apps/docs/src/content/inspectors/ssr-http.md | 203 ++++- apps/docs/src/content/security.md | 129 +++- apps/docs/src/main.server.ts | 6 +- apps/docs/src/main.ts | 6 +- apps/docs/src/marked-extensions/index.ts | 8 +- .../src/marked-extensions/ngmd-code-group.ts | 6 +- .../marked-extensions/ngmd-code-highlight.ts | 9 +- .../src/marked-extensions/ngmd-code-import.ts | 16 +- apps/docs/src/marked-extensions/ngmd-image.ts | 2 +- .../src/marked-extensions/ngmd-keywords.ts | 2 +- apps/docs/src/marked-extensions/ngmd-video.ts | 2 +- apps/docs/src/marked-extensions/runtime.ts | 8 +- .../src/marked-extensions/shiki-shared.ts | 2 +- apps/docs/src/ngmd.config.ts | 70 +- apps/docs/src/test-setup.ts | 2 +- apps/docs/src/vite-env.d.ts | 4 +- apps/docs/tsconfig.json | 2 +- apps/docs/vars.plugin.ts | 10 +- apps/docs/vite.config.ts | 26 +- 115 files changed, 5162 insertions(+), 1272 deletions(-) create mode 100644 apps/docs/.prettierignore create mode 100644 apps/docs/.prettierrc.json create mode 100644 apps/docs/public/logos/angular.svg create mode 100644 apps/docs/public/logos/devframe.svg create mode 100644 apps/docs/public/logos/vite.svg create mode 100644 apps/docs/src/app/ui/brand-icons.ts 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/api-gen.plugin.ts b/apps/docs/api-gen.plugin.ts index 8f72040..4cb2c2f 100644 --- a/apps/docs/api-gen.plugin.ts +++ b/apps/docs/api-gen.plugin.ts @@ -1,8 +1,8 @@ -import { existsSync } from 'node:fs'; -import { join, relative } from 'node:path'; -import type { Plugin } from 'vite'; -import { Project, ts } from 'ts-morph'; -import type { ApiConfig, SymbolRecord, SymbolKind } from './src/types/api.ts'; +import {existsSync} from 'node:fs'; +import {join, relative} from 'node:path'; +import type {Plugin} from 'vite'; +import {Project, ts} from 'ts-morph'; +import type {ApiConfig, SymbolRecord, SymbolKind} from './src/types/api.ts'; /** * API-reference auto-generation plugin. @@ -39,7 +39,7 @@ export function apiGenPlugin(): Plugin { if (!existsSync(path)) return null; try { const proj = new Project({ - compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext }, + 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 @@ -102,11 +102,11 @@ export function apiGenPlugin(): Plugin { const jsDocHost = jsDocHostFor(first); const jsDoc = ( jsDocHost && 'getJsDocs' in jsDocHost - ? (jsDocHost as { getJsDocs: () => unknown[] }).getJsDocs() + ? (jsDocHost as {getJsDocs: () => unknown[]}).getJsDocs() : [] ) as Array<{ getDescription: () => string; - getTags: () => Array<{ getTagName: () => string }>; + getTags: () => Array<{getTagName: () => string}>; }>; const description = jsDoc[0]?.getDescription().trim() ?? ''; const tags = jsDoc.flatMap((d) => d.getTags().map((t) => t.getTagName())); @@ -148,7 +148,7 @@ export function apiGenPlugin(): Plugin { const records = extractRecords(config); return `export const apiIndex = ${JSON.stringify(records, null, 2)};\n`; }, - handleHotUpdate({ file, server }) { + handleHotUpdate({file, server}) { // Invalidate the project cache when any source under scope changes. // Cheap because `Project` re-uses TypeScript's incremental machinery. if (file.endsWith('.ts') || file.endsWith('ngmd.api.ts')) { @@ -169,7 +169,7 @@ export function apiGenPlugin(): Plugin { } function symbolKindOf(decl: unknown): SymbolKind | null { - const kindGetter = (decl as { getKindName?: () => string }).getKindName; + const kindGetter = (decl as {getKindName?: () => string}).getKindName; const kindName = typeof kindGetter === 'function' ? kindGetter.call(decl) : ''; switch (kindName) { case 'ClassDeclaration': @@ -237,12 +237,12 @@ function parseLiteralAsConfig(literal: { getInitializer?: () => unknown; }>; }): ApiConfig { - const out: Partial<ApiConfig> = { scope: [], exclude: [] }; + const out: Partial<ApiConfig> = {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(); + 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)); diff --git a/apps/docs/link-guard.plugin.ts b/apps/docs/link-guard.plugin.ts index 9e52187..e044497 100644 --- a/apps/docs/link-guard.plugin.ts +++ b/apps/docs/link-guard.plugin.ts @@ -1,7 +1,7 @@ -import { readFileSync, statSync } from 'node:fs'; -import { join, relative } from 'node:path'; -import type { Plugin } from 'vite'; -import { routeFromPagePath, slugify, walkContentFiles, walkPageFiles } from './plugin-utils.ts'; +import {readFileSync, statSync} from 'node:fs'; +import {join, relative} from 'node:path'; +import type {Plugin} from 'vite'; +import {routeFromPagePath, slugify, walkContentFiles, walkPageFiles} from './plugin-utils.ts'; /** * Build-time guard that errors on broken internal links inside markdown files. diff --git a/apps/docs/page-meta.plugin.ts b/apps/docs/page-meta.plugin.ts index 67f946b..0dd27e8 100644 --- a/apps/docs/page-meta.plugin.ts +++ b/apps/docs/page-meta.plugin.ts @@ -1,7 +1,7 @@ -import { statSync } from 'node:fs'; -import { join } from 'node:path'; -import type { Plugin } from 'vite'; -import { gitDate, routeFromPagePath, walkContentFiles, walkPageFiles } from './plugin-utils.ts'; +import {statSync} from 'node:fs'; +import {join} from 'node:path'; +import type {Plugin} from 'vite'; +import {gitDate, routeFromPagePath, walkContentFiles, walkPageFiles} from './plugin-utils.ts'; /** * Build-time map of page URL → { editUrl, lastUpdated }. @@ -24,7 +24,7 @@ export interface PageMeta { const VIRTUAL_ID = 'virtual:ngmd/page-meta'; const RESOLVED_ID = '\0' + VIRTUAL_ID; -export function pageMetaPlugin(opts: { repoUrl: string; branch?: string; dir?: string }): Plugin { +export function pageMetaPlugin(opts: {repoUrl: string; branch?: string; dir?: string}): Plugin { const branch = opts.branch ?? 'main'; const prefix = opts.dir ? `${opts.dir.replace(/\/+$/, '')}/` : ''; let root = process.cwd(); diff --git a/apps/docs/plugin-utils.ts b/apps/docs/plugin-utils.ts index dcb55c6..5263e85 100644 --- a/apps/docs/plugin-utils.ts +++ b/apps/docs/plugin-utils.ts @@ -1,6 +1,6 @@ -import { execFileSync } from 'node:child_process'; -import { readdirSync, statSync } from 'node:fs'; -import { join, relative } from 'node:path'; +import {execFileSync} from 'node:child_process'; +import {readdirSync, statSync} from 'node:fs'; +import {join, relative} from 'node:path'; /** * Shared helpers for the build-time Vite plugins (`page-meta`, `sitemap`, @@ -19,7 +19,7 @@ import { join, relative } from 'node:path'; /** 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 })) { + for (const entry of readdirSync(dir, {withFileTypes: true})) { const full = join(dir, entry.name); if (entry.isDirectory()) { walkPageFiles(full, root, out); @@ -41,7 +41,7 @@ export function walkContentFiles( baseDir: string = dir, out: Array<[string, string]> = [], ): Array<[string, string]> { - for (const entry of readdirSync(dir, { withFileTypes: true })) { + for (const entry of readdirSync(dir, {withFileTypes: true})) { const full = join(dir, entry.name); if (entry.isDirectory()) { walkContentFiles(full, root, baseDir, out); 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 @@ +<svg viewBox="0 0 223 236" fill="none" xmlns="http://www.w3.org/2000/svg"> + <g clip-path="url(#ng-a)"> + <path fill="url(#ng-b)" d="m222.077 39.192-8.019 125.923L137.387 0l84.69 39.192Zm-53.105 162.825-57.933 33.056-57.934-33.056 11.783-28.556h92.301l11.783 28.556ZM111.039 62.675l30.357 73.803H80.681l30.358-73.803ZM7.937 165.115 0 39.192 84.69 0 7.937 165.115Z"/> + <path fill="url(#ng-c)" d="m222.077 39.192-8.019 125.923L137.387 0l84.69 39.192Zm-53.105 162.825-57.933 33.056-57.934-33.056 11.783-28.556h92.301l11.783 28.556ZM111.039 62.675l30.357 73.803H80.681l30.358-73.803ZM7.937 165.115 0 39.192 84.69 0 7.937 165.115Z"/> + </g> + <defs> + <linearGradient id="ng-b" x1="49.009" x2="225.829" y1="213.75" y2="129.722" gradientUnits="userSpaceOnUse"> + <stop stop-color="#E40035"/> + <stop offset=".24" stop-color="#F60A48"/> + <stop offset=".352" stop-color="#F20755"/> + <stop offset=".494" stop-color="#DC087D"/> + <stop offset=".745" stop-color="#9717E7"/> + <stop offset="1" stop-color="#6C00F5"/> + </linearGradient> + <linearGradient id="ng-c" x1="41.025" x2="156.741" y1="28.344" y2="160.344" gradientUnits="userSpaceOnUse"> + <stop stop-color="#FF31D9"/> + <stop offset="1" stop-color="#FF5BE1" stop-opacity="0"/> + </linearGradient> + <clipPath id="ng-a"> + <path fill="#fff" d="M0 0h223v236H0z"/> + </clipPath> + </defs> +</svg> 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 @@ +<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g filter="url(#filter0_d_8_208)"> +<path d="M238.464 40H66V236.007H104.577C220.688 236.007 232.79 158.437 232.79 121.733C232.79 108.867 243.38 108.867 243.38 121.733C251.318 236.701 356.35 245.457 365.527 246.223L365.542 246.224C374.619 246.981 382.561 251.521 365.542 254.548C253.592 264.765 243.758 364.282 243.38 375.634C243.002 386.986 235.438 399.851 232.79 375.634C222.957 274.603 144.289 257.954 104.577 257.954H66V460.772H238.464C358.356 460.772 452.152 371.472 452.152 251.521C452.152 131.571 356.465 40 238.464 40Z" fill="url(#paint0_radial_8_208)"/> +<path d="M238.464 40H66V236.007H104.577C220.688 236.007 232.79 158.437 232.79 121.733C232.79 108.867 243.38 108.867 243.38 121.733C251.318 236.701 356.35 245.457 365.527 246.223L365.542 246.224C374.619 246.981 382.561 251.521 365.542 254.548C253.592 264.765 243.758 364.282 243.38 375.634C243.002 386.986 235.438 399.851 232.79 375.634C222.957 274.603 144.289 257.954 104.577 257.954H66V460.772H238.464C358.356 460.772 452.152 371.472 452.152 251.521C452.152 131.571 356.465 40 238.464 40Z" stroke="#86997F" stroke-opacity="0.5" stroke-width="1.33156"/> +</g> +<defs> +<filter id="filter0_d_8_208" x="60.7404" y="39.3342" width="396.671" height="431.358" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feOffset dy="4.66045"/> +<feGaussianBlur stdDeviation="2.29694"/> +<feComposite in2="hardAlpha" operator="out"/> +<feColorMatrix type="matrix" values="0 0 0 0 0.25464 0 0 0 0 0.285175 0 0 0 0 0.243391 0 0 0 0.1 0"/> +<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_8_208"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_8_208" result="shape"/> +</filter> +<radialGradient id="paint0_radial_8_208" cx="0" cy="0" r="1" gradientTransform="matrix(399.011 555.102 -568.423 408.982 -11.6221 -34.9218)" gradientUnits="userSpaceOnUse"> +<stop stop-color="#ADC77F"/> +<stop offset="0.71903" stop-color="#517158"/> +<stop offset="1" stop-color="#486954"/> +</radialGradient> +</defs> +</svg> 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 @@ +<svg viewBox="0 0 23 14" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path d="M20.7482 0H18.8887C21.641 3.93959 21.6571 10.0462 18.8887 14H20.7482C23.516 10.0462 23.4999 3.93959 20.7482 0Z" fill="white"/> + <path d="M2.07027 3.05176e-05C-0.682028 3.93963 -0.698142 10.0463 2.07027 14H3.92985C1.16208 10.0463 1.1782 3.93963 3.92985 3.05176e-05H2.07027Z" fill="white"/> + <path d="M12.0135 13.6771C11.815 13.9297 11.4089 13.7892 11.4089 13.4682V10.3853C11.4089 10.0114 11.106 9.70846 10.7321 9.70846H7.32818C7.05295 9.70846 6.89245 9.39713 7.05295 9.17347L9.29089 6.04023C9.61124 5.59225 9.29089 4.9696 8.73979 4.9696H4.62036C4.34513 4.9696 4.18463 4.65828 4.34512 4.43461L7.24632 0.372548C7.31013 0.283598 7.41262 0.230743 7.52155 0.230743H16.1671C16.4424 0.230743 16.6029 0.542069 16.4424 0.765734L14.2044 3.89897C13.8841 4.34695 14.2044 4.9696 14.7555 4.9696H18.1595C18.4418 4.9696 18.6004 5.29511 18.4257 5.51748L12.0142 13.6777L12.0135 13.6771Z" fill="#863BFF"/> + <mask id="mask0_127_17274" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="4" y="0" width="15" height="14"> + <path d="M11.9823 13.677C11.7838 13.9296 11.3777 13.7891 11.3777 13.4681V10.3852C11.3777 10.0113 11.0747 9.70837 10.7009 9.70837H7.29693C7.0217 9.70837 6.8612 9.39704 7.0217 9.17338L9.25964 6.04014C9.57999 5.59216 9.25964 4.96951 8.70854 4.96951H4.58911C4.31388 4.96951 4.15338 4.65818 4.31387 4.43452L7.21507 0.372457C7.27888 0.283506 7.38137 0.230652 7.4903 0.230652H16.1359C16.4111 0.230652 16.5716 0.541978 16.4111 0.765643L14.1732 3.89888C13.8528 4.34686 14.1732 4.96951 14.7243 4.96951H18.1282C18.4106 4.96951 18.5691 5.29502 18.3944 5.51739L11.9829 13.6776L11.9823 13.677Z" fill="black"/> + </mask> + <g mask="url(#mask0_127_17274)"> + <g filter="url(#filter0_f_127_17274)"> + <ellipse cx="1.6481" cy="4.39979" rx="1.6481" ry="4.39979" transform="matrix(0.00324134 0.999995 0.999995 -0.00324134 2.91309 9.66077)" fill="#EDE6FF"/> + </g> + <g filter="url(#filter1_f_127_17274)"> + <ellipse cx="3.11172" cy="8.9321" rx="3.11172" ry="8.9321" transform="matrix(0.00324134 0.999995 0.999995 -0.00324134 -7.51758 2.58936)" fill="#EDE6FF"/> + </g> + <g filter="url(#filter2_f_127_17274)"> + <ellipse cx="1.6481" cy="9.12221" rx="1.6481" ry="9.12221" transform="matrix(0.00324134 0.999995 0.999995 -0.00324134 -7.8584 3.61816)" fill="#7E14FF"/> + </g> + <g filter="url(#filter3_f_127_17274)"> + <ellipse cx="1.6481" cy="9.15566" rx="1.6481" ry="9.15566" transform="matrix(0.00324134 0.999995 0.999995 -0.00324134 -6.4834 9.00391)" fill="#7E14FF"/> + </g> + <g filter="url(#filter4_f_127_17274)"> + <ellipse cx="1.6481" cy="9.15566" rx="1.6481" ry="9.15566" transform="matrix(0.00324134 0.999995 0.999995 -0.00324134 -6.02441 9.34766)" fill="#7E14FF"/> + </g> + <g filter="url(#filter5_f_127_17274)"> + <ellipse cx="4.21045" cy="6.60625" rx="4.21045" ry="6.60625" transform="matrix(0.0584509 -0.99829 -0.99829 -0.0584509 26.4971 8.26871)" fill="#EDE6FF"/> + </g> + <g filter="url(#filter6_f_127_17274)"> + <ellipse cx="1.03839" cy="6.43346" rx="1.03839" ry="6.43346" transform="matrix(-0.0172986 -0.99985 -0.99985 0.0172986 26.9297 5.63535)" fill="#7E14FF"/> + </g> + <g filter="url(#filter7_f_127_17274)"> + <ellipse cx="1.03839" cy="6.43346" rx="1.03839" ry="6.43346" transform="matrix(-0.0172986 -0.99985 -0.99985 0.0172986 26.9297 5.63535)" fill="#7E14FF"/> + </g> + <g filter="url(#filter8_f_127_17274)"> + <ellipse cx="4.36576" cy="2.91514" rx="1.31855" ry="8.70955" transform="rotate(39.5103 4.36576 2.91514)" fill="#7E14FF"/> + </g> + <g filter="url(#filter9_f_127_17274)"> + <ellipse cx="18.4697" cy="-1.59207" rx="1.31855" ry="8.70955" transform="rotate(37.8923 18.4697 -1.59207)" fill="#7E14FF"/> + </g> + <g filter="url(#filter10_f_127_17274)"> + <ellipse cx="16.6417" cy="2.12577" rx="1.78679" ry="2.89199" transform="rotate(37.8923 16.6417 2.12577)" fill="#47BFFF"/> + </g> + <g filter="url(#filter11_f_127_17274)"> + <ellipse cx="3.68841" cy="11.7003" rx="1.31855" ry="8.70955" transform="rotate(37.8923 3.68841 11.7003)" fill="#7E14FF"/> + </g> + <g filter="url(#filter12_f_127_17274)"> + <ellipse cx="3.68841" cy="11.7003" rx="1.31855" ry="8.70955" transform="rotate(37.8923 3.68841 11.7003)" fill="#7E14FF"/> + </g> + <g filter="url(#filter13_f_127_17274)"> + <ellipse cx="14.9179" cy="9.17936" rx="1.31855" ry="8.70955" transform="rotate(37.8923 14.9179 9.17936)" fill="#7E14FF"/> + </g> + <g filter="url(#filter14_f_127_17274)"> + <ellipse cx="15.7453" cy="9.92533" rx="1.78679" ry="4.57726" transform="rotate(37.8923 15.7453 9.92533)" fill="#47BFFF"/> + </g> + </g> + <defs> + <filter id="filter0_f_127_17274" x="-1.66562" y="5.06287" width="17.967" height="12.4635" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix"/> + <feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/> + <feGaussianBlur stdDeviation="2.29179" result="effect1_foregroundBlur_127_17274"/> + </filter> + <filter id="filter1_f_127_17274" x="-12.0914" y="-2.02332" width="27.0314" height="15.3908" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix"/> + <feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/> + <feGaussianBlur stdDeviation="2.29179" result="effect1_foregroundBlur_127_17274"/> + </filter> + <filter id="filter2_f_127_17274" x="-10.6037" y="0.838165" width="23.7454" height="8.79703" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix"/> + <feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/> + <feGaussianBlur stdDeviation="1.37508" result="effect1_foregroundBlur_127_17274"/> + </filter> + <filter id="filter3_f_127_17274" x="-9.22867" y="6.22382" width="23.8118" height="8.79703" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix"/> + <feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/> + <feGaussianBlur stdDeviation="1.37508" result="effect1_foregroundBlur_127_17274"/> + </filter> + <filter id="filter4_f_127_17274" x="-8.76968" y="6.56757" width="23.8118" height="8.79703" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix"/> + <feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/> + <feGaussianBlur stdDeviation="1.37508" result="effect1_foregroundBlur_127_17274"/> + </filter> + <filter id="filter5_f_127_17274" x="8.96524" y="-5.12549" width="22.3664" height="17.6096" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix"/> + <feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/> + <feGaussianBlur stdDeviation="2.29179" result="effect1_foregroundBlur_127_17274"/> + </filter> + <filter id="filter6_f_127_17274" x="11.2967" y="0.91397" width="18.3655" height="7.58884" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix"/> + <feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/> + <feGaussianBlur stdDeviation="1.37508" result="effect1_foregroundBlur_127_17274"/> + </filter> + <filter id="filter7_f_127_17274" x="11.2967" y="0.91397" width="18.3655" height="7.58884" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix"/> + <feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/> + <feGaussianBlur stdDeviation="1.37508" result="effect1_foregroundBlur_127_17274"/> + </filter> + <filter id="filter8_f_127_17274" x="-4.01871" y="-6.60739" width="16.7698" height="19.045" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix"/> + <feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/> + <feGaussianBlur stdDeviation="1.37508" result="effect1_foregroundBlur_127_17274"/> + </filter> + <filter id="filter9_f_127_17274" x="10.2694" y="-11.2637" width="16.4007" height="19.3433" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix"/> + <feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/> + <feGaussianBlur stdDeviation="1.37508" result="effect1_foregroundBlur_127_17274"/> + </filter> + <filter id="filter10_f_127_17274" x="11.6239" y="-3.15738" width="10.0355" height="10.5663" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix"/> + <feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/> + <feGaussianBlur stdDeviation="1.37508" result="effect1_foregroundBlur_127_17274"/> + </filter> + <filter id="filter11_f_127_17274" x="-4.51187" y="2.02869" width="16.4007" height="19.3433" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix"/> + <feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/> + <feGaussianBlur stdDeviation="1.37508" result="effect1_foregroundBlur_127_17274"/> + </filter> + <filter id="filter12_f_127_17274" x="-4.51187" y="2.02869" width="16.4007" height="19.3433" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix"/> + <feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/> + <feGaussianBlur stdDeviation="1.37508" result="effect1_foregroundBlur_127_17274"/> + </filter> + <filter id="filter13_f_127_17274" x="6.71762" y="-0.49228" width="16.4007" height="19.3433" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix"/> + <feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/> + <feGaussianBlur stdDeviation="1.37508" result="effect1_foregroundBlur_127_17274"/> + </filter> + <filter id="filter14_f_127_17274" x="9.84946" y="3.39893" width="11.7913" height="13.0528" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix"/> + <feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/> + <feGaussianBlur stdDeviation="1.37508" result="effect1_foregroundBlur_127_17274"/> + </filter> + </defs> +</svg> diff --git a/apps/docs/raw-md.plugin.ts b/apps/docs/raw-md.plugin.ts index 99089c8..51d4829 100644 --- a/apps/docs/raw-md.plugin.ts +++ b/apps/docs/raw-md.plugin.ts @@ -1,7 +1,7 @@ -import { readdirSync, readFileSync, statSync } from 'node:fs'; -import { extname, join, relative } from 'node:path'; -import type { Plugin } from 'vite'; -import { substituteMdVars } from './vars.plugin.ts'; +import {readdirSync, readFileSync, statSync} from 'node:fs'; +import {extname, join, relative} from 'node:path'; +import type {Plugin} from 'vite'; +import {substituteMdVars} from './vars.plugin.ts'; /** * Serves the raw markdown body at the same URL plus a `.md` suffix. @@ -73,7 +73,7 @@ export function rawMdPlugin(): Plugin { const walk = (dir: string): string[] => { const out: string[] = []; - for (const entry of readdirSync(dir, { withFileTypes: true })) { + for (const entry of readdirSync(dir, {withFileTypes: true})) { const full = join(dir, entry.name); if (entry.isDirectory()) out.push(...walk(full)); else if (entry.isFile() && entry.name.endsWith('.md')) out.push(full); diff --git a/apps/docs/search-index.plugin.ts b/apps/docs/search-index.plugin.ts index 85c7a9f..b3267b6 100644 --- a/apps/docs/search-index.plugin.ts +++ b/apps/docs/search-index.plugin.ts @@ -1,8 +1,8 @@ -import { readFileSync, statSync } from 'node:fs'; -import { join } from 'node:path'; -import type { Plugin } from 'vite'; -import type { IndexDoc, SearchHitKind } from './src/types/search.ts'; -import { slugify, walkContentFiles } from './plugin-utils.ts'; +import {readFileSync, statSync} from 'node:fs'; +import {join} from 'node:path'; +import type {Plugin} from 'vite'; +import type {IndexDoc, SearchHitKind} from './src/types/search.ts'; +import {slugify, walkContentFiles} from './plugin-utils.ts'; /** * Build-time search index. Walks `src/content/**\/*.md` and emits a flat list @@ -25,9 +25,9 @@ interface Frontmatter { noIndex?: boolean; } -function parseFrontmatter(text: string): { fm: Frontmatter; body: string } { +function parseFrontmatter(text: string): {fm: Frontmatter; body: string} { const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/); - if (!match) return { fm: {}, body: text }; + if (!match) return {fm: {}, body: text}; const fm: Frontmatter = {}; for (const line of match[1].split(/\r?\n/)) { const m = line.match(/^\s*(\w+)\s*:\s*(.+?)\s*$/); @@ -37,7 +37,7 @@ function parseFrontmatter(text: string): { fm: Frontmatter; body: string } { if (key === 'title') fm.title = raw; if (key === 'noIndex') fm.noIndex = /^(true|yes|1)$/i.test(raw); } - return { fm, body: match[2] }; + return {fm, body: match[2]}; } const ENTITIES: Record<string, string> = { @@ -67,15 +67,15 @@ function stripMarkdown(s: string): string { /** 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 }> { +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 sections: Array<{heading: string; body: string}> = []; + let current: {heading: string; body: string} = {heading: '', body: ''}; for (const line of lines) { const m = line.match(/^(##+)\s+(.+?)\s*$/); if (m) { if (current.heading || current.body.trim()) sections.push(current); - current = { heading: m[2], body: '' }; + current = {heading: m[2], body: ''}; } else { current.body += line + '\n'; } @@ -145,7 +145,7 @@ export function searchIndexPlugin(): Plugin { } catch { continue; } - const { fm, body } = parseFrontmatter(raw); + const {fm, body} = parseFrontmatter(raw); if (fm.noIndex) continue; const slug = url.split('/').pop() || ''; diff --git a/apps/docs/sitemap.plugin.ts b/apps/docs/sitemap.plugin.ts index 7c8ca78..8ddaac8 100644 --- a/apps/docs/sitemap.plugin.ts +++ b/apps/docs/sitemap.plugin.ts @@ -1,7 +1,7 @@ -import { statSync } from 'node:fs'; -import { join } from 'node:path'; -import type { Plugin } from 'vite'; -import { gitDate, routeFromPagePath, walkContentFiles, walkPageFiles } from './plugin-utils.ts'; +import {statSync} from 'node:fs'; +import {join} from 'node:path'; +import type {Plugin} from 'vite'; +import {gitDate, routeFromPagePath, walkContentFiles, walkPageFiles} from './plugin-utils.ts'; /** * Emits `sitemap.xml` and `robots.txt` into the client build output. @@ -27,7 +27,7 @@ function escapeXml(s: string): string { .replace(/'/g, '''); } -export function sitemapPlugin(opts: { siteUrl: string }): Plugin { +export function sitemapPlugin(opts: {siteUrl: string}): Plugin { let root = process.cwd(); const siteUrl = opts.siteUrl.replace(/\/+$/, ''); const today = () => new Date().toISOString().slice(0, 10); diff --git a/apps/docs/src/app/app.config.server.ts b/apps/docs/src/app/app.config.server.ts index 0da63b0..2397ce8 100644 --- a/apps/docs/src/app/app.config.server.ts +++ b/apps/docs/src/app/app.config.server.ts @@ -1,7 +1,7 @@ -import { mergeApplicationConfig, ApplicationConfig } from '@angular/core'; -import { provideServerRendering } from '@angular/platform-server'; +import {mergeApplicationConfig, ApplicationConfig} from '@angular/core'; +import {provideServerRendering} from '@angular/platform-server'; -import { appConfig } from './app.config'; +import {appConfig} from './app.config'; const serverConfig: ApplicationConfig = { providers: [provideServerRendering()], diff --git a/apps/docs/src/app/app.config.ts b/apps/docs/src/app/app.config.ts index 58f631b..2bfd18a 100644 --- a/apps/docs/src/app/app.config.ts +++ b/apps/docs/src/app/app.config.ts @@ -1,4 +1,4 @@ -import { provideHttpClient, withFetch, withInterceptors } from '@angular/common/http'; +import {provideHttpClient, withFetch, withInterceptors} from '@angular/common/http'; import { ApplicationConfig, Injector, @@ -11,15 +11,15 @@ import { 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'; +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: [ @@ -39,7 +39,7 @@ export const appConfig: ApplicationConfig = { provideHttpClient(withFetch(), withInterceptors([requestContextInterceptor])), provideClientHydration(withEventReplay(), withNoIncrementalHydration()), provideContent(withMarkdownRenderer(), withShikiHighlighter()), - { provide: TitleStrategy, useClass: NgmdTitleStrategy }, + {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 `<analog-markdown [content]>` parse diff --git a/apps/docs/src/app/app.spec.ts b/apps/docs/src/app/app.spec.ts index 6ac92e2..eb31557 100644 --- a/apps/docs/src/app/app.spec.ts +++ b/apps/docs/src/app/app.spec.ts @@ -1,8 +1,8 @@ -import { TestBed } from '@angular/core/testing'; -import { provideRouter } from '@angular/router'; -import { provideLocationMocks } from '@angular/common/testing'; +import {TestBed} from '@angular/core/testing'; +import {provideRouter} from '@angular/router'; +import {provideLocationMocks} from '@angular/common/testing'; -import { App } from './app'; +import {App} from './app'; describe('App', () => { beforeEach(async () => { diff --git a/apps/docs/src/app/app.ts b/apps/docs/src/app/app.ts index 8a6b2a2..6228781 100644 --- a/apps/docs/src/app/app.ts +++ b/apps/docs/src/app/app.ts @@ -1,5 +1,5 @@ -import { Component, DestroyRef, computed, inject, OnInit, signal } from '@angular/core'; -import { Router, RouterLink, RouterOutlet } from '@angular/router'; +import {Component, DestroyRef, computed, inject, OnInit, signal} from '@angular/core'; +import {Router, RouterLink, RouterOutlet} from '@angular/router'; import { LucideDynamicIcon, LucideMenu, @@ -9,27 +9,27 @@ import { 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 {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 {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'; @Component({ selector: 'app-root', @@ -286,7 +286,7 @@ export class App implements OnInit { 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); + setTimeout(() => window.scrollTo({top: 0, behavior: 'smooth'}), 0); }); } } diff --git a/apps/docs/src/app/components/breadcrumb.ts b/apps/docs/src/app/components/breadcrumb.ts index ec63a50..e4cd02a 100644 --- a/apps/docs/src/app/components/breadcrumb.ts +++ b/apps/docs/src/app/components/breadcrumb.ts @@ -1,7 +1,7 @@ -import { Component, computed, inject } from '@angular/core'; -import { RouterLink } from '@angular/router'; -import { LucideDynamicIcon, LucideChevronRight, LucideHouse } from '@lucide/angular'; -import { RouteUrlService } from '../services/route-url/route-url.service'; +import {Component, computed, inject} from '@angular/core'; +import {RouterLink} from '@angular/router'; +import {LucideDynamicIcon, LucideChevronRight, LucideHouse} from '@lucide/angular'; +import {RouteUrlService} from '../services/route-url/route-url.service'; interface Crumb { label: string; diff --git a/apps/docs/src/app/components/code-copy.ts b/apps/docs/src/app/components/code-copy.ts index e4bf7ef..5560c1e 100644 --- a/apps/docs/src/app/components/code-copy.ts +++ b/apps/docs/src/app/components/code-copy.ts @@ -1,8 +1,8 @@ -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'; +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 <pre> code blocks and injects a copy button diff --git a/apps/docs/src/app/components/code-group.ts b/apps/docs/src/app/components/code-group.ts index 7c2ea92..287db21 100644 --- a/apps/docs/src/app/components/code-group.ts +++ b/apps/docs/src/app/components/code-group.ts @@ -1,6 +1,6 @@ -import { AfterViewInit, Component, DestroyRef, inject } from '@angular/core'; -import { Router } from '@angular/router'; -import { enhanceOnNavigation } from '../utils/enhance-on-navigation'; +import {AfterViewInit, Component, DestroyRef, inject} from '@angular/core'; +import {Router} from '@angular/router'; +import {enhanceOnNavigation} from '../utils/enhance-on-navigation'; /** * Wires tab-switching for `<div class="ngmd-code-group">` blocks emitted by diff --git a/apps/docs/src/app/components/command-palette.ts b/apps/docs/src/app/components/command-palette.ts index af6ba70..765371b 100644 --- a/apps/docs/src/app/components/command-palette.ts +++ b/apps/docs/src/app/components/command-palette.ts @@ -8,8 +8,8 @@ import { signal, viewChild, } from '@angular/core'; -import { NgTemplateOutlet } from '@angular/common'; -import { Router } from '@angular/router'; +import {NgTemplateOutlet} from '@angular/common'; +import {Router} from '@angular/router'; import { LucideDynamicIcon, LucideArrowRight, @@ -21,8 +21,8 @@ import { LucideTrash, LucideX, } from '@lucide/angular'; -import type { SearchHit } from '../../types/search'; -import { SearchService } from '../services/search/search.service'; +import type {SearchHit} from '../../types/search'; +import {SearchService} from '../services/search/search.service'; /** * Cmd+K palette. The heavy lifting lives in `SearchService`; this component @@ -73,7 +73,7 @@ import { SearchService } from '../services/search/search.service'; </div> @for (item of search.favorites(); track item.url) { <ng-container - *ngTemplateOutlet="historyRow; context: { $implicit: item, favorite: true }" + *ngTemplateOutlet="historyRow; context: {$implicit: item, favorite: true}" /> } } @@ -93,7 +93,7 @@ import { SearchService } from '../services/search/search.service'; </div> @for (item of search.recents(); track item.url) { <ng-container - *ngTemplateOutlet="historyRow; context: { $implicit: item, favorite: false }" + *ngTemplateOutlet="historyRow; context: {$implicit: item, favorite: false}" /> } } @@ -370,7 +370,7 @@ export class CommandPalette { this.navigateTo(hit.url); } - selectHistory(item: { id: string; url: string; labelHtml: string; subLabelHtml: string }) { + selectHistory(item: {id: string; url: string; labelHtml: string; subLabelHtml: string}) { // Re-record so a re-visited recent moves to the top of the list. this.search.recordVisit({ id: item.id, @@ -398,7 +398,7 @@ export class CommandPalette { // Already on the target route. Skip the router round-trip and just // scroll, otherwise Angular short-circuits and nothing happens. if (hash) this.scrollToWhenReady(hash); - else window.scrollTo({ top: 0, behavior: 'smooth' }); + else window.scrollTo({top: 0, behavior: 'smooth'}); return; } this.router.navigateByUrl(path).then(() => { @@ -413,7 +413,7 @@ export class CommandPalette { setTimeout(() => this.scrollToWhenReady(slug, attempt + 1), 50); return; } - el.scrollIntoView({ behavior: 'smooth', block: 'start' }); + el.scrollIntoView({behavior: 'smooth', block: 'start'}); history.replaceState(null, '', `${location.pathname}#${slug}`); } } diff --git a/apps/docs/src/app/components/content-banners.ts b/apps/docs/src/app/components/content-banners.ts index 4a7a927..c6d55fc 100644 --- a/apps/docs/src/app/components/content-banners.ts +++ b/apps/docs/src/app/components/content-banners.ts @@ -1,4 +1,4 @@ -import { Component, computed, inject } from '@angular/core'; +import {Component, computed, inject} from '@angular/core'; import { LucideDynamicIcon, LucideArchive, @@ -6,7 +6,7 @@ import { LucideRocket, LucideTriangleAlert, } from '@lucide/angular'; -import { VersionService } from '../services/version/version.service'; +import {VersionService} from '../services/version/version.service'; /** * Banner rendered above every documentation route when THIS deployment diff --git a/apps/docs/src/app/components/external-links.ts b/apps/docs/src/app/components/external-links.ts index bad610a..ed07af3 100644 --- a/apps/docs/src/app/components/external-links.ts +++ b/apps/docs/src/app/components/external-links.ts @@ -1,6 +1,6 @@ -import { AfterViewInit, Component, DestroyRef, inject } from '@angular/core'; -import { Router } from '@angular/router'; -import { enhanceOnNavigation } from '../utils/enhance-on-navigation'; +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 diff --git a/apps/docs/src/app/components/heading-anchors.ts b/apps/docs/src/app/components/heading-anchors.ts index 26080b6..4f2937e 100644 --- a/apps/docs/src/app/components/heading-anchors.ts +++ b/apps/docs/src/app/components/heading-anchors.ts @@ -1,7 +1,7 @@ -import { AfterViewInit, Component, DestroyRef, inject } from '@angular/core'; -import { Router } from '@angular/router'; -import { writeToClipboard } from '../utils/clipboard'; -import { enhanceOnNavigation } from '../utils/enhance-on-navigation'; +import {AfterViewInit, Component, DestroyRef, inject} from '@angular/core'; +import {Router} from '@angular/router'; +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 diff --git a/apps/docs/src/app/components/llm-actions.ts b/apps/docs/src/app/components/llm-actions.ts index e9a5ed2..f6ddda2 100644 --- a/apps/docs/src/app/components/llm-actions.ts +++ b/apps/docs/src/app/components/llm-actions.ts @@ -1,4 +1,4 @@ -import { Component, DestroyRef, HostListener, computed, inject, signal } from '@angular/core'; +import {Component, DestroyRef, HostListener, computed, inject, signal} from '@angular/core'; import { LucideDynamicIcon, type LucideIcon, @@ -6,19 +6,18 @@ import { LucideChevronDown, LucideCopy, LucideLink, - LucideMessageSquare, - LucideSparkles, } 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 {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'; + icon: LucideIcon | 'github' | 'claude' | 'openai'; /** Either a click handler or a target URL — drives the `<button>` vs * `<a>` rendering and what action fires. Return value is ignored; the * loose typing accommodates handlers that report success via boolean. */ @@ -44,7 +43,7 @@ interface MenuItem { */ @Component({ selector: 'app-llm-actions', - imports: [LucideDynamicIcon, GithubIcon], + imports: [LucideDynamicIcon, GithubIcon, ClaudeIcon, OpenaiIcon], template: ` @if (hasMdSource()) { <div class="relative"> @@ -95,10 +94,19 @@ interface MenuItem { class="flex items-center gap-2.5 px-3 py-2 text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-900" (click)="close()" > - @if (item.icon === 'github') { - <svg ngmdGithubIcon class="size-4 text-zinc-500"></svg> - } @else { - <svg [lucideIcon]="item.icon" class="size-4 text-zinc-500"></svg> + @switch (item.icon) { + @case ('github') { + <svg ngmdGithubIcon class="size-4 text-zinc-500"></svg> + } + @case ('claude') { + <svg ngmdClaudeIcon class="size-4"></svg> + } + @case ('openai') { + <svg ngmdOpenaiIcon class="size-4 text-zinc-700 dark:text-zinc-300"></svg> + } + @default { + <svg [lucideIcon]="$any(item.icon)" class="size-4 text-zinc-500"></svg> + } } {{ item.label }} </a> @@ -109,10 +117,19 @@ interface MenuItem { class="flex w-full items-center gap-2.5 px-3 py-2 text-left text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-900" (click)="runAndClose(item.handler!)" > - @if (item.icon === 'github') { - <svg ngmdGithubIcon class="size-4 text-zinc-500"></svg> - } @else { - <svg [lucideIcon]="item.icon" class="size-4 text-zinc-500"></svg> + @switch (item.icon) { + @case ('github') { + <svg ngmdGithubIcon class="size-4 text-zinc-500"></svg> + } + @case ('claude') { + <svg ngmdClaudeIcon class="size-4"></svg> + } + @case ('openai') { + <svg ngmdOpenaiIcon class="size-4 text-zinc-700 dark:text-zinc-300"></svg> + } + @default { + <svg [lucideIcon]="$any(item.icon)" class="size-4 text-zinc-500"></svg> + } } {{ item.label }} </button> @@ -146,8 +163,6 @@ export class LlmActions { readonly checkIcon = LucideCheck; readonly chevronIcon = LucideChevronDown; readonly linkIcon = LucideLink; - readonly chatGptIcon = LucideSparkles; - readonly claudeIcon = LucideMessageSquare; readonly open = signal(false); readonly copied = signal(false); @@ -178,16 +193,16 @@ export class LlmActions { } protected readonly items = computed<MenuItem[]>(() => [ - { label: 'Copy Markdown Link', icon: this.linkIcon, handler: () => this.copyLinkAction() }, - { label: 'Open in GitHub', icon: 'github', href: this.editUrl() }, + {label: 'Copy Markdown Link', icon: this.linkIcon, handler: () => this.copyLinkAction()}, + {label: 'Open in GitHub', icon: 'github', href: this.editUrl()}, { label: 'Open in ChatGPT', - icon: this.chatGptIcon, + icon: 'openai', href: `https://chatgpt.com/?q=${encodeURIComponent(this.prompt())}`, }, { label: 'Open in Claude', - icon: this.claudeIcon, + icon: 'claude', href: `https://claude.ai/new?q=${encodeURIComponent(this.prompt())}`, }, ]); diff --git a/apps/docs/src/app/components/media-enhancer.ts b/apps/docs/src/app/components/media-enhancer.ts index 6da000d..52b73f8 100644 --- a/apps/docs/src/app/components/media-enhancer.ts +++ b/apps/docs/src/app/components/media-enhancer.ts @@ -1,6 +1,6 @@ -import { AfterViewInit, Component, DestroyRef, inject } from '@angular/core'; -import { Router } from '@angular/router'; -import { enhanceOnNavigation } from '../utils/enhance-on-navigation'; +import {AfterViewInit, Component, DestroyRef, inject} from '@angular/core'; +import {Router} from '@angular/router'; +import {enhanceOnNavigation} from '../utils/enhance-on-navigation'; /** * Hydrates the placeholder divs emitted by the ngmd-video and ngmd-image diff --git a/apps/docs/src/app/components/page-footer.ts b/apps/docs/src/app/components/page-footer.ts index 2b81bdd..d6f8c7a 100644 --- a/apps/docs/src/app/components/page-footer.ts +++ b/apps/docs/src/app/components/page-footer.ts @@ -1,8 +1,8 @@ -import { Component, computed, inject } from '@angular/core'; -import { RouterLink } from '@angular/router'; -import { LucideDynamicIcon, LucideArrowLeft, LucideArrowRight } from '@lucide/angular'; -import { navItems } from '../../ngmd.config'; -import { RouteUrlService } from '../services/route-url/route-url.service'; +import {Component, computed, inject} from '@angular/core'; +import {RouterLink} from '@angular/router'; +import {LucideDynamicIcon, LucideArrowLeft, LucideArrowRight} from '@lucide/angular'; +import {navItems} from '../../ngmd.config'; +import {RouteUrlService} from '../services/route-url/route-url.service'; const RELATED_MAX = 4; diff --git a/apps/docs/src/app/components/sidebar.ts b/apps/docs/src/app/components/sidebar.ts index 9f52f44..6e62e10 100644 --- a/apps/docs/src/app/components/sidebar.ts +++ b/apps/docs/src/app/components/sidebar.ts @@ -1,9 +1,9 @@ -import { AfterViewInit, Component, DestroyRef, ElementRef, inject, signal } from '@angular/core'; -import { Router, RouterLink, RouterLinkActive } from '@angular/router'; -import { LucideDynamicIcon, LucideChevronDown } from '@lucide/angular'; +import {AfterViewInit, Component, DestroyRef, ElementRef, inject, signal} from '@angular/core'; +import {Router, RouterLink, RouterLinkActive} from '@angular/router'; +import {LucideDynamicIcon, LucideChevronDown} from '@lucide/angular'; import config from '../../ngmd.config'; -import { BADGE_VARIANTS, type BadgeVariant } from '../../types/badge'; -import { onNavigation } from '../utils/enhance-on-navigation'; +import {BADGE_VARIANTS, type BadgeVariant} from '../../types/badge'; +import {onNavigation} from '../utils/enhance-on-navigation'; @Component({ selector: 'app-sidebar', @@ -32,7 +32,7 @@ import { onNavigation } from '../utils/enhance-on-navigation'; <a [routerLink]="item.href" routerLinkActive="bg-[color:var(--accent-soft)]! text-[color:var(--accent-strong)]! font-medium" - [routerLinkActiveOptions]="{ exact: true }" + [routerLinkActiveOptions]="{exact: true}" ariaCurrentWhenActive="page" class="flex items-center justify-between gap-2 rounded-md px-3 py-1.5 text-zinc-700 dark:text-zinc-300 hover:bg-[color:var(--accent-soft)] hover:text-[color:var(--accent-strong)] focus:outline-none focus-visible:outline-2 focus-visible:outline focus-visible:outline-offset-[-2px] focus-visible:outline-[color:var(--accent)]" > @@ -96,6 +96,6 @@ export class Sidebar implements AfterViewInit { private scrollActiveIntoView(): void { if (typeof document === 'undefined') return; const active = this.host.nativeElement.querySelector<HTMLElement>('a[aria-current="page"]'); - active?.scrollIntoView({ block: 'nearest', behavior: 'instant' }); + active?.scrollIntoView({block: 'nearest', behavior: 'instant'}); } } diff --git a/apps/docs/src/app/components/site-footer.ts b/apps/docs/src/app/components/site-footer.ts index 4a1998b..b0dc77c 100644 --- a/apps/docs/src/app/components/site-footer.ts +++ b/apps/docs/src/app/components/site-footer.ts @@ -1,7 +1,7 @@ -import { Component } from '@angular/core'; -import { RouterLink } from '@angular/router'; -import { GithubIcon } from '../ui/github-icon'; -import { DiscordIcon } from '../ui/discord-icon'; +import {Component} from '@angular/core'; +import {RouterLink} from '@angular/router'; +import {GithubIcon} from '../ui/github-icon'; +import {DiscordIcon} from '../ui/discord-icon'; import siteConfig from '../../ngmd.config'; @Component({ diff --git a/apps/docs/src/app/components/source-actions.ts b/apps/docs/src/app/components/source-actions.ts index 03e221c..39fedbf 100644 --- a/apps/docs/src/app/components/source-actions.ts +++ b/apps/docs/src/app/components/source-actions.ts @@ -1,8 +1,8 @@ -import { Component, computed, inject } from '@angular/core'; -import { LucideDynamicIcon, LucideCode, LucidePencil } from '@lucide/angular'; -import { pageMeta } from 'virtual:ngmd/page-meta'; -import { LlmActions } from './llm-actions'; -import { RouteUrlService } from '../services/route-url/route-url.service'; +import {Component, computed, inject} from '@angular/core'; +import {LucideDynamicIcon, LucideCode, LucidePencil} from '@lucide/angular'; +import {pageMeta} from 'virtual:ngmd/page-meta'; +import {LlmActions} from './llm-actions'; +import {RouteUrlService} from '../services/route-url/route-url.service'; /** * Top-right floating icon row showing two GitHub links per route: diff --git a/apps/docs/src/app/components/sponsor-list.ts b/apps/docs/src/app/components/sponsor-list.ts index ad0fdaa..5628800 100644 --- a/apps/docs/src/app/components/sponsor-list.ts +++ b/apps/docs/src/app/components/sponsor-list.ts @@ -1,4 +1,4 @@ -import { Component, input } from '@angular/core'; +import {Component, input} from '@angular/core'; import siteConfig from '../../ngmd.config'; @Component({ diff --git a/apps/docs/src/app/components/toaster.ts b/apps/docs/src/app/components/toaster.ts index 1bb1b99..1d87a3c 100644 --- a/apps/docs/src/app/components/toaster.ts +++ b/apps/docs/src/app/components/toaster.ts @@ -1,4 +1,4 @@ -import { Component, DestroyRef, effect, inject, signal } from '@angular/core'; +import {Component, DestroyRef, effect, inject, signal} from '@angular/core'; import { LucideDynamicIcon, LucideCircleAlert, @@ -6,7 +6,7 @@ import { LucideInfo, LucideX, } from '@lucide/angular'; -import { ToastService, type Toast } from '../services/toast/toast.service'; +import {ToastService, type Toast} from '../services/toast/toast.service'; /** ms the slide-out keyframes take. Matches `ngmd-toast-slide-out` in * `styles.css`. Toasts dwell `duration` ms, then play the exit animation diff --git a/apps/docs/src/app/components/toc.ts b/apps/docs/src/app/components/toc.ts index 3e80714..699f08f 100644 --- a/apps/docs/src/app/components/toc.ts +++ b/apps/docs/src/app/components/toc.ts @@ -1,6 +1,6 @@ -import { AfterViewInit, Component, DestroyRef, inject, input, signal } from '@angular/core'; -import { Router } from '@angular/router'; -import { onNavigation } from '../utils/enhance-on-navigation'; +import {AfterViewInit, Component, DestroyRef, inject, input, signal} from '@angular/core'; +import {Router} from '@angular/router'; +import {onNavigation} from '../utils/enhance-on-navigation'; interface Heading { id: string; @@ -71,7 +71,7 @@ export class Toc implements AfterViewInit { this.active.set(list[list.length - 1].id); } }; - window.addEventListener('scroll', onScroll, { passive: true }); + window.addEventListener('scroll', onScroll, {passive: true}); this.destroyRef.onDestroy(() => window.removeEventListener('scroll', onScroll)); } @@ -85,7 +85,7 @@ export class Toc implements AfterViewInit { event.preventDefault(); const el = document.getElementById(id); if (el) { - el.scrollIntoView({ behavior: 'smooth', block: 'start' }); + el.scrollIntoView({behavior: 'smooth', block: 'start'}); // Force-activate the clicked id. The IntersectionObserver uses a // `rootMargin: '0px 0px -70% 0px'` so only the top 30% of viewport // counts as "in view"; the LAST heading can't reach that region if @@ -123,7 +123,7 @@ export class Toc implements AfterViewInit { this.contentObserver = new MutationObserver(() => { if (this.tryScan()) this.contentObserver?.disconnect(); }); - this.contentObserver.observe(main, { childList: true, subtree: true }); + this.contentObserver.observe(main, {childList: true, subtree: true}); } private tryScan(): boolean { @@ -173,7 +173,7 @@ export class Toc implements AfterViewInit { } } }, - { rootMargin: '0px 0px -70% 0px', threshold: 0 }, + {rootMargin: '0px 0px -70% 0px', threshold: 0}, ); nodes.forEach((node) => this.observer!.observe(node)); // The bottom-of-page scroll guard lives in `ngAfterViewInit` so it diff --git a/apps/docs/src/app/components/version-switcher.spec.ts b/apps/docs/src/app/components/version-switcher.spec.ts index 8441792..b350eee 100644 --- a/apps/docs/src/app/components/version-switcher.spec.ts +++ b/apps/docs/src/app/components/version-switcher.spec.ts @@ -1,12 +1,12 @@ -import { computed } from '@angular/core'; -import { TestBed } from '@angular/core/testing'; -import type { VersionEntry } from '../../ngmd.config'; -import { VersionService } from '../services/version/version.service'; -import { VersionSwitcher } from './version-switcher'; +import {computed} from '@angular/core'; +import {TestBed} from '@angular/core/testing'; +import type {VersionEntry} from '../../ngmd.config'; +import {VersionService} from '../services/version/version.service'; +import {VersionSwitcher} from './version-switcher'; const list: VersionEntry[] = [ - { label: 'v2', url: 'https://v2.example.com', status: 'current' }, - { label: 'v1', url: 'https://v1.example.com', status: 'deprecated' }, + {label: 'v2', url: 'https://v2.example.com', status: 'current'}, + {label: 'v1', url: 'https://v1.example.com', status: 'deprecated'}, ]; describe('VersionSwitcher', () => { @@ -16,7 +16,7 @@ describe('VersionSwitcher', () => { providers: [ { provide: VersionService, - useValue: { list: computed(() => list), self: computed(() => list[0]) }, + useValue: {list: computed(() => list), self: computed(() => list[0])}, }, ], }); @@ -35,7 +35,7 @@ describe('VersionSwitcher', () => { expect(trigger.getAttribute('aria-expanded')).toBe('true'); expect(el.querySelectorAll('[role="option"]').length).toBe(2); - document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); + document.dispatchEvent(new KeyboardEvent('keydown', {key: 'Escape'})); await fixture.whenStable(); expect(trigger.getAttribute('aria-expanded')).toBe('false'); expect(el.querySelector('[role="listbox"]')).toBeNull(); diff --git a/apps/docs/src/app/components/version-switcher.ts b/apps/docs/src/app/components/version-switcher.ts index 7230fd9..623df13 100644 --- a/apps/docs/src/app/components/version-switcher.ts +++ b/apps/docs/src/app/components/version-switcher.ts @@ -1,12 +1,12 @@ -import { Component, computed, ElementRef, HostListener, inject, signal } from '@angular/core'; +import {Component, computed, ElementRef, HostListener, inject, signal} from '@angular/core'; import { LucideDynamicIcon, LucideCheck, LucideChevronDown, LucideExternalLink, } from '@lucide/angular'; -import type { VersionStatus } from '../../ngmd.config'; -import { VersionService } from '../services/version/version.service'; +import type {VersionStatus} from '../../ngmd.config'; +import {VersionService} from '../services/version/version.service'; /** * Header dropdown of every documentation version. diff --git a/apps/docs/src/app/layout-mode.service.ts b/apps/docs/src/app/layout-mode.service.ts index 3ce69c4..4e7f276 100644 --- a/apps/docs/src/app/layout-mode.service.ts +++ b/apps/docs/src/app/layout-mode.service.ts @@ -1,10 +1,10 @@ -import { Injectable, signal } from '@angular/core'; +import {Injectable, signal} from '@angular/core'; /** * Pages that want to render without the docs site frame (sidebar, breadcrumb, * TOC) can flip this signal in their constructor. The app shell reads it. */ -@Injectable({ providedIn: 'root' }) +@Injectable({providedIn: 'root'}) export class LayoutMode { readonly chromeHidden = signal(false); } diff --git a/apps/docs/src/app/pages/[...slug].page.ts b/apps/docs/src/app/pages/[...slug].page.ts index 14d59f7..8148b43 100644 --- a/apps/docs/src/app/pages/[...slug].page.ts +++ b/apps/docs/src/app/pages/[...slug].page.ts @@ -1,4 +1,4 @@ -import { AsyncPipe } from '@angular/common'; +import {AsyncPipe} from '@angular/common'; import { Component, CUSTOM_ELEMENTS_SCHEMA, @@ -8,14 +8,14 @@ import { inject, resource, } from '@angular/core'; -import { toSignal } from '@angular/core/rxjs-interop'; -import { Router, RouterLink } from '@angular/router'; -import { injectContent, MarkdownComponent } from '@analogjs/content'; -import { LucideDynamicIcon, LucideArrowRight, LucideSearch } from '@lucide/angular'; -import { LayoutMode } from '../layout-mode.service'; -import { SearchService } from '../services/search/search.service'; -import { RouteUrlService } from '../services/route-url/route-url.service'; -import { ContentBanners } from '../components/content-banners'; +import {toSignal} from '@angular/core/rxjs-interop'; +import {Router, RouterLink} from '@angular/router'; +import {injectContent, MarkdownComponent} from '@analogjs/content'; +import {LucideDynamicIcon, LucideArrowRight, LucideSearch} from '@lucide/angular'; +import {LayoutMode} from '../layout-mode.service'; +import {SearchService} from '../services/search/search.service'; +import {RouteUrlService} from '../services/route-url/route-url.service'; +import {ContentBanners} from '../components/content-banners'; /** * Catch-all route for every markdown page. @@ -113,7 +113,7 @@ export default class DocPage implements OnDestroy { readonly searchIcon = LucideSearch; readonly arrowIcon = LucideArrowRight; - readonly content$ = injectContent<{ title: string }>('slug', NOT_FOUND); + readonly content$ = injectContent<{title: string}>('slug', NOT_FOUND); private readonly doc = toSignal(this.content$); private readonly missing = computed(() => this.doc()?.content === NOT_FOUND); @@ -131,7 +131,7 @@ export default class DocPage implements OnDestroy { * search session. */ private readonly suggestionsResource = resource({ params: () => this.suggestionTerm() || undefined, - loader: async ({ params }) => (params ? this.search.searchOnce(params) : []), + loader: async ({params}) => (params ? this.search.searchOnce(params) : []), }); /** Single best guess for the failed URL, or `null` when Orama has diff --git a/apps/docs/src/app/pages/index.page.ts b/apps/docs/src/app/pages/index.page.ts index 3907a9e..3215b67 100644 --- a/apps/docs/src/app/pages/index.page.ts +++ b/apps/docs/src/app/pages/index.page.ts @@ -1,264 +1,557 @@ -import { Component, inject } from '@angular/core'; -import { RouterLink } from '@angular/router'; -import { LayoutMode } from '../layout-mode.service'; -import { NgmdCard, NgmdCardGrid, NgmdCodeBlock, NgmdTab, NgmdTabs } from '../ui'; -import { GithubIcon } from '../ui/github-icon'; -import { DiscordIcon } from '../ui/discord-icon'; -import { SponsorList } from '../components/sponsor-list'; +import { + AfterViewInit, + Component, + computed, + ElementRef, + inject, + signal, + viewChild, +} from '@angular/core'; +import {RouterLink} from '@angular/router'; +import { + LucideDynamicIcon, + type LucideIconInput, + LucideArrowRight, + LucideBox, + LucideCheck, + LucideCompass, + LucideCopy, + LucideDatabase, + LucideFileText, + LucideHeart, + LucideLayers, + LucideServer, + LucideShieldCheck, + LucideSparkles, + LucideZap, +} from '@lucide/angular'; +import {GithubIcon} from '../ui/github-icon'; +import {DiscordIcon} from '../ui/discord-icon'; +import {SponsorList} from '../components/sponsor-list'; +import {animate, stagger} from 'motion'; import siteConfig from '../../ngmd.config'; - -const INSTALL = `npm install @santoshyadavdev/ng-devtools devframe`; - -const EXPRESS = `// server.ts -import { initNgDevtoolsHub } from '@santoshyadavdev/ng-devtools/hub'; - -const devtools = initNgDevtoolsHub({ ws: false }); -app.use(devtools.nodeMiddleware);`; - -const VITE = `// 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()], -});`; - -const MCP = `{ - "mcpServers": { - "ng-devtools": { - "command": "npx", - "args": ["@santoshyadavdev/ng-devtools", "mcp"] - } - } -}`; - -interface Feature { - title: string; - icon: string; - link: string; - body: string; -} - -const FEATURES: Feature[] = [ - { - title: 'Components', - icon: 'box', - link: '/inspectors/components', - body: 'Every instance on the page with live inputs, outputs, change detection and injected services.', - }, - { - title: 'Signals', - icon: 'zap', - link: '/inspectors/signals', - body: 'The live signal graph of a component, with a value history for each signal.', - }, - { - title: 'Injectors', - icon: 'layers', - link: '/inspectors/injectors', - body: 'The injector hierarchy, the lookup path for any token, and the providers at each level.', - }, - { - title: 'Router', - icon: 'compass', - link: '/inspectors/router', - body: 'Every navigation as a story: who started it, redirects, timing, and the guard that decided it.', - }, - { - title: 'Forms', - icon: 'file', - link: '/inspectors/forms', - body: 'Signal Forms, reactive and template-driven forms with readable errors, a timeline and a lint.', - }, - { - title: 'NgRx Store', - icon: 'settings', - link: '/inspectors/ngrx-store', - body: 'Live signal stores and @ngrx/store state with change logs, diffs and restore.', - }, - { - title: 'SSR & HTTP', - icon: 'rocket', - link: '/inspectors/ssr-http', - body: 'SSR and client HTTP calls, fault injection, hydration stats and the TransferState payload.', - }, - { - title: 'Agent tools', - icon: 'sparkles', - link: '/agents/tools', - body: 'More than 40 MCP tools, so your coding agent can read and act on the running app.', - }, - { - title: 'Local only', - icon: 'shield', - link: '/security', - body: 'The devtools answer only your machine, and secret-looking values are redacted.', - }, -]; +import {ToastService} from '../services/toast/toast.service'; +import {writeToClipboard} from '../utils/clipboard'; @Component({ selector: 'app-home', - imports: [ - RouterLink, - NgmdCard, - NgmdCardGrid, - NgmdCodeBlock, - NgmdTab, - NgmdTabs, - GithubIcon, - DiscordIcon, - SponsorList, - ], + imports: [RouterLink, LucideDynamicIcon, GithubIcon, DiscordIcon, SponsorList], template: ` - <div class="relative overflow-hidden"> + <!-- Spotlight backdrop --> + <div + class="pointer-events-none absolute inset-x-0 top-0 -z-10 h-[40rem] overflow-hidden" + aria-hidden="true" + > <div - class="pointer-events-none absolute inset-x-0 top-0 h-[480px] opacity-80" - style="background-image: var(--accent-gradient-soft)" - aria-hidden="true" + class="absolute left-1/2 top-0 -translate-x-1/2 -translate-y-24 size-[60rem] rounded-full opacity-20 blur-3xl" + [style.background-image]="angularGradient" ></div> + </div> + + <!-- Hero --> + <section class="relative"> + <div class="mx-auto max-w-6xl px-6 pt-24 pb-20 text-center"> + <a + [href]="githubUrl" + target="_blank" + rel="noopener noreferrer" + class="inline-flex items-center gap-2 rounded-full border border-zinc-200 dark:border-zinc-800 bg-white/60 dark:bg-zinc-900/60 backdrop-blur px-4 py-1.5 text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-6 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors" + > + <span class="text-yellow-400">★</span> + Star on GitHub + <svg [lucideIcon]="arrowIcon" class="size-3.5"></svg> + </a> - <section class="relative mx-auto max-w-5xl px-4 sm:px-6 pt-20 pb-16 text-center"> - <img src="/logo-mark.svg" alt="" class="mx-auto size-16" aria-hidden="true" /> - <h1 class="mt-6 text-4xl sm:text-5xl font-bold tracking-tight"> - <span - class="bg-clip-text text-transparent" - style="background-image: var(--accent-gradient)" + <h1 #hero class="text-5xl sm:text-7xl font-bold tracking-tight leading-[1.05]"> + <span class="ngmd-hero-anim inline-block">One</span> <span + class="ngmd-hero-anim inline-block" + >devtool</span + > <span class="ngmd-hero-anim inline-block">to</span> <span + class="ngmd-hero-anim inline-block" + >rule</span + > + <span class="ngmd-hero-anim block pb-2" + >the + <span + class="bg-clip-text text-transparent ngmd-hero-gradient" + [style.background-image]="angularGradient" + >Angular</span + > + ecosystem</span > - {{ name }} - </span> </h1> - <p class="mx-auto mt-5 max-w-2xl text-lg text-zinc-700 dark:text-zinc-300"> - {{ description }} + + <p + class="mt-6 text-lg sm:text-xl text-zinc-600 dark:text-zinc-400 max-w-2xl mx-auto leading-relaxed" + > + Components, signals, injectors, routes, forms and stores, live. In the page, from the CLI, + in Chrome DevTools, and for your coding agent over MCP. </p> - <div class="mt-8 flex flex-wrap items-center justify-center gap-3"> + + <a + href="https://devfra.me" + target="_blank" + rel="noopener noreferrer" + class="mt-5 inline-flex items-center gap-2 text-sm text-zinc-500 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-100 transition-colors" + > + Built on + <img src="/logos/devframe.svg" alt="" aria-hidden="true" class="size-5" /> + <span class="font-semibold text-zinc-700 dark:text-zinc-200">Devframe</span> + </a> + + <div class="mt-10 flex flex-wrap items-center justify-center gap-3"> <a routerLink="/getting-started/introduction" - class="inline-flex items-center rounded-lg px-5 py-2.5 text-sm font-semibold bg-[color:var(--accent)] text-[color:var(--accent-fg)] hover:opacity-90 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--accent)]" + class="inline-flex items-center gap-2 rounded-md bg-zinc-900 dark:bg-zinc-50 px-6 py-3 text-base font-medium text-zinc-50 dark:text-zinc-900 hover:bg-zinc-700 dark:hover:bg-zinc-200 transition-colors" > Get started + <svg [lucideIcon]="arrowIcon" class="size-4"></svg> </a> <a routerLink="/agents/mcp-server" - class="inline-flex items-center rounded-lg border border-zinc-300 dark:border-zinc-700 px-5 py-2.5 text-sm font-semibold hover:bg-zinc-100 dark:hover:bg-zinc-900" + class="inline-flex items-center gap-2 rounded-md border border-zinc-200 dark:border-zinc-800 bg-white/60 dark:bg-zinc-900/60 backdrop-blur px-6 py-3 text-base font-medium hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors" > + <svg [lucideIcon]="sparklesIcon" class="size-4"></svg> Connect an agent </a> <a [href]="githubUrl" target="_blank" rel="noopener noreferrer" - class="inline-flex items-center gap-2 rounded-lg border border-zinc-300 dark:border-zinc-700 px-5 py-2.5 text-sm font-semibold hover:bg-zinc-100 dark:hover:bg-zinc-900" + class="inline-flex items-center gap-2 rounded-md border border-zinc-200 dark:border-zinc-800 bg-white/60 dark:bg-zinc-900/60 backdrop-blur px-6 py-3 text-base font-medium hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors" > <svg ngmdGithubIcon class="size-4"></svg> - GitHub + View on GitHub </a> </div> - </section> - </div> - <section class="mx-auto max-w-5xl px-4 sm:px-6 pb-16" aria-labelledby="features-heading"> - <h2 id="features-heading" class="text-2xl font-bold tracking-tight">What you can inspect</h2> - <p class="mt-2 text-zinc-700 dark:text-zinc-300"> - Run it in the page, from the CLI, in Chrome DevTools, or through your coding agent. - </p> - <ngmd-card-grid columns="3"> - @for (feature of features; track feature.link) { - <ngmd-card [title]="feature.title" [icon]="feature.icon" [link]="feature.link"> - {{ feature.body }} - </ngmd-card> - } - </ngmd-card-grid> + <!-- Stack badges --> + <div class="mt-14"> + <p class="text-xs font-medium tracking-[0.2em] text-zinc-400 dark:text-zinc-500 mb-5"> + WORKS WITH + </p> + <div class="flex flex-wrap items-center justify-center gap-2 sm:gap-3"> + @for (tech of stack; track tech.name) { + <a + [href]="tech.url" + target="_blank" + rel="noopener noreferrer" + class="inline-flex items-center gap-2 rounded-lg border border-zinc-200/60 dark:border-zinc-800/60 bg-white/50 dark:bg-zinc-900/30 px-3 py-1.5 text-sm font-medium text-zinc-700 dark:text-zinc-300 hover:bg-white dark:hover:bg-zinc-900 transition-colors" + > + <img [src]="tech.logo" [alt]="tech.name" class="size-5 object-contain" /> + {{ tech.name }} + </a> + } + </div> + <p + class="mt-8 text-[10px] font-medium tracking-[0.2em] text-zinc-400 dark:text-zinc-500 mb-3" + > + COMING SOON + </p> + <div class="flex flex-wrap items-center justify-center gap-2 sm:gap-3"> + @for (tech of comingSoon; track tech.name) { + <a + [href]="tech.url" + target="_blank" + rel="noopener noreferrer" + class="inline-flex items-center gap-2 rounded-lg border border-dashed border-zinc-300 dark:border-zinc-700 px-2.5 py-1 text-xs font-medium text-zinc-500 dark:text-zinc-400 hover:bg-white dark:hover:bg-zinc-900 transition-colors" + > + <img [src]="tech.logo" [alt]="tech.name" class="size-4 object-contain opacity-70" /> + {{ tech.name }} + </a> + } + </div> + </div> + </div> </section> - <section class="mx-auto max-w-5xl px-4 sm:px-6 pb-16" aria-labelledby="quick-start-heading"> - <h2 id="quick-start-heading" class="text-2xl font-bold tracking-tight">Quick start</h2> - <p class="mt-2 text-zinc-700 dark:text-zinc-300"> - Install the package, then mount the devtools where your app runs. - </p> - <ngmd-code-block header="Terminal" language="bash" [code]="install" /> - <ngmd-tabs> - <ngmd-tab title="Express (SSR)" icon="terminal"> - <ngmd-code-block header="server.ts" language="ts" [code]="express" /> - </ngmd-tab> - <ngmd-tab title="Vite and Analog" icon="zap"> - <ngmd-code-block header="vite.config.ts" language="ts" [code]="vite" /> - </ngmd-tab> - <ngmd-tab title="MCP (stdio)" icon="sparkles"> - <ngmd-code-block header="claude_desktop_config.json" language="json" [code]="mcp" /> - </ngmd-tab> - </ngmd-tabs> - <p class="text-zinc-700 dark:text-zinc-300"> - Then load the overlay in your app. - <a - routerLink="/getting-started/installation" - class="font-medium underline underline-offset-4 hover:text-[color:var(--accent)]" - >Read the installation guide</a - >. - </p> + <!-- Code preview --> + <section + class="border-y border-zinc-200 dark:border-zinc-800 bg-zinc-50/50 dark:bg-zinc-900/20" + > + <div class="mx-auto max-w-5xl px-6 py-20"> + <div class="text-center mb-10"> + <h2 class="text-3xl sm:text-4xl font-bold tracking-tight">Two lines to a live panel</h2> + <p class="mt-3 text-zinc-600 dark:text-zinc-400 max-w-xl mx-auto"> + Mount the hub where your app runs, load the overlay, and open the panel on your page. + </p> + </div> + <div class="grid gap-6 md:grid-cols-2"> + <div + class="rounded-xl border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-950 overflow-hidden" + > + <div + class="flex items-center justify-between border-b border-zinc-200 dark:border-zinc-800 px-4 py-2 text-xs font-mono text-zinc-500" + > + <span>src/server.ts</span> + <span class="text-zinc-400">typescript</span> + </div> + <pre + class="p-4 text-sm overflow-x-auto text-zinc-700 dark:text-zinc-300 leading-relaxed" + ><code><span class="text-[color:var(--accent)] font-semibold">import</span> {{ '{' }} initNgDevtoolsHub {{ '}' }} + <span class="text-[color:var(--accent)] font-semibold">from</span> '@santoshyadavdev/ng-devtools/hub'; + +<span class="text-[color:var(--accent)] font-semibold">const</span> devtools = initNgDevtoolsHub({{ '{' }} ws: false {{ '}' }}); +app.use(devtools.nodeMiddleware); + +<span class="text-zinc-400">// src/main.ts</span> +<span class="text-[color:var(--accent)] font-semibold">import</span> '@santoshyadavdev/ng-devtools/overlay';</code></pre> + </div> + <div + class="rounded-xl border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-950 overflow-hidden" + > + <div + class="flex items-center justify-between border-b border-zinc-200 dark:border-zinc-800 px-4 py-2 text-xs font-mono text-zinc-500" + > + <span>Browser → Components</span> + <span class="text-[color:var(--accent)]">live</span> + </div> + <div class="p-6 font-mono text-sm text-zinc-700 dark:text-zinc-300 space-y-1.5"> + <p>▾ <app-root></p> + <p class="pl-4">▾ <app-search></p> + <p class="pl-8 text-zinc-500"> + query <span class="text-[color:var(--accent)]">signal</span> = "Lisbon" + </p> + <p class="pl-8 text-zinc-500"> + results <span class="text-[color:var(--accent)]">computed</span> = 12 items + </p> + <p class="pl-4">▸ <app-trip-list></p> + <p class="pl-4">▸ <router-outlet></p> + </div> + </div> + </div> + </div> </section> - <section class="mx-auto max-w-5xl px-4 sm:px-6 pb-16" aria-labelledby="sponsors-heading"> - <h2 id="sponsors-heading" class="text-2xl font-bold tracking-tight">Sponsors</h2> - <p class="mt-2 text-zinc-700 dark:text-zinc-300"> - Thanks to our current sponsors. Your support keeps development going. - </p> - <div class="mt-4"> - <app-sponsor-list /> + <!-- Features --> + <section class="mx-auto max-w-6xl px-6 py-20"> + <div class="text-center mb-12"> + <h2 class="text-3xl sm:text-4xl font-bold tracking-tight">Everything you can inspect</h2> + <p class="mt-3 text-zinc-600 dark:text-zinc-400 max-w-xl mx-auto"> + One inspector per part of Angular, all reading the live page. + </p> + </div> + <div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3"> + @for (feature of features; track feature.title) { + <a + [routerLink]="feature.link" + class="rounded-xl border border-zinc-200 dark:border-zinc-800 p-6 hover:border-zinc-300 dark:hover:border-zinc-700 transition-colors" + > + @if (feature.logo) { + <img + [src]="feature.logo" + alt="" + aria-hidden="true" + class="size-6 mb-4 object-contain" + /> + } @else { + <svg + [lucideIcon]="feature.icon" + class="size-6 mb-4 text-[color:var(--accent)]" + aria-hidden="true" + ></svg> + } + <p class="text-base font-semibold text-zinc-900 dark:text-zinc-100"> + {{ feature.title }} + </p> + <p class="mt-2 text-sm text-zinc-600 dark:text-zinc-400 leading-relaxed"> + {{ feature.description }} + </p> + </a> + } </div> - <a - [href]="sponsorUrl" - target="_blank" - rel="noopener noreferrer" - class="mt-4 inline-flex items-center rounded-lg px-4 py-2 text-sm font-semibold bg-[color:var(--accent)] text-[color:var(--accent-fg)] hover:opacity-90" - > - Sponsor on GitHub - </a> </section> - <section class="mx-auto max-w-5xl px-4 sm:px-6 pb-24" aria-labelledby="community-heading"> - <h2 id="community-heading" class="text-2xl font-bold tracking-tight">Community</h2> - <p class="mt-2 text-zinc-700 dark:text-zinc-300"> - Ask questions, share feedback and follow development. - </p> - <div class="mt-4 flex flex-wrap gap-3"> - @if (discordUrl) { + <!-- Maintainers --> + <section class="border-t border-zinc-200 dark:border-zinc-800"> + <div class="mx-auto max-w-3xl px-6 py-20 text-center"> + <h2 class="text-3xl sm:text-4xl font-bold tracking-tight">Maintainers</h2> + <p class="mt-3 text-zinc-600 dark:text-zinc-400">The people who build and look after it.</p> + <div class="mt-8 flex flex-wrap justify-center gap-6"> + @for (m of maintainers; track m.login) { + <a + [href]="'https://github.com/' + m.login" + target="_blank" + rel="noopener noreferrer" + class="flex flex-col items-center gap-3 rounded-xl border border-zinc-200 dark:border-zinc-800 px-8 py-6 hover:border-zinc-300 dark:hover:border-zinc-700 transition-colors" + > + <img + [src]="'https://github.com/' + m.login + '.png?size=160'" + alt="" + width="80" + height="80" + class="size-20 rounded-full" + /> + <span class="text-base font-semibold text-zinc-900 dark:text-zinc-100">{{ + m.name + }}</span> + <span class="text-sm text-zinc-500">@{{ m.login }}</span> + </a> + } + </div> + </div> + </section> + + <!-- Sponsors --> + <section class="border-t border-zinc-200 dark:border-zinc-800"> + <div class="mx-auto max-w-3xl px-6 py-20 text-center"> + <h2 class="text-3xl sm:text-4xl font-bold tracking-tight">Sponsors</h2> + <p class="mt-3 text-zinc-600 dark:text-zinc-400"> + Thanks to our current sponsors. Your support keeps development going. + </p> + <div class="mt-8 flex justify-center"> + <app-sponsor-list /> + </div> + <div class="mt-8 flex flex-wrap items-center justify-center gap-3"> <a - [href]="discordUrl" + [href]="sponsorUrl" target="_blank" rel="noopener noreferrer" - class="inline-flex items-center gap-2 rounded-lg border border-zinc-300 dark:border-zinc-700 px-4 py-2 text-sm font-semibold hover:bg-zinc-100 dark:hover:bg-zinc-900" + class="inline-flex items-center gap-2 rounded-md border border-pink-200 dark:border-pink-900/60 bg-pink-50 dark:bg-pink-950/40 px-5 py-2.5 text-sm font-medium text-pink-700 dark:text-pink-300 hover:bg-pink-100 dark:hover:bg-pink-950/70 transition-colors" > - <svg ngmdDiscordIcon class="size-4"></svg> - Discord + <svg [lucideIcon]="heartIcon" class="size-4 fill-current" aria-hidden="true"></svg> + Sponsor on GitHub </a> - } - <a - [href]="githubUrl + '/issues'" - target="_blank" - rel="noopener noreferrer" - class="inline-flex items-center gap-2 rounded-lg border border-zinc-300 dark:border-zinc-700 px-4 py-2 text-sm font-semibold hover:bg-zinc-100 dark:hover:bg-zinc-900" + @if (discordUrl) { + <a + [href]="discordUrl" + target="_blank" + rel="noopener noreferrer" + class="inline-flex items-center gap-2 rounded-md bg-[#5865f2] px-5 py-2.5 text-sm font-medium text-white hover:bg-[#4752c4] transition-colors" + > + <svg ngmdDiscordIcon class="size-4" aria-hidden="true"></svg> + Join the Discord + </a> + } + </div> + </div> + </section> + + <!-- CTA --> + <section class="border-t border-zinc-200 dark:border-zinc-800"> + <div class="mx-auto max-w-3xl px-6 py-20 text-center"> + <h2 class="text-3xl sm:text-4xl font-bold tracking-tight">Ready to look inside?</h2> + <p class="mt-3 text-zinc-600 dark:text-zinc-400"> + Install the package, mount the hub, and open the panel on your page. + </p> + <div + class="mt-8 inline-block w-[34rem] max-w-full rounded-lg border border-zinc-200 dark:border-zinc-800 bg-zinc-50 dark:bg-zinc-900 overflow-hidden text-left" > - <svg ngmdGithubIcon class="size-4"></svg> - Issues - </a> + <div role="tablist" class="flex border-b border-zinc-200 dark:border-zinc-800"> + @for (cmd of installCommands; track cmd.pm) { + <button + type="button" + role="tab" + [attr.aria-selected]="activePM() === cmd.pm" + (click)="activePM.set(cmd.pm)" + class="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors aria-selected:border-[color:var(--accent)] aria-selected:text-[color:var(--accent)] [&[aria-selected=false]]:border-transparent [&[aria-selected=false]]:text-zinc-500 [&[aria-selected=false]]:hover:text-zinc-900 dark:[&[aria-selected=false]]:hover:text-zinc-100" + > + <img [src]="cmd.logo" alt="" aria-hidden="true" class="size-4 object-contain" /> + {{ cmd.pm }} + </button> + } + </div> + <div class="flex items-center gap-3 pl-4 pr-2 py-2.5 font-mono text-sm"> + <span class="text-zinc-400">$</span> + <span class="overflow-x-auto whitespace-nowrap">{{ activeCmd() }}</span> + <button + type="button" + (click)="copyCmd(activeCmd())" + [attr.aria-label]="copied() === activeCmd() ? 'Copied' : 'Copy'" + class="ml-auto shrink-0 inline-flex items-center justify-center size-7 rounded-md text-zinc-500 hover:bg-zinc-200 dark:hover:bg-zinc-800 hover:text-zinc-900 dark:hover:text-zinc-100 transition-colors" + > + <svg + [lucideIcon]="copied() === activeCmd() ? checkIcon : copyIcon" + class="size-3.5" + ></svg> + </button> + </div> + </div> + <div class="mt-8"> + <a + routerLink="/getting-started/installation" + class="inline-flex items-center gap-2 text-base font-medium text-[color:var(--accent)] hover:opacity-80" + > + Read the installation guide + <svg [lucideIcon]="arrowIcon" class="size-4"></svg> + </a> + </div> </div> </section> `, }) -export default class HomePage { - protected readonly name = siteConfig.site.name; - protected readonly description = siteConfig.site.description; - protected readonly githubUrl = siteConfig.site.githubUrl; - protected readonly discordUrl = siteConfig.site.links?.discord; - protected readonly sponsorUrl = siteConfig.site.links?.sponsor ?? ''; - protected readonly features = FEATURES; - protected readonly install = INSTALL; - protected readonly express = EXPRESS; - protected readonly vite = VITE; - protected readonly mcp = MCP; +export default class Home implements AfterViewInit { + private readonly toast = inject(ToastService); + readonly hero = viewChild<ElementRef<HTMLElement>>('hero'); + + readonly angularGradient = + 'linear-gradient(to right, #d97706, #f5a524, #fcd34d, #f5a524, #d97706)'; + + readonly arrowIcon = LucideArrowRight; + readonly heartIcon = LucideHeart; + readonly sparklesIcon = LucideSparkles; + readonly copyIcon = LucideCopy; + readonly checkIcon = LucideCheck; + readonly githubUrl = siteConfig.site.githubUrl; + readonly discordUrl = siteConfig.site.links?.discord; + readonly sponsorUrl = siteConfig.site.links?.sponsor ?? ''; + + readonly copied = signal(''); + + readonly installCommands = [ + { + pm: 'npm', + cmd: 'npm install @santoshyadavdev/ng-devtools devframe', + logo: 'https://cdn.simpleicons.org/npm/CB3837', + }, + { + pm: 'pnpm', + cmd: 'pnpm add @santoshyadavdev/ng-devtools devframe', + logo: 'https://cdn.simpleicons.org/pnpm/F69220', + }, + { + pm: 'yarn', + cmd: 'yarn add @santoshyadavdev/ng-devtools devframe', + logo: 'https://cdn.simpleicons.org/yarn/2C8EBB', + }, + { + pm: 'bun', + cmd: 'bun add @santoshyadavdev/ng-devtools devframe', + logo: 'https://bun.sh/logo.svg', + }, + ]; - constructor() { - inject(LayoutMode).chromeHidden.set(true); + readonly activePM = signal('npm'); + readonly activeCmd = computed( + () => this.installCommands.find((c) => c.pm === this.activePM())?.cmd ?? '', + ); + + async copyCmd(cmd: string): Promise<void> { + if (!(await writeToClipboard(cmd))) { + this.toast.error('Could not copy command.'); + return; + } + this.copied.set(cmd); + setTimeout(() => this.copied.set(''), 1500); + } + + ngAfterViewInit(): void { + if (typeof window === 'undefined') return; + if (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) return; + + const root = this.hero()?.nativeElement; + if (!root) return; + const parts = root.querySelectorAll<HTMLElement>('.ngmd-hero-anim'); + if (parts.length === 0) return; + + animate( + parts, + {opacity: [0, 1], transform: ['translateY(0.5em)', 'translateY(0)']}, + {duration: 1.1, delay: stagger(0.18), ease: [0.22, 1, 0.36, 1]}, + ); } + + readonly stack = [ + {name: 'Angular', url: 'https://angular.dev', logo: '/logos/angular.svg'}, + { + name: 'AnalogJS', + url: 'https://analogjs.org', + logo: 'https://analogjs.org/img/logos/analog-logo.svg', + }, + {name: 'Vite', url: 'https://vite.dev', logo: '/logos/vite.svg'}, + { + name: 'Express', + url: 'https://expressjs.com', + logo: 'https://cdn.simpleicons.org/express/71717A', + }, + {name: 'NgRx', url: 'https://ngrx.io', logo: 'https://cdn.simpleicons.org/ngrx/BA2BD2'}, + ]; + + readonly maintainers = [ + {name: 'Santosh Yadav', login: 'santoshyadavdev'}, + {name: 'Erkam Yaman', login: 'erkamyaman'}, + ]; + + readonly comingSoon = [ + { + name: 'NativeScript', + url: 'https://github.com/santoshyadavdev/angular-devtools/pull/16', + logo: 'https://cdn.simpleicons.org/nativescript/3C5AFD', + }, + { + name: 'Capacitor', + url: 'https://github.com/santoshyadavdev/angular-devtools/pull/21', + logo: 'https://cdn.simpleicons.org/capacitor/119EFF', + }, + ]; + + readonly features: { + icon: LucideIconInput; + logo?: string; + title: string; + link: string; + description: string; + }[] = [ + { + icon: LucideBox, + title: 'Components', + link: '/inspectors/components', + description: + 'Every instance on the page with live inputs, outputs, change detection and injected services.', + }, + { + icon: LucideZap, + title: 'Signals', + link: '/inspectors/signals', + description: 'The live signal graph of a component, with a value history for each signal.', + }, + { + icon: LucideLayers, + title: 'Injectors', + link: '/inspectors/injectors', + description: + 'The injector hierarchy, the lookup path for any token, and the providers at each level.', + }, + { + icon: LucideCompass, + title: 'Router', + link: '/inspectors/router', + description: + 'Every navigation as a story: who started it, redirects, timing, and the guard that decided it.', + }, + { + icon: LucideFileText, + title: 'Forms', + link: '/inspectors/forms', + description: + 'Signal Forms, reactive and template-driven forms with readable errors, a timeline and a lint.', + }, + { + icon: LucideDatabase, + logo: 'https://cdn.simpleicons.org/ngrx/BA2BD2', + title: 'NgRx Store', + link: '/inspectors/ngrx-store', + description: 'Live signal stores and @ngrx/store state with change logs, diffs and restore.', + }, + { + icon: LucideServer, + title: 'SSR & HTTP', + link: '/inspectors/ssr-http', + description: + 'SSR and client HTTP calls, fault injection, hydration stats and the TransferState payload.', + }, + { + icon: LucideSparkles, + title: 'Agent tools', + link: '/agents/tools', + description: + 'More than 40 MCP tools, so your coding agent can read and act on the running app.', + }, + { + icon: LucideShieldCheck, + title: 'Local only', + link: '/security', + description: 'The devtools answer only your machine, and secret-looking values are redacted.', + }, + ]; } diff --git a/apps/docs/src/app/pages/sponsors.page.ts b/apps/docs/src/app/pages/sponsors.page.ts index f3be17a..e8228ec 100644 --- a/apps/docs/src/app/pages/sponsors.page.ts +++ b/apps/docs/src/app/pages/sponsors.page.ts @@ -1,7 +1,7 @@ -import { Component, inject } from '@angular/core'; -import type { RouteMeta } from '@analogjs/router'; -import { LayoutMode } from '../layout-mode.service'; -import { SponsorList } from '../components/sponsor-list'; +import {Component, inject} from '@angular/core'; +import type {RouteMeta} from '@analogjs/router'; +import {LayoutMode} from '../layout-mode.service'; +import {SponsorList} from '../components/sponsor-list'; import siteConfig from '../../ngmd.config'; export const routeMeta: RouteMeta = { diff --git a/apps/docs/src/app/register-elements.spec.ts b/apps/docs/src/app/register-elements.spec.ts index eb212b0..d643bbd 100644 --- a/apps/docs/src/app/register-elements.spec.ts +++ b/apps/docs/src/app/register-elements.spec.ts @@ -1,6 +1,6 @@ -import { ApplicationRef, Injector } from '@angular/core'; -import { TestBed } from '@angular/core/testing'; -import { registerNgmdElements } from './register-elements'; +import {ApplicationRef, Injector} from '@angular/core'; +import {TestBed} from '@angular/core/testing'; +import {registerNgmdElements} from './register-elements'; describe('registerNgmdElements', () => { afterEach(() => { diff --git a/apps/docs/src/app/register-elements.ts b/apps/docs/src/app/register-elements.ts index 402908c..281d22a 100644 --- a/apps/docs/src/app/register-elements.ts +++ b/apps/docs/src/app/register-elements.ts @@ -1,17 +1,17 @@ -import { Injector, type Type } from '@angular/core'; +import {Injector, type Type} from '@angular/core'; -import { NgmdAccordion, NgmdAccordionItem } from './ui/accordion'; -import { NgmdAlert } from './ui/alert'; -import { NgmdBadge } from './ui/badge'; -import { NgmdCallout } from './ui/callout'; -import { NgmdCard } from './ui/card'; -import { NgmdCardGrid } from './ui/card-grid'; -import { NgmdHero } from './ui/hero'; -import { NgmdImage } from './ui/image'; -import { NgmdPill, NgmdPillRow } from './ui/pill'; -import { NgmdTab, NgmdTabs } from './ui/tabs'; -import { NgmdVideo } from './ui/video'; -import { NgmdStep, NgmdWorkflow } from './ui/workflow'; +import {NgmdAccordion, NgmdAccordionItem} from './ui/accordion'; +import {NgmdAlert} from './ui/alert'; +import {NgmdBadge} from './ui/badge'; +import {NgmdCallout} from './ui/callout'; +import {NgmdCard} from './ui/card'; +import {NgmdCardGrid} from './ui/card-grid'; +import {NgmdHero} from './ui/hero'; +import {NgmdImage} from './ui/image'; +import {NgmdPill, NgmdPillRow} from './ui/pill'; +import {NgmdTab, NgmdTabs} from './ui/tabs'; +import {NgmdVideo} from './ui/video'; +import {NgmdStep, NgmdWorkflow} from './ui/workflow'; /** * Map of every NgmdUi component to its custom-element tag name. @@ -71,7 +71,7 @@ export const MARKDOWN_HOSTS = 'analog-markdown, analog-markdown-route'; export async function registerNgmdElements(injector: Injector): Promise<void> { if (typeof customElements === 'undefined') return; - const { createCustomElement } = await import('@angular/elements'); + const {createCustomElement} = await import('@angular/elements'); for (const [tag, component] of elementMap) { if (customElements.get(tag)) continue; const NgElementCtor = createCustomElement(component, { diff --git a/apps/docs/src/app/services/route-url/route-url.service.ts b/apps/docs/src/app/services/route-url/route-url.service.ts index 7692656..20653fb 100644 --- a/apps/docs/src/app/services/route-url/route-url.service.ts +++ b/apps/docs/src/app/services/route-url/route-url.service.ts @@ -1,7 +1,7 @@ -import { Injectable, computed, inject, type Signal } from '@angular/core'; -import { toSignal } from '@angular/core/rxjs-interop'; -import { NavigationEnd, Router } from '@angular/router'; -import { filter, map, startWith } from 'rxjs'; +import {Injectable, computed, inject, type Signal} from '@angular/core'; +import {toSignal} from '@angular/core/rxjs-interop'; +import {NavigationEnd, Router} from '@angular/router'; +import {filter, map, startWith} from 'rxjs'; /** * Single source of truth for the current route URL as a signal. @@ -12,7 +12,7 @@ import { filter, map, startWith } from 'rxjs'; * `router.events.pipe(filter(NavigationEnd))` ladder verbatim. Inject this * instead and read the signal. */ -@Injectable({ providedIn: 'root' }) +@Injectable({providedIn: 'root'}) export class RouteUrlService { private readonly router = inject(Router); @@ -24,7 +24,7 @@ export class RouteUrlService { map(() => this.router.url), startWith(this.router.url), ), - { initialValue: this.router.url || '/' }, + {initialValue: this.router.url || '/'}, ); /** `url` with the query string and `#fragment` stripped. The shape diff --git a/apps/docs/src/app/services/search/algolia-provider.ts b/apps/docs/src/app/services/search/algolia-provider.ts index f5c7bb7..0ddce6a 100644 --- a/apps/docs/src/app/services/search/algolia-provider.ts +++ b/apps/docs/src/app/services/search/algolia-provider.ts @@ -1,4 +1,4 @@ -import type { SearchHit, SearchHitKind, SearchProvider } from '../../../types/search'; +import type {SearchHit, SearchHitKind, SearchProvider} from '../../../types/search'; /** * Optional Algolia DocSearch backend. Activated when the user populates @@ -21,24 +21,24 @@ export interface AlgoliaConfig { interface AlgoliaHit { objectID: string; url: string; - hierarchy: { lvl0?: string; lvl1?: string; lvl2?: string; lvl3?: string; lvl4?: string }; + hierarchy: {lvl0?: string; lvl1?: string; lvl2?: string; lvl3?: string; lvl4?: string}; content?: string; _snippetResult?: { - content?: { value: string }; + content?: {value: string}; hierarchy?: { - lvl0?: { value: string; matchLevel?: string }; - lvl1?: { value: string; matchLevel?: string }; - lvl2?: { value: string; matchLevel?: string }; - lvl3?: { value: string; matchLevel?: string }; - lvl4?: { value: string; matchLevel?: string }; + lvl0?: {value: string; matchLevel?: string}; + lvl1?: {value: string; matchLevel?: string}; + lvl2?: {value: string; matchLevel?: string}; + lvl3?: {value: string; matchLevel?: string}; + lvl4?: {value: string; matchLevel?: string}; }; }; } export class AlgoliaSearchProvider implements SearchProvider { private clientPromise: Promise<{ - search: (params: Array<{ indexName: string; params: Record<string, unknown> }>) => Promise<{ - results: Array<{ hits: AlgoliaHit[] }>; + search: (params: Array<{indexName: string; params: Record<string, unknown>}>) => Promise<{ + results: Array<{hits: AlgoliaHit[]}>; }>; }> | null = null; @@ -58,8 +58,8 @@ export class AlgoliaSearchProvider implements SearchProvider { key: string, ) => { search: ( - params: Array<{ indexName: string; params: Record<string, unknown> }>, - ) => Promise<{ results: Array<{ hits: AlgoliaHit[] }> }>; + params: Array<{indexName: string; params: Record<string, unknown>}>, + ) => Promise<{results: Array<{hits: AlgoliaHit[]}>}>; }; }; return mod.liteClient(this.config.appId, this.config.apiKey); diff --git a/apps/docs/src/app/services/search/orama-provider.ts b/apps/docs/src/app/services/search/orama-provider.ts index 88ca1b4..d7b40bd 100644 --- a/apps/docs/src/app/services/search/orama-provider.ts +++ b/apps/docs/src/app/services/search/orama-provider.ts @@ -1,8 +1,8 @@ -import { create, insertMultiple, search as oramaSearch, type AnyOrama } from '@orama/orama'; -import { searchIndex } from 'virtual:ngmd/search-index'; -import { apiIndex } from 'virtual:ngmd/api-index'; -import type { IndexDoc, SearchHit, SearchHitKind, SearchProvider } from '../../../types/search'; -import type { SymbolRecord } from '../../../types/api'; +import {create, insertMultiple, search as oramaSearch, type AnyOrama} from '@orama/orama'; +import {searchIndex} from 'virtual:ngmd/search-index'; +import {apiIndex} from 'virtual:ngmd/api-index'; +import type {IndexDoc, SearchHit, SearchHitKind, SearchProvider} from '../../../types/search'; +import type {SymbolRecord} from '../../../types/api'; /** * Default search backend. Builds an in-memory Orama index once on init, @@ -56,7 +56,7 @@ export class OramaSearchProvider implements SearchProvider { properties: ['pageTitle', 'heading', 'body'], // Heading > title > body so a query that matches a heading ranks above // the same query merely appearing in body prose. - boost: { heading: 3, pageTitle: 2, body: 1 }, + boost: {heading: 3, pageTitle: 2, body: 1}, limit: 20, // Scale fuzzy tolerance with query length. Short queries (≤4 chars) // with 1-edit fuzzy match too many neighbouring words and drag in diff --git a/apps/docs/src/app/services/search/search.service.ts b/apps/docs/src/app/services/search/search.service.ts index 2baaf6e..8fe7a69 100644 --- a/apps/docs/src/app/services/search/search.service.ts +++ b/apps/docs/src/app/services/search/search.service.ts @@ -8,9 +8,9 @@ import { type Signal, } from '@angular/core'; import config from '../../../ngmd.config'; -import type { SearchHit, SearchProvider } from '../../../types/search'; -import { OramaSearchProvider } from './orama-provider'; -import { AlgoliaSearchProvider } from './algolia-provider'; +import type {SearchHit, SearchProvider} from '../../../types/search'; +import {OramaSearchProvider} from './orama-provider'; +import {AlgoliaSearchProvider} from './algolia-provider'; const SEARCH_DEBOUNCE = 200; const HISTORY_KEY = 'ngmd-search-history-v1'; @@ -42,7 +42,7 @@ export interface HistoryItem { * Swap to Algolia by populating `site.algolia` in `ngmd.config.ts`. Nothing * in this file changes when you do; the provider just picks itself up. */ -@Injectable({ providedIn: 'root' }) +@Injectable({providedIn: 'root'}) export class SearchService { private readonly isBrowser = typeof window !== 'undefined'; @@ -69,7 +69,7 @@ export class SearchService { * treated as "no params" so the loader doesn't run on an empty input. */ private readonly resultsResource = resource({ params: () => this.debouncedQuery() || undefined, - loader: async ({ params }) => { + loader: async ({params}) => { if (!params) return [] as SearchHit[]; try { return await this.provider.search(params); @@ -154,7 +154,7 @@ export class SearchService { toggleFavorite(url: string): void { if (!this.isBrowser) return; this.historyState.update((items) => { - const flipped = items.map((h) => (h.url === url ? { ...h, isFavorite: !h.isFavorite } : h)); + const flipped = items.map((h) => (h.url === url ? {...h, isFavorite: !h.isFavorite} : h)); const favorites = flipped.filter((h) => h.isFavorite); const recents = flipped.filter((h) => !h.isFavorite).slice(0, HISTORY_MAX); return [...favorites, ...recents]; diff --git a/apps/docs/src/app/services/toast/toast.service.ts b/apps/docs/src/app/services/toast/toast.service.ts index a18eb34..f674801 100644 --- a/apps/docs/src/app/services/toast/toast.service.ts +++ b/apps/docs/src/app/services/toast/toast.service.ts @@ -1,4 +1,4 @@ -import { Injectable, signal, type Signal } from '@angular/core'; +import {Injectable, signal, type Signal} from '@angular/core'; export type ToastVariant = 'success' | 'error' | 'info'; @@ -23,7 +23,7 @@ const DEFAULT_DURATION = 3000; * the component. Service is SSR-safe by construction — no platform checks * or timers. */ -@Injectable({ providedIn: 'root' }) +@Injectable({providedIn: 'root'}) export class ToastService { private readonly state = signal<Toast[]>([]); private nextId = 0; @@ -33,7 +33,7 @@ export class ToastService { show(message: string, variant: ToastVariant = 'info', duration = DEFAULT_DURATION): number { const id = ++this.nextId; - this.state.update((items) => [{ id, message, variant, duration }, ...items]); + this.state.update((items) => [{id, message, variant, duration}, ...items]); return id; } diff --git a/apps/docs/src/app/services/version/version.service.ts b/apps/docs/src/app/services/version/version.service.ts index 70f4cc9..fcc48d8 100644 --- a/apps/docs/src/app/services/version/version.service.ts +++ b/apps/docs/src/app/services/version/version.service.ts @@ -1,6 +1,6 @@ -import { Injectable, computed } from '@angular/core'; +import {Injectable, computed} from '@angular/core'; import config from '../../../ngmd.config'; -import type { VersionEntry } from '../../../ngmd.config'; +import type {VersionEntry} from '../../../ngmd.config'; /** * Version registry for the header switcher. @@ -20,7 +20,7 @@ import type { VersionEntry } from '../../../ngmd.config'; * uses this to link visitors stuck on `next` / `rc` / `deprecated` * deployments toward the live production docs. */ -@Injectable({ providedIn: 'root' }) +@Injectable({providedIn: 'root'}) export class VersionService { /** Static registry from config. `null` when versions are not configured. */ readonly config = computed(() => config.versions ?? null); diff --git a/apps/docs/src/app/theme.ts b/apps/docs/src/app/theme.ts index 4b6e78c..216804a 100644 --- a/apps/docs/src/app/theme.ts +++ b/apps/docs/src/app/theme.ts @@ -1,10 +1,10 @@ -import { DOCUMENT } from '@angular/common'; -import { inject, Injectable, signal } from '@angular/core'; +import {DOCUMENT} from '@angular/common'; +import {inject, Injectable, signal} from '@angular/core'; type Mode = 'light' | 'dark' | 'auto'; const STORAGE_KEY = 'ngmd-theme'; -@Injectable({ providedIn: 'root' }) +@Injectable({providedIn: 'root'}) export class ThemeService { private readonly document = inject(DOCUMENT); private readonly isBrowser = typeof window !== 'undefined'; diff --git a/apps/docs/src/app/title-strategy.ts b/apps/docs/src/app/title-strategy.ts index a7e9244..1bf0057 100644 --- a/apps/docs/src/app/title-strategy.ts +++ b/apps/docs/src/app/title-strategy.ts @@ -1,8 +1,8 @@ -import { Injectable } from '@angular/core'; -import { Title } from '@angular/platform-browser'; -import { TitleStrategy, type RouterStateSnapshot } from '@angular/router'; -import siteConfig, { navLabels } from '../ngmd.config'; -import { stripUrl } from './services/route-url/route-url.service'; +import {Injectable} from '@angular/core'; +import {Title} from '@angular/platform-browser'; +import {TitleStrategy, type RouterStateSnapshot} from '@angular/router'; +import siteConfig, {navLabels} from '../ngmd.config'; +import {stripUrl} from './services/route-url/route-url.service'; /** * Custom title strategy. Every page renders as `NgMd | <Title>`, where @@ -17,7 +17,7 @@ import { stripUrl } from './services/route-url/route-url.service'; * Replaces Angular's `DefaultTitleStrategy`, which would otherwise overwrite * our format with just the raw frontmatter title. */ -@Injectable({ providedIn: 'root' }) +@Injectable({providedIn: 'root'}) export class NgmdTitleStrategy extends TitleStrategy { constructor(private readonly title: Title) { super(); diff --git a/apps/docs/src/app/ui/accordion.spec.ts b/apps/docs/src/app/ui/accordion.spec.ts index 3689c58..e416971 100644 --- a/apps/docs/src/app/ui/accordion.spec.ts +++ b/apps/docs/src/app/ui/accordion.spec.ts @@ -1,5 +1,5 @@ -import { TestBed } from '@angular/core/testing'; -import { NgmdAccordionItem } from './accordion'; +import {TestBed} from '@angular/core/testing'; +import {NgmdAccordionItem} from './accordion'; describe('NgmdAccordionItem', () => { it('toggles aria-expanded and rotates the chevron', async () => { diff --git a/apps/docs/src/app/ui/accordion.ts b/apps/docs/src/app/ui/accordion.ts index ab8ecc7..a9e1fdf 100644 --- a/apps/docs/src/app/ui/accordion.ts +++ b/apps/docs/src/app/ui/accordion.ts @@ -1,5 +1,5 @@ -import { Component, effect, input, signal } from '@angular/core'; -import { LucideDynamicIcon, LucideChevronDown } from '@lucide/angular'; +import {Component, effect, input, signal} from '@angular/core'; +import {LucideDynamicIcon, LucideChevronDown} from '@lucide/angular'; let idCounter = 0; diff --git a/apps/docs/src/app/ui/alert.ts b/apps/docs/src/app/ui/alert.ts index a0ee1f7..011be6b 100644 --- a/apps/docs/src/app/ui/alert.ts +++ b/apps/docs/src/app/ui/alert.ts @@ -1,4 +1,4 @@ -import { Component, computed, input } from '@angular/core'; +import {Component, computed, input} from '@angular/core'; import { LucideDynamicIcon, type LucideIcon, diff --git a/apps/docs/src/app/ui/badge.ts b/apps/docs/src/app/ui/badge.ts index c477d9e..20a7149 100644 --- a/apps/docs/src/app/ui/badge.ts +++ b/apps/docs/src/app/ui/badge.ts @@ -1,5 +1,5 @@ -import { Component, computed, input } from '@angular/core'; -import { BADGE_VARIANTS, type BadgeVariant } from '../../types/badge'; +import {Component, computed, input} from '@angular/core'; +import {BADGE_VARIANTS, type BadgeVariant} from '../../types/badge'; /** * Small inline status pill, designed to sit next to a heading or in a diff --git a/apps/docs/src/app/ui/brand-icons.ts b/apps/docs/src/app/ui/brand-icons.ts new file mode 100644 index 0000000..6b5d13f --- /dev/null +++ b/apps/docs/src/app/ui/brand-icons.ts @@ -0,0 +1,33 @@ +import {Component} from '@angular/core'; + +@Component({ + selector: 'svg[ngmdClaudeIcon]', + host: { + xmlns: 'http://www.w3.org/2000/svg', + width: '24', + height: '24', + viewBox: '0 0 24 24', + fill: '#D97757', + 'aria-hidden': 'true', + }, + template: `<svg:path + d="m4.7144 15.9555 4.7174-2.6471.079-.2307-.079-.1275h-.2307l-.7893-.0486-2.6956-.0729-2.3375-.0971-2.2646-.1214-.5707-.1215-.5343-.7042.0546-.3522.4797-.3218.686.0608 1.5179.1032 2.2767.1578 1.6514.0972 2.4468.255h.3886l.0546-.1579-.1336-.0971-.1032-.0972L6.973 9.8356l-2.55-1.6879-1.3356-.9714-.7225-.4918-.3643-.4614-.1578-1.0078.6557-.7225.8803.0607.2246.0607.8925.686 1.9064 1.4754 2.4893 1.8336.3643.3035.1457-.1032.0182-.0728-.164-.2733-1.3539-2.4467-1.445-2.4893-.6435-1.032-.17-.6194c-.0607-.255-.1032-.4674-.1032-.7285L6.287.1335 6.6997 0l.9957.1336.419.3642.6192 1.4147 1.0018 2.2282 1.5543 3.0296.4553.8985.2429.8318.091.255h.1579v-.1457l.1275-1.706.2368-2.0947.2307-2.6957.0789-.7589.3764-.9107.7468-.4918.5828.2793.4797.686-.0668.4433-.2853 1.8517-.5586 2.9021-.3643 1.9429h.2125l.2429-.2429.9835-1.3053 1.6514-2.0643.7286-.8196.85-.9046.5464-.4311h1.0321l.759 1.1293-.34 1.1657-1.0625 1.3478-.8804 1.1414-1.2628 1.7-.7893 1.36.0729.1093.1882-.0183 2.8535-.607 1.5421-.2794 1.8396-.3157.8318.3886.091.3946-.3278.8075-1.967.4857-2.3072.4614-3.4364.8136-.0425.0304.0486.0607 1.5482.1457.6618.0364h1.621l3.0175.2247.7892.522.4736.6376-.079.4857-1.2142.6193-1.6393-.3886-3.825-.9107-1.3113-.3279h-.1822v.1093l1.0929 1.0686 2.0035 1.8092 2.5075 2.3314.1275.5768-.3218.4554-.34-.0486-2.2039-1.6575-.85-.7468-1.9246-1.621h-.1275v.17l.4432.6496 2.3436 3.5214.1214 1.0807-.17.3521-.6071.2125-.6679-.1214-1.3721-1.9246L14.38 17.959l-1.1414-1.9428-.1397.079-.674 7.2552-.3156.3703-.7286.2793-.6071-.4614-.3218-.7468.3218-1.4753.3886-1.9246.3157-1.53.2853-1.9004.17-.6314-.0121-.0425-.1397.0182-1.4328 1.9672-2.1796 2.9446-1.7243 1.8456-.4128.164-.7164-.3704.0667-.6618.4008-.5889 2.386-3.0357 1.4389-1.882.929-1.0868-.0062-.1579h-.0546l-6.3385 4.1164-1.1293.1457-.4857-.4554.0608-.7467.2307-.2429 1.9064-1.3114Z" + />`, +}) +export class ClaudeIcon {} + +@Component({ + selector: 'svg[ngmdOpenaiIcon]', + host: { + xmlns: 'http://www.w3.org/2000/svg', + width: '24', + height: '24', + viewBox: '0 0 24 24', + fill: 'currentColor', + 'aria-hidden': 'true', + }, + template: `<svg:path + d="M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z" + />`, +}) +export class OpenaiIcon {} diff --git a/apps/docs/src/app/ui/callout.ts b/apps/docs/src/app/ui/callout.ts index fb26254..636e0ff 100644 --- a/apps/docs/src/app/ui/callout.ts +++ b/apps/docs/src/app/ui/callout.ts @@ -1,5 +1,5 @@ -import { Component, input } from '@angular/core'; -import { NgTemplateOutlet } from '@angular/common'; +import {Component, input} from '@angular/core'; +import {NgTemplateOutlet} from '@angular/common'; type CalloutType = 'info' | 'tip' | 'success' | 'warning' | 'danger'; diff --git a/apps/docs/src/app/ui/card-grid.ts b/apps/docs/src/app/ui/card-grid.ts index b3bda90..510e717 100644 --- a/apps/docs/src/app/ui/card-grid.ts +++ b/apps/docs/src/app/ui/card-grid.ts @@ -1,4 +1,4 @@ -import { Component, computed, input } from '@angular/core'; +import {Component, computed, input} from '@angular/core'; /** * Grid container around `<ngmd-card>` (or any block children). Two- or diff --git a/apps/docs/src/app/ui/card.ts b/apps/docs/src/app/ui/card.ts index 4eba728..bca088d 100644 --- a/apps/docs/src/app/ui/card.ts +++ b/apps/docs/src/app/ui/card.ts @@ -1,6 +1,6 @@ -import { Component, computed, input } from '@angular/core'; -import { NgTemplateOutlet } from '@angular/common'; -import { RouterLink } from '@angular/router'; +import {Component, computed, input} from '@angular/core'; +import {NgTemplateOutlet} from '@angular/common'; +import {RouterLink} from '@angular/router'; import { LucideDynamicIcon, type LucideIcon, @@ -92,7 +92,8 @@ const ICON_MAP: Record<string, LucideIcon> = { <img [src]="image()" [alt]="title() || ''" - class="size-8 mb-4 object-contain" + class="mb-4" + [class]="avatar() ? 'size-14 rounded-full object-cover' : 'size-8 object-contain'" aria-hidden="true" loading="lazy" /> @@ -107,9 +108,13 @@ const ICON_MAP: Record<string, LucideIcon> = { <!-- <p> not <h3>: card titles are labels, not section headings. Using <h3> here would pollute the page TOC (Toc scanner picks up h2/h3 inside analog-markdown) with every card title. --> - <p class="text-base font-semibold mb-2 text-zinc-900 dark:text-zinc-100">{{ title() }}</p> + <p class="text-base font-semibold mt-0 mb-2 text-zinc-900 dark:text-zinc-100"> + {{ title() }} + </p> } - <div class="text-sm text-zinc-600 dark:text-zinc-400 flex-1"> + <div + class="text-sm text-zinc-600 dark:text-zinc-400 flex-1 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0" + > <ng-content></ng-content> </div> @if (cta()) { @@ -135,6 +140,9 @@ export class NgmdCard { * matters more than a generic Lucide glyph. */ readonly image = input<string>(''); + readonly avatar = input(false, { + transform: (v: boolean | string) => v === '' || v === true || v === 'true', + }); protected readonly iconImg = computed(() => ICON_MAP[this.icon()] ?? null); } diff --git a/apps/docs/src/app/ui/code-block.ts b/apps/docs/src/app/ui/code-block.ts index 13fafd0..b3f2957 100644 --- a/apps/docs/src/app/ui/code-block.ts +++ b/apps/docs/src/app/ui/code-block.ts @@ -1,6 +1,6 @@ -import { Component, DestroyRef, effect, inject, input, signal } from '@angular/core'; -import { DomSanitizer, type SafeHtml } from '@angular/platform-browser'; -import { writeToClipboard } from '../utils/clipboard'; +import {Component, DestroyRef, effect, inject, input, signal} from '@angular/core'; +import {DomSanitizer, type SafeHtml} from '@angular/platform-browser'; +import {writeToClipboard} from '../utils/clipboard'; /** * Code block with a header bar and shiki syntax highlighting at runtime. @@ -137,11 +137,11 @@ export class NgmdCodeBlock { if (!code || typeof window === 'undefined') return; try { - const { codeToHtml } = await import('shiki'); + const {codeToHtml} = await import('shiki'); if (cancelled) return; const html = await codeToHtml(code, { lang: lang || 'text', - themes: { light: 'github-light', dark: 'github-dark' }, + themes: {light: 'github-light', dark: 'github-dark'}, defaultColor: false, }); if (!cancelled) { diff --git a/apps/docs/src/app/ui/discord-icon.ts b/apps/docs/src/app/ui/discord-icon.ts index 51fdab9..3223256 100644 --- a/apps/docs/src/app/ui/discord-icon.ts +++ b/apps/docs/src/app/ui/discord-icon.ts @@ -1,4 +1,4 @@ -import { Component } from '@angular/core'; +import {Component} from '@angular/core'; @Component({ selector: 'svg[ngmdDiscordIcon]', diff --git a/apps/docs/src/app/ui/github-icon.ts b/apps/docs/src/app/ui/github-icon.ts index da2b69d..faa1201 100644 --- a/apps/docs/src/app/ui/github-icon.ts +++ b/apps/docs/src/app/ui/github-icon.ts @@ -1,4 +1,4 @@ -import { Component } from '@angular/core'; +import {Component} from '@angular/core'; @Component({ selector: 'svg[ngmdGithubIcon]', diff --git a/apps/docs/src/app/ui/hero.ts b/apps/docs/src/app/ui/hero.ts index de65074..dfc0413 100644 --- a/apps/docs/src/app/ui/hero.ts +++ b/apps/docs/src/app/ui/hero.ts @@ -1,4 +1,4 @@ -import { Component, computed, input } from '@angular/core'; +import {Component, computed, input} from '@angular/core'; @Component({ selector: 'ngmd-hero', @@ -8,13 +8,18 @@ import { Component, computed, input } from '@angular/core'; [class]="bgClass()" [style.background-image]="gradient() ? 'var(--accent-gradient-soft)' : null" > - <h1 - class="text-3xl sm:text-4xl font-bold tracking-tight m-0 mb-3" - [class]="titleClass()" - [style.background-image]="gradient() ? 'var(--accent-gradient)' : null" - > - {{ title() }} - </h1> + <div class="flex items-center gap-3 mb-3"> + @if (logo()) { + <img [src]="logo()" alt="" aria-hidden="true" class="size-9 sm:size-10 object-contain" /> + } + <h1 + class="text-3xl sm:text-4xl font-bold tracking-tight m-0" + [class]="titleClass()" + [style.background-image]="gradient() ? 'var(--accent-gradient)' : null" + > + {{ title() }} + </h1> + </div> <div class="text-base sm:text-lg leading-relaxed max-w-prose [&>*:first-child]:mt-0 [&>*:last-child]:mb-0" [class]="bodyClass()" @@ -26,6 +31,7 @@ import { Component, computed, input } from '@angular/core'; }) export class NgmdHero { readonly title = input.required<string>(); + readonly logo = input<string>(); readonly gradient = input(false, { transform: (v: boolean | string) => v === '' || v === true || v === 'true', }); diff --git a/apps/docs/src/app/ui/image.ts b/apps/docs/src/app/ui/image.ts index 100187f..11a808e 100644 --- a/apps/docs/src/app/ui/image.ts +++ b/apps/docs/src/app/ui/image.ts @@ -1,4 +1,4 @@ -import { Component, input } from '@angular/core'; +import {Component, input} from '@angular/core'; @Component({ selector: 'ngmd-image', diff --git a/apps/docs/src/app/ui/index.ts b/apps/docs/src/app/ui/index.ts index 6db2d05..603251f 100644 --- a/apps/docs/src/app/ui/index.ts +++ b/apps/docs/src/app/ui/index.ts @@ -1,30 +1,30 @@ -export { NgmdAccordion, NgmdAccordionItem } from './accordion'; -export { NgmdAlert } from './alert'; -export { NgmdBadge } from './badge'; -export { NgmdCallout } from './callout'; -export { NgmdCard } from './card'; -export { NgmdCardGrid } from './card-grid'; -export { NgmdCodeBlock } from './code-block'; -export { NgmdHero } from './hero'; -export { NgmdImage } from './image'; -export { NgmdPill, NgmdPillRow } from './pill'; -export { NgmdStep, NgmdWorkflow } from './workflow'; -export { NgmdTab, NgmdTabs } from './tabs'; -export { NgmdVideo } from './video'; +export {NgmdAccordion, NgmdAccordionItem} from './accordion'; +export {NgmdAlert} from './alert'; +export {NgmdBadge} from './badge'; +export {NgmdCallout} from './callout'; +export {NgmdCard} from './card'; +export {NgmdCardGrid} from './card-grid'; +export {NgmdCodeBlock} from './code-block'; +export {NgmdHero} from './hero'; +export {NgmdImage} from './image'; +export {NgmdPill, NgmdPillRow} from './pill'; +export {NgmdStep, NgmdWorkflow} from './workflow'; +export {NgmdTab, NgmdTabs} from './tabs'; +export {NgmdVideo} from './video'; -import { NgmdAccordion, NgmdAccordionItem } from './accordion'; -import { NgmdAlert } from './alert'; -import { NgmdBadge } from './badge'; -import { NgmdCallout } from './callout'; -import { NgmdCard } from './card'; -import { NgmdCardGrid } from './card-grid'; -import { NgmdCodeBlock } from './code-block'; -import { NgmdHero } from './hero'; -import { NgmdImage } from './image'; -import { NgmdPill, NgmdPillRow } from './pill'; -import { NgmdStep, NgmdWorkflow } from './workflow'; -import { NgmdTab, NgmdTabs } from './tabs'; -import { NgmdVideo } from './video'; +import {NgmdAccordion, NgmdAccordionItem} from './accordion'; +import {NgmdAlert} from './alert'; +import {NgmdBadge} from './badge'; +import {NgmdCallout} from './callout'; +import {NgmdCard} from './card'; +import {NgmdCardGrid} from './card-grid'; +import {NgmdCodeBlock} from './code-block'; +import {NgmdHero} from './hero'; +import {NgmdImage} from './image'; +import {NgmdPill, NgmdPillRow} from './pill'; +import {NgmdStep, NgmdWorkflow} from './workflow'; +import {NgmdTab, NgmdTabs} from './tabs'; +import {NgmdVideo} from './video'; /** * Spread into a page's `imports` to get every authoring component in one go: diff --git a/apps/docs/src/app/ui/pill.ts b/apps/docs/src/app/ui/pill.ts index eb6f62c..d346948 100644 --- a/apps/docs/src/app/ui/pill.ts +++ b/apps/docs/src/app/ui/pill.ts @@ -1,6 +1,6 @@ -import { Component, computed, input } from '@angular/core'; -import { RouterLink } from '@angular/router'; -import { LucideDynamicIcon, LucideArrowRight, LucideArrowUpRight } from '@lucide/angular'; +import {Component, computed, input} from '@angular/core'; +import {RouterLink} from '@angular/router'; +import {LucideDynamicIcon, LucideArrowRight, LucideArrowUpRight} from '@lucide/angular'; /** * Pill-shaped link. Internal hrefs route via `RouterLink`; external (http(s)) diff --git a/apps/docs/src/app/ui/tabs.ts b/apps/docs/src/app/ui/tabs.ts index ff3f7ab..37db1e9 100644 --- a/apps/docs/src/app/ui/tabs.ts +++ b/apps/docs/src/app/ui/tabs.ts @@ -7,7 +7,7 @@ import { input, signal, } from '@angular/core'; -import { watchHostAttribute } from '../utils/watch-host-attribute'; +import {watchHostAttribute} from '../utils/watch-host-attribute'; import { LucideDynamicIcon, type LucideIcon, diff --git a/apps/docs/src/app/ui/video.ts b/apps/docs/src/app/ui/video.ts index 4a3ffd6..fb15220 100644 --- a/apps/docs/src/app/ui/video.ts +++ b/apps/docs/src/app/ui/video.ts @@ -1,5 +1,5 @@ -import { Component, computed, inject, input } from '@angular/core'; -import { DomSanitizer } from '@angular/platform-browser'; +import {Component, computed, inject, input} from '@angular/core'; +import {DomSanitizer} from '@angular/platform-browser'; @Component({ selector: 'ngmd-video', diff --git a/apps/docs/src/app/ui/workflow.ts b/apps/docs/src/app/ui/workflow.ts index 62d2613..b87541d 100644 --- a/apps/docs/src/app/ui/workflow.ts +++ b/apps/docs/src/app/ui/workflow.ts @@ -10,7 +10,7 @@ import { QueryList, signal, } from '@angular/core'; -import { watchHostAttribute } from '../utils/watch-host-attribute'; +import {watchHostAttribute} from '../utils/watch-host-attribute'; @Component({ selector: 'ngmd-step', diff --git a/apps/docs/src/app/utils/enhance-on-navigation.ts b/apps/docs/src/app/utils/enhance-on-navigation.ts index d3a8ddc..b557763 100644 --- a/apps/docs/src/app/utils/enhance-on-navigation.ts +++ b/apps/docs/src/app/utils/enhance-on-navigation.ts @@ -1,7 +1,7 @@ -import { DestroyRef } from '@angular/core'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { NavigationEnd, Router } from '@angular/router'; -import { filter } from 'rxjs'; +import {DestroyRef} from '@angular/core'; +import {takeUntilDestroyed} from '@angular/core/rxjs-interop'; +import {NavigationEnd, Router} from '@angular/router'; +import {filter} from 'rxjs'; /** * Run a DOM-enhancing function on initial mount and after every router @@ -28,7 +28,7 @@ export function enhanceOnNavigation( destroyRef: DestroyRef, selector: string, enhanceEach: (el: HTMLElement) => void, - opts: { maxAttempts?: number; delayMs?: number } = {}, + opts: {maxAttempts?: number; delayMs?: number} = {}, ): void { const maxAttempts = opts.maxAttempts ?? 20; const delayMs = opts.delayMs ?? 50; diff --git a/apps/docs/src/app/utils/watch-host-attribute.ts b/apps/docs/src/app/utils/watch-host-attribute.ts index c578b7d..59b3edb 100644 --- a/apps/docs/src/app/utils/watch-host-attribute.ts +++ b/apps/docs/src/app/utils/watch-host-attribute.ts @@ -22,6 +22,6 @@ export function watchHostAttribute( onChange(host.getAttribute(attribute)); if (typeof MutationObserver === 'undefined') return () => {}; const observer = new MutationObserver(() => onChange(host.getAttribute(attribute))); - observer.observe(host, { attributes: true, attributeFilter: [attribute] }); + observer.observe(host, {attributes: true, attributeFilter: [attribute]}); return () => observer.disconnect(); } diff --git a/apps/docs/src/content/agents/mcp-server.md b/apps/docs/src/content/agents/mcp-server.md index d00b4fc..df4fc28 100644 --- a/apps/docs/src/content/agents/mcp-server.md +++ b/apps/docs/src/content/agents/mcp-server.md @@ -1,26 +1,46 @@ --- title: MCP server -description: Connect a coding agent to the devtools over stdio or HTTP. +description: Connect a coding agent to the devtools over stdio or HTTP, in Claude Code, Cursor or VS Code. --- +<ngmd-hero title="MCP server" logo="https://cdn.simpleicons.org/modelcontextprotocol/71717A" gradient> + Give your coding agent the same view of the app that you have. Routes, components, forms, stores and the live page, as MCP tools and resources. +</ngmd-hero> + # MCP server The devtools expose their inspectors to coding agents as *MCP tools and resources. An agent can list your routes, read the live component tree, explain why a form is invalid, or navigate the app. There are two ways to connect. Pick one based on the data your agent needs. +## Pick a transport + +<ngmd-card-grid columns="2"> + <ngmd-card icon="terminal" title="stdio" cta="Source only"> + Your client starts <code>ng-devtools mcp</code> in the project folder. The server scans your source. No page ever connects to it. + </ngmd-card> + <ngmd-card icon="zap" title="HTTP" cta="Source and live page"> + Your client calls <code>/__devframes/__mcp</code> on the server that runs your app. Pages open in a browser report to it, so the live tools work. + </ngmd-card> +</ngmd-card-grid> + | Transport | Live page data | Setup | | --------------------------- | ------------------------------------ | ------------------------------------ | | stdio (`ng-devtools mcp`) | No. Source scan tools only. | A command in your MCP client config. | | HTTP (`/__devframes/__mcp`) | Yes, with the app open in a browser. | A URL on your app's dev server. | -## stdio +## Connect over stdio -The `mcp` command starts a server over stdio in the current directory. It scans your source, but no page ever connects to it, so tools that need the running app answer that no page is attached. +The package ships an `ng-devtools` binary. Its `mcp` command starts an MCP server on stdin and stdout. -**Claude Desktop**: add to `claude_desktop_config.json`: +### Add the stdio server to your client + +```bash group="stdio" name="Claude Code" active +claude mcp add ng-devtools -- npx @santoshyadavdev/ng-devtools mcp +``` -```json +```json group="stdio" name="Cursor" +// .cursor/mcp.json { "mcpServers": { "ng-devtools": { @@ -31,12 +51,12 @@ The `mcp` command starts a server over stdio in the current directory. It scans } ``` -**VS Code**: add to `.vscode/mcp.json`: - -```json +```json group="stdio" name="VS Code" +// .vscode/mcp.json { "servers": { "ng-devtools": { + "type": "stdio", "command": "npx", "args": ["@santoshyadavdev/ng-devtools", "mcp"] } @@ -44,9 +64,27 @@ The `mcp` command starts a server over stdio in the current directory. It scans } ``` -## HTTP +### Start it in the project folder + +The server scans the folder it starts in. Start it from the root of your Angular or Analog project, next to `package.json` and `angular.json`. + +<ngmd-alert severity="helpful"> + Inside this repository, <code>pnpm devtools:mcp</code> runs the same server against the demo app. +</ngmd-alert> + +### What stdio can answer -When the devtools are embedded in your app's server, the MCP endpoint is also available over HTTP at `/__devframes/__mcp` (or `/__ng-devtools/__mcp` without the hub). This endpoint sees the pages that are connected to that server, so the live tools work. Open the app in a browser with the [overlay](/getting-started/overlay) loaded, then call the tools. +Over stdio, the source scan tools work: `get-routes`, `get-components`, `get-signals`, `get-providers`, `get-ngrx-store`, `get-pipes` and `build-meta`. So do the tools that read files only, like `lint-pipes`, `analog-routes` and `analog-lint`. + +Tools that need the running app reply that no page is attached. Resources stay empty. Use HTTP for those. + +## Connect over HTTP + +When the devtools are embedded in your app's server, the same tools are served over HTTP. This endpoint sees the pages that connect to that server. + +### Find your endpoint + +The path depends on how you mount the devtools. Use the port your server actually runs on. | Setup | Endpoint | | --------------------------------------- | ----------------------------------------- | @@ -54,38 +92,98 @@ When the devtools are embedded in your app's server, the MCP endpoint is also av | [Vite plugin](/getting-started/vite) | `http://localhost:5173/__devframes/__mcp` | | [Standalone CLI](/getting-started/cli) | `http://localhost:9999/__mcp` | -Use the port your server actually runs on. +If you mount the devtools panel without the hub, at `/__ng-devtools/`, the endpoint is `/__ng-devtools/__mcp`. + +### Send an Origin header -<ngmd-callout type="warning" title="Send an Origin header"> +<ngmd-callout type="warning" title="Requests without an Origin header get 403"> The HTTP endpoint only answers requests from this machine that carry a local <code>Origin</code> header, such as <code>http://localhost:4000</code>. Requests without one get <code>403 Forbidden</code>. If your MCP client does not send an <code>Origin</code> header, add it in the client config. </ngmd-callout> -For example, in `.vscode/mcp.json`: +The header value is the origin of your dev server. Every example below sets it. + +### Add the HTTP endpoint to your client + +```bash group="http" name="Claude Code" active +claude mcp add --transport http ng-devtools http://localhost:4000/__devframes/__mcp \ + --header "Origin: http://localhost:4000" +``` -```json +```json group="http" name="Cursor" +// .cursor/mcp.json +{ + "mcpServers": { + "ng-devtools": { + "url": "http://localhost:4000/__devframes/__mcp", + "headers": {"Origin": "http://localhost:4000"} + } + } +} +``` + +```json group="http" name="VS Code" +// .vscode/mcp.json { "servers": { "ng-devtools": { "type": "http", "url": "http://localhost:4000/__devframes/__mcp", - "headers": { "Origin": "http://localhost:4000" } + "headers": {"Origin": "http://localhost:4000"} } } } ``` -## Tool names +### Open the app in a browser + +The live tools read what the page reports. Without an open page, they have nothing to answer with. + +<ngmd-workflow> + <ngmd-step title="Start your app"> + Run the server that mounts the devtools: your Express SSR server, the Vite dev server, or <code>ng-devtools dev</code>. + </ngmd-step> + <ngmd-step title="Open it in a browser"> + Load the app with the <a href="/getting-started/overlay">overlay</a>. The page connects to the devtools and starts reporting. + </ngmd-step> + <ngmd-step title="Call a tool"> + Ask your agent something the page knows, like "why is the checkout form invalid?". It calls <code>explain-form-invalid</code> on the connected page. + </ngmd-step> +</ngmd-workflow> + +## How tools behave + +### Tool names + +Tools are registered with a colon, as `ng-devtools:get-routes`. MCP clients see them with an underscore, as `ng-devtools_get-routes`. Calls with either form work. + +### Read and action tools + +Read-only tools are marked read-only for your client. Five tools act on the app, so they are not: -Tools are registered with a colon, as `ng-devtools:get-routes`. MCP clients see these with an underscore, as `ng-devtools_get-routes`. Calls with either form work. +<ngmd-pill-row> + <ngmd-pill href="/agents/tools#components-signals-and-di" title="highlight"></ngmd-pill> + <ngmd-pill href="/agents/tools#act-on-the-router" title="navigate"></ngmd-pill> + <ngmd-pill href="/agents/tools#act-on-a-form" title="form-action"></ngmd-pill> + <ngmd-pill href="/agents/tools#act-on-a-form" title="fill-form"></ngmd-pill> + <ngmd-pill href="/agents/tools#call-a-server-route" title="analog-call-api"></ngmd-pill> +</ngmd-pill-row> -Read-only tools are marked read-only for your client. Tools that act on the app (`highlight`, `navigate`, `form-action`, `fill-form` and `analog-call-api`) are not, so your client can ask before it runs them. +Your client can ask you before it runs them. -## Pages +### Pages and tabs -Each browser tab reports on its own and gets a page id. Tools that read live data use the most recent page by default. Pass `page` (or `pageId`) to pick another tab. Pages that stop reporting are dropped after a short time. +Each browser tab reports on its own and gets a page id. Tools that read live data use the most recent page by default. Pass `page` (or `pageId` for `inspect-providers`) to pick another tab. Pages that stop reporting are dropped after a short time. -## Next steps +## Where to next -- [Tools](/agents/tools): every tool with its inputs. -- [Resources](/agents/resources): live state an agent can read. -- [Security](/security): what leaves the page and what is redacted. +<ngmd-card-grid columns="3"> + <ngmd-card icon="wrench" title="Tools" link="/agents/tools" cta="Every tool"> + Each tool grouped by inspector, with what it answers and its arguments. + </ngmd-card> + <ngmd-card icon="layers" title="Resources" link="/agents/resources" cta="Live state"> + The live state an agent can read as JSON. + </ngmd-card> + <ngmd-card icon="shield" title="Security" link="/security" cta="Redaction"> + What leaves the page and what is redacted. + </ngmd-card> +</ngmd-card-grid> diff --git a/apps/docs/src/content/agents/resources.md b/apps/docs/src/content/agents/resources.md index 11b923b..394a79a 100644 --- a/apps/docs/src/content/agents/resources.md +++ b/apps/docs/src/content/agents/resources.md @@ -1,39 +1,111 @@ --- title: Resources -description: Live state an agent can read as MCP resources. +description: Live state an agent can read as MCP resources, and the shared-state keys behind them. --- +<ngmd-hero title="Resources" logo="https://cdn.simpleicons.org/modelcontextprotocol/71717A" gradient> + Six JSON resources hold what the connected pages reported. Shared-state keys cover the rest. +</ngmd-hero> + # Resources -Resources hold the live data the connected pages reported. They are empty when no page is connected, so read them through the [HTTP endpoint](/agents/mcp-server#http) with the app open in a browser. +Resources hold the live data the connected pages reported. An agent reads them when it wants the raw state instead of a tool's summary. + +## Read a resource + +### Connect over HTTP + +Resources are empty when no page is connected. Read them through the [HTTP endpoint](/agents/mcp-server#connect-over-http), with the app open in a browser. + +<ngmd-alert severity="warning"> + Over stdio, no page ever connects. Every resource stays empty. +</ngmd-alert> + +### Resource URIs + +Clients see each resource at a `devframe://resource/` URI with the id encoded. For example, `ng-devtools:component-tree` is served at: + +```text +devframe://resource/ng-devtools%3Acomponent-tree +``` + +Each one returns JSON. + +## Available resources + +| Resource | Name | Content | +| ---------------------------- | ---------------------- | ------------------------------- | +| `ng-devtools:component-tree` | Angular Component Tree | Live component hierarchy | +| `ng-devtools:signal-graph` | Angular Signal Graph | Signal dependency graph | +| `ng-devtools:injector-tree` | Angular Injector Tree | DI injector hierarchy | +| `ng-devtools:ngrx-store` | NgRx Store State | Live NgRx stores and change log | +| `ng-devtools:forms` | Angular Forms | Live forms and recent changes | +| `ng-devtools:router` | Angular Router | Live route and navigations | + +### component-tree + +The component instances of each page, under `pages[pageId].roots`. Each node has an instance id, class name, host tag and the directives on its host. `detail` holds the live inputs, outputs, listeners, change detection, encapsulation and injected dependencies of the instance selected in the panel. `nodes` repeats the roots of the most recent page. + +The instance ids here are what `highlight` accepts. + +### signal-graph -| 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 | +The signal graph of each page, under `pages[pageId]`. `graph` is the latest one. It holds the nodes (`signal`, `computed`, `effect`, `linkedSignal`), producer to consumer edges, the component it belongs to, and recent value history per node. Only signals a template or an effect has read appear. -Clients see each resource at a `devframe://resource/` URI with the id encoded, for example `devframe://resource/ng-devtools%3Acomponent-tree`. Each one returns JSON. +### injector-tree -## What each resource holds +The injector hierarchy the page last reported, with the providers at each level. -- **component-tree**: the component instances of each page (instance id, class name, host tag, directives on the host), and the details of the instance selected in the panel. -- **signal-graph**: the signal graph of each page, with nodes, producer to consumer edges, the component it belongs to, and recent value history per node. -- **injector-tree**: the injector hierarchy the page reported, with providers at each level. -- **ngrx-store**: each `@ngrx/signals` store (state, computed values, methods, the component fields that reference it) and the `@ngrx/store` state, plus the change log with a state diff per entry. -- **forms**: every form with each field's value, status, touched, dirty and errors, plus recent changes. When the data is too large, the resource returns a summary and points to `inspect-forms`. -- **router**: the active route tree and recent navigations of each page. When the data is too large, the resource returns a summary and points to `inspect-route`, `explain-navigation` and `list-routes`. +### ngrx-store + +Each `@ngrx/signals` store on the page: state, computed values, methods, and the component fields that reference it. It also holds the `@ngrx/store` state and the change log, with a state diff per entry. The log records method calls, `patchState` writes and dispatched actions. + +### forms + +Every form the page reported (Signal Forms, reactive and template-driven), with each field's value, status, touched, dirty and errors, plus recent changes. + +<ngmd-callout type="info" title="Large forms return a summary"> + When the data is too large, the resource returns a summary per form (status, field count, error count) and points to <code>inspect-forms</code>. +</ngmd-callout> + +### router + +The active route tree (params, data, guards, resolvers) and recent navigations of each page. When the data is too large, the resource returns the URL and recent navigations of each page, and points to `inspect-route`, `explain-navigation` and `list-routes`. ## Shared state -Every shared-state key is also listed as a resource at `devframe://state/<key>`, and the `devframe_state_read` tool reads the same keys. This covers data without its own resource: +The devtools keep their live data in shared-state keys. Every key is also listed as a resource, at `devframe://state/<key>` with the key encoded. + +### Keys + +This covers data without its own resource. | Key | Content | | ------------------------ | ------------------------------------------------------------------------------ | | `ng-devtools:http` | The SSR & HTTP timeline, fault rules, hydration data and TransferState payload | | `ng-devtools:pipe-usage` | Live pipe instances and recorded calls | | `ng-devtools:analog` | Analog page data and the server call log | -| `ng-devtools:routes` | Routes from the source scan | +| `ng-devtools:routes` | Declared but not filled. Use `get-routes` or `list-routes` instead. | + +The keys behind the six resources above (`ng-devtools:component-tree`, `ng-devtools:forms`, and so on) are listed too. + +### Read a key with a tool + +Some clients only use tools. The `devframe_state_read` tool reads the same keys: + +<ngmd-workflow> + <ngmd-step title="List the keys"> + Call <code>devframe_state_read</code> without arguments. It returns every key. + </ngmd-step> + <ngmd-step title="Read one"> + Call it again with <code>key</code>, for example <code>ng-devtools:http</code>. It returns the value as JSON. + </ngmd-step> +</ngmd-workflow> + +## Where to next + +<ngmd-pill-row> + <ngmd-pill href="/agents/tools" title="Tools"></ngmd-pill> + <ngmd-pill href="/agents/mcp-server" title="MCP server"></ngmd-pill> + <ngmd-pill href="/security" title="Security"></ngmd-pill> +</ngmd-pill-row> diff --git a/apps/docs/src/content/agents/tools.md b/apps/docs/src/content/agents/tools.md index ebc09c7..96c2b73 100644 --- a/apps/docs/src/content/agents/tools.md +++ b/apps/docs/src/content/agents/tools.md @@ -1,99 +1,262 @@ --- title: Tools -description: Every agent tool the devtools expose, with its inputs. +description: Every agent tool the devtools expose, grouped by inspector, with what it answers and its arguments. --- +<ngmd-hero title="Tools" logo="https://cdn.simpleicons.org/modelcontextprotocol/71717A" gradient> + Forty-three tools, grouped by inspector. Each one answers a question you would otherwise answer by clicking through the panel. +</ngmd-hero> + # Tools -MCP clients see these with an underscore, as `ng-devtools_get-routes`. Tools marked **live** need a connected page, which means the [HTTP endpoint](/agents/mcp-server#http) with the app open in a browser. The others read your source and also work over stdio. +This page lists every tool the [MCP server](/agents/mcp-server) exposes. Each group matches an inspector in the panel. + +## Before you call a tool + +### Names + +Tool ids use a colon, as `ng-devtools:get-routes`. MCP clients see them with an underscore, as `ng-devtools_get-routes`. The tables below drop the `ng-devtools:` prefix. + +### Source and live tools + +Each tool reads from one of three places. + +<ngmd-card-grid columns="3"> + <ngmd-card icon="file" title="Source"> + Reads your files. Works over stdio and HTTP, with or without a browser. + </ngmd-card> + <ngmd-card icon="zap" title="Page"> + Reads what a connected page reported. Needs the HTTP endpoint and the app open in a browser. + </ngmd-card> + <ngmd-card icon="terminal" title="Vite plugin"> + Reads what the Vite dev server recorded. Needs the Vite plugin. + </ngmd-card> +</ngmd-card-grid> + +### The page argument -Most live tools take an optional `page` input to pick a browser tab. It defaults to the most recent one. +Most page tools take an optional `page` argument to pick a browser tab. It defaults to the most recent one. `inspect-providers` calls it `pageId`. The tables below leave `page` out. + +### Action tools + +<ngmd-alert severity="important"> + <code>highlight</code>, <code>navigate</code>, <code>form-action</code>, <code>fill-form</code> and <code>analog-call-api</code> act on the app. Every other tool is marked read-only for your client. +</ngmd-alert> ## Source scan -These tools take no inputs. +These seven tools take no arguments. They all read your source. -| Tool | Description | -| ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ng-devtools:get-routes` | Angular routes from your route files, with full URL path, kind (page, group, redirect or wildcard), guards, resolvers, and file and line. In Analog apps, the file routes. | -| `ng-devtools:get-components` | Components and directives from `@Component` and `@Directive` classes, with selector, kind, inputs, outputs, and file and line. | -| `ng-devtools:get-signals` | `signal()`, `computed()`, `linkedSignal()`, `effect()`, `toSignal()` and resource declarations, plus signal inputs, models and queries. | -| `ng-devtools:get-providers` | DI providers: `@Injectable` services, `inject()` calls and `providers` arrays. | -| `ng-devtools:get-ngrx-store` | NgRx declarations: actions, reducers, effects, selectors, features, store setup, `signalStore` (with its members), `signalState` and `signalMethod`. | -| `ng-devtools:get-pipes` | Custom `@Pipe` classes, and built-in pipes in use in templates, with purity and standalone status. | -| `ng-devtools:build-meta` | Angular and TypeScript versions, the project name and SSR status. | +| Tool | What it answers | +| ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `get-routes` | Angular routes from your route files, with full URL path (parents and `loadChildren` prefixes included), kind (page, group, redirect or wildcard), guards, resolvers, and file and line. | +| `get-components` | Components and directives from `@Component` and `@Directive` classes, with class name, selector, kind, inputs, outputs, and file and line. | +| `get-signals` | `signal()`, `computed()`, `linkedSignal()`, `effect()`, `toSignal()` and resource declarations (`resource`, `httpResource`, `rxResource`), plus signal inputs, models and queries. | +| `get-providers` | DI providers: `@Injectable` services, `inject()` calls and `providers` arrays, with token, file and where each one is provided. | +| `get-ngrx-store` | NgRx declarations: `@ngrx/store` actions, reducers, effects, selectors, features and store setup, and `@ngrx/signals` `signalStore` (with its members), `signalState` and `signalMethod`. | +| `get-pipes` | Custom `@Pipe` classes, and built-in pipes from `@angular/common` in use in templates, with purity, standalone status, and where each is declared or used. | +| `build-meta` | The Angular and TypeScript versions and SSR status. | ## Components, signals and DI -| Tool | Live | Inputs | Description | -| ------------------------------- | ---- | ------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------- | -| `ng-devtools:highlight` | yes | `selector` (required): instance id, class name, host tag or CSS selector | Highlights a component in the page and makes it the target of `inspect-signals`. | -| `ng-devtools:inspect-signals` | yes | `selector` (required) | The signal graph the page reported, with dependency edges and recent value history per node. | -| `ng-devtools:inspect-providers` | yes | `selector`, `pageId` | The injector hierarchy a page reported, with the providers at each level. | +### highlight + +Highlights a component in the page and makes it the target of `inspect-signals`. Reads: page. <ngmd-badge variant="alpha">Action</ngmd-badge> + +| Argument | Required | Value | +| ---------- | -------- | ------------------------------------------------------------------------------------------------------------- | +| `selector` | yes | An instance id from the `component-tree` resource (like `c12`), a class name, a host tag or any CSS selector. | + +An instance id targets that exact instance, for example the second card of a list. + +### inspect-signals + +The signal graph the page reported: nodes (`signal`, `computed`, `linkedSignal`, `effect`), dependency edges, the component they belong to, and recent value history per node. Reads: page. + +| Argument | Required | Value | +| ---------- | -------- | ---------------------------------------------------------------------- | +| `selector` | yes | Host tag, class name or instance id of the component, like `app-root`. | + +<ngmd-callout type="tip" title="One graph per page"> + The page reports one graph: the component picked on the Signals page or with <code>highlight</code>, otherwise the deepest component in the primary router outlet. Call <code>highlight</code> first to switch the graph to another component. Only signals a template or an effect has read appear. +</ngmd-callout> + +### inspect-providers + +The injector hierarchy a page reported, with the providers at each level. Element injectors list what each component injected and which injector supplied it. Environment injectors run from the platform down to the root and route injectors. Reads: page. + +| Argument | Required | Value | +| ---------- | -------- | --------------------------------------------------------------- | +| `selector` | no | Only labels the answer. The page always reports the whole tree. | +| `pageId` | no | The tab to read. Defaults to the most recent. | ## Router -| Tool | Live | Inputs | Description | -| --------------------------------- | ------ | --------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ng-devtools:inspect-route` | yes | `selector` | The current route: URL, params, data, guards, resolvers, the navigation in flight and the outlet tree. With `selector`, the route a component was rendered for, or whether a link is active. | -| `ng-devtools:explain-navigation` | yes | `url`, `id`, `limit` (1 to 50, default 5), `perf` | Recent navigations and why each succeeded or not, with timing, redirects and guard verdicts. `perf` lists the slowest navigations and preloads. | -| `ng-devtools:list-routes` | yes | `match`, `audit`, `filter` | The live route 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` | yes | | Route config mistakes, each with a fix. | -| `ng-devtools:router-config` | yes | | Router options, features and strategies in effect. | -| `ng-devtools:export-navigation` | yes | `id` | A markdown repro of a navigation. Defaults to the latest one that did not succeed. | -| `ng-devtools:explain-render-mode` | partly | `url` | The `ServerRoute` and render mode for a URL, read from `*.routes.server.ts`. | -| `ng-devtools:navigate` | yes | `action` (required), `url`, `pattern`, `params`, `id`, `on`, `routeId`, `replaceUrl`, `skipLocationChange`, `waitFor` | Acts on the router, in development only. Actions: `navigate`, `abort`, `replay`, `probe`, `instrument` and `resolve-lazy`. Only same-origin relative URLs are accepted. | +All router tools read the page, except `explain-render-mode`, which also reads your `*.routes.server.ts` files. + +### Read the current route + +| Tool | What it answers | Arguments | +| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | +| `inspect-route` | The current route: URL, query params, fragment, title, the navigation in flight, the active route tree (component, params, data, guards, resolvers) and the outlet tree. With `selector`, the route a component was rendered for, or whether a link is active. | `selector`: component class, element tag or link text | +| `explain-navigation` | Recent navigations, newest first: who started each one, redirects, per-phase timing, guard and resolver verdicts, lazy loads, and the cancel or error reason in plain language. | `url`, `id`, `limit` (1 to 50, default 5), `perf` | +| `export-navigation` | A markdown repro of one navigation, with router options and the relevant slice of the route config. Defaults to the latest one that did not succeed. | `id` | + +Use `explain-navigation` for "why was I redirected". Pass `perf: true` for "why is navigation slow": it lists the slowest navigations and preloads. + +### Read the route config + +| Tool | What it answers | Arguments | +| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------- | +| `list-routes` | The live route config: every route with its full path, component or redirect, lazy state, guards, resolvers, title, source file and an example URL. | `match`, `audit`, `filter` | +| `lint-routes` | Route config mistakes, such as routes after `**`, redirect cycles, deprecated class guards, missing titles and param typos. Each finding says how Angular reacts and how to fix it. | none | +| `router-config` | How the router is set up: `provideRouter` or `forRoot`, effective options, enabled features, strategies, base href and hydration. | none | +| `explain-render-mode` | The `ServerRoute` and render mode (Server, Client, Prerender) a URL gets, plus server entries that match no client route. | `url`, defaults to the page URL | + +`list-routes` takes three optional arguments: + +- `match`: a URL such as `/users/42`. The tool predicts which route it hits, or the nearest routes when it hits none. +- `audit`: set to `true` to list the guards that protect each page. +- `filter`: only routes whose path or component contains this text. + +### Act on the router + +`navigate` acts on the running app's router, in development only. Reads: page. <ngmd-badge variant="alpha">Action</ngmd-badge> + +| Action | What it does | Arguments | +| -------------- | ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------- | +| `navigate` | Goes to `url`, or to `pattern` with `params`, and waits for the outcome. | `url` or `pattern` + `params`, `replaceUrl`, `skipLocationChange`, `waitFor` (`navigation` or `stable`) | +| `abort` | Stops the navigation in flight. | none | +| `replay` | Runs navigation `id` again and compares the outcome. | `id` | +| `probe` | Runs the real matcher for `url` without navigating. It runs `canMatch` and may load lazy chunks. | `url` | +| `instrument` | Turns per-guard and per-resolver recording on or off. | `on` | +| `resolve-lazy` | Reads the routes of an unloaded lazy route without registering them. | `routeId`, from `list-routes` | + +`action` is required. Only same-origin URLs that start with `/` are accepted. ## Forms -`form` is a form id (like `Checkout.form@ab12`) or part of its label. `path` is a dotted field path, like `address.city` or `items.0.qty`. - -| Tool | Inputs | Description | -| ------------------------------------ | ----------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | -| `ng-devtools:inspect-forms` | `form`, `path`, `onlyInvalid`, `includeValues` | Forms on the page with every field's state and errors. | -| `ng-devtools:explain-form-invalid` | `form` | Which fields make a form invalid, and why. Without `form`, every invalid or pending form. | -| `ng-devtools:explain-field` | `form`, `path`, `selector` | One field: error sources, skip reasons, binding and source. `selector` starts from a CSS selector instead. | -| `ng-devtools:explain-submit` | `form` | What submit will do, and why it might do nothing. | -| `ng-devtools:form-payload` | `form` | What the form sends: value vs raw value, and unvalidated fields. | -| `ng-devtools:form-history` | `form`, `path`, `type`, `origin`, `since`, `limit` | Change timeline with origin (user, code, devtools). Returns the current marker. | -| `ng-devtools:form-diff` | `form`, `since` | Net change since a marker. | -| `ng-devtools:lint-forms` | `form` | Form bugs and model-aware accessibility checks. | -| `ng-devtools:explain-custom-control` | `form`, `path` | How a field is bound, and what is wrong with the binding. | -| `ng-devtools:export-form` | `form`, `format` (`snapshot` or `fixture`) | JSON snapshot or test fixture. | -| `ng-devtools:wait-for-form` | `form`, `until` (`settled`, `valid`, `not-pending` or `submitted`), `since`, `timeoutMs` | Waits until the condition holds, or reports the state on timeout. | -| `ng-devtools:form-action` | `action` (required), `form` (required), `path`, `value`, `mode`, `confirm`, `force`, `snapshot` | Set, touch, revalidate, reset, submit, focus, snapshot, restore and more. | -| `ng-devtools:fill-form` | `form` (required), `values` (required), `mode`, `submit`, `confirm` | Fills several fields through the inputs, like a user would. | - -All forms tools are live. `form-action` needs a development build. `reset`, `submit` and `restore` need `confirm: true`, and so does `fill-form` with `submit`. Secret, hidden and readonly fields are never written. +All forms tools read the page. They cover Signal Forms, reactive forms and template-driven forms. + +Two arguments come up in almost every tool: + +- `form`: a form id (like `Checkout.form@ab12`) or part of its label (`Component.property`). +- `path`: a dotted field path, like `address.city` or `items.0.qty`. Empty for the form itself. + +### Read form state + +| Tool | What it answers | Arguments | +| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------- | +| `inspect-forms` | Without arguments, each form with its status and error count. With `form`, its field tree: value, status, touched, dirty and errors. | `form`, `path`, `onlyInvalid`, `includeValues` | +| `explain-form-invalid` | Which fields make a form invalid, and why: the failing validator, its message, the value and whether it was touched. Without `form`, every invalid or pending form. | `form` | +| `explain-field` | One field: where each error comes from, why validation is skipped, the binding and DOM facts like the label and visible error text. | `form`, `path`, or `selector` (a CSS selector) | +| `explain-submit` | What submit will do, and why it might do nothing. | `form` | +| `form-payload` | What the form sends: `value` against `getRawValue()`, fields that are sent without validation, and which fields the user changed. | `form` | +| `explain-custom-control` | How a field is bound to its element, and what is wrong with the binding, such as value drift or a missing `setDisabledState`. | `form`, `path` | + +For "why is this form invalid", call `explain-form-invalid` first. Password and other secret-looking values are redacted. + +### Track changes + +| Tool | What it answers | Arguments | +| --------------- | ---------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | +| `form-history` | A timeline of changes, each tagged with its origin (`user`, `code`, `devtools`, `binding`). Returns the current marker. | `form`, `path`, `type`, `origin`, `since`, `limit` (default 50, at most 200) | +| `form-diff` | The net change since a marker: each field whose value or status ended up different. | `form`, `since` | +| `wait-for-form` | Waits until a condition holds, or reports the state on timeout. | `form`, `until` (`settled`, `valid`, `not-pending` or `submitted`), `since`, `timeoutMs` (default 5000, at most 30000) | +| `export-form` | A JSON snapshot, or a test fixture with the expected status. Secret values stay redacted. | `form`, `format` (`snapshot` or `fixture`) | +| `lint-forms` | Form bugs, NG01xxx setup errors and model-aware accessibility checks, like a missing label or error text that is not linked. | `form` | + +Markers let an agent check its own work: read the marker, act, then call `form-diff` with `since` set to it. + +### Act on a form + +Both tools are action tools and need a development build. Secret, hidden and readonly fields are never written. + +| Tool | What it does | Arguments | +| ------------- | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | +| `form-action` | One action on a form or field. | `action` (required), `form` (required, the full id), `path`, `value`, `mode` (`code` or `user`), `confirm`, `force`, `snapshot` | +| `fill-form` | Fills several fields by path, through the inputs like a user would. Optionally submits afterwards. | `form` (required), `values` (required, a map of path to value), `mode`, `submit`, `confirm` | + +`form-action` accepts these actions: `set-value`, `mark-touched`, `mark-untouched`, `mark-dirty`, `mark-pristine`, `touch-all`, `revalidate`, `reset`, `enable`, `disable`, `submit`, `focus`, `focus-first-invalid`, `store-as-global`, `snapshot`, `restore` and `instrument`. + +<ngmd-callout type="warning" title="Some actions need confirm"> + <code>reset</code>, <code>submit</code> and <code>restore</code> need <code>confirm: true</code>, and so does <code>fill-form</code> with <code>submit</code>. Disabled reactive fields need <code>force</code>. +</ngmd-callout> ## Pipes -| Tool | Live | Inputs | Description | -| -------------------------- | ------ | ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | -| `ng-devtools:lint-pipes` | no | | Impure pipes inside `@for`, `\| json` left in templates, and pure pipes that read signals. | -| `ng-devtools:explain-pipe` | partly | `name` (required) | One pipe: where it is declared or used, purity, live counts, last input and output (when recording), a stale-value warning and lint findings. | +### Lint pipes + +`lint-pipes` checks the pipes in your source. Reads: source. No arguments. + +It finds impure pipes used inside `@for`, `| json` left in templates, and pure pipes whose `transform()` reads a signal. + +### Explain a pipe + +`explain-pipe` explains one pipe: where it is declared or used, whether it is pure, live instance and call counts, the last input and output, a stale-value warning and lint findings. Reads: source, plus the page for live counts. + +| Argument | Required | Value | +| -------- | -------- | ----------------------------------------------- | +| `name` | yes | The pipe name as used after `\|` in a template. | + +Live counts, input and output appear when recording is on in the [Pipes inspector](/inspectors/pipes). ## Analog -| Tool | Live | Inputs | Description | -| ----------------------------------- | ------ | ---------------------------------------------- | ------------------------------------------------------------------------------------------------- | -| `ng-devtools:analog-routes` | no | `filter` | Analog file routes with their page, layout and server files. | -| `ng-devtools:analog-explain-url` | no | `url` (required) | Which Analog files render a URL, or why nothing matches. | -| `ng-devtools:analog-current-page` | yes | | The open page's files, `load()` data and hydration state. | -| `ng-devtools:analog-server-calls` | yes | `kind`, `route`, `limit` | Page renders, `load()`, server function and API calls. | -| `ng-devtools:analog-api-routes` | no | | Server routes with method, URL and file. | -| `ng-devtools:analog-call-api` | yes | `path` (required), `method`, `body`, `confirm` | Sends a request to a server route. Methods other than GET, HEAD and OPTIONS need `confirm: true`. | -| `ng-devtools:analog-render-modes` | partly | | SSR, prerendered or client only, per page. | -| `ng-devtools:analog-prerender-plan` | no | | `prerender.routes` compared with pages and build output. | -| `ng-devtools:analog-content` | no | `filter` | Markdown content with slug and frontmatter. | -| `ng-devtools:analog-lint` | partly | | Analog routing, server, prerender and content mistakes. | - -`analog-server-calls` and `analog-call-api` need the [Vite plugin](/getting-started/vite), because the plugin records calls and knows the dev server address. +These tools cover *Analog apps. Most read your source. Two read what the Vite plugin recorded. + +### Routes and files + +| Tool | What it answers | Arguments | +| -------------------- | ----------------------------------------------------------------------------------------------------------------------------- | ---------------- | +| `analog-routes` | File routes in match order: URL pattern, page or layout file, route groups, params, the sibling `.server.ts`, and route meta. | `filter` | +| `analog-explain-url` | Which files render a URL (layouts, page, `.server.ts` load), the params, or why nothing matches. | `url` (required) | +| `analog-api-routes` | Server routes under `src/server/routes` with method, URL and file, plus server middleware. | none | +| `analog-content` | Markdown content files with slug, frontmatter, the route that serves them and parse errors. | `filter` | + +### The running page + +| Tool | What it answers | Reads | Arguments | +| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | ----------- | -------------------------------------------------------- | +| `analog-current-page` | The open page: its files, the `load()` data it received, server rendering and hydration state, and hydration errors. | page | none | +| `analog-server-calls` | Recent page renders, `load()` fetches, server functions and API calls, with status, time and size. Flags a `load()` fetched twice. | Vite plugin | `kind` (`page`, `load`, `fn` or `api`), `route`, `limit` | + +### Rendering + +| Tool | What it answers | Arguments | +| ----------------------- | ---------------------------------------------------------------------------------------------------- | --------- | +| `analog-render-modes` | For each page: server rendered, prerendered, or client only, and what the last request actually did. | none | +| `analog-prerender-plan` | `prerender.routes` compared with the page files and the build output. | none | + +### Call a server route + +`analog-call-api` sends a request to a route on the running dev server, like `GET /api/v1/hello`, and returns the status, time and body. Reads: Vite plugin. <ngmd-badge variant="alpha">Action</ngmd-badge> + +| Argument | Required | Value | +| --------- | -------- | ------------------------------------------------------------- | +| `path` | yes | The route path. | +| `method` | no | `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, `HEAD` or `OPTIONS`. | +| `body` | no | A JSON body. | +| `confirm` | no | Required for methods other than `GET`, `HEAD` and `OPTIONS`. | + +### Lint + +`analog-lint` finds Analog mistakes: two files for one URL, a missing default export, a layout without `router-outlet`, bad API method suffixes, prerender entries that match nothing, and frontmatter errors. It also reports live problems, like a `load()` fetched twice or a restart needed. No arguments. + +<ngmd-alert severity="helpful"> + <code>analog-server-calls</code> and <code>analog-call-api</code> need the <a href="/getting-started/vite">Vite plugin</a>. The plugin records the calls and knows the dev server address. +</ngmd-alert> ## Shared state -| Tool | Inputs | Description | -| --------------------- | ------ | --------------------------------------------------------------------------------------------------------------------- | -| `devframe_state_read` | `key` | Reads the devtools' live shared state. Call it without arguments to list the keys, then with a key to read its value. | +`devframe_state_read` reads the devtools' live shared state. Call it without arguments to list the keys, then with `key` to read a value as JSON. + +Use it for data that has no dedicated tool, such as the SSR & HTTP timeline (`ng-devtools:http`) or live pipe usage (`ng-devtools:pipe-usage`). See [Resources](/agents/resources) for every key. + +## Where to next -This is the way to read data that has no dedicated tool, such as the SSR & HTTP timeline (`ng-devtools:http`) or live pipe usage (`ng-devtools:pipe-usage`). See [Resources](/agents/resources). +<ngmd-pill-row> + <ngmd-pill href="/agents/mcp-server" title="MCP server"></ngmd-pill> + <ngmd-pill href="/agents/resources" title="Resources"></ngmd-pill> + <ngmd-pill href="/security" title="Security"></ngmd-pill> +</ngmd-pill-row> diff --git a/apps/docs/src/content/community.md b/apps/docs/src/content/community.md index 94ffad2..ba5690a 100644 --- a/apps/docs/src/content/community.md +++ b/apps/docs/src/content/community.md @@ -3,16 +3,88 @@ title: Get involved description: Where to ask questions, report bugs and support the project. --- +<ngmd-hero title="Get involved" gradient> + Ask questions on Discord, report bugs on GitHub, send a pull request, or sponsor the project. +</ngmd-hero> + # Get involved -## Discord +Angular DevTools is open source under the MIT license. Here is where to reach the people behind it, and how to help. + +## Maintainers + +<ngmd-card-grid columns="2"> + <ngmd-card avatar image="https://github.com/santoshyadavdev.png?size=96" title="Santosh Yadav" link="https://github.com/santoshyadavdev" cta="GitHub"> + Maintainer. + </ngmd-card> + <ngmd-card avatar image="https://github.com/erkamyaman.png?size=96" title="Erkam Yaman" link="https://github.com/erkamyaman" cta="GitHub"> + Maintainer. + </ngmd-card> +</ngmd-card-grid> + +## Talk to us <ngmd-badge variant="stable">MIT</ngmd-badge> + +<ngmd-card-grid columns="2"> + <ngmd-card icon="sparkles" title="Discord" link="https://discord.gg/YRTyJd6Qx" cta="Join"> + Join the conversation, ask questions, and share feedback. + </ngmd-card> + <ngmd-card icon="shield" title="Bug reports" link="https://github.com/santoshyadavdev/angular-devtools/issues" cta="Open an issue"> + File an issue when something doesn't work as documented. + </ngmd-card> + <ngmd-card icon="lightbulb" title="Feature requests" link="https://github.com/santoshyadavdev/angular-devtools/issues" cta="Suggest"> + Propose a new inspector, agent tool or setup. + </ngmd-card> + <ngmd-card icon="code" title="Pull requests" link="https://github.com/santoshyadavdev/angular-devtools/pulls" cta="Contribute"> + Fixes, docs and features. + </ngmd-card> +</ngmd-card-grid> + +### Before you open an issue + +A good bug report saves a round-trip. Include: + +- your Angular version and the version of `@santoshyadavdev/ng-devtools`, +- your setup: Angular CLI and Express, Vite and Analog, or the standalone CLI, +- the tab that misbehaves, and what you expected to see, +- a small reproduction, if you can. -Join the conversation, ask questions, and share feedback on [Discord](https://discord.gg/YRTyJd6Qx). +## Contribute code -## Issues and pull requests +### Where to start -Report bugs and request features in [GitHub issues](https://github.com/santoshyadavdev/angular-devtools/issues). To contribute code, start with the [development setup](/contributing/development). +<ngmd-card-grid columns="2"> + <ngmd-card icon="wrench" title="Development setup" link="/contributing/development" cta="Set up"> + Clone the repository, install dependencies and run the devtools locally. + </ngmd-card> + <ngmd-card icon="rocket" title="Demo apps" link="/contributing/demo-apps" cta="Explore"> + The apps in this repository that give every inspector something to show. + </ngmd-card> + <ngmd-card icon="compass" title="Build the extension" link="/contributing/chrome-extension" cta="Build"> + Build and package the Chrome extension. + </ngmd-card> + <ngmd-card icon="box" title="Publishing" link="/contributing/publishing" cta="Release"> + How the npm package is built and published. + </ngmd-card> +</ngmd-card-grid> + +### Improve the docs + +These docs live in `apps/docs` in the same repository. Every page has an edit link, so small fixes are one pull request away. ## Sponsor -If the devtools help your work, please consider [sponsoring the project on GitHub](https://github.com/sponsors/santoshyadavdev). See the current [sponsors](/sponsors). +### Support the project + +If the devtools help your work, please consider [sponsoring the project on GitHub](https://github.com/sponsors/santoshyadavdev). Your support keeps development going. + +### Current sponsors + +Thanks to everyone who sponsors the project. See the [sponsors](/sponsors) page for the current list. + +## Where to next + +<ngmd-pill-row> + <ngmd-pill href="/sponsors" title="Sponsors"></ngmd-pill> + <ngmd-pill href="/contributing/development" title="Development setup"></ngmd-pill> + <ngmd-pill href="https://github.com/santoshyadavdev/angular-devtools" title="View on GitHub"></ngmd-pill> +</ngmd-pill-row> diff --git a/apps/docs/src/content/contributing/chrome-extension.md b/apps/docs/src/content/contributing/chrome-extension.md index cada129..ff72d79 100644 --- a/apps/docs/src/content/contributing/chrome-extension.md +++ b/apps/docs/src/content/contributing/chrome-extension.md @@ -3,9 +3,13 @@ title: Build the extension description: Build, load and package the Chrome DevTools extension. --- +<ngmd-hero title="Build the extension" logo="https://cdn.simpleicons.org/googlechrome/4285F4" gradient> + A thin Manifest V3 shell around the devtools UI. Build it, load it unpacked, and zip it for the Chrome Web Store. +</ngmd-hero> + # Build the extension -The Chrome extension lives in `extension/`. It is a thin shell: it detects Angular pages, creates the panel, and loads the devtools UI from `extension/ui`. +The Chrome extension lives in `extension/`. It detects Angular pages, creates the panel, and loads the devtools UI from `extension/ui`. ## Files @@ -23,22 +27,50 @@ extension/ ui/ # The built devtools UI (committed) ``` +### What the manifest asks for + +<ngmd-card-grid columns="3"> + <ngmd-card icon="shield" title="No permissions"> + <code>permissions</code> is empty. + </ngmd-card> + <ngmd-card icon="compass" title="Loopback hosts only"> + Host access to <code>localhost</code> and <code>127.0.0.1</code>, over HTTP and HTTPS. + </ngmd-card> + <ngmd-card icon="settings" title="Chrome 111 or later"> + Set by <code>minimum_chrome_version</code>. + </ngmd-card> +</ngmd-card-grid> + ## Build ```bash pnpm extension:build ``` -This builds the devtools UI (`pnpm devtools:build`) and copies `dist/devtools-ui` into `extension/ui`. +This builds the devtools UI (`pnpm devtools:build`), then replaces `extension/ui` with a copy of `dist/devtools-ui`. -`extension/ui` is committed. If you change `app/`, run `pnpm extension:build` and commit the result. CI fails when it is stale. +<ngmd-callout type="warning" title="Commit extension/ui"> + <code>extension/ui</code> is committed. If you change <code>app/</code>, run <code>pnpm extension:build</code> and commit the result. CI builds the extension and fails when <code>extension/ui</code> is stale. +</ngmd-callout> ## Load in Chrome -1. Go to `chrome://extensions` -2. Enable **Developer mode** -3. Click **Load unpacked** and select the `extension/` directory -4. Open DevTools on an Angular app. The panel appears. +<ngmd-workflow> + <ngmd-step title="Open the extensions page"> + Go to <code>chrome://extensions</code>. + </ngmd-step> + <ngmd-step title="Turn on Developer mode"> + Use the toggle in the top right corner. + </ngmd-step> + <ngmd-step title="Load it unpacked"> + Click <strong>Load unpacked</strong> and select the <code>extension/</code> directory. + </ngmd-step> + <ngmd-step title="Open DevTools on an Angular app"> + Start a demo app and open DevTools. The <strong>Angular DevTools</strong> panel appears once the page is detected as Angular. + </ngmd-step> +</ngmd-workflow> + +After a rebuild, click the reload icon on the extension card, then reopen DevTools. ## Package for the Chrome Web Store @@ -46,14 +78,40 @@ This builds the devtools UI (`pnpm devtools:build`) and copies `dist/devtools-ui pnpm extension:zip ``` -This runs `extension:build`, then writes `dist/ng-devtools-extension.zip`. +This runs `extension:build`, then writes `dist/ng-devtools-extension.zip`. `.DS_Store` files are left out. -1. Go to the [Chrome Developer Dashboard](https://chrome.google.com/webstore/devconsole) -2. Click **New item** and upload the zip -3. Fill in the listing details and submit for review +### Upload -Bump `version` in `extension/manifest.json` before each upload. The privacy policy for the listing is in `docs/privacy-policy.html`. +1. Bump `version` in `extension/manifest.json`. +2. Go to the <a href="https://chrome.google.com/webstore/devconsole" target="_blank" rel="noopener noreferrer">Chrome Developer Dashboard</a>. +3. Click **New item** (or open the existing item) and upload the zip. +4. Fill in the listing details and submit for review. + +<ngmd-alert severity="helpful"> + The privacy policy for the listing is in <code>docs/privacy-policy.html</code>. +</ngmd-alert> ## How the panel connects -`panel-bridge.js` reads the origin of the inspected page. On `localhost` and `127.0.0.1`, it looks for the devtools server at `/__ng-devtools/`, `/__devframes/ng-devtools/`, `/__devframe/` and `/`, and passes the address to the UI. The UI accepts a loopback address only when it runs inside the extension. On other hosts, the panel shows the UI without a connection. +### Finding the server + +`panel-bridge.js` reads the origin of the inspected page. On `localhost` and `127.0.0.1`, it looks for the devtools server at these paths, in order: + +1. `/__ng-devtools/` +2. `/__devframes/ng-devtools/` +3. `/__devframe/` +4. `/` + +It passes the first path that serves a devframe connection file to the UI. It runs the search again after each navigation. + +### Other hosts + +The UI accepts a loopback address only when it runs inside the extension. On other hosts, the panel shows the UI without a connection. + +## Where to next + +<ngmd-pill-row> + <ngmd-pill href="/getting-started/chrome-extension" title="Use the extension"></ngmd-pill> + <ngmd-pill href="/contributing/publishing" title="Publishing"></ngmd-pill> + <ngmd-pill href="/contributing/development" title="Development setup"></ngmd-pill> +</ngmd-pill-row> diff --git a/apps/docs/src/content/contributing/demo-apps.md b/apps/docs/src/content/contributing/demo-apps.md index 3fc896a..1348909 100644 --- a/apps/docs/src/content/contributing/demo-apps.md +++ b/apps/docs/src/content/contributing/demo-apps.md @@ -1,33 +1,116 @@ --- title: Demo apps -description: The Angular Travel demo and the Analog demo in the repository. +description: The Angular Travel demo and the Analog demo in the repository, and how to run each in development and production. --- +<ngmd-hero title="Demo apps" gradient> + Two apps that give every inspector something to show. One Angular CLI app with SSR, one Analog app on Vite. +</ngmd-hero> + # Demo apps -The repository has two demo apps, so every inspector has something to show. +The repository has two demo apps. Use them to try a change against a real app before you open a PR. + +<ngmd-card-grid columns="2"> + <ngmd-card icon="compass" title="Angular Travel" cta="src/"> + An Angular CLI app with SSR and Express. It uses the hub, the overlay and the HTTP providers. + </ngmd-card> + <ngmd-card icon="zap" title="Analog demo" cta="examples/analog"> + An Analog app wired with the Vite plugin. It covers file routes, server loads, API routes and content. + </ngmd-card> +</ngmd-card-grid> ## Angular Travel -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: +**Angular Travel** (`src/`) looks and behaves like a real booking site. + +### What's inside + +- **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, forms, pipes and HTTP. +- **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 errors. + +Destination photos are from Unsplash, credited in `public/destinations/CREDITS.md`. + +### Where the devtools are wired + +The demo shows the full setup in three files: + +| File | What it adds | +| ----------------------- | ----------------------------------------------------------------------- | +| `src/server.ts` | The hub, with `initNgDevtoolsHub()` mounted as Express middleware | +| `src/main.ts` | The overlay and `registerNgrxSignals`, loaded in development only | +| `src/app/app.config.ts` | `withNgDevtools()` and `provideNgDevtoolsHttp()` for the SSR & HTTP tab | + +### Run in development + +```bash +pnpm start +``` + +This builds the devtools package, then runs `ng serve` with SSR and hot reload on port 4200. Click the amber button in the corner to open the devtools. + +### Run the SSR server + +To see server calls in the SSR & HTTP tab, run the built SSR server: -- **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, forms, pipes and HTTP -- **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 +```bash +pnpm build --configuration development +node dist/angular-devtools/server/server.mjs +``` + +It listens on port 4000, or on `PORT` when set. + +<ngmd-callout type="warning" title="Use the development configuration"> + <code>pnpm build</code> defaults to the production configuration. <code>src/main.ts</code> only loads the overlay when <code>ngDevMode</code> is on, and the HTTP interceptor passes requests through in production. Build with <code>--configuration development</code> to use the devtools. +</ngmd-callout> -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`. +### Render modes -The demo shows the full setup: the hub in `src/server.ts`, the overlay and `registerNgrxSignals` in `src/main.ts`, and the HTTP providers in `src/app/app.config.ts`. +`src/app/app.routes.server.ts` sets a render mode per route, so the SSR tools have something to compare: + +- `destinations`, `destinations/:id` and `examples/http` render on the server per request. +- `book/:id`, `trips`, `sign-in` and some `examples/routes` pages render on the client only. +- Everything else is prerendered. ## Analog demo -`examples/analog` is an Analog app wired with the [Vite plugin](/getting-started/vite). +`examples/analog` is an *Analog app wired with the [Vite plugin](/getting-started/vite). Its project name is `analog-demo`. + +### What's in the Analog demo + +- File routes with route groups (`(auth)`, `(marketing)`), a `[id]` param and a `[...slug]` catch-all. +- `.server.ts` loads next to pages, like `products/[id].server.ts`. +- API routes under `src/server/routes/api/v1`, with method suffixes such as `index.get.ts` and `index.post.ts`, and a timing middleware. +- Markdown content under `src/content/blog` and `src/content/docs`. +- Prerendered routes listed in `vite.config.ts`, and `/dashboard` as client only (`ssr: false`). + +### Run Analog in development ```bash pnpm analog:dev ``` -The script builds the devtools package, then starts the Vite dev server. +The script builds the devtools package, then starts the Vite dev server. The devtools and the MCP endpoint are served by that dev server. + +### Build and preview + +```bash +pnpm --filter analog-demo build +pnpm --filter analog-demo preview +``` + +<ngmd-alert severity="helpful"> + The Vite plugin runs on the dev server only, and the overlay loads only when <code>import.meta.env.DEV</code> is true. A production build has no devtools. Use it to check the <code>analog-prerender-plan</code> tool against real build output. +</ngmd-alert> + +## Where to next + +<ngmd-pill-row> + <ngmd-pill href="/contributing/development" title="Development setup"></ngmd-pill> + <ngmd-pill href="/guides/analog" title="Set up Analog"></ngmd-pill> + <ngmd-pill href="/guides/ssr-http" title="Set up SSR & HTTP"></ngmd-pill> +</ngmd-pill-row> diff --git a/apps/docs/src/content/contributing/development.md b/apps/docs/src/content/contributing/development.md index 968e27c..e458176 100644 --- a/apps/docs/src/content/contributing/development.md +++ b/apps/docs/src/content/contributing/development.md @@ -1,16 +1,28 @@ --- title: Development setup -description: Set up the repository, run the devtools UI and the demo app, and run the checks. +description: Set up the repository, run the devtools UI and the demo apps, and run the same checks as CI. --- +<ngmd-hero title="Development setup" logo="https://cdn.simpleicons.org/nx/71717A" gradient> + Clone, install, and run the devtools against a real Angular app. The same checks CI runs, on your machine. +</ngmd-hero> + # Development setup +The repository is an Nx workspace with pnpm. It holds the npm package, the devtools UI, the Chrome extension, two demo apps and this docs site. + ## Prerequisites -- Node.js 24 or later -- pnpm 10 or later +<ngmd-card-grid columns="2"> + <ngmd-card icon="terminal" title="Node.js 24 or later"> + <code>.nvmrc</code> pins 24, and the root <code>package.json</code> requires <code>>=24</code>. + </ngmd-card> + <ngmd-card icon="box" title="pnpm 10 or later"> + The root <code>package.json</code> sets <code>packageManager</code> to <code>pnpm@10.33.4</code>. + </ngmd-card> +</ngmd-card-grid> -## Setup +## Set up the repository ```bash git clone https://github.com/santoshyadavdev/angular-devtools.git @@ -18,89 +30,170 @@ cd angular-devtools pnpm install ``` +`pnpm-workspace.yaml` lists `packages/*`, `examples/*` and `apps/*`, so one install covers every project. + ## Project structure ```text app/ # Devtools UI SPA (Angular + Vite) src/app.ts # Root component with tab navigation src/pages/ # One component per tab - vite.config.ts # Vite config with Analog Angular plugin + vite.config.ts # Vite config with the Analog Angular plugin packages/ ng-devtools/ # Publishable npm package - src/devframe.ts # defineDevframe(): tool definition - src/overlay.ts # Client script running in user's page - src/rpc/ # Node-side RPC functions + src/devframe.ts # defineDevframe(): the tool definition + src/overlay.ts # Client script running in the user's page + src/rpc/ # Node-side RPC functions and agent tools extension/ # Chrome DevTools extension examples/analog/ # Analog demo app apps/docs/ # This documentation site -src/ # Angular host app (demo/playground) +src/ # Angular Travel, the host demo app ``` -The workspace uses Nx. `pnpm-workspace.yaml` lists `packages/*`, `examples/*` and `apps/*`. +### Nx projects + +| Project | Root | Targets | +| ------------------------------ | ---------------------- | -------------------------------- | +| `angular-devtools` | `.` (`project.json`) | `build`, `serve`, `test` | +| `@santoshyadavdev/ng-devtools` | `packages/ng-devtools` | `build` | +| `analog-demo` | `examples/analog` | `dev`, `build`, `preview` | +| `angular-devtools-docs` | `apps/docs` | `dev`, `build`, `test`, and more | + +Run `pnpm exec nx show projects` to list them. Package projects get their targets from their `package.json` scripts. + +## Run things -## Development +### Root scripts + +Most work goes through the root `package.json` scripts: ```bash -# Install dependencies -pnpm install +pnpm devtools:dev # Devtools UI with hot reload and live RPC +pnpm devtools:build # Build the devtools UI SPA into dist/devtools-ui +pnpm devtools:build-pkg # Build the npm package (library + UI in dist/) +pnpm start # Build the package, then serve Angular Travel +``` -# Dev server for the devtools UI (with live RPC) -pnpm devtools:dev +### Nx targets + +The scripts call Nx. You can also run a target on a project directly: + +```bash group="nx" name="Build" active +pnpm exec nx build # Angular Travel +pnpm exec nx build @santoshyadavdev/ng-devtools # The npm package +pnpm exec nx build angular-devtools-docs # This site +``` -# Build the devtools UI SPA -pnpm devtools:build +```bash group="nx" name="Test" +pnpm exec nx test # Angular Travel +pnpm exec nx test angular-devtools-docs # This site +``` -# Build the publishable package (library + UI in dist/) -pnpm devtools:build-pkg +```bash group="nx" name="Serve" +pnpm exec nx serve # Angular Travel on port 4200 +pnpm exec nx dev analog-demo +``` -# Run the Angular host app (builds the package first, includes in-page devtools popup) -pnpm start +```bash group="nx" name="Affected" +pnpm exec nx affected -t test build ``` +`build` and `test` are cached. `build` runs the `build` of its dependencies first, so building a demo also builds the package. + +<ngmd-alert severity="helpful"> + The package's <code>build</code> target lists <code>app/**</code> as an input. A change to the devtools UI invalidates the package build. +</ngmd-alert> + +### Ports + | Command | Port | Notes | | ---------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------------------------- | | `pnpm start` | 4200 | `ng serve` with SSR and hot reload. The popup and live data work without a separate server. | | `pnpm build --configuration development && node dist/angular-devtools/server/server.mjs` | 4000 | The demo app as an SSR server. | | `pnpm devtools:dev` | 5173 | The devtools UI with hot reload. It needs the SSR server running for live data. | -The SSR server serves the UI built into `packages/ng-devtools/dist/public`. Run `pnpm devtools:build-pkg` to refresh it after you change `app/`. +<ngmd-callout type="warning" title="Refresh the bundled UI"> + The SSR server serves the UI built into <code>packages/ng-devtools/dist/public</code>. Run <code>pnpm devtools:build-pkg</code> to refresh it after you change <code>app/</code>. +</ngmd-callout> + +## Run the checks -## Checks +Run the same checks as CI before you open a PR: ```bash -pnpm test # host app -pnpm test:devtools # devtools package -pnpm typecheck # host app + specs, devtools UI, devtools package + its tests -pnpm format:check +pnpm format:check # Prettier +pnpm typecheck # Host app + specs, devtools UI, devtools package + its tests +pnpm exec nx affected -t test build # Test and build affected projects +pnpm test:devtools # Devtools package tests (Vitest) +pnpm extension:build # Chrome extension ``` -CI runs these, then `nx affected -t test build`, builds the Chrome extension and checks that the committed `extension/ui` is current. - -## Making changes - -### Adding a new RPC function +### What CI runs + +`.github/workflows/ci.yml` runs on pushes and pull requests to `main`, in this order: + +<ngmd-workflow> + <ngmd-step title="Install"> + <code>pnpm install --frozen-lockfile</code> on the Node version from <code>.nvmrc</code>. + </ngmd-step> + <ngmd-step title="Check formatting and types"> + <code>pnpm format:check</code>, then <code>pnpm typecheck</code>. + </ngmd-step> + <ngmd-step title="Test and build affected projects"> + <code>nx affected -t test build</code>, compared against the last green commit on <code>main</code>. + </ngmd-step> + <ngmd-step title="Test the devtools package"> + <code>pnpm test:devtools</code>. + </ngmd-step> + <ngmd-step title="Build the extension and check it is committed"> + <code>pnpm extension:build</code>. The job fails when <code>extension/ui</code> differs from the committed copy. + </ngmd-step> + <ngmd-step title="Smoke-test the CLI"> + <code>node bin.mjs --help</code>. + </ngmd-step> +</ngmd-workflow> + +## Make changes + +### 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 an agent tool + +Add `agent: { description }` to an RPC function, or call `ctx.agent.registerTool()` in the devframe setup. List the new tool on the [Tools](/agents/tools) page. -Add `agent: { description }` to any RPC function, or use `ctx.agent.registerTool()` in the devframe setup. +<ngmd-callout type="tip" title="Changed app/?"> + Run <code>pnpm extension:build</code> and commit <code>extension/ui</code>. CI fails when it is stale. See <a href="/contributing/chrome-extension">Build the extension</a>. +</ngmd-callout> -## The docs site +## Work on the docs This site lives in `apps/docs`. It is built with [NgMd](https://github.com/erkamyaman/ngmd) on *Analog. ```bash -pnpm docs:dev # dev server -pnpm docs:build # production build +pnpm docs:dev # Dev server +pnpm docs:build # Production build ``` -Pages are markdown files under `apps/docs/src/content`. The sidebar comes from `apps/docs/src/ngmd.config.ts`. The build fails on broken internal links, so run `pnpm docs:build` before you open a PR. +Pages are markdown files under `apps/docs/src/content`. The sidebar comes from `apps/docs/src/ngmd.config.ts`. + +<ngmd-alert severity="important"> + The build fails on broken internal links and on raw external anchors without <code>target="_blank"</code>. Run <code>pnpm docs:build</code> before you open a PR. +</ngmd-alert> + +## Where to next + +<ngmd-pill-row> + <ngmd-pill href="/contributing/demo-apps" title="Demo apps"></ngmd-pill> + <ngmd-pill href="/contributing/chrome-extension" title="Build the extension"></ngmd-pill> + <ngmd-pill href="/contributing/publishing" title="Publishing"></ngmd-pill> +</ngmd-pill-row> diff --git a/apps/docs/src/content/contributing/publishing.md b/apps/docs/src/content/contributing/publishing.md index 7b1c317..0e2d161 100644 --- a/apps/docs/src/content/contributing/publishing.md +++ b/apps/docs/src/content/contributing/publishing.md @@ -1,22 +1,94 @@ --- title: Publishing -description: Publish the npm package. +description: Bump the version, build, and publish the npm package. Ship the Chrome extension with a fresh UI. --- +<ngmd-hero title="Publishing" gradient> + One npm package and one Chrome extension, each with its own version. Bump, build, publish. +</ngmd-hero> + # 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`. +The devtools ship as one npm package, `@santoshyadavdev/ng-devtools`, from `packages/ng-devtools`. It holds the Node-side logic, RPC, CLI, overlay, popup, and the built UI in `dist/public`. + +## What ships + +The package publishes `dist/` and `bin.mjs`. On publish, `publishConfig.exports` points every entry point at the built files: + +| Import | Published file | +| --------------------------------------- | ------------------- | +| `@santoshyadavdev/ng-devtools` | `dist/devframe.mjs` | +| `@santoshyadavdev/ng-devtools/devframe` | `dist/devframe.mjs` | +| `@santoshyadavdev/ng-devtools/overlay` | `dist/overlay.mjs` | +| `@santoshyadavdev/ng-devtools/popup` | `dist/popup.mjs` | +| `@santoshyadavdev/ng-devtools/http` | `dist/http.mjs` | +| `@santoshyadavdev/ng-devtools/hub` | `dist/hub.mjs` | +| `@santoshyadavdev/ng-devtools/vite` | `dist/vite.mjs` | + +The `ng-devtools` binary is `bin.mjs`. In the workspace, the exports point at the TypeScript sources instead. + +### How the package builds + +The package's `build` script runs two steps: + +<ngmd-workflow> + <ngmd-step title="Bundle the library"> + <code>tsdown</code> builds the entry points into <code>dist/</code>. + </ngmd-step> + <ngmd-step title="Build the UI"> + <code>vite build</code> with <code>app/vite.config.ts</code> writes the devtools UI to <code>dist/public</code>. + </ngmd-step> +</ngmd-workflow> + +`prepack` runs `pnpm build`, so every publish builds first. + +## Publish the npm package + +### 1. Bump the version + +Update `version` in `packages/ng-devtools/package.json`. Release commits change only that line, with a message like `chore(release): ng-devtools 0.0.5`. + +### 2. Check the build + +Run the checks from [Development setup](/contributing/development), then build the package without publishing: + +```bash +pnpm devtools:build-pkg +``` + +### 3. Refresh the extension UI + +<ngmd-callout type="warning" title="extension/ui must be fresh"> + If <code>app/</code> changed since the last release, run <code>pnpm extension:build</code> and commit <code>extension/ui</code> before you publish. CI fails when the committed copy is stale. +</ngmd-callout> + +### 4. Publish ```bash -# Builds on prepack, then publishes pnpm devtools:publish ``` -## Steps +This runs `pnpm --filter @santoshyadavdev/ng-devtools publish --access public`. The `prepack` build bundles the library and the UI. + +<ngmd-alert severity="important"> + <code>pnpm publish</code> checks git before it publishes. Run it from a clean working tree on <code>main</code>. +</ngmd-alert> + +## Release the Chrome extension + +The extension has its own version, in `extension/manifest.json`. It does not follow the npm package version. + +1. Bump `version` in `extension/manifest.json`. +2. Run `pnpm extension:zip`. It rebuilds `extension/ui` first. +3. Commit `extension/ui` and the manifest. +4. Upload `dist/ng-devtools-extension.zip`. -1. Update the version in `packages/ng-devtools/package.json`. -2. Run `pnpm devtools:publish`. The package build bundles the UI. +See [Build the extension](/contributing/chrome-extension) for the upload steps. -To build the package without publishing, run `pnpm devtools:build-pkg`. +## Where to next -The Chrome extension has its own version in `extension/manifest.json`. See [Build the extension](/contributing/chrome-extension). +<ngmd-pill-row> + <ngmd-pill href="/contributing/chrome-extension" title="Build the extension"></ngmd-pill> + <ngmd-pill href="/contributing/development" title="Development setup"></ngmd-pill> + <ngmd-pill href="/getting-started/installation" title="Installation"></ngmd-pill> +</ngmd-pill-row> diff --git a/apps/docs/src/content/getting-started/chrome-extension.md b/apps/docs/src/content/getting-started/chrome-extension.md index f6f72a2..0e75e71 100644 --- a/apps/docs/src/content/getting-started/chrome-extension.md +++ b/apps/docs/src/content/getting-started/chrome-extension.md @@ -3,31 +3,123 @@ title: Chrome extension description: Open the devtools as a panel inside Chrome DevTools. --- +<ngmd-hero title="Chrome extension" logo="https://cdn.simpleicons.org/googlechrome/4285F4" gradient> + An Angular DevTools panel inside Chrome DevTools. It loads the devtools UI and connects it to the dev server of the page you inspect. +</ngmd-hero> + # Chrome extension -The Chrome extension adds a panel to Chrome DevTools. The panel loads the devtools UI and connects it to the dev server of the page you are inspecting. +The Chrome extension adds a panel named **Angular DevTools** to Chrome DevTools. The panel loads the devtools UI and connects it to the dev server of the page you are inspecting. + +<ngmd-callout type="info" title="An extra, not a setup"> + The page still needs the devtools mounted on its server and the <a href="/getting-started/overlay">overlay</a> loaded. The extension replaces the floating button, not the setup. Start with <a href="/getting-started/express">Angular CLI and Express</a> or <a href="/getting-started/vite">Vite and Analog</a>. +</ngmd-callout> + +## Before you start + +<ngmd-card-grid columns="3"> + <ngmd-card icon="compass" title="Chrome 111 or later"> + The manifest sets <code>minimum_chrome_version</code> to 111. + </ngmd-card> + <ngmd-card icon="terminal" title="A clone of the repository"> + The extension lives in the <code>extension/</code> folder. You build it from source. + </ngmd-card> + <ngmd-card icon="box" title="Node.js 24 and pnpm"> + The repository itself needs Node.js 24 or later and pnpm 10 or later. + </ngmd-card> +</ngmd-card-grid> ## Install -The extension lives in the `extension/` folder of the repository. Build it and load it unpacked: +### Build and load it + +<ngmd-workflow> + <ngmd-step title="Install dependencies"> + Run <code>pnpm install</code> in the root of the repository. + </ngmd-step> + <ngmd-step title="Build the extension"> + Run <code>pnpm extension:build</code>. It builds the devtools UI and copies it into <code>extension/ui</code>. + </ngmd-step> + <ngmd-step title="Open the extensions page"> + Go to <code>chrome://extensions</code> and turn on <strong>Developer mode</strong>. + </ngmd-step> + <ngmd-step title="Load it unpacked"> + Click <strong>Load unpacked</strong> and select the <code>extension/</code> directory. + </ngmd-step> + <ngmd-step title="Open DevTools on an Angular app"> + The <strong>Angular DevTools</strong> panel appears next to the built-in panels. + </ngmd-step> +</ngmd-workflow> + +### Commands -1. Run `pnpm extension:build` in the repository. -2. Go to `chrome://extensions`. -3. Enable **Developer mode**. -4. Click **Load unpacked** and select the `extension/` directory. -5. Open DevTools on an Angular app. The panel appears. +```bash +git clone https://github.com/santoshyadavdev/angular-devtools.git +cd angular-devtools +pnpm install +pnpm extension:build +``` [Build the extension](/contributing/chrome-extension) covers the build and the store package in detail. ## How it works -- A content script checks each page for Angular (an `ng-version` attribute or `window.ng`). The panel is created only on Angular pages. -- On pages served from `localhost` or `127.0.0.1`, the panel looks for the devtools server on the same origin, at `/__ng-devtools/`, `/__devframes/ng-devtools/`, `/__devframe/` and `/`. When it finds one, it connects the UI to it. -- On other hosts, the panel shows the UI without a connection. -- When the inspected page navigates, the panel looks for the server again. +### Angular detection + +A content script checks each page for Angular: an `ng-version` attribute or a `window.ng` global. It checks once, then retries for a few seconds for apps that bootstrap late. The panel is created only on Angular pages. + +### Finding the server + +On pages served from `localhost` or `127.0.0.1`, the panel looks for the devtools server on the same origin. It tries these paths in order: + +| Path | Mounted by | +| --------------------------- | ------------------------------------- | +| `/__ng-devtools/` | A panel mounted with `initDevframe()` | +| `/__devframes/ng-devtools/` | The Express hub or the Vite plugin | +| `/__devframe/` | A bare devframe mount | +| `/` | A devframe served at the root | + +When it finds a connection file on one of them, it connects the UI to it. -The page still needs the devtools mounted on its server and the [overlay](/getting-started/overlay) loaded. The extension replaces the floating button, not the setup. +### Other hosts + +On other hosts, the panel shows the UI without a connection. It does not probe them. + +### Navigation + +When the inspected page navigates, the panel looks for the server again. ## Permissions -The extension needs Chrome 111 or later. It requests no permissions beyond host access to `localhost` and `127.0.0.1`. It detects Angular on every page, but it only connects to local dev servers. +### Host access + +The extension requests no permissions beyond host access to `localhost` and `127.0.0.1`, over HTTP and HTTPS. + +### Content scripts + +Content scripts run on every page to detect Angular, but the panel only connects to local dev servers. The Vite plugin accepts requests from Chrome extension origins. See [Access and redaction](/security). + +## FAQ + +<ngmd-accordion> + <ngmd-accordion-item title="The panel does not appear" open> + The page did not look like an Angular app. Check that it renders an <code>ng-version</code> attribute or exposes <code>window.ng</code>, which development builds do. Then close and reopen DevTools. + </ngmd-accordion-item> + <ngmd-accordion-item title="The panel shows no data"> + Check that the page is served from <code>localhost</code> or <code>127.0.0.1</code>, that its server mounts the devtools, and that the overlay is loaded. + </ngmd-accordion-item> + <ngmd-accordion-item title="Does the floating button go away?"> + No. The overlay still adds the button to the page. Use the button or the panel, whichever you prefer. + </ngmd-accordion-item> +</ngmd-accordion> + +## Next steps + +<ngmd-card-grid columns="2"> + <ngmd-card icon="wrench" title="Build the extension" link="/contributing/chrome-extension" cta="Build and package"> + The build, the zip and the store package. + </ngmd-card> + <ngmd-card icon="shield" title="Access and redaction" link="/security" cta="Security"> + Which origins the devtools trust. + </ngmd-card> +</ngmd-card-grid> diff --git a/apps/docs/src/content/getting-started/cli.md b/apps/docs/src/content/getting-started/cli.md index 78e24c4..4787507 100644 --- a/apps/docs/src/content/getting-started/cli.md +++ b/apps/docs/src/content/getting-started/cli.md @@ -3,25 +3,62 @@ title: Standalone CLI description: Run the devtools from the command line, build a static report, or start an MCP server. --- +<ngmd-hero title="Standalone CLI" gradient> + Three commands from one binary. A local devtools server, an offline report, and an MCP server for coding agents. +</ngmd-hero> + # Standalone CLI -The package installs an `ng-devtools` binary. Run it from the root of your Angular workspace. +The package installs an `ng-devtools` binary. Run it from the root of your Angular workspace. It scans the source files in the current directory, so it works without starting your app. -```bash -# Dev server with live RPC +## Commands + +| Command | What it does | +| ------- | --------------------------------------------------- | +| `dev` | Starts a local server with the devtools UI. | +| `build` | Writes a static copy of the devtools with the scan. | +| `mcp` | Starts an MCP server over stdio for coding agents. | + +### Run it without installing + +```bash group="run" name="npx" image="https://cdn.simpleicons.org/npm/CB3837" active npx @santoshyadavdev/ng-devtools dev +``` -# Static report (offline HTML) -npx @santoshyadavdev/ng-devtools build --outDir dist-report +```bash group="run" name="pnpm" image="https://cdn.simpleicons.org/pnpm/F69220" +pnpm dlx @santoshyadavdev/ng-devtools dev +``` -# MCP server for coding agents -npx @santoshyadavdev/ng-devtools mcp +```bash group="run" name="yarn" image="https://cdn.simpleicons.org/yarn/2C8EBB" +yarn dlx @santoshyadavdev/ng-devtools dev +``` + +```bash group="run" name="bun" image="https://bun.sh/logo.svg" +bunx @santoshyadavdev/ng-devtools dev +``` + +### Run the installed binary + +With the package installed in your project, call the binary through your package manager: + +```bash +npx ng-devtools dev +npx ng-devtools build --outDir dist-report +npx ng-devtools mcp ``` ## Dev server +### Start it + The default command starts a local server with the devtools UI. `dev` is optional: `npx @santoshyadavdev/ng-devtools` does the same. +```bash +npx @santoshyadavdev/ng-devtools dev --port 9999 --open +``` + +### Dev server flags + | Flag | What it does | | --------------------- | ----------------------------------------------------------------------------------- | | `--port <port>` | Port to listen on. The default is 9999. If it is taken, the next free port is used. | @@ -30,17 +67,85 @@ The default command starts a local server with the devtools UI. `dev` is optiona | `--no-auth` | Turn off the one-time code the server asks for. | | `--mcp`, `--no-mcp` | Mount the MCP endpoint at `/__mcp`, or not. It is on by default. | -The server scans the source files in the current directory. No page is connected to it, so the tabs show what your source declares: components, routes, signals, providers, NgRx declarations and pipes. For live data, mount the devtools in your app's own server. See [Angular CLI and Express](/getting-started/express) or [Vite and Analog](/getting-started/vite). +<ngmd-callout type="warning" title="Keep it on localhost"> + The server binds to <code>localhost</code> by default and asks for a one-time code. Changing <code>--host</code> or passing <code>--no-auth</code> widens who can reach it. See <a href="/security">Access and redaction</a>. +</ngmd-callout> + +### What it shows + +No page is connected to the CLI server. The tabs show what your source declares: + +<ngmd-pill-row> + <ngmd-pill href="/inspectors/components" title="Components"></ngmd-pill> + <ngmd-pill href="/inspectors/router" title="Routes"></ngmd-pill> + <ngmd-pill href="/inspectors/signals" title="Signals"></ngmd-pill> + <ngmd-pill href="/inspectors/injectors" title="Providers"></ngmd-pill> + <ngmd-pill href="/inspectors/ngrx-store" title="NgRx declarations"></ngmd-pill> + <ngmd-pill href="/inspectors/pipes" title="Pipes"></ngmd-pill> +</ngmd-pill-row> + +<ngmd-alert severity="helpful"> + For live data, mount the devtools in your app's own server. See <a href="/getting-started/express">Angular CLI and Express</a> or <a href="/getting-started/vite">Vite and Analog</a>. +</ngmd-alert> ## Static report -`build` writes a self-contained static copy of the devtools with the source scan baked in. Open it offline or host it anywhere. +### Build it + +`build` writes a self-contained static copy of the devtools with the source scan baked in. + +```bash +npx @santoshyadavdev/ng-devtools build --outDir dist-report +``` + +### Report flags | Flag | What it does | | ---------------- | ----------------------------------------------------- | | `--outDir <dir>` | Output directory. The default is `dist-static`. | | `--pretty` | Pretty-print the data files. They get larger on disk. | +### Open or host it + +The output is static files. Open it offline or host it on any static file server. It is a snapshot of your source at build time, so rebuild it after code changes. + ## MCP server -`mcp` starts an MCP server over stdio for coding agents. It has no page connected, so only the source scan tools return data. See [MCP server](/agents/mcp-server) for client setup and for the HTTP endpoint that gives agents live data. +### Start it over stdio + +`mcp` starts an *MCP server over stdio for coding agents: + +```bash +npx @santoshyadavdev/ng-devtools mcp +``` + +Your agent client runs this command for you. [MCP server](/agents/mcp-server) covers client setup. + +### Source scan only + +The stdio server has no page connected, so only the source scan tools return data. For live data, point your agent at the HTTP endpoint of a running app instead. On a hub it lives at `/__devframes/__mcp`. + +## FAQ + +<ngmd-accordion> + <ngmd-accordion-item title="Do I need to run my app first?" open> + No. All three commands read your source files. Only live data needs a running app with the overlay loaded. + </ngmd-accordion-item> + <ngmd-accordion-item title="Which directory should I run it from?"> + The root of your Angular workspace. The scan starts from the current directory. + </ngmd-accordion-item> + <ngmd-accordion-item title="Port 9999 is taken"> + The server moves to the next free port. Pass <code>--port</code> to choose one yourself. + </ngmd-accordion-item> +</ngmd-accordion> + +## Next steps + +<ngmd-card-grid columns="2"> + <ngmd-card icon="sparkles" title="MCP server" link="/agents/mcp-server" cta="Connect an agent"> + Client setup for stdio, and the HTTP endpoint for live data. + </ngmd-card> + <ngmd-card icon="layers" title="Angular CLI and Express" link="/getting-started/express" cta="Live data"> + Mount the hub in your app for live inspectors. + </ngmd-card> +</ngmd-card-grid> diff --git a/apps/docs/src/content/getting-started/express.md b/apps/docs/src/content/getting-started/express.md index b9ab20e..0fd0e30 100644 --- a/apps/docs/src/content/getting-started/express.md +++ b/apps/docs/src/content/getting-started/express.md @@ -3,11 +3,24 @@ title: Angular CLI and Express description: Mount the devtools hub in the Express server of an Angular SSR app. --- +<ngmd-hero title="Angular CLI and Express" logo="https://cdn.simpleicons.org/express/71717A" gradient> + Mount the devtools hub in your <code>server.ts</code>, load the overlay in <code>main.ts</code>, and open the panel from a button on your page. +</ngmd-hero> + # Angular CLI and Express -In an Angular app with server-side rendering, the devtools run inside your Express server. You add a middleware on the server and load the overlay in the browser. +In an *Angular app with server-side rendering, the devtools run inside your Express server. You add a middleware on the server and load the overlay in the browser. + +<ngmd-callout type="info" title="You need an SSR app"> + This setup mounts the devtools in the Express <code>server.ts</code> that Angular SSR generates. For an Analog app, follow <a href="/getting-started/vite">Vite and Analog</a> instead. +</ngmd-callout> + +## Setup at a glance <ngmd-workflow> + <ngmd-step title="Install the package"> + Add <code>@santoshyadavdev/ng-devtools</code> and <code>devframe</code>. See <a href="/getting-started/installation">Installation</a>. + </ngmd-step> <ngmd-step title="Mount the hub"> Add <code>initNgDevtoolsHub()</code> to <code>server.ts</code>, before your other routes. </ngmd-step> @@ -15,33 +28,62 @@ In an Angular app with server-side rendering, the devtools run inside your Expre Import the overlay in <code>main.ts</code>, in development only. </ngmd-step> <ngmd-step title="Open the devtools"> - Start the app and click the amber button in the corner of the page. + Start the app in development mode and click the amber button in the corner of the page. </ngmd-step> </ngmd-workflow> ## Mount the hub -Mount the devtools hub in your Express server: +### Add the middleware -```ts +```ts {4-7} // server.ts -import { initNgDevtoolsHub } from '@santoshyadavdev/ng-devtools/hub'; +import {initNgDevtoolsHub} from '@santoshyadavdev/ng-devtools/hub'; -const devtools = initNgDevtoolsHub({ ws: false }); +const devtools = initNgDevtoolsHub({ws: false}); app.use(devtools.nodeMiddleware); ``` -`ws: false` turns the WebSocket off, so the browser connects over server-sent events. Mount the middleware before `express.static` and the Angular SSR handler, so the devtools routes answer first. - 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. -When you run `ng serve`, the Angular dev server runs `server.ts` too, so the hub also answers on port 4200. +### Middleware order -<ngmd-callout type="info" title="One-time code"> - The hub protects its connection with a one-time code by default. The server prints the code, and a browser can read data only after it exchanges that code. On a machine only you use, pass <code>auth: false</code> to turn the gate off. -</ngmd-callout> +Mount the middleware before `express.static` and the Angular SSR handler, so the devtools routes answer first. + +```ts +// server.ts +const app = express(); +const devtools = initNgDevtoolsHub({ws: false}); +app.use(devtools.nodeMiddleware); // devtools first + +app.use(express.static(browserDistFolder, {index: false})); +app.use((req, res, next) => { + angularApp + .handle(req) + .then((response) => (response ? writeResponseToNodeResponse(response, res) : next())) + .catch(next); +}); +``` + +The middleware only handles requests under its base path (`/__devframes/` by default). Everything else goes to the next handler. -[Security](/security) covers the other access options. +### Pick a transport + +The browser talks to the hub over server-sent events or a WebSocket. Pick one with the `ws` option: + +```ts group="transport" name="Server-sent events" active +// No WebSocket. The browser connects over SSE on the same port. +const devtools = initNgDevtoolsHub({ws: false}); +``` + +```ts group="transport" name="WebSocket side-car" +// The WebSocket runs on its own port, picked automatically. +const devtools = initNgDevtoolsHub({ws: {sidecar: true}}); +``` + +`ws: false` is the simplest choice. Every request goes through your Express server, including under `ng serve`. + +### Hub options `initNgDevtoolsHub()` accepts the options of `initHub()` from `@devframes/hub`, apart from `devframes` and `ui`. These are the ones you are most likely to set: @@ -53,13 +95,21 @@ When you run `ng serve`, the Angular dev server runs `server.ts` too, so the hub | `allowedOrigins` | loopback origins | Extra origins allowed to open the WebSocket. `false` turns the origin check off. | | `mcp` | `'auto'` | Mounts the MCP endpoint at `<base>__mcp` once agent tools exist. | +### Access control + +<ngmd-callout type="warning" title="One-time code"> + The hub protects its connection with a one-time code by default. The server prints the code, and a browser can read data only after it exchanges that code. On a machine only you use, pass <code>auth: false</code> to turn the gate off. +</ngmd-callout> + +The origin check is on by default too. Only loopback origins can open the WebSocket. [Access and redaction](/security) covers both checks. + The demo app in this repository mounts the hub like this: ```ts // src/server.ts const auth = process.env['NG_DEVTOOLS_AUTH'] === 'true'; const devtools = initNgDevtoolsHub({ - ws: { sidecar: true }, + ws: {sidecar: true}, auth, allowedOrigins: false, }); @@ -68,11 +118,17 @@ app.use(devtools.nodeMiddleware); It turns the origin check off because it runs as a public demo. Keep the check on for your own apps. +<ngmd-alert severity="warning"> + <code>initNgDevtoolsHub()</code> has no production switch of its own. If your <code>server.ts</code> also runs in production, decide there whether to mount it. +</ngmd-alert> + ## Load the overlay +### Import it in development + The [overlay](/getting-started/overlay) collects live data from the page. Import it after bootstrap, in development only: -```ts +```ts {4-6} // main.ts bootstrapApplication(App, appConfig) .then(() => { @@ -84,6 +140,10 @@ bootstrapApplication(App, appConfig) .catch((err) => console.error(err)); ``` +`ngDevMode` is false in production builds, so the import never runs there and the overlay stays out of your production bundle. + +### The dock entries + A floating button appears on your page. It opens the devtools with one dock entry per tool: | Dock entry | Shows | @@ -94,14 +154,43 @@ A floating button appears on your page. It opens the devtools with one dock entr | NativeScript | Coming soon | | Capacitor | Coming soon | +[Popup and hub](/getting-started/popup-and-hub) covers the panel, its dock modes and deep links. + +## Run the app + +### With the dev server + +When you run `ng serve`, the Angular dev server runs `server.ts` too, so the hub answers on port 4200 as well. + +```bash group="run" name="ng serve" image="https://cdn.simpleicons.org/angular/DD0031" active +ng serve +# open http://localhost:4200 and click the amber button +``` + +```bash group="run" name="SSR server" image="https://cdn.simpleicons.org/nodedotjs/5FA04E" +ng build --configuration development +node dist/<your-app>/server/server.mjs +# open http://localhost:4000 and click the amber button +``` + +### With the built SSR server + +To test the real Express process, build with the development configuration and start `server.mjs`. Replace `<your-app>` with your project name. + +<ngmd-callout type="danger" title="A plain build has no button"> + <code>ng build</code> uses the production configuration by default. <code>ngDevMode</code> is false there, so the overlay is never imported and the button never appears. The live tabs need <code>--configuration development</code>. In this repository, the same applies to <code>pnpm build</code>. +</ngmd-callout> + ## Fill the SSR & HTTP tab +### Add the providers + To fill the SSR & HTTP tab, add the interceptor and hydration hooks to your app config: -```ts +```ts {2,7-8} // app.config.ts -import { provideHttpClient, withFetch } from '@angular/common/http'; -import { provideNgDevtoolsHttp, withNgDevtools } from '@santoshyadavdev/ng-devtools/http'; +import {provideHttpClient, withFetch} from '@angular/common/http'; +import {provideNgDevtoolsHttp, withNgDevtools} from '@santoshyadavdev/ng-devtools/http'; export const appConfig: ApplicationConfig = { providers: [ @@ -112,8 +201,65 @@ export const appConfig: ApplicationConfig = { }; ``` -The [SSR & HTTP guide](/guides/ssr-http) covers interceptor order and fault injection. +`withNgDevtools()` records requests and applies fault rules. `provideNgDevtoolsHttp()` captures hydration warnings before the overlay loads. In production builds the interceptor passes requests through untouched. + +### Put withNgDevtools first + +<ngmd-callout type="tip" title="Interceptor order"> + Register <code>withNgDevtools()</code> before your own interceptors, for example <code>provideHttpClient(withNgDevtools(), withInterceptors([auth]))</code>. It then records requests as the app makes them, and fault rules apply before anything else. +</ngmd-callout> + +### Run SSR in the same process + +SSR and the devtools middleware must run in the same Express process. Otherwise the server-side calls never reach the tab. + +The [SSR & HTTP guide](/guides/ssr-http) covers interceptor order and fault injection in detail. ## Mount only the panel -To mount only the devtools panel without the dock, use `initDevframe(ngDevtools, { base: '/__ng-devtools/' })` from `devframe/initiate`. The overlay looks for that path too. +To mount only the devtools panel without the dock, use `initDevframe()` from `devframe/initiate`: + +```ts +// server.ts +import {initDevframe} from 'devframe/initiate'; +import ngDevtools from '@santoshyadavdev/ng-devtools/devframe'; + +const devtools = initDevframe(ngDevtools, {base: '/__ng-devtools/'}); +app.use(devtools.nodeMiddleware); +``` + +The overlay looks for `/__ng-devtools/` too. Without the hub, every tab sits in one tab bar. + +## Troubleshooting + +<ngmd-accordion> + <ngmd-accordion-item title="The button does not appear" open> + The app is probably a production build. Run <code>ng serve</code>, or build with <code>--configuration development</code>. Then check that <code>main.ts</code> imports the overlay. + </ngmd-accordion-item> + <ngmd-accordion-item title="The panel says Disconnected"> + Check that the server is running and that the hub middleware is mounted before <code>express.static</code> and the SSR handler. Then reload the page. + </ngmd-accordion-item> + <ngmd-accordion-item title="The panel connects but shows no data"> + With <code>auth</code> on, a browser reads data only after it exchanges the one-time code the server printed. On a machine only you use, pass <code>auth: false</code>. + </ngmd-accordion-item> + <ngmd-accordion-item title="The SSR & HTTP tab shows no server calls"> + Add <code>withNgDevtools()</code> and <code>provideNgDevtoolsHttp()</code>, and run SSR in the same Express process as the hub. Prerendered routes make no requests at runtime. + </ngmd-accordion-item> +</ngmd-accordion> + +## Next steps + +<ngmd-card-grid columns="2"> + <ngmd-card icon="zap" title="Browser overlay" link="/getting-started/overlay" cta="How it connects"> + What the overlay sends, and how to point it at a custom mount path. + </ngmd-card> + <ngmd-card icon="layers" title="Popup and hub" link="/getting-started/popup-and-hub" cta="Use the panel"> + Dock modes, the hub rail, deep links and connection status. + </ngmd-card> + <ngmd-card icon="search" title="SSR & HTTP guide" link="/guides/ssr-http" cta="Set it up"> + Interceptor order, fault rules and hydration warnings. + </ngmd-card> + <ngmd-card icon="shield" title="Access and redaction" link="/security" cta="Security"> + Who can reach the hub, and what is redacted. + </ngmd-card> +</ngmd-card-grid> diff --git a/apps/docs/src/content/getting-started/installation.md b/apps/docs/src/content/getting-started/installation.md index 349716d..5205e5c 100644 --- a/apps/docs/src/content/getting-started/installation.md +++ b/apps/docs/src/content/getting-started/installation.md @@ -3,25 +3,53 @@ title: Installation description: Install the devtools package and choose where it runs. --- +<ngmd-hero title="Installation" gradient> + One package, two parts. A server part that hosts the devtools, and a browser part that sends live data from your page. +</ngmd-hero> + # Installation The devtools ship as one npm package, `@santoshyadavdev/ng-devtools`. It contains the Node side, the browser overlay, the in-page popup, the CLI and the built UI. -```bash group="install" name="npm" active -npm install @santoshyadavdev/ng-devtools devframe -``` +## Prerequisites + +<ngmd-card-grid columns="3"> + <ngmd-card icon="terminal" title="Node.js 22 or later"> + The package declares <code>node >=22</code> in its <code>engines</code> field. + </ngmd-card> + <ngmd-card icon="code" title="Angular 20 or later"> + <code>@angular/core</code> and <code>@angular/common</code> 20 and newer are supported. + </ngmd-card> + <ngmd-card icon="box" title="Package manager"> + pnpm, npm, yarn or bun. Any of the four. + </ngmd-card> +</ngmd-card-grid> + +<ngmd-callout type="warning" title="Development builds only"> + Live data comes from Angular's debug API (<code>window.ng</code>). Production builds remove it, so the live tabs stay empty there. Run your app in development mode while you inspect it. +</ngmd-callout> -```bash group="install" name="pnpm" +## Install the package + +```bash group="install" name="pnpm" image="https://cdn.simpleicons.org/pnpm/F69220" active pnpm add @santoshyadavdev/ng-devtools devframe ``` -```bash group="install" name="yarn" +```bash group="install" name="npm" image="https://cdn.simpleicons.org/npm/CB3837" +npm install @santoshyadavdev/ng-devtools devframe +``` + +```bash group="install" name="yarn" image="https://cdn.simpleicons.org/yarn/2C8EBB" yarn add @santoshyadavdev/ng-devtools devframe ``` -MCP agent support (`@devframes/agentic`) is included. +```bash group="install" name="bun" image="https://bun.sh/logo.svg" +bun add @santoshyadavdev/ng-devtools devframe +``` -## Entry points +MCP agent support (`@devframes/agentic`) is included. You don't install it separately. + +### Entry points | Import | Use it for | | --------------------------------------- | ---------------------------------------------------------------- | @@ -32,13 +60,110 @@ MCP agent support (`@devframes/agentic`) is included. | `@santoshyadavdev/ng-devtools/http` | The HTTP interceptor and hydration hooks for the SSR & HTTP tab. | | `@santoshyadavdev/ng-devtools/devframe` | The devframe definition, for custom hosts. | -The package also installs an `ng-devtools` binary for the [standalone CLI](/getting-started/cli). +### The CLI binary + +The package also installs an `ng-devtools` binary. It runs the devtools without your app: a local server, a static report or an MCP server. See [Standalone CLI](/getting-started/cli). ## Pick a setup -Every setup has two parts. A server part serves the devtools UI and receives data. A browser part, the [overlay](/getting-started/overlay), runs in your page and sends live data to the server. +Every setup has two parts: + +- **Server part**: serves the devtools UI and receives data. +- **Browser part**: the [overlay](/getting-started/overlay). It runs in your page and sends live data to the server. + +### Server part + +Pick the tab that matches your app: + +```ts group="setup" name="Angular CLI + Express" image="https://cdn.simpleicons.org/express/71717A" active +// server.ts +import {initNgDevtoolsHub} from '@santoshyadavdev/ng-devtools/hub'; + +const devtools = initNgDevtoolsHub({ws: false}); +app.use(devtools.nodeMiddleware); +``` + +```ts group="setup" name="Analog (Vite)" image="https://cdn.simpleicons.org/vite/646CFF" +// 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()], +}); +``` + +```bash group="setup" name="Standalone CLI" image="https://cdn.simpleicons.org/gnubash/4EAA25" +# Run from the root of your Angular workspace +npx @santoshyadavdev/ng-devtools +``` + +### Browser part + +Load the overlay after bootstrap, in development only. The check depends on your build tool: + +```ts group="overlay" name="Angular CLI" image="https://cdn.simpleicons.org/angular/DD0031" active +// main.ts +bootstrapApplication(App, appConfig) + .then(() => { + if (typeof ngDevMode === 'undefined' || ngDevMode) { + return import('@santoshyadavdev/ng-devtools/overlay'); + } + return undefined; + }) + .catch((err) => console.error(err)); +``` + +```ts group="overlay" name="Analog (Vite)" image="https://cdn.simpleicons.org/vite/646CFF" +// src/main.ts +bootstrapApplication(App, appConfig).then(() => { + if (import.meta.env.DEV) void import('@santoshyadavdev/ng-devtools/overlay'); +}); +``` + +The standalone CLI has no page connected, so it needs no browser part. + +### Add the Chrome extension + +The [Chrome extension](/getting-started/chrome-extension) adds a panel to Chrome DevTools. It sits on top of the Express or Vite setup. It does not replace the server part or the overlay. + +## Check that it works + +<ngmd-workflow> + <ngmd-step title="Start your app in development mode"> + Run <code>ng serve</code> for an Angular CLI app, or the Vite dev server for an Analog app. + </ngmd-step> + <ngmd-step title="Look for the button"> + An amber button appears in the bottom-right corner of the page. The overlay adds it. + </ngmd-step> + <ngmd-step title="Open the panel"> + Click the button. The header shows <strong>Live</strong> once the panel is connected. + </ngmd-step> + <ngmd-step title="Open the full-page viewer"> + Go to <code>/__devframes/</code> on the same server to see the devtools on their own page. + </ngmd-step> +</ngmd-workflow> + +## FAQ + +<ngmd-accordion> + <ngmd-accordion-item title="Why install devframe next to the package?" open> + The devtools are built on Devframe. Some setups import from <code>devframe</code> directly, for example <code>initDevframe</code> from <code>devframe/initiate</code> to <a href="/getting-started/express#mount-only-the-panel">mount only the panel</a>. Package managers like pnpm only resolve imports of direct dependencies. + </ngmd-accordion-item> + <ngmd-accordion-item title="Where does the package need to be installed?"> + Wherever your server part runs. An Express app imports the hub in <code>server.ts</code>, so the package must be installed where that server starts. The overlay import in <code>main.ts</code> only runs in development builds. + </ngmd-accordion-item> + <ngmd-accordion-item title="The button does not appear"> + Check that the app runs as a development build and that <code>main.ts</code> imports the overlay. A production build skips the import, so there is no button. + </ngmd-accordion-item> +</ngmd-accordion> + +## Where to next -- Your app uses the Angular CLI with SSR (an Express `server.ts`): follow [Angular CLI and Express](/getting-started/express). -- Your app is an Analog app: follow [Vite and Analog](/getting-started/vite). -- You only want the source scan, or a server for agents: use the [standalone CLI](/getting-started/cli). -- You want a panel inside Chrome DevTools: add the [Chrome extension](/getting-started/chrome-extension) on top of one of the setups above. +<ngmd-pill-row> + <ngmd-pill href="/getting-started/express" title="Angular CLI and Express"></ngmd-pill> + <ngmd-pill href="/getting-started/vite" title="Vite and Analog"></ngmd-pill> + <ngmd-pill href="/getting-started/cli" title="Standalone CLI"></ngmd-pill> + <ngmd-pill href="/getting-started/overlay" title="Browser overlay"></ngmd-pill> +</ngmd-pill-row> diff --git a/apps/docs/src/content/getting-started/introduction.md b/apps/docs/src/content/getting-started/introduction.md index d3a512e..1a7834d 100644 --- a/apps/docs/src/content/getting-started/introduction.md +++ b/apps/docs/src/content/getting-started/introduction.md @@ -3,53 +3,157 @@ title: Introduction description: What the devtools inspect, and the ways you can run them. --- +<ngmd-hero title="Angular DevTools" logo="/logo-mark.svg" gradient> + Inspect components, signals, injectors, routes, forms, pipes, NgRx stores and HTTP calls. In the page, from the command line, or through a coding agent. +</ngmd-hero> + # Introduction -The devtools inspect a running Angular app: its components, signals, injectors, routes, forms, pipes, NgRx stores, HTTP calls and hydration. They also scan your source files, so they can answer questions before the app even runs. +The devtools inspect a running *Angular app. They read components, signals, injectors, routes, forms, pipes, NgRx stores, HTTP calls and hydration. They also scan your source files, so they can answer questions before the app even runs. The same tool runs in several places. It is built with *Devframe, so one definition powers every mode. -| Mode | What you get | -| ---------------- | ------------------------------------------------------------------- | -| Embedded panel | A floating button on your page opens the devtools next to your app. | -| Standalone CLI | A local server that serves the devtools UI. | -| Static report | An offline HTML build of the source scan. | -| MCP server | Every inspector exposed to coding agents as tools and resources. | -| Chrome extension | A panel in Chrome DevTools that connects to your local dev server. | - -## Features - -- **Components**: every component instance on the page, with live inputs, outputs, change detection, encapsulation, DOM listeners, host directives and injected services. Hover a row to highlight the element in the page. -- **Signals**: the live signal graph of one component (signal, computed, linkedSignal and effect nodes with their edges), plus a value history per signal. Needs Angular 19 or later. -- **Injectors**: the element and environment injector hierarchy, the lookup path for a token, and the providers at each level. Needs Angular 17 or later. -- **Routes**: 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, the router setup and a route lint. -- **NgRx Store**: live `@ngrx/signals` stores with a change log, per-change diffs and state restore, plus the `@ngrx/store` state and action log, with time travel through `provideStoreDevtools()`. -- **Forms**: every Signal Form, reactive form and template-driven form on the page, with each field's value, status and readable errors, a change timeline, submit and payload explanations, and a lint. -- **Pipes**: custom and built-in pipes, where they are used, live instances, call recording, async subscriptions and a pipe lint. -- **SSR & HTTP**: an HTTP timeline for SSR and client calls, fault injection, hydration stats and the TransferState payload. -- **Analog**: file routes, server calls, render modes, content and lint for *Analog apps. -- **Dashboard**: the Angular and TypeScript versions, SSR status and a count for each inspector. -- **Agent tools**: the inspectors are exposed as *MCP tools and resources, so a coding agent can read and act on the running app. +<ngmd-callout type="info" title="One package"> + Everything ships in <code>@santoshyadavdev/ng-devtools</code>: the server side, the browser overlay, the in-page popup, the CLI and the built UI. See <a href="/getting-started/installation">Installation</a>. +</ngmd-callout> + +## What it inspects + +### Live inspectors + +These tabs read the running page through Angular's debug API. They need a development build. + +<ngmd-card-grid columns="2"> + <ngmd-card icon="layers" title="Components" link="/inspectors/components" cta="Components"> + Every component instance on the page, with live inputs, outputs, change detection, encapsulation, DOM listeners, host directives and injected services. Hover a row to highlight the element. + </ngmd-card> + <ngmd-card icon="zap" title="Signals" link="/inspectors/signals" cta="Signals"> + The live signal graph of one component (signal, computed, linkedSignal and effect nodes with their edges), plus a value history per signal. + </ngmd-card> + <ngmd-card icon="box" title="Injectors" link="/inspectors/injectors" cta="Injectors"> + The element and environment injector hierarchy, the lookup path for a token, and the providers at each level. + </ngmd-card> + <ngmd-card icon="compass" title="Router" link="/inspectors/router" cta="Router"> + The live route, every navigation as a full story, the live route config with URL testing, the router setup and a route lint. + </ngmd-card> + <ngmd-card icon="file" title="Forms" link="/inspectors/forms" cta="Forms"> + Every Signal Form, reactive form and template-driven form, with values, status, readable errors, a change timeline and a lint. + </ngmd-card> + <ngmd-card icon="wrench" title="Pipes" link="/inspectors/pipes" cta="Pipes"> + Custom and built-in pipes, where they are used, live instances, call recording, async subscriptions and a pipe lint. + </ngmd-card> + <ngmd-card icon="settings" title="NgRx Store" link="/inspectors/ngrx-store" cta="NgRx Store"> + Live <code>@ngrx/signals</code> stores with a change log, diffs and state restore, plus the <code>@ngrx/store</code> state and action log. + </ngmd-card> + <ngmd-card icon="search" title="SSR & HTTP" link="/inspectors/ssr-http" cta="SSR & HTTP"> + An HTTP timeline for SSR and client calls, fault injection, hydration stats and the TransferState payload. + </ngmd-card> +</ngmd-card-grid> + +### Project overview + +- **Dashboard**: the Angular and TypeScript versions, SSR status and a count for each inspector. See [Dashboard](/inspectors/dashboard). +- **Analog**: file routes, server calls, render modes, content and lint for *Analog apps. See [Analog](/inspectors/analog). + +### Source scan + +The devtools also read your source files. Components, routes, signals, providers, NgRx declarations and pipes show up even with no page connected. The [standalone CLI](/getting-started/cli) and the static report run on the source scan alone. + +### Agent tools + +The inspectors are exposed as *MCP tools and resources, so a coding agent can read and act on the running app. See [MCP server](/agents/mcp-server). + +## Ways to run it + +### Inside your app + +Your app's server hosts the devtools, and a script in the page sends live data to it. A floating button on the page opens the panel next to your app. + +| Setup | Server part | Guide | +| ----------------------- | --------------------------- | ----------------------------------------------------- | +| Angular CLI with SSR | `initNgDevtoolsHub()` | [Angular CLI and Express](/getting-started/express) | +| Analog | The Vite plugin | [Vite and Analog](/getting-started/vite) | +| Chrome DevTools (extra) | One of the two setups above | [Chrome extension](/getting-started/chrome-extension) | + +### Outside your app + +| Mode | What you get | +| -------------- | ---------------------------------------------------------------- | +| Standalone CLI | A local server that serves the devtools UI over the source scan. | +| Static report | An offline HTML build of the source scan. | +| MCP server | Every inspector exposed to coding agents over stdio. | + +All three come from the `ng-devtools` binary. See [Standalone CLI](/getting-started/cli). + +## Built on Devframe + +The devtools are a <a href="https://devfra.me" target="_blank" rel="noopener noreferrer">Devframe</a> tool. Devframe lets one tool definition run in many places, so the inspectors, the RPC functions and the agent tools are written once. + +### What Devframe provides + +<ngmd-card-grid columns="2"> + <ngmd-card image="/logos/devframe.svg" title="One definition, every mode"> + The same definition serves the embedded panel, the standalone CLI, the static report, the MCP server and the Chrome extension. + </ngmd-card> + <ngmd-card icon="layers" title="The hub and dock"> + The hub comes from <code>@devframes/hub</code>, so other Devframe tools can join the same dock next to the devtools. + </ngmd-card> + <ngmd-card icon="zap" title="RPC and shared state"> + The UI talks to the server over Devframe RPC, and live data sits in shared state that the UI and agents both read. + </ngmd-card> + <ngmd-card icon="sparkles" title="Agent tools for free"> + An RPC function marked for agents becomes an MCP tool, and shared state is exposed as MCP resources. + </ngmd-card> +</ngmd-card-grid> ## Requirements -- The package supports Angular 20 and later. -- Live data needs a development build. The devtools read Angular's debug API (`window.ng`), which production builds remove. -- Node.js 22 or later runs the package. +<ngmd-card-grid columns="3"> + <ngmd-card icon="code" title="Angular 20 or later"> + The package declares Angular 20 and later as its peer range. + </ngmd-card> + <ngmd-card icon="terminal" title="Node.js 22 or later"> + The package runs on Node.js 22 and newer. + </ngmd-card> + <ngmd-card icon="rocket" title="A development build"> + Live data comes from <code>window.ng</code>, which production builds remove. + </ngmd-card> +</ngmd-card-grid> + +<ngmd-alert severity="important"> + The devtools read Angular's debug API. In a production build the overlay has nothing to read, so the live tabs stay empty. The source scan still works. +</ngmd-alert> + +## FAQ + +<ngmd-accordion> + <ngmd-accordion-item title="Do I need a browser extension?" open> + No. The overlay adds a floating button to your page and opens the devtools in a panel. The <a href="/getting-started/chrome-extension">Chrome extension</a> is optional. It adds the same UI as a panel in Chrome DevTools. + </ngmd-accordion-item> + <ngmd-accordion-item title="Does it work without SSR?"> + The devtools need a server part. An Angular CLI app mounts it in its Express <code>server.ts</code>. An Analog app gets it from the Vite plugin. Without either, the <a href="/getting-started/cli">standalone CLI</a> serves the source scan. + </ngmd-accordion-item> + <ngmd-accordion-item title="Does it ship in my production bundle?"> + Not if you follow the setup guides. They load the overlay with a dynamic import that only runs in development builds. + </ngmd-accordion-item> + <ngmd-accordion-item title="Can other people on my network reach it?"> + By default, no. The Vite plugin only answers requests from your machine, and the Express hub asks for a one-time code. See <a href="/security">Access and redaction</a>. + </ngmd-accordion-item> +</ngmd-accordion> ## Next steps <ngmd-card-grid columns="2"> - <ngmd-card title="Install" link="/getting-started/installation" cta="Install the package"> + <ngmd-card icon="box" title="Install" link="/getting-started/installation" cta="Install the package"> Add the package and pick how you want to run it. </ngmd-card> - <ngmd-card title="Angular CLI and Express" link="/getting-started/express" cta="Set it up"> + <ngmd-card icon="layers" title="Angular CLI and Express" link="/getting-started/express" cta="Set it up"> Mount the devtools in the Express server of an SSR app. </ngmd-card> - <ngmd-card title="Vite and Analog" link="/getting-started/vite" cta="Add the plugin"> - Add the Vite plugin next to `analog()`. + <ngmd-card icon="zap" title="Vite and Analog" link="/getting-started/vite" cta="Add the plugin"> + Add the Vite plugin next to <code>analog()</code>. </ngmd-card> - <ngmd-card title="Agent tools" link="/agents/mcp-server" cta="Connect an agent"> + <ngmd-card icon="sparkles" title="Agent tools" link="/agents/mcp-server" cta="Connect an agent"> Give your coding agent access to the inspectors. </ngmd-card> </ngmd-card-grid> diff --git a/apps/docs/src/content/getting-started/overlay.md b/apps/docs/src/content/getting-started/overlay.md index f755e87..6ed16b6 100644 --- a/apps/docs/src/content/getting-started/overlay.md +++ b/apps/docs/src/content/getting-started/overlay.md @@ -3,38 +3,153 @@ title: Browser overlay description: The script that runs in your page and sends live data to the devtools. --- +<ngmd-hero title="Browser overlay" gradient> + The script that runs inside your page. It reads Angular's debug API and sends live data to the devtools server. +</ngmd-hero> + # Browser overlay -The overlay runs inside your Angular page. It reads Angular's debug API and sends live data to the devtools server: the component tree, the signal graph, the injector tree, NgRx stores, forms, pipes, the router, HTTP calls and Analog page data. Importing the module starts it, so in most apps that import is all that is needed: +The overlay runs inside your *Angular page. It reads Angular's debug API and sends live data to the devtools server. Importing the module starts it, so in most apps that import is all that is needed: ```ts import '@santoshyadavdev/ng-devtools/overlay'; ``` -Load it in development builds only. Production builds remove the debug API, so the overlay has nothing to read. The [Express](/getting-started/express) and [Vite](/getting-started/vite) pages show a dynamic import that only runs in development. +## Load it in development + +### Pick your build tool + +Load the overlay after bootstrap, with a dynamic import that only runs in development: + +```ts group="overlay" name="Angular CLI" image="https://cdn.simpleicons.org/angular/DD0031" active +// main.ts +bootstrapApplication(App, appConfig) + .then(() => { + if (typeof ngDevMode === 'undefined' || ngDevMode) { + return import('@santoshyadavdev/ng-devtools/overlay'); + } + return undefined; + }) + .catch((err) => console.error(err)); +``` + +```ts group="overlay" name="Analog (Vite)" image="https://cdn.simpleicons.org/vite/646CFF" +// src/main.ts +bootstrapApplication(App, appConfig).then(() => { + if (import.meta.env.DEV) void import('@santoshyadavdev/ng-devtools/overlay'); +}); +``` + +### Why development only + +<ngmd-callout type="warning" title="Production builds have nothing to read"> + The overlay reads <code>window.ng</code>, Angular's debug API. Production builds remove it, so the overlay has nothing to read there. The dynamic import keeps it out of your production bundle. +</ngmd-callout> + +## What it sends + +<ngmd-card-grid columns="3"> + <ngmd-card icon="layers" title="Component tree"> + Components, inputs, outputs and injected services. + </ngmd-card> + <ngmd-card icon="zap" title="Signal graph"> + Signal, computed, linkedSignal and effect nodes. + </ngmd-card> + <ngmd-card icon="box" title="Injector tree"> + Element and environment injectors with their providers. + </ngmd-card> + <ngmd-card icon="settings" title="NgRx stores"> + Signal stores and the global store. + </ngmd-card> + <ngmd-card icon="file" title="Forms and pipes"> + Every form on the page, and pipe instances. + </ngmd-card> + <ngmd-card icon="compass" title="Router, HTTP and Analog"> + Navigations, HTTP calls and Analog page data. + </ngmd-card> +</ngmd-card-grid> ## How it connects -It looks for the devframe connection next to the page, then at `/__ng-devtools/` and `/__devframes/ng-devtools/`. It also adds the floating button; with the hub mounted, the button opens the whole hub (every dock in a side rail). +### Where it looks + +The overlay looks for the devframe connection next to the page first. Then it tries these paths in order: + +1. `/__ng-devtools/` +2. `/__devframes/ng-devtools/` -The overlay sends a fresh snapshot every 3 seconds and skips data that did not change. Router events are sent as they happen. Each browser tab gets its own page id, so the devtools can tell tabs apart. When a tab closes, its data is dropped. +It also adds the [floating button](/getting-started/popup-and-hub). With the hub mounted, the button opens the whole hub, with every dock in a side rail. + +### Snapshots and events + +The overlay sends a fresh snapshot every 3 seconds and skips data that did not change. Router events are sent as they happen. + +### One id per tab + +Each browser tab gets its own page id, kept in `sessionStorage`. The devtools use it to tell tabs apart. When a tab closes, its data is dropped. ## A custom mount path -`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: +### Call initOverlay + +`initOverlay` is exported for a devtools mounted somewhere else: ```ts -import { initOverlay } from '@santoshyadavdev/ng-devtools/overlay'; +import {initOverlay} from '@santoshyadavdev/ng-devtools/overlay'; -const dispose = await initOverlay({ baseURL: '/__my-devtools/' }); +const dispose = await initOverlay({baseURL: '/__my-devtools/'}); ``` `baseURL` takes one path or a list of paths to try in order. `initOverlay` resolves to a function that stops the overlay and removes its hooks. +### Avoid two overlays + +<ngmd-callout type="danger" title="Importing the module already starts one"> + By the time you call <code>initOverlay</code>, the import has already started an overlay on the default URLs. Dispose of that one before starting another. Otherwise the page ends up with two connections and two polling intervals. +</ngmd-callout> + ## NgRx signal stores -The overlay also exports `registerNgrxSignals`. Call it once with `patchState` so that restoring a store's state also notifies `watchState` listeners. See [Restore NgRx signal state](/guides/ngrx-signals-restore). +The overlay also exports `registerNgrxSignals`. Call it once with `patchState` so that restoring a store's state also notifies `watchState` listeners: + +```ts {4-7} +// main.ts +bootstrapApplication(App, appConfig).then(() => { + if (typeof ngDevMode === 'undefined' || ngDevMode) { + return Promise.all([ + import('@santoshyadavdev/ng-devtools/overlay'), + import('@ngrx/signals'), + ]).then(([devtools, {patchState}]) => devtools.registerNgrxSignals({patchState})); + } + return undefined; +}); +``` + +See [Restore NgRx signal state](/guides/ngrx-signals-restore). ## Highlighting When you hover a component in the devtools, the overlay draws an amber box around its element in the page. The box follows the element and clears after 2 seconds. + +## FAQ + +<ngmd-accordion> + <ngmd-accordion-item title="Do I need to call createDevtoolsPopup too?" open> + No. The overlay adds the floating button itself. See <a href="/getting-started/popup-and-hub">Popup and hub</a>. + </ngmd-accordion-item> + <ngmd-accordion-item title="Does it slow down my app?"> + It polls every 3 seconds and only sends data that changed. With the dynamic import above, it never loads in production builds. + </ngmd-accordion-item> + <ngmd-accordion-item title="Which values leave the page?"> + Live values are sent to the devtools server. Secret-looking values are redacted first. See <a href="/security">Access and redaction</a>. + </ngmd-accordion-item> +</ngmd-accordion> + +## Next steps + +<ngmd-pill-row> + <ngmd-pill href="/getting-started/popup-and-hub" title="Popup and hub"></ngmd-pill> + <ngmd-pill href="/getting-started/express" title="Angular CLI and Express"></ngmd-pill> + <ngmd-pill href="/getting-started/vite" title="Vite and Analog"></ngmd-pill> + <ngmd-pill href="/guides/ngrx-signals-restore" title="Restore NgRx signal state"></ngmd-pill> +</ngmd-pill-row> diff --git a/apps/docs/src/content/getting-started/popup-and-hub.md b/apps/docs/src/content/getting-started/popup-and-hub.md index 284dd54..1135ac0 100644 --- a/apps/docs/src/content/getting-started/popup-and-hub.md +++ b/apps/docs/src/content/getting-started/popup-and-hub.md @@ -3,32 +3,85 @@ title: Popup and hub description: The floating button, the panel and its dock modes, the hub rail and deep links. --- +<ngmd-hero title="Popup and hub" gradient> + A floating button on your page opens the devtools in a panel. With the hub mounted, the panel shows every tool in a side rail. +</ngmd-hero> + # Popup and hub -When the overlay loads, a floating button appears in the bottom-right corner of your page. Click it to open the devtools in a panel on top of your app. +When the overlay loads, a floating button appears in the bottom-right corner of your page. Click it to open the devtools in a panel on top of your app. No browser extension is needed. + +## The floating button + +### Where it comes from -## The in-page popup +Importing the [overlay](/getting-started/overlay) adds the button. The overlay first checks whether the page's server mounts the hub at `/__devframes/`. If it does, the button opens the whole hub. If not, it opens the devtools panel on its own. -The devtools can appear as a floating popup directly on your page. No browser extension is needed: +### Create it yourself + +Most apps never call the popup API. To add the button without the overlay, call `createDevtoolsPopup()`: ```ts -import { createDevtoolsPopup } from '@santoshyadavdev/ng-devtools/popup'; +import {createDevtoolsPopup} from '@santoshyadavdev/ng-devtools/popup'; createDevtoolsPopup(); ``` -This adds a floating button (bottom-right) that opens the full devtools UI in an iframe. Importing the overlay already adds the button, so most apps never call `createDevtoolsPopup()` themselves. Calling it again returns the same popup. +It adds the button and opens the full devtools UI in an iframe. Calling it again returns the same popup. + +<ngmd-alert severity="helpful"> + The popup alone sends no live data. Load the overlay for that. +</ngmd-alert> + +### Match your app colors + +The button reads CSS variables from your page. Set them on `:root` to match your app: + +```css +:root { + --ng-devtools-accent: #f5a524; /* button background */ + --ng-devtools-accent-ink: #1c1300; /* button icon */ + --ng-devtools-title: #f5a524; /* panel title and active dock mode */ +} +``` + +The values above are the defaults. + +## The panel -- **Dock modes**: float, bottom (full width, 40% of the height) and right (40% of the width, full height). Only the floating panel can be dragged. -- **Resize** the panel with its resize handle. -- **Move the button**: drag it, or focus it and use the arrow keys (hold Shift for bigger steps). Double-click resets its position. -- **Escape** closes the panel. -- **Persistence**: position, size and dock mode are saved in `localStorage` under `ng-devtools-popup`. +### Dock modes -The button color follows the `--ng-devtools-accent` CSS variable, so you can match it to your app. +<ngmd-card-grid columns="3"> + <ngmd-card icon="layers" title="Float"> + A free panel on top of your app. Drag it by the toolbar and resize it from the corner. + </ngmd-card> + <ngmd-card icon="box" title="Bottom"> + Full width, 40% of the viewport height. Resize it vertically. + </ngmd-card> + <ngmd-card icon="compass" title="Right"> + 40% of the viewport width, full height. Resize it horizontally. + </ngmd-card> +</ngmd-card-grid> + +Only the floating panel can be dragged. Switch modes from the buttons in the panel toolbar. + +### Keyboard and mouse + +| Action | How | +| ------------------------- | ------------------------------------------- | +| Close the panel | <kbd>Escape</kbd> | +| Move the button | Drag it, or focus it and use the arrow keys | +| Move the button further | Hold <kbd>Shift</kbd> with the arrow keys | +| Reset the button position | Double-click it | + +### Saved layout + +Position, size and dock mode are saved in `localStorage` under `ng-devtools-popup`. The panel keeps its layout across reloads. Clear that key to reset it. ## The hub +### Docks in the side rail + When the page's server mounts the hub (`/__devframes/`), the button opens the whole hub. A side rail shows one dock per tool: | Dock | Shows | @@ -39,12 +92,18 @@ When the page's server mounts the hub (`/__devframes/`), the button opens the wh | NativeScript | Coming soon | | Capacitor | Coming soon | +### Full-page viewer + The full-page viewer is at `/__devframes/` on the same server. The hub is built on [`@devframes/hub`](https://github.com/devframes/devframe), so other devframe tools can join the same rail. -Without the hub (for example the standalone CLI, or a panel mounted with `initDevframe()`), every tab sits in one tab bar and the Store tab is a regular tab. +### Without the hub + +Without the hub (for example the standalone CLI, or a panel mounted with `initDevframe()`), every tab sits in one tab bar. The Store tab is a regular tab there. ## Deep links +### Tab hashes + The URL hash selects a tab. Open `/__devframes/ng-devtools/#tab=signals` to land on the Signals tab. Switching tabs updates the hash, so you can copy the URL at any time. | Tab | Hash | @@ -60,8 +119,42 @@ The URL hash selects a tab. Open `/__devframes/ng-devtools/#tab=signals` to land | SSR & HTTP | `#tab=network` | | Analog | `#tab=analog` | -A hash only works for a tab that exists when the panel opens. The Analog tab appears after the server confirms the app is an Analog app, so `#tab=analog` does not select it on load. Inside the Angular dock, the Store and Analog tabs live in their own docks. +### Limits + +<ngmd-callout type="info" title="The tab must exist on load"> + A hash only works for a tab that exists when the panel opens. The Analog tab appears after the server confirms the app is an Analog app, so <code>#tab=analog</code> does not select it on load. Inside the Angular dock, the Store and Analog tabs live in their own docks. +</ngmd-callout> ## Connection status -The header shows **Live** when the panel is connected, **Connecting…** while it tries, and **Disconnected** when the server is gone. If the panel cannot reach the server, check that the dev server is running, then reload. +The panel header shows the state of the connection: + +| Label | Meaning | +| ---------------- | ---------------------------------------- | +| **Live** | The panel is connected to the server. | +| **Connecting…** | The panel is trying to reach the server. | +| **Disconnected** | The server is gone. | + +If the panel cannot reach the server, check that the dev server is running, then reload. + +## Troubleshooting + +<ngmd-accordion> + <ngmd-accordion-item title="The button is in the way" open> + Drag it somewhere else, or focus it and use the arrow keys. Double-click it to reset its position. + </ngmd-accordion-item> + <ngmd-accordion-item title="Reset the panel layout"> + Remove the <code>ng-devtools-popup</code> key from <code>localStorage</code> and reload. + </ngmd-accordion-item> + <ngmd-accordion-item title="The button opens the panel, not the hub"> + The overlay did not find the hub at <code>/__devframes/</code>. Check that your server mounts <code>initNgDevtoolsHub()</code> or the Vite plugin on the default base. + </ngmd-accordion-item> +</ngmd-accordion> + +## Next steps + +<ngmd-pill-row> + <ngmd-pill href="/getting-started/overlay" title="Browser overlay"></ngmd-pill> + <ngmd-pill href="/getting-started/chrome-extension" title="Chrome extension"></ngmd-pill> + <ngmd-pill href="/inspectors/dashboard" title="Dashboard"></ngmd-pill> +</ngmd-pill-row> diff --git a/apps/docs/src/content/getting-started/vite.md b/apps/docs/src/content/getting-started/vite.md index 75aa131..6feaccb 100644 --- a/apps/docs/src/content/getting-started/vite.md +++ b/apps/docs/src/content/getting-started/vite.md @@ -3,37 +3,88 @@ title: Vite and Analog description: Add the devtools Vite plugin to an Analog app. --- +<ngmd-hero title="Vite and Analog" logo="/logos/vite.svg" gradient> + One plugin next to <code>analog()</code>, one import in <code>main.ts</code>. The hub mounts on the Vite dev server. +</ngmd-hero> + # Vite and Analog -For *Analog apps, add the Vite plugin next to `analog()` and load the overlay in `main.ts`: +For *Analog apps, add the *Vite plugin next to `analog()` and load the overlay in `main.ts`. The plugin mounts the devtools hub on the Vite dev server. -```ts +## Setup at a glance + +<ngmd-workflow> + <ngmd-step title="Install the package"> + Add <code>@santoshyadavdev/ng-devtools</code> and <code>devframe</code>. See <a href="/getting-started/installation">Installation</a>. + </ngmd-step> + <ngmd-step title="Add the plugin"> + Register <code>ngDevtools()</code> after <code>analog()</code> in <code>vite.config.ts</code>. + </ngmd-step> + <ngmd-step title="Load the overlay"> + Import the overlay in <code>src/main.ts</code> when <code>import.meta.env.DEV</code> is true. + </ngmd-step> + <ngmd-step title="Open the devtools"> + Start the dev server and click the amber button, or open <code>/__devframes/</code>. + </ngmd-step> +</ngmd-workflow> + +## Add the plugin + +### Register it in vite.config.ts + +```ts {3,7} // vite.config.ts import analog from '@analogjs/platform'; import ngDevtools from '@santoshyadavdev/ng-devtools/vite'; -import { defineConfig } from 'vite'; +import {defineConfig} from 'vite'; export default defineConfig({ plugins: [analog(), ngDevtools()], }); ``` -```ts +### Load the overlay + +The plugin does not inject the overlay. Your app imports it in `main.ts`: + +```ts {3} // 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`. +`import.meta.env.DEV` is false in `vite build`, so the overlay stays out of your production bundle. + +### Where to find it + +| What | Where | +| ----------------- | -------------------------------------- | +| Floating button | Bottom-right corner of your page | +| Full-page viewer | `/__devframes/` on the Vite dev server | +| HTTP MCP endpoint | `/__devframes/__mcp` | ## What the plugin does -- It runs on the dev server only. `vite build` is not affected. -- It mounts the devtools hub on the Vite dev server and shares its HTTP server for the WebSocket. -- It records Analog page renders, `load()` fetches, server functions and API calls for the [Analog inspector](/inspectors/analog). -- It only answers requests from your machine. See [Security](/security). -- It does not inject the overlay. Your app imports it in `main.ts`, as shown above. +### Dev server only + +The plugin applies to `vite serve` only. `vite build` is not affected, so nothing from the plugin reaches your production output. + +### Mounts the hub + +It mounts the devtools hub on the Vite dev server. The WebSocket shares Vite's HTTP server when it can. Otherwise it runs on its own port. + +### Records Analog server activity + +It records Analog page renders, `load()` fetches, server functions and API calls for the [Analog inspector](/inspectors/analog). The `apiPrefix` option tells it which requests are API calls. + +### Answers only your machine + +<ngmd-callout type="warning" title="Loopback only"> + The plugin only answers requests from a loopback address (<code>127.0.0.1</code> or <code>::1</code>). Other requests to the devtools get <code>403</code> with the message "ng-devtools only answers requests from this machine." WebSocket upgrades follow the same rules. See <a href="/security">Access and redaction</a>. +</ngmd-callout> + +The Vite plugin turns the one-time code off. The loopback and origin checks take its place. ## Options @@ -51,12 +102,73 @@ ngDevtools({ | `apiPrefix` | Analog's `apiPrefix`, or `'api'` | The prefix of your server routes, used to classify API calls. | | `allowedOrigins` | none | Extra exact origins allowed to reach the devtools, for example a tunnel. | +### base + +Change `base` if `/__devframes/` clashes with a route of your own. The overlay looks for `/__devframes/ng-devtools/` and `/__ng-devtools/` by default, so a custom base also needs a custom overlay path. See [A custom mount path](/getting-started/overlay#a-custom-mount-path). + +### apiPrefix + +The plugin reads `apiPrefix` from your Analog config. Set it here only when the detection is wrong. + +### allowedOrigins + +Each entry is an exact origin, such as `https://tunnel.example`. The request itself must still come from a loopback address. + ## Hostnames other than localhost -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'] })`. +### Local hostnames -## Angular CLI apps +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`. The devtools trust it too. -The Angular CLI dev server does not accept Vite plugins. For an Angular CLI app, mount the hub in your Express server instead. See [Angular CLI and Express](/getting-started/express). +```ts {3} +// vite.config.ts +export default defineConfig({ + server: {allowedHosts: ['myapp.test']}, + plugins: [analog(), ngDevtools()], +}); +``` + +### Tunnels and other origins + +Add other origins with `allowedOrigins`: + +```ts +ngDevtools({allowedOrigins: ['https://tunnel.example']}); +``` + +## Angular CLI apps -The [Analog guide](/guides/analog) walks through a full setup, including the demo in this repository. +<ngmd-alert severity="important"> + The Angular CLI dev server does not accept Vite plugins. For an Angular CLI app, mount the hub in your Express server instead. See <a href="/getting-started/express">Angular CLI and Express</a>. +</ngmd-alert> + +## FAQ + +<ngmd-accordion> + <ngmd-accordion-item title="Does the plugin change my production build?" open> + No. It applies to the dev server only, and the overlay import is guarded by <code>import.meta.env.DEV</code>. + </ngmd-accordion-item> + <ngmd-accordion-item title="Why do I get a 403 from the devtools?"> + The request did not come from your machine, or its origin is not trusted. Open the app on <code>localhost</code>, list your hostname in <code>server.allowedHosts</code>, or add the origin to <code>allowedOrigins</code>. + </ngmd-accordion-item> + <ngmd-accordion-item title="The Analog tab shows no server calls"> + The plugin records server calls made through the Vite dev server. Check that the plugin is registered and that <code>apiPrefix</code> matches your server routes. The <a href="/guides/analog">Analog guide</a> walks through a full setup. + </ngmd-accordion-item> +</ngmd-accordion> + +## Next steps + +<ngmd-card-grid columns="2"> + <ngmd-card icon="rocket" title="Set up Analog" link="/guides/analog" cta="Read the guide"> + A full Analog setup, including the demo in this repository. + </ngmd-card> + <ngmd-card icon="layers" title="Analog inspector" link="/inspectors/analog" cta="Inspector"> + File routes, server calls, render modes, content and lint. + </ngmd-card> + <ngmd-card icon="zap" title="Browser overlay" link="/getting-started/overlay" cta="How it connects"> + What the overlay sends, and how it finds the server. + </ngmd-card> + <ngmd-card icon="shield" title="Access and redaction" link="/security" cta="Security"> + The loopback check and the origin rules in detail. + </ngmd-card> +</ngmd-card-grid> diff --git a/apps/docs/src/content/guides/analog.md b/apps/docs/src/content/guides/analog.md index 827c630..fda25ac 100644 --- a/apps/docs/src/content/guides/analog.md +++ b/apps/docs/src/content/guides/analog.md @@ -3,67 +3,158 @@ title: Set up Analog description: Add the devtools to an Analog app, step by step. --- -# Set up Analog - -This guide adds the devtools to an *Analog app. You get the Angular inspectors, the NgRx dock and the Analog dock, with an MCP endpoint on the Vite dev server. +<ngmd-hero title="Set up Analog" logo="https://analogjs.org/img/logos/analog-logo.svg" gradient> + One Vite plugin and one dynamic import. You get the Angular inspectors, the NgRx dock, the Analog dock and an MCP endpoint on the Vite dev server. +</ngmd-hero> -## 1. Install +# Set up Analog -```bash group="install" name="npm" active -npm install @santoshyadavdev/ng-devtools devframe +This guide adds the devtools to an *Analog app. Everything runs on the *Vite dev server, so there is no separate server to start. + +## What you'll get + +<ngmd-card-grid columns="2"> + <ngmd-card icon="layers" title="Angular inspectors"> + Components, injectors, signals, forms, router and pipes, reading the live page. + </ngmd-card> + <ngmd-card icon="compass" title="Analog dock"> + File routes, server calls, render modes, content and lint. + </ngmd-card> + <ngmd-card icon="box" title="NgRx dock"> + Signal stores and the change log, when your app uses NgRx. + </ngmd-card> + <ngmd-card icon="terminal" title="MCP endpoint"> + <code>/__devframes/__mcp</code> on the Vite dev server, with the Analog tools. + </ngmd-card> +</ngmd-card-grid> + +## The flow + +<ngmd-workflow> + <ngmd-step title="Install the package"> + Add <code>@santoshyadavdev/ng-devtools</code> and <code>devframe</code>. + </ngmd-step> + <ngmd-step title="Add the Vite plugin"> + Register it next to <code>analog()</code> in <code>vite.config.ts</code>. + </ngmd-step> + <ngmd-step title="Load the overlay"> + Import the overlay in <code>src/main.ts</code>, in development only. + </ngmd-step> + <ngmd-step title="Open the devtools"> + Start the dev server and click the floating button. + </ngmd-step> +</ngmd-workflow> + +## Step 1: Install + +```bash group="install" name="pnpm" image="https://cdn.simpleicons.org/pnpm/F69220" active +pnpm add @santoshyadavdev/ng-devtools devframe ``` -```bash group="install" name="pnpm" -pnpm add @santoshyadavdev/ng-devtools devframe +```bash group="install" name="npm" image="https://cdn.simpleicons.org/npm/CB3837" +npm install @santoshyadavdev/ng-devtools devframe ``` -## 2. Add the Vite plugin +## Step 2: Add the Vite plugin -Add the plugin next to `analog()`: +Add the plugin after `analog()`: -```ts +```ts {3,7} // vite.config.ts import analog from '@analogjs/platform'; import ngDevtools from '@santoshyadavdev/ng-devtools/vite'; -import { defineConfig } from 'vite'; +import {defineConfig} from 'vite'; -export default defineConfig({ +export default defineConfig(() => ({ plugins: [analog(), ngDevtools()], -}); +})); ``` -The plugin runs on the dev server only. +The plugin runs on the dev server only (`apply: 'serve'`). Production builds do not include it. + +### Plugin options + +All three are optional. + +| Option | Default | What it does | +| ---------------- | ---------------------------- | ------------------------------------------------------ | +| `base` | `/__devframes/` | Where the hub is mounted. | +| `apiPrefix` | Read from your Analog config | The API prefix used to tell API calls from page calls. | +| `allowedOrigins` | none | Extra page origins accepted next to localhost. | -## 3. Load the overlay +### Custom hostnames -```ts +The devtools only answer requests from this machine. If you open the dev server through another hostname, add it to Vite's `server.allowedHosts`. See [Security](/security). + +## Step 3: Load the overlay + +```ts {7} // src/main.ts +import {bootstrapApplication} from '@angular/platform-browser'; +import {App} from './app/app'; +import {appConfig} from './app/app.config'; + bootstrapApplication(App, appConfig).then(() => { if (import.meta.env.DEV) void import('@santoshyadavdev/ng-devtools/overlay'); }); ``` -## 4. Open the devtools +The import is dynamic and guarded by `import.meta.env.DEV`, so production bundles do not include it. -Start the dev server. 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`. +## Step 4: Open the devtools + +Start the dev server as usual. Then: + +| What | Where | +| --------------- | ------------------------------------------- | +| Floating button | On every page of your app | +| Full viewer | `/__devframes/` on the Vite dev server | +| MCP endpoint | `/__devframes/__mcp` on the Vite dev server | Open the **Analog** dock to see file routes, server calls, render modes, content and lint. See [the Analog inspector](/inspectors/analog) for each view. -## Optional: HTTP timeline +<ngmd-callout type="tip" title="Connect your agent"> + Point your MCP client at <code>http://localhost:5173/__devframes/__mcp</code> with an <code>Origin</code> header. See <a href="/agents/mcp-server">MCP server</a>. The <code>analog-server-calls</code> and <code>analog-call-api</code> tools only work through the Vite plugin. +</ngmd-callout> -To record `HttpClient` calls in the SSR & HTTP tab, add `withNgDevtools()` and `provideNgDevtoolsHttp()` to your app config. See [Set up SSR & HTTP](/guides/ssr-http). Analog's own `load()` fetches and API calls show in the Analog dock without it. +## Optional: record HttpClient calls -## Custom hostnames +Analog's own `load()` fetches and API calls show in the Analog dock without extra setup. To also record `HttpClient` calls in the SSR & HTTP tab, add the devtools providers to your app config: -If you open the dev server through another hostname, add it to Vite's `server.allowedHosts`. See [Security](/security). +```ts {2,7-8} +// src/app/app.config.ts +import {provideNgDevtoolsHttp, withNgDevtools} from '@santoshyadavdev/ng-devtools/http'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideFileRouter(), + provideHttpClient(withFetch(), withNgDevtools()), + provideNgDevtoolsHttp(), + ], +}; +``` + +See [Set up SSR & HTTP](/guides/ssr-http) for the interceptor order. ## Try the demo -The repository has an Analog demo in `examples/analog`. From the repository root: +The repository has an Analog demo in `examples/analog`. It has file routes with route groups, `.server.ts` loads, API routes under `src/server/routes/api/v1`, markdown content, prerendered pages and a client-only `/dashboard`. ```bash pnpm install pnpm analog:dev ``` -It builds the devtools package first, then starts the Vite dev server. +The script builds the devtools package first, then starts the Vite dev server. + +<ngmd-alert severity="helpful"> + The demo aliases <code>@santoshyadavdev/ng-devtools/overlay</code> to the built package in its <code>vite.config.ts</code>. Your app does not need that alias. +</ngmd-alert> + +## Where to next + +<ngmd-pill-row> + <ngmd-pill href="/inspectors/analog" title="Analog inspector"></ngmd-pill> + <ngmd-pill href="/getting-started/vite" title="Vite and Analog"></ngmd-pill> + <ngmd-pill href="/agents/tools" title="Analog tools"></ngmd-pill> +</ngmd-pill-row> diff --git a/apps/docs/src/content/guides/ngrx-signals-restore.md b/apps/docs/src/content/guides/ngrx-signals-restore.md index e9ba02a..24695d0 100644 --- a/apps/docs/src/content/guides/ngrx-signals-restore.md +++ b/apps/docs/src/content/guides/ngrx-signals-restore.md @@ -3,21 +3,48 @@ title: Restore NgRx signal state description: Register patchState so that restoring a signal store also notifies watchState listeners. --- +<ngmd-hero title="Restore NgRx signal state" logo="https://cdn.simpleicons.org/ngrx/BA2BD2" gradient> + Put a signal store back to any state in its change log. Register patchState once, and your watchState listeners run too. +</ngmd-hero> + # Restore NgRx signal state The [NgRx Store tab](/inspectors/ngrx-store) can put a signal store back to its state after any change in the log. By default it writes the state signals directly. That updates your components, but `watchState` listeners do not run. Register `patchState` once, and restore goes through it instead. Then `watchState` listeners run as they would for any other change. +## Why it matters + +A `watchState` listener runs on every state change made through `patchState`. Stores often use one to save state or sync it somewhere else: + +```ts {9-11} +// travel.store.ts +import {signalStore, watchState, withHooks, withState} from '@ngrx/signals'; + +export const TravelStore = signalStore( + {providedIn: 'root'}, + withState({query: '', saved: [] as string[]}), + withHooks({ + onInit(store) { + watchState(store, (state) => { + localStorage.setItem('travel', JSON.stringify(state)); + }); + }, + }), +); +``` + +Without `registerNgrxSignals`, a restore changes the store but skips this listener. With it, the listener runs. + ## Register patchState -Call `registerNgrxSignals({ patchState })` from `@santoshyadavdev/ng-devtools/overlay` once, after the app starts. The demo app in this repository does it in `main.ts`, together with loading the overlay: +Call `registerNgrxSignals({ patchState })` from `@santoshyadavdev/ng-devtools/overlay` once, after the app starts. Load both modules with dynamic imports in development only, so production bundles do not include the devtools. -```ts -// main.ts -import { bootstrapApplication } from '@angular/platform-browser'; -import { appConfig } from './app/app.config'; -import { App } from './app/app'; +```ts group="register" name="Angular CLI" active +// src/main.ts +import {bootstrapApplication} from '@angular/platform-browser'; +import {appConfig} from './app/app.config'; +import {App} from './app/app'; bootstrapApplication(App, appConfig) .then((ref) => { @@ -27,22 +54,49 @@ bootstrapApplication(App, appConfig) .then(() => Promise.all([import('@santoshyadavdev/ng-devtools/overlay'), import('@ngrx/signals')]), ) - .then(([devtools, { patchState }]) => devtools.registerNgrxSignals({ patchState })); + .then(([devtools, {patchState}]) => devtools.registerNgrxSignals({patchState})); } return undefined; }) .catch((err) => console.error(err)); ``` -Both imports are dynamic and run in development only, so production bundles do not include the devtools. +```ts group="register" name="Vite and Analog" +// src/main.ts +import {bootstrapApplication} from '@angular/platform-browser'; +import {App} from './app/app'; +import {appConfig} from './app/app.config'; + +bootstrapApplication(App, appConfig).then(async () => { + if (import.meta.env.DEV) { + const [devtools, {patchState}] = await Promise.all([ + import('@santoshyadavdev/ng-devtools/overlay'), + import('@ngrx/signals'), + ]); + devtools.registerNgrxSignals({patchState}); + } +}); +``` + +The Angular CLI version is the demo app's `src/main.ts`. It loads the overlay and registers `patchState` in the same step. ## Restore a state -1. Open the Store tab (the **NgRx** dock with the hub). -2. Select a store, then open an entry in its change log. -3. Click **Restore this state**, then **Restore**. +<ngmd-workflow> + <ngmd-step title="Open the Store tab"> + With the hub, it is the <strong>NgRx</strong> dock. + </ngmd-step> + <ngmd-step title="Pick a change"> + Select a store, then open an entry in its change log. + </ngmd-step> + <ngmd-step title="Restore it"> + Click <strong>Restore this state</strong>, then <strong>Restore</strong>. + </ngmd-step> +</ngmd-workflow> -Every state key of the store goes back to its value right after that change. Components that read the store update at once, and a new **Restore** entry is added to the log. +### What happens + +Every state key of the store goes back to its value right after that change. Components that read the store update at once. A new **Restore** entry is added to the log. <ngmd-callout type="info" title="Without registerNgrxSignals"> Restore still works, but the log entry says that <code>watchState</code> listeners were not notified. @@ -50,6 +104,32 @@ Every state key of the store goes back to its value right after that change. Com ## Limits -- Restore needs every state key to be writable. -- The log keeps the last 200 entries per page. -- For `@ngrx/store`, restore uses Store DevTools instead. Add `provideStoreDevtools()` to enable it. Without it, the action log is read-only. +### Signal stores + +- Restore needs every state key to be writable. A read-only store state cannot be restored. +- The log keeps the last 200 entries per page. Older changes cannot be restored. + +### @ngrx/store + +For `@ngrx/store`, restore uses Store DevTools instead. Add `provideStoreDevtools()` to enable it. + +```ts {6} +// src/app/app.config.ts +import {provideStoreDevtools} from '@ngrx/store-devtools'; + +export const appConfig: ApplicationConfig = { + providers: [provideStoreDevtools()], +}; +``` + +<ngmd-alert severity="warning"> + Without <code>provideStoreDevtools()</code>, the action log is read-only. +</ngmd-alert> + +## Where to next + +<ngmd-pill-row> + <ngmd-pill href="/inspectors/ngrx-store" title="NgRx Store inspector"></ngmd-pill> + <ngmd-pill href="/getting-started/overlay" title="Browser overlay"></ngmd-pill> + <ngmd-pill href="/agents/resources" title="ngrx-store resource"></ngmd-pill> +</ngmd-pill-row> diff --git a/apps/docs/src/content/guides/ssr-http.md b/apps/docs/src/content/guides/ssr-http.md index 70be5a0..5f4b07f 100644 --- a/apps/docs/src/content/guides/ssr-http.md +++ b/apps/docs/src/content/guides/ssr-http.md @@ -3,18 +3,58 @@ title: Set up SSR & HTTP description: Add the interceptor and hydration hooks, in the right order, to fill the SSR & HTTP tab. --- -# Set up SSR & HTTP - -The [SSR & HTTP tab](/inspectors/ssr-http) records every `HttpClient` call during server rendering and in the browser. It needs an interceptor, a hydration hook, and SSR running next to the devtools. - -## 1. Add the providers +<ngmd-hero title="Set up SSR & HTTP" gradient> + Record every HttpClient call on the server and in the browser, then break them on purpose with fault rules. +</ngmd-hero> -To fill the SSR & HTTP tab, add the interceptor and hydration hooks to your app config: +# Set up SSR & HTTP -```ts -// app.config.ts -import { provideHttpClient, withFetch } from '@angular/common/http'; -import { provideNgDevtoolsHttp, withNgDevtools } from '@santoshyadavdev/ng-devtools/http'; +The [SSR & HTTP tab](/inspectors/ssr-http) records every `HttpClient` call during server rendering and in the browser. It needs three things: an interceptor, a hydration hook, and SSR running next to the devtools. + +## What you'll set up + +<ngmd-card-grid columns="3"> + <ngmd-card icon="zap" title="Interceptor"> + <code>withNgDevtools()</code> records each request and applies fault rules. + </ngmd-card> + <ngmd-card icon="lightbulb" title="Hydration hook"> + <code>provideNgDevtoolsHttp()</code> captures the NG05xx hydration warnings Angular logs. + </ngmd-card> + <ngmd-card icon="layers" title="One server process"> + SSR and the devtools hub run in the same Express process. + </ngmd-card> +</ngmd-card-grid> + +## The flow + +<ngmd-workflow> + <ngmd-step title="Add the providers"> + Register the interceptor and the hydration hook in <code>app.config.ts</code>. + </ngmd-step> + <ngmd-step title="Put the interceptor first"> + Place <code>withNgDevtools()</code> before your own interceptors. + </ngmd-step> + <ngmd-step title="Mount the hub in server.ts"> + SSR and the devtools middleware share one Express process. + </ngmd-step> + <ngmd-step title="Render the pages you test on the server"> + Use <code>RenderMode.Server</code> for them in <code>app.routes.server.ts</code>. + </ngmd-step> + <ngmd-step title="Inject a fault"> + Add a rule in the tab and reload the page. + </ngmd-step> +</ngmd-workflow> + +## Step 1: Add the providers + +Both functions come from `@santoshyadavdev/ng-devtools/http`. + +```ts {4,10-11} +// src/app/app.config.ts +import {ApplicationConfig} from '@angular/core'; +import {provideHttpClient, withFetch} from '@angular/common/http'; +import {provideNgDevtoolsHttp, withNgDevtools} from '@santoshyadavdev/ng-devtools/http'; +import {provideClientHydration} from '@angular/platform-browser'; export const appConfig: ApplicationConfig = { providers: [ @@ -25,43 +65,116 @@ export const appConfig: ApplicationConfig = { }; ``` +### What each provider does + - `withNgDevtools()` adds the interceptor that records calls and applies fault rules. -- `provideNgDevtoolsHttp()` captures the hydration warnings (NG05xx) Angular logs. +- `provideNgDevtoolsHttp()` captures the hydration warnings (NG05xx) before the overlay loads. + +<ngmd-alert severity="helpful"> + The interceptor checks <code>ngDevMode</code>. In production builds it passes every request through untouched. +</ngmd-alert> -## 2. Put withNgDevtools first +## Step 2: Put withNgDevtools first -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. +Register `withNgDevtools()` before your own interceptors. Then it records requests as the app makes them, and fault rules apply before anything else. ```ts provideHttpClient(withFetch(), withNgDevtools(), withInterceptors([auth])); ``` -## 3. Run SSR next to the devtools +### How transfer cache hits are detected -SSR and the devtools middleware must run in the same Express process. The interceptor on the server hands its calls to the devtools through that process. Mount the hub in `server.ts` as shown in [Angular CLI and Express](/getting-started/express). +A call counts as a transfer cache hit when the cached response comes back right away. It also counts when the page's TransferState holds a GET or HEAD entry for the same URL. So an async interceptor after `withNgDevtools()` does not hide cache hits. -It works in development builds only; in production the interceptor passes requests through untouched. +## Step 3: Mount the hub in server.ts -## 4. Test with server rendering +The interceptor on the server hands its calls to the devtools through the Node process. So SSR and the devtools middleware must run in the same Express process. -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. +```ts {4,9-14} +// src/server.ts +import {AngularNodeAppEngine, createNodeRequestHandler} from '@angular/ssr/node'; +import express from 'express'; +import {initNgDevtoolsHub} from '@santoshyadavdev/ng-devtools/hub'; -```ts -// app.routes.server.ts -import { RenderMode, ServerRoute } from '@angular/ssr'; +const app = express(); +const angularApp = new AngularNodeAppEngine(); + +const devtools = initNgDevtoolsHub({ + ws: {sidecar: true}, + auth: false, + allowedOrigins: false, +}); +app.use(devtools.nodeMiddleware); + +// ... your API routes, static files and the Angular handler + +export const reqHandler = createNodeRequestHandler(app); +``` + +This is adapted from the demo app's `src/server.ts`. See [Angular CLI and Express](/getting-started/express) for every option. + +## Step 4: Render the pages you test on the server + +Routes that are prerendered at build time make no requests at runtime. SSR rules do not apply to them. Use `RenderMode.Server` for the pages you want to test. + +```ts {5} +// src/app/app.routes.server.ts +import {RenderMode, ServerRoute} from '@angular/ssr'; export const serverRoutes: ServerRoute[] = [ - { path: 'products', renderMode: RenderMode.Server }, - { path: '**', renderMode: RenderMode.Prerender }, + {path: 'products', renderMode: RenderMode.Server}, + {path: '**', renderMode: RenderMode.Prerender}, ]; ``` -## Inject a fault +<ngmd-callout type="tip" title="Check a route's render mode"> + The <code>explain-render-mode</code> agent tool tells you which <code>ServerRoute</code> and render mode a URL gets. See <a href="/agents/tools">Tools</a>. +</ngmd-callout> + +## Step 5: Inject a fault + +<ngmd-workflow> + <ngmd-step title="Open Fault injection"> + Open the SSR & HTTP tab and go to <strong>Fault injection</strong>. + </ngmd-step> + <ngmd-step title="Match a URL"> + Enter a URL pattern, for example <code>/api/*</code>. + </ngmd-step> + <ngmd-step title="Pick where it applies"> + <strong>SSR + client</strong>, <strong>SSR only</strong> or <strong>Client only</strong>. + </ngmd-step> + <ngmd-step title="Set the response"> + Set a status (for example <code>500</code>), a delay, or a mock JSON body. Click <strong>Add rule</strong>. + </ngmd-step> + <ngmd-step title="Reload"> + SSR rules apply from the next page load. Client rules apply right away. + </ngmd-step> +</ngmd-workflow> + +### How a rule answers + +- A status of 400 or more fails the request with an `HttpErrorResponse`. +- A lower status returns the body as a mocked response. + +<ngmd-callout type="warning" title="SSR mocks are not transferred"> + SSR mocks are not written to TransferState, so the browser requests the URL again. Apply the rule on <strong>SSR + client</strong> to mock both. +</ngmd-callout> + +## Try it on the demo + +The demo app has an SSR & HTTP example at `/examples/http`. It fetches `/api/products` during SSR and replays it from the transfer cache. The endpoint accepts `?delay=` and `?fail=` for backend errors. + +```bash +pnpm build --configuration development +node dist/angular-devtools/server/server.mjs +``` + +Open `http://localhost:4000/examples/http`. See [Demo apps](/contributing/demo-apps) for the rest. -1. Open the SSR & HTTP tab and go to **Fault injection**. -2. Enter a URL pattern, for example `/api/*`. -3. Pick where it applies: **SSR + client**, **SSR only** or **Client only**. -4. Set a status (for example `500`), a delay, or a mock JSON body, then click **Add rule**. -5. Reload the page. SSR rules apply from the next page load. Client rules apply right away. +## Where to next -A status of 400 or more fails the request with an `HttpErrorResponse`. A lower status returns the body as a mocked response. SSR mocks are not written to TransferState, so the browser requests the URL again. Apply the rule on SSR + client to mock both. +<ngmd-pill-row> + <ngmd-pill href="/inspectors/ssr-http" title="SSR & HTTP inspector"></ngmd-pill> + <ngmd-pill href="/getting-started/express" title="Angular CLI and Express"></ngmd-pill> + <ngmd-pill href="/guides/analog" title="Set up Analog"></ngmd-pill> +</ngmd-pill-row> diff --git a/apps/docs/src/content/inspectors/analog.md b/apps/docs/src/content/inspectors/analog.md index b766b0a..fc9454f 100644 --- a/apps/docs/src/content/inspectors/analog.md +++ b/apps/docs/src/content/inspectors/analog.md @@ -3,43 +3,192 @@ title: Analog description: File routes, server calls, render modes, content and lint for Analog apps. --- +<ngmd-hero title="Analog" logo="https://analogjs.org/img/logos/analog-logo.svg" gradient> + How an Analog app is put together and what its dev server does. File routes, server calls, render modes, content files and a lint. +</ngmd-hero> + # Analog -The Analog tab shows how an *Analog app is put together and what its dev server does. With the hub mounted, it lives in the **Analog** dock. +The Analog tab reads an *Analog app from three sides: its files, its dev server, and the page open in the browser. With the hub mounted, it lives in the **Analog** dock. -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. +The Analog dock is always in the rail. In other apps it shows a **This app doesn’t use Analog** page. Without the hub, the Analog tab appears only in Analog apps. -## Requirements +## Setup -Add the Vite plugin next to `analog()` and load the overlay. See [Vite and Analog](/getting-started/vite). The plugin records server calls on the dev server, and the overlay reports the page that is open in the browser. +Add the Vite plugin next to `analog()` and load the overlay. See [Vite and Analog](/getting-started/vite) and the [Analog guide](/guides/analog). -## Where the data comes from +```ts {3,7} +// 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()], +}); +``` + +The plugin runs on the dev server only. The app counts as Analog when its `package.json` depends on `@analogjs/platform` or `@analogjs/router`. + +## What it shows + +### Summary + +The summary at the top shows the Analog version, and the number of pages, API routes, server calls and issues. It also shows the page open in the browser. + +### Routes -- **Source**: the server scans your pages, layouts, `.server.ts` files, server routes, content files and `vite.config.ts`. -- **Dev server**: the Vite plugin records page renders, `load()` fetches, server functions and API calls. -- **Live**: the overlay reports the open page, the `load()` data it received and its hydration state. +Every page, layout and markdown file with its URL, route groups, `[param]` and catch-all segments, `.server.ts` files and `routeMeta`. -## Views +Type a URL into **Test a URL** and click **Explain** to see which files render it: the layout chain, the page and its params. A URL that matches nothing gets the closest candidates. -The summary at the top shows the Analog version and the number of pages, API routes, server calls and issues. +### Server -- **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. A prerender plan compares `prerender.routes` with your pages and the build output. -- **Content**: markdown files with title, URL, slug and date. -- **Lint**: checks for duplicate URLs, missing default exports, layouts without `<router-outlet>`, orphan `.server.ts` files, API method suffixes, prerender entries and frontmatter. +Page renders (server rendered or client only), `load()` fetches, server functions and API calls. Each row shows the status, the time, who called it, and a redacted response preview. Filter by kind, and click **Clear calls** to empty the list. -## For agents +A `load()` that runs during server rendering and again in the browser right after is flagged. It means TransferState did not serve the server result. -The Analog tools read the same data: +The **API routes** table lists your server routes. Click **Try** to open one in the **Request playground**, which sends real requests to your dev server. -- `ng-devtools:analog-routes`, `ng-devtools:analog-explain-url` and `ng-devtools:analog-current-page` for routes and the open page. -- `ng-devtools:analog-server-calls`, `ng-devtools:analog-api-routes` and `ng-devtools:analog-call-api` for the server. -- `ng-devtools:analog-render-modes` and `ng-devtools:analog-prerender-plan` for rendering. -- `ng-devtools:analog-content` and `ng-devtools:analog-lint` for content and checks. +### Render -`analog-call-api` sends a real request to your dev server. Methods other than GET, HEAD and OPTIONS need `confirm: true`. See [Tools](/agents/tools). +How each page is rendered: **SSR**, **Prerendered** or **Client only**. It reads the config, the build output and the last request, and marks a page whose last request differs from its config. -## Tips +The **Prerender plan** compares `prerender.routes` with your pages and the build output. It lists static pages left out, dynamic pages that need explicit entries, and listed routes missing from `dist`. + +### Content + +Markdown files under `src/content`, with title, URL, slug, date and file. Files with frontmatter errors are marked. + +### Lint + +Checks grouped by rule, each with a fix: + +- Two files for one URL, and sibling `[param]` files. +- Missing default exports, and layouts without `<router-outlet>`. +- `.server.ts` files without `load` or without a page. +- Redirect mistakes. +- API method suffixes, duplicate API routes, and routes outside the API prefix. +- Prerender entries that match nothing. +- Frontmatter errors, duplicate slugs, and content that shadows a page. +- From the live page: `load()` fetched twice, hydration errors, API routes not found, and new pages that need a restart. + +## Where the data comes from -Tested with Analog 2.7 on Angular 20 (a fresh app from the Analog template, npm and pnpm) and Angular 22. The demo lives in `examples/analog` (`pnpm analog:dev`). +<ngmd-card-grid columns="3"> + <ngmd-card icon="file" title="Source"> + The server scans your pages, layouts, <code>.server.ts</code> files, server routes, middleware, content files, <code>vite.config</code> and the build output. + </ngmd-card> + <ngmd-card icon="terminal" title="Dev server"> + The Vite plugin records page renders, <code>load()</code> fetches, server functions and API calls. + </ngmd-card> + <ngmd-card icon="zap" title="Live"> + The overlay reports the open page, the <code>load()</code> data it received, and its hydration state. + </ngmd-card> +</ngmd-card-grid> + +### Render mode rules + +A page is **Client only** when `routeRules` or the `ssr` option turns SSR off for it. It is **Prerendered** when it is in the build output or in `prerender.routes`. Otherwise it is **SSR**. + +### Other tabs in Analog apps + +- The Routes tab adds the Analog file routes in front of the routes from route config files. +- The Dashboard SSR chip follows the `ssr` option of `analog()`. + +## How to use it + +### Find which file renders a URL + +<ngmd-workflow> + <ngmd-step title="Open Routes"> + Type the URL into <strong>Test a URL</strong>. + </ngmd-step> + <ngmd-step title="Explain"> + Click <strong>Explain</strong>. The result lists the layouts, the page and the params. + </ngmd-step> +</ngmd-workflow> + +### Fix a load() that runs twice + +<ngmd-workflow> + <ngmd-step title="Open Server"> + A warning at the top names the route. + </ngmd-step> + <ngmd-step title="Check TransferState"> + Open the SSR & HTTP tab and look for the Analog entry in the payload. + </ngmd-step> + <ngmd-step title="Reload and compare"> + After the fix, the browser should not fetch the route's <code>load()</code> again. + </ngmd-step> +</ngmd-workflow> + +### Call an API route + +<ngmd-workflow> + <ngmd-step title="Pick the route"> + Click <strong>Try</strong> in the <strong>API routes</strong> table. + </ngmd-step> + <ngmd-step title="Send the request"> + For methods other than GET, add a JSON body and check <strong>This request can change data on the dev server</strong>. + </ngmd-step> + <ngmd-step title="Read the response"> + The status, the time and the body appear below. The call also shows in the list. + </ngmd-step> +</ngmd-workflow> + +## Agent tools + +| Tool | Inputs | What it does | +| ----------------------------------- | ---------------------------------------------- | ----------------------------------------------------------------------- | +| `ng-devtools:analog-routes` | `filter` | File routes in match order, with page, layout and server files. | +| `ng-devtools:analog-explain-url` | `url` (required) | Which files render a URL, or the closest candidates. | +| `ng-devtools:analog-current-page` | `page` | The open page: its files, `load()` data, rendering and hydration state. | +| `ng-devtools:analog-server-calls` | `kind`, `route`, `limit` | Recent server calls. Flags `load()` fetched twice. | +| `ng-devtools:analog-api-routes` | | Server routes with method, URL and file, plus middleware. | +| `ng-devtools:analog-call-api` | `path` (required), `method`, `body`, `confirm` | Sends a real request to the dev server. | +| `ng-devtools:analog-render-modes` | | The render mode of each page, and what the last request did. | +| `ng-devtools:analog-prerender-plan` | | The prerender plan. | +| `ng-devtools:analog-content` | `filter` | Markdown files with slug, frontmatter, route and parse errors. | +| `ng-devtools:analog-lint` | | The Analog checks. | + +`analog-current-page` is the only place that shows the `load()` data a page received. See [Tools](/agents/tools). + +## Limits and gotchas + +<ngmd-callout type="warning" title="analog-call-api changes real data"> + It sends a real request to your dev server. Methods other than GET, HEAD and OPTIONS need <code>confirm: true</code>. It works only through the Vite plugin. +</ngmd-callout> + +<ngmd-callout type="info" title="New pages need a restart"> + The running router does not know page files added after the dev server started. The lint flags them. Restart the dev server. +</ngmd-callout> + +<ngmd-callout type="tip" title="Redaction"> + Response previews and <code>load()</code> data redact secret-looking keys, tokens, <code>Bearer</code> values and secret query parameters. See <a href="/security">Security</a>. +</ngmd-callout> + +<ngmd-alert severity="helpful"> + The server keeps the last 200 calls. Previews are cut to 1000 characters, and page renders have no preview. +</ngmd-alert> + +## Try the demo + +The demo lives in `examples/analog`. It uses Analog 2.7 on Angular 22. Run it with `pnpm analog:dev`. + +## Related pages + +<ngmd-card-grid columns="2"> + <ngmd-card icon="wrench" title="Set up Analog" link="/guides/analog" cta="Guide"> + Install, add the plugin and load the overlay. + </ngmd-card> + <ngmd-card icon="rocket" title="Vite and Analog" link="/getting-started/vite" cta="Set up"> + The Vite plugin and its options. + </ngmd-card> + <ngmd-card icon="layers" title="SSR & HTTP" link="/inspectors/ssr-http" cta="Open"> + The TransferState payload, with Analog entries decoded. + </ngmd-card> + <ngmd-card icon="compass" title="Router" link="/inspectors/router" cta="Open"> + The live router of the Analog app. + </ngmd-card> +</ngmd-card-grid> diff --git a/apps/docs/src/content/inspectors/components.md b/apps/docs/src/content/inspectors/components.md index cc7a0df..0c1613a 100644 --- a/apps/docs/src/content/inspectors/components.md +++ b/apps/docs/src/content/inspectors/components.md @@ -3,51 +3,185 @@ title: Components description: Every component instance on the page, with live inputs, outputs and injected services. --- +<ngmd-hero title="Components" gradient> + Every component instance on the page, in DOM order. Hover a row to find it in the page. Select it to read its live inputs, outputs and injected services. +</ngmd-hero> + # Components -The Components tab lists every component instance on the page, in the order Angular rendered them. Hover a row to highlight its host element in the page. Select it to read its live inputs, outputs and injected services. +The Components tab lists each rendered component instance as a tree. It starts at the app root and walks the page in document order, including shadow roots. When no page is connected, it lists what your source declares instead. -## Where the data comes from +## What it shows + +### The tree + +Each row shows the class name and the host tag. Routed components get a chip with their route path. A **+N** chip means N directives sit on the same host. + +- Filter by class, tag or directive name. +- The toolbar counts the instances on the page. +- **Hover or focus** a row to highlight its host element in the page. +- **Click** a row, or press Enter or Space, to select it. Click it again to clear the selection. -- **Live**: the [overlay](/getting-started/overlay) walks the page with Angular's debug API and sends the tree every few seconds. -- **Source**: the server scans your files for `@Component` and `@Directive` classes. The tab falls back to this list when no page is connected, or when the page reports no instances (for example a production build). +The tree shows up to 2000 components, and up to 256 levels of nesting. Past either limit, a notice says the page has more components than the tree shows. -## The tree +### Detail header -Each row shows the class name, the host tag, a chip with the route path for routed components, and **+N** when directives sit on the same host. Filter by class, tag or directive name. +The header of the selected instance shows the class name, the host tag, and the source file and line. The file and line come from the source scan, matched by class name. They are missing when the scan has no match. -- **Hover or focus** a row to highlight the element in the page. -- **Click** a row, or press Enter or Space, to select it. -- Use the arrow keys, Home and End to move through the tree. +When a form in the same source file exists, a **Show … in Forms** button opens it in the [Forms tab](/inspectors/forms). -The tree shows up to 2000 components. A notice appears when the page has more. +### Facts -## Details +- **Change detection**: `OnPush` or `Default`. +- **Encapsulation**: `Emulated`, `None`, `ShadowDom` or `IsolatedShadowDom`. +- **Host path**: where the host element sits in the page. +- **Routed**: for routed components, the route and the outlet that rendered it. -The detail panel for the selected instance shows: +A fact shows **Unknown** when Angular does not report it. -- The class name, the host tag, and the source file and line. -- **Change detection**, **Encapsulation**, the **Host path**, and for routed components, the route and outlet that rendered it. -- **Inputs** with their live values (and aliases). +### Inputs, outputs and listeners + +- **Inputs** with their live values. Signal inputs are unwrapped. When a component input has an alias, the row shows both names. - **Outputs**, each marked **listened** or **no listener**. -- **DOM listeners** on the host element. -- One block per directive on the host. -- **Injected**: each token the component injects, with its flags and the injector that provided it, or **not provided**. +- **DOM listeners** on the host element. This block appears only when there are any. +- One block per directive on the host, with its inputs and outputs. -When a form belongs to the selected component, a **Show … in Forms** button opens it in the [Forms tab](/inspectors/forms). +### Injected services -## Source mode +**Injected** lists each token the component class injects, with its flags and the injector that provided it. A token nobody provides is marked **not provided**. Tokens injected by the host directives are not listed here. Use the [Injectors tab](/inspectors/injectors) for those. -Without live data, the tab lists what the source declares. Expand a row to see its class, file, standalone flag, inputs and outputs. Use **Refresh** to scan again. +### Source mode -## For agents +Without live data, the tab lists the `@Component` and `@Directive` classes in your source. Expand a row to see its class, file, standalone flag, inputs and outputs. Click **Refresh** to scan again. -- `ng-devtools:get-components` lists components and directives from source. -- `ng-devtools:highlight` highlights a component in the page. -- The `ng-devtools:component-tree` resource holds the live tree. +A notice at the top says why you see the source list: no page is connected, or the page reported no instances. -See [Tools](/agents/tools) and [Resources](/agents/resources). +## Where the data comes from -## Requirements +<ngmd-card-grid columns="2"> + <ngmd-card icon="zap" title="Live page"> + The overlay walks the page with Angular's debug API and pushes the tree every 3 seconds. Unchanged trees are skipped. + </ngmd-card> + <ngmd-card icon="file" title="Source scan"> + The server scans your files for <code>@Component</code> and <code>@Directive</code> classes. It also supplies the file and line in the detail header. + </ngmd-card> +</ngmd-card-grid> + +### Debug APIs + +The live tree reads `window.ng`, which only development builds expose. It uses these functions: + +| Function | Used for | +| -------------------------------------------------------------------------- | ---------------------------------------------------- | +| `ng.getComponent`, `ng.getDirectives` | Finding instances and the directives on each host. | +| `ng.getDirectiveMetadata` | Inputs, outputs, change detection and encapsulation. | +| `ng.getListeners` | Output listeners and DOM listeners. | +| `ng.getInjector`, `ɵgetDependenciesFromInjectable`, `ɵgetInjectorMetadata` | The **Injected** block. | + +### Refresh rate + +The page pushes every 3 seconds. The detail block is read only for the selected instance, on each push. The server drops a page after 15 seconds without a report. + +## How to use it + +### Find a component in the page + +<ngmd-workflow> + <ngmd-step title="Filter the tree"> + Type part of the class, tag or directive name. + </ngmd-step> + <ngmd-step title="Hover the rows"> + The page highlights each host element as you move over it. + </ngmd-step> + <ngmd-step title="Select the match"> + Click the row to open its details. + </ngmd-step> +</ngmd-workflow> + +### Check why an output does nothing + +<ngmd-workflow> + <ngmd-step title="Select the child component"> + Pick the component that declares the output. + </ngmd-step> + <ngmd-step title="Read the Outputs block"> + An output marked <strong>no listener</strong> has no parent binding. Check the parent template. + </ngmd-step> +</ngmd-workflow> + +### Track down a missing provider + +<ngmd-workflow> + <ngmd-step title="Select the component"> + Open the component that throws. + </ngmd-step> + <ngmd-step title="Read the Injected block"> + A token marked <strong>not provided</strong> is the one to fix. + </ngmd-step> + <ngmd-step title="Follow the lookup path"> + Open the Injectors tab to see where Angular searched. + </ngmd-step> +</ngmd-workflow> + +### Keyboard + +Use the arrow keys, Home and End to move through the tree. The right arrow expands a row or moves to its first child. The left arrow collapses a row or moves to its parent. + +## Agent tools + +| Tool or resource | Kind | What it does | +| ---------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------- | +| `ng-devtools:get-components` | tool | Lists components and directives from source, with selector, kind, inputs, outputs, file and line. | +| `ng-devtools:highlight` | tool | Highlights a component in the page. Takes an instance id, class name, host tag or CSS selector. Also retargets the Signals graph. | +| `ng-devtools:component-tree` | resource | The live tree per page, with the detail of the selected instance. | + +See [Tools](/agents/tools) and [Resources](/agents/resources). -Live data needs a development build, because it reads `ng.getComponent` from Angular's debug API. Input values are shortened: nested values stop at a few levels, and long strings are cut. Values with secret-looking names are replaced with `[redacted]`. See [Security](/security). +## Limits and gotchas + +<ngmd-callout type="warning" title="Development builds only"> + Live data reads <code>window.ng</code>. Production builds remove it, so the tab falls back to the source list. +</ngmd-callout> + +<ngmd-callout type="info" title="Values are shortened"> + Input values stop at 3 levels of nesting, 30 keys or items, and 300 characters. Past that, the value is cut and marked. +</ngmd-callout> + +<ngmd-callout type="tip" title="Secrets are redacted"> + Inputs with secret-looking names are replaced with <code>[redacted]</code>. Tokens and <code>Bearer</code> values inside strings are redacted too. See <a href="/security">Security</a>. +</ngmd-callout> + +<ngmd-alert severity="helpful"> + Instance ids change on every page load. Don't store them between sessions. +</ngmd-alert> + +## FAQ + +<ngmd-accordion> + <ngmd-accordion-item title="Why do I see the source list instead of the tree?"> + No page is connected, or the connected page is a production build. Open the app in a development build with the overlay loaded. + </ngmd-accordion-item> + <ngmd-accordion-item title="Why is the file and line missing?"> + The detail header matches the class name against the source scan. Classes outside the scanned folders, or from libraries, have no match. + </ngmd-accordion-item> + <ngmd-accordion-item title="Why does a component show +2?"> + Two directives sit on its host element. Select it to see one block per directive. + </ngmd-accordion-item> +</ngmd-accordion> + +## Related pages + +<ngmd-card-grid columns="2"> + <ngmd-card icon="layers" title="Injectors" link="/inspectors/injectors" cta="Open"> + The injector tree and the lookup path of each token. + </ngmd-card> + <ngmd-card icon="zap" title="Signals" link="/inspectors/signals" cta="Open"> + The live signal graph of one component. + </ngmd-card> + <ngmd-card icon="file" title="Forms" link="/inspectors/forms" cta="Open"> + Every form on the page, with field state and errors. + </ngmd-card> + <ngmd-card icon="compass" title="Browser overlay" link="/getting-started/overlay" cta="Set up"> + The script that reports the live page. + </ngmd-card> +</ngmd-card-grid> diff --git a/apps/docs/src/content/inspectors/dashboard.md b/apps/docs/src/content/inspectors/dashboard.md index 78ab2cb..7fc26fb 100644 --- a/apps/docs/src/content/inspectors/dashboard.md +++ b/apps/docs/src/content/inspectors/dashboard.md @@ -3,24 +3,44 @@ title: Dashboard description: Project metadata and a count for each inspector. --- +<ngmd-hero title="Dashboard" gradient> + The first tab. It shows what the project is built with and how much each inspector found. +</ngmd-hero> + # Dashboard -The Dashboard is the first tab. It shows what the project is built with and how much each inspector found. +The Dashboard opens by default. The top block describes your workspace. The cards below count what each inspector found, and each card opens its tab. -## Project +<ngmd-pill-row> + <ngmd-pill href="/inspectors/components" title="Components"></ngmd-pill> + <ngmd-pill href="/inspectors/signals" title="Signals"></ngmd-pill> + <ngmd-pill href="/inspectors/injectors" title="Injectors"></ngmd-pill> + <ngmd-pill href="/inspectors/router" title="Router"></ngmd-pill> + <ngmd-pill href="/inspectors/pipes" title="Pipes"></ngmd-pill> +</ngmd-pill-row> -The top block shows the project name and a chip for each of: +## What it shows -- **Angular**: the installed Angular version. -- **TypeScript**: the installed TypeScript version. -- **SSR**: On or Off. -- **Analog**: the Analog version, in Analog apps only. +### Project block -The server reads these from your workspace. Versions come from the installed packages in `node_modules`, with the range in `package.json` as a fallback. The project name comes from `angular.json`, or from `package.json` when there is none. SSR is on when the build options set `ssr` or `server`. For Analog apps, SSR follows the `ssr` option of `analog()`. +The top block shows the project name and a chip for each of these: -The same data is available to agents through the `ng-devtools:build-meta` tool, and it is baked into [static reports](/getting-started/cli). +<ngmd-card-grid columns="2"> + <ngmd-card icon="box" title="Angular"> + The installed Angular version. + </ngmd-card> + <ngmd-card icon="code" title="TypeScript"> + The installed TypeScript version. + </ngmd-card> + <ngmd-card icon="layers" title="SSR"> + <code>On</code> or <code>Off</code>. + </ngmd-card> + <ngmd-card icon="rocket" title="Analog"> + The Analog version. Shown in Analog apps only. + </ngmd-card> +</ngmd-card-grid> -## Cards +### Inspector cards Each card counts what one inspector found. Click a card to open its tab. @@ -33,9 +53,77 @@ Each card counts what one inspector found. Click a card to open its tab. | NgRx declarations | NgRx declarations in source, broken down by kind. | | Pipes | Custom pipes in source, plus the built-in pipes in use. | -A card shows **Counting…** while it loads, and **Count unavailable** when its data could not be read. +### Card states + +A card shows **Counting…** while it loads. It shows **Count unavailable** when its data could not be read. + +<ngmd-alert severity="helpful"> + The NgRx card opens the <strong>NgRx</strong> dock when the hub is mounted. +</ngmd-alert> + +## Where the data comes from + +Most of the Dashboard reads your workspace, not the running page. It works before the app has even loaded in a browser. + +### Versions and project name + +The server reads versions from the installed packages in `node_modules`. When a package is not installed, it falls back to the range in `package.json`. + +The project name comes from `angular.json`. When there is no `angular.json`, it comes from `package.json`. + +### SSR status + +SSR is **On** when the build options set `ssr` or `server`. For *Analog apps, SSR follows the `ssr` option of `analog()`. + +### Counts + +The Components, Routes, NgRx and Pipes cards count the source scan. The Signals and Injectors cards use the live page when one is connected, and the source scan otherwise. + +## How to use it + +<ngmd-workflow> + <ngmd-step title="Check the versions"> + Confirm the Angular and TypeScript chips match what you expect. A mismatch usually means a stale install. + </ngmd-step> + <ngmd-step title="Open the app in a browser"> + The Signals and Injectors cards switch to live counts once a page connects. + </ngmd-step> + <ngmd-step title="Jump to an inspector"> + Click the card for the area you want to look at. It opens that tab. + </ngmd-step> +</ngmd-workflow> + +## Agent tools + +| Tool | What it returns | +| ------------------------ | ----------------------------------------------------------------- | +| `ng-devtools:build-meta` | Angular and TypeScript versions, the project name and SSR status. | + +The same data is baked into [static reports](/getting-started/cli). See [Tools](/agents/tools) for every tool. + +## Limits and gotchas + +<ngmd-callout type="warning" title="Project details unavailable"> + If the project block says <strong>Project details unavailable</strong>, check that the dev server is running, then reload the panel. +</ngmd-callout> + +<ngmd-callout type="info" title="Source counts do not need a page"> + The source-based cards fill in from the workspace scan. Only the Signals and Injectors cards change when a page connects. +</ngmd-callout> -## Tips +## Related pages -- If the project block says **Project details unavailable**, check that the dev server is running, then reload the panel. -- The NgRx card opens the NgRx dock when the hub is mounted. +<ngmd-card-grid columns="2"> + <ngmd-card icon="layers" title="Components" link="/inspectors/components" cta="Open"> + Every component instance on the page, with live inputs and outputs. + </ngmd-card> + <ngmd-card icon="compass" title="Router" link="/inspectors/router" cta="Open"> + The live route, every navigation, and a route lint. + </ngmd-card> + <ngmd-card icon="terminal" title="Standalone CLI" link="/getting-started/cli" cta="Run"> + Serve the devtools or build a static report. + </ngmd-card> + <ngmd-card icon="sparkles" title="Agent tools" link="/agents/tools" cta="Browse"> + Every tool a coding agent can call. + </ngmd-card> +</ngmd-card-grid> diff --git a/apps/docs/src/content/inspectors/forms.md b/apps/docs/src/content/inspectors/forms.md index 462570f..9431c47 100644 --- a/apps/docs/src/content/inspectors/forms.md +++ b/apps/docs/src/content/inspectors/forms.md @@ -3,63 +3,221 @@ title: Forms description: Every form on the page with each field's state and errors, a change timeline, submit explanations and a lint. --- +<ngmd-hero title="Forms" gradient> + Every Signal Form, reactive form and template-driven form on the page. Each field's value, state and errors, where each error comes from, a change timeline, submit explanations and a lint. +</ngmd-hero> + # 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. +The Forms tab reads the forms of the running page, in development builds only. It covers Signal Forms, reactive forms and template-driven forms. Actions you run from the tab are sent back to the page and run there. -## Where the data comes from +## What it shows -The [overlay](/getting-started/overlay) finds the forms on the page and sends their state. Form actions (touch, reset, submit and others) are sent back to the page and run there. The server adds the source file and line of each form and its rules. +### Forms list -## Forms list +The sidebar lists each form with its label, its kind (**Signal Forms**, **Reactive** or **Template-driven**) and its error count. When the panel runs inside a page and other tabs report forms, check **All pages** to include them. -The sidebar lists each form with its label, kind (**Signal Forms**, **Reactive** or **Template-driven**) and error count. Check **All pages** to include forms from other open tabs. +Select a form to see its status, whether it is dirty or touched, whether it was submitted or is submitting, and an **Error summary**. -Select a form to see its status, dirty and touched state, whether it was submitted or is submitting, and an error summary. +### Fields view -## Fields +Each field shows its value, status, touched and dirty state, and errors. Extra facts depend on the kind: -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. +- **Reactive and template-driven**: whether validators and async validators are attached, the value `reset()` goes back to, `updateOn`, and the bound `ControlValueAccessor`. -- For 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`. +Filter by path, or with the **Invalid**, **Dirty**, **Touched**, **Disabled** and **Error not shown** chips. Hover a field to highlight its input in the page. -Filter by path, or with the **Invalid**, **Dirty**, **Touched**, **Disabled** and **Error not shown** chips. Hover a field to highlight its input in the page. Click it for field details, where you can set a value, focus, touch or revalidate the field, or store it as a global (`$form` in the page console). +### Error sources -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()`. +Each error says where it comes from: -## Actions +| Label | Meaning | +| ------------------ | -------------------------------------------------------- | +| validator | A validator on the control. | +| template attribute | A template attribute, such as `required` or `minlength`. | +| cross-field rule | A rule on an ancestor, with the ancestor's path. | +| async | An async validator. | +| parse | The input could not parse the typed text. | +| schema | A Standard Schema, with the path it reported. | +| server | A server or submission error. | +| setErrors | Code set the error with `setErrors()`. | -The actions bar works on the selected form: +### Field details -- **Touch all**, **Revalidate**, **Focus first invalid** and **Pick field on page**. -- **Snapshot** saves the form's values. **Restore** puts them back. -- **Reset** and **Submit**. +Click a field to open its details. There you can set a value, **Focus**, **Touch** or **Revalidate** the field, or **Store as global**. That stores the form as `$form`, and the field as `$control`, in the page console. -Restore, reset and submit ask for confirmation first. Secret fields are never written. +### Timeline view -## Views +Recent changes, newest first, each tagged with its origin: user, code or devtools. Array items are tracked by identity, so moves show as moves. Async validation times show as **pending** tags. -The Forms tab has four views: +Check **Record details** to add the calling code of each change, validator changes, and component renders per keystroke. It is off by default and applies to the whole page. -- **Fields**: the field table with filters and per-field actions. -- **Timeline**: recent changes, each tagged with its origin (user, code or devtools). Check **Record details** to add 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. -- **Submit**: what submit will do and why it might do nothing, plus what the form sends. **Copy test fixture** copies a fixture for your tests. -- **Lint**: form bugs and model-aware accessibility checks. For generic accessibility checks, run axe on the page. +### Submit view -Pick a field on the page to select it, or open a form from its component in the [Components tab](/inspectors/components). +What submit will do, and why it might do nothing. It also shows what the form sends. **Copy test fixture** copies a fixture for your tests. -## For agents +### Lint view -`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 `Checkout.form@ab12`, or part of a label like `Checkout.form`) to explain one form. +Form bugs and model-aware accessibility checks, each with a fix. For generic accessibility checks, run axe on the page. -- `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. -- `ng-devtools: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`. +### Actions bar -See [Tools](/agents/tools) for the full list. +The actions bar works on the selected form: + +- **Touch all**, **Revalidate** and **Focus first invalid**. +- **Pick field on page**: click a field in the app to select it. Esc cancels. +- **Snapshot** saves the form's values. **Restore s1**, **Restore s2** and so on put them back. +- **Reset** and **Submit**. -## Privacy +## Where the data comes from -Form values leave the page: they are sent to the devtools server, shown in the Forms tab and returned to agents. Password fields and fields with secret-looking names are replaced with `[redacted]`. See [Security](/security) for the full rules and how to mask or unmask a field. +<ngmd-card-grid columns="2"> + <ngmd-card icon="zap" title="Live page"> + The overlay finds the forms through Angular's debug API and pushes their state. + </ngmd-card> + <ngmd-card icon="file" title="Source scan"> + The server adds the file and line of each form and its rules. + </ngmd-card> +</ngmd-card-grid> + +### When the page reports + +The overlay pushes the forms every 3 seconds, and shortly after each `input`, `change`, `focusout`, `submit` or `reset` event. Reactive and template-driven forms also report each change through `control.events`. + +### Validators run only when needed + +To tell where each error comes from, the devtools run the sync validators of reactive and template-driven fields themselves. They do this only for enabled leaf fields. The result is reused for up to 5 seconds while the value and the validators stay the same. With **Record details** on, they run on every report. + +Async validators are never run by the devtools. The probe emits no form events, so it does not show up in the timeline. + +<ngmd-callout type="warning" title="Validators with side effects"> + The devtools call your sync validators. A validator that logs, counts or changes state will see extra calls while the Forms tab is open. +</ngmd-callout> + +## How to use it + +### Find why a form is invalid + +<ngmd-workflow> + <ngmd-step title="Select the form"> + The error count in the sidebar shows which forms fail. + </ngmd-step> + <ngmd-step title="Filter to Invalid"> + Click the <strong>Invalid</strong> chip. + </ngmd-step> + <ngmd-step title="Read the source"> + Each error says which validator, attribute or rule set it. + </ngmd-step> + <ngmd-step title="Check what the user sees"> + Click <strong>Error not shown</strong> to find errors that have no visible message. + </ngmd-step> +</ngmd-workflow> + +### Find why submit does nothing + +<ngmd-workflow> + <ngmd-step title="Open Submit"> + It explains what submit will do. + </ngmd-step> + <ngmd-step title="Read the payload"> + Compare the value with what your API expects. + </ngmd-step> + <ngmd-step title="Copy a fixture"> + Click <strong>Copy test fixture</strong> to reproduce it in a test. + </ngmd-step> +</ngmd-workflow> + +### Test a form by hand + +<ngmd-workflow> + <ngmd-step title="Snapshot"> + Save the current values. + </ngmd-step> + <ngmd-step title="Change things"> + Type in the app, or set values from the field details. + </ngmd-step> + <ngmd-step title="Restore"> + Click <strong>Restore s1</strong>, then click again to confirm. + </ngmd-step> +</ngmd-workflow> + +You can also open a form from its component in the [Components tab](/inspectors/components). + +## Agent tools + +`form` is a form id like `Checkout.form@ab12`, or part of its label. `path` is a dotted field path, like `address.city`. + +### Read tools + +| Tool | What it does | +| ------------------------------------ | ----------------------------------------------------------------------------------------------------------------- | +| `ng-devtools:explain-form-invalid` | Start here. Every invalid or pending form, with each failing field's value, validator, message and touched state. | +| `ng-devtools:inspect-forms` | The forms with status and error counts. With `form`, the field tree. Narrow with `path` or `onlyInvalid`. | +| `ng-devtools:explain-field` | One field: error sources, skip reasons, pending values, binding, visible errors, and source lines. | +| `ng-devtools:explain-submit` | What submit will do, and why it might do nothing. | +| `ng-devtools:form-payload` | What the form sends: value against raw value, and unvalidated fields. | +| `ng-devtools:form-history` | The change timeline with origins. Returns a marker. | +| `ng-devtools:form-diff` | The net change since a marker. | +| `ng-devtools:lint-forms` | Form bugs and accessibility checks. | +| `ng-devtools:explain-custom-control` | How a field binds to its element, and what is wrong with the binding. | +| `ng-devtools:export-form` | A JSON snapshot or a test fixture. | +| `ng-devtools:wait-for-form` | Waits until the form is settled, valid, not pending or submitted. | + +### Write tools + +| Tool | What it does | +| ------------------------- | ---------------------------------------------------------------------------------- | +| `ng-devtools:form-action` | Set, touch, revalidate, reset, submit, focus, snapshot, restore and more. | +| `ng-devtools:fill-form` | Fills several fields through the inputs, like a user would. Can submit afterwards. | + +Agents can loop: inspect, act, `wait-for-form`, then `form-diff` from the marker they had. The `ng-devtools:forms` resource holds every form and recent changes. See [Tools](/agents/tools). + +## Limits and gotchas + +<ngmd-callout type="warning" title="Reset, submit and restore ask first"> + In the tab, the button turns into <strong>Confirm reset</strong>, <strong>Confirm submit</strong> or <strong>Confirm restore</strong>. Click again to run it. Agents pass <code>confirm: true</code> for the same actions, and for <code>fill-form</code> with <code>submit</code>. +</ngmd-callout> + +<ngmd-callout type="info" title="Fields that are never written"> + Secret fields are never written. For Signal Forms, hidden, readonly and rule-disabled fields are skipped too. Disabled reactive fields are written only with <code>force</code>. +</ngmd-callout> + +<ngmd-callout type="info" title="Snapshots"> + Up to 20 snapshots are kept. They are lost on reload. Restore fails when the form's shape has changed, and it keeps the current value of secret fields. +</ngmd-callout> + +<ngmd-callout type="danger" title="Form values leave the page"> + Values are sent to the devtools server, shown in the tab and returned to agents. Password fields and fields with secret-looking names are replaced with <code>[redacted]</code>. See <a href="/security">Security</a> to mask or unmask a field. +</ngmd-callout> + +## FAQ + +<ngmd-accordion> + <ngmd-accordion-item title="Why does the tab say no forms on this page?"> + The current tab has no form yet. Click <strong>Show forms from all pages</strong> to see forms from other tabs. + </ngmd-accordion-item> + <ngmd-accordion-item title="Why are there no callers in the timeline?"> + Callers are recorded only with <strong>Record details</strong> checked. + </ngmd-accordion-item> + <ngmd-accordion-item title="Does the tab change my form when I only look at it?"> + No. It reads state and runs sync validators without emitting events. Only the actions write. + </ngmd-accordion-item> +</ngmd-accordion> + +## Related pages + +<ngmd-card-grid columns="2"> + <ngmd-card icon="layers" title="Components" link="/inspectors/components" cta="Open"> + Open a form from the component that owns it. + </ngmd-card> + <ngmd-card icon="shield" title="Security" link="/security" cta="Read"> + What is redacted, and how to mask a field. + </ngmd-card> + <ngmd-card icon="sparkles" title="Agent tools" link="/agents/tools" cta="Browse"> + Every tool a coding agent can call. + </ngmd-card> + <ngmd-card icon="compass" title="Browser overlay" link="/getting-started/overlay" cta="Set up"> + The script that reports the live page. + </ngmd-card> +</ngmd-card-grid> diff --git a/apps/docs/src/content/inspectors/injectors.md b/apps/docs/src/content/inspectors/injectors.md index 15efa17..3aa5c5c 100644 --- a/apps/docs/src/content/inspectors/injectors.md +++ b/apps/docs/src/content/inspectors/injectors.md @@ -3,39 +3,145 @@ title: Injectors description: The injector hierarchy, token lookup paths and the providers at each level. --- +<ngmd-hero title="Injectors" gradient> + The injector tree of the running page. Find a token, see who provides it, and follow the path Angular takes to resolve it. +</ngmd-hero> + # Injectors -Every component and directive gets an injector. When it asks for a token, Angular walks up this tree, then through the environment injectors, until something provides it. The Injectors tab shows that tree. +When a component asks for a token, Angular walks up the element injectors, then through the environment injectors, until something provides it. The Injectors tab shows that tree. Without a live page, it lists the DI found in your source. -## Where the data comes from +## What it shows + +### Elements and Environment + +A switch at the top picks the view: -- **Live**: the [overlay](/getting-started/overlay) reads the injector tree from Angular's debug API. This needs Angular 17 or later and a development build. -- **Source**: without a live tree, the tab lists DI found in your files, grouped as **Root Providers (provide\*)**, **Injectable Services**, **inject() Calls** and **Component Providers**. +- **Elements**: one node per host element that has a component or a directive. +- **Environment**: the environment injectors, such as the root and platform injectors. -## The tree +Each row shows a kind letter (`C`, `D` or `E`), the tag or injector name, and icons with the number of injected and provided tokens. -Switch between **Elements** (component and directive injectors) and **Environment** (the environment injectors, such as the root injector). +### Search and filters -- Search for a token, component or injector. A match shows **Provided by** chips; click one to jump to that injector. -- **Components only** hides directive injectors. It is on by default. +- Search for a token, component, directive or injector. When a token matches, **Provided by** chips list the injectors that provide it. Click one to jump there. +- **Components only** hides elements without a component. It keeps an element when it is an ancestor of one that stays. It is on by default, in the Elements view only. - **With providers** hides injectors that provide nothing. -- Hover an element injector to highlight its element in the page. +- Hover or focus an element injector to highlight its element in the page. + +### Lookup path + +Select an injector to see the **Lookup path**: the injectors Angular asks, in order, until one has the token. The path ends at the null injector, which throws `NullInjectorError`. Click any step, except the null injector, to open it. + +### Injected here + +For element injectors, **Injected here** lists each token requested at this level and the injector that answered. A token that nobody provides is marked **not provided anywhere**. When the element has more than one class, each row says which class asked. + +### Provides + +**Provides** lists each provider with its kind: `useClass`, `useValue`, `useFactory` or `useExisting`. A bare class shows as `useClass`. Chips mark **viewProviders** and **multi** providers. Providers that come from imported modules show the import path, as `via A › B`. + +### Source mode + +Without a live tree, the tab lists DI found in your source files, in four groups: + +| Group | Lists | +| ------------------------------ | --------------------------------------------------------------------------------------------------- | +| **Root Providers (provide\*)** | Calls to known Angular `provide*()` functions, such as `provideRouter()` and `provideHttpClient()`. | +| **Injectable Services** | `@Injectable` classes, plus `signalStore` and `InjectionToken` declarations with `providedIn`. | +| **inject() Calls** | `inject(T)` field initializers and `@Inject(T)` parameters. | +| **Component Providers** | Any `providers` or `viewProviders` array, in components, routes, app config or NgModules. | + +## Where the data comes from + +<ngmd-card-grid columns="2"> + <ngmd-card icon="zap" title="Live page"> + The overlay reads the tree with Angular's debug API and pushes it with the component tree, every 3 seconds. + </ngmd-card> + <ngmd-card icon="file" title="Source scan"> + The server reads your <code>.ts</code> files, skipping specs and type declarations. + </ngmd-card> +</ngmd-card-grid> + +### Debug APIs + +The live tree needs a development build. It uses `ng.getInjector`, `ng.getComponent`, `ng.getDirectives` and these private helpers: + +- `ɵgetInjectorMetadata` tells element and environment injectors apart. +- `ɵgetInjectorProviders` lists the providers of each injector. +- `ɵgetInjectorResolutionPath` gives the lookup path. +- `ɵgetDependenciesFromInjectable` gives the tokens each class injects. + +The source-mode notice says to connect the overlay on Angular 17 or later for the live tree. + +## How to use it + +### Fix a NullInjectorError + +<ngmd-workflow> + <ngmd-step title="Search for the token"> + Type the token name in the search box. If no <strong>Provided by</strong> chip appears, nothing on the page provides it. + </ngmd-step> + <ngmd-step title="Select the component that asks for it"> + Read <strong>Injected here</strong>. The token is marked <strong>not provided anywhere</strong>. + </ngmd-step> + <ngmd-step title="Read the lookup path"> + Each step is an injector Angular asked. Add the provider to one of them, usually the app config or the component. + </ngmd-step> +</ngmd-workflow> + +### Find which instance a component gets + +<ngmd-workflow> + <ngmd-step title="Select the component"> + Open its element injector. + </ngmd-step> + <ngmd-step title="Read Injected here"> + Each token shows the injector that answered. A component-level provider shadows the root one. + </ngmd-step> +</ngmd-workflow> + +### Keyboard + +Arrow keys, Home and End move the selection through the tree. The first row is selected when nothing else is. + +## Agent tools + +| Tool or resource | Kind | What it does | +| ------------------------------- | -------- | ------------------------------------------------------------------------------------------- | +| `ng-devtools:get-providers` | tool | DI providers from source: `@Injectable` services, `inject()` calls and `providers` arrays. | +| `ng-devtools:inspect-providers` | tool | The injector tree a page reported. `pageId` picks a tab. `selector` only labels the answer. | +| `ng-devtools:injector-tree` | resource | The live tree last reported by a page. | -## Details +See [Tools](/agents/tools) and [Resources](/agents/resources). -Select an injector to see: +## Limits and gotchas -- **Lookup path**: the injectors Angular asks, in order, until one has the token. The path ends at the null injector, which throws `NullInjectorError`. Click any step to open it. -- **Injected here** (element injectors): each token requested at this level, the directive that asked for it, and the injector that answered, or **not provided anywhere**. -- **Provides**: each provider with its kind (`useClass`, `useValue`, `useFactory` or `useExisting`), and whether it is a view provider or a multi provider. Providers that come from imported modules show the import path. +<ngmd-callout type="warning" title="Up to 2000 element injectors"> + The Elements view stops at 2000 injectors, without a notice. Environment injectors are not capped. +</ngmd-callout> -## For agents +<ngmd-callout type="info" title="Source mode only knows some provide functions"> + The <strong>Root Providers</strong> group matches a fixed list of Angular <code>provide*()</code> functions. Your own provider functions are not listed there. +</ngmd-callout> -- `ng-devtools:get-providers` lists DI providers from source. -- `ng-devtools:inspect-providers` returns the injector tree a page reported. -- The `ng-devtools:injector-tree` resource holds the live tree. +<ngmd-alert severity="helpful"> + Constructor parameters without <code>@Inject()</code> are not found by the source scan. The live tree has them. +</ngmd-alert> -## Tips +## Related pages -- If a token throws `NullInjectorError`, search for it and read the lookup path of the component that asks for it. -- The tree shows up to 2000 injectors. +<ngmd-card-grid columns="2"> + <ngmd-card icon="layers" title="Components" link="/inspectors/components" cta="Open"> + Each instance, with the services it injects. + </ngmd-card> + <ngmd-card icon="box" title="NgRx Store" link="/inspectors/ngrx-store" cta="Open"> + Signal stores, found through the injectors. + </ngmd-card> + <ngmd-card icon="sparkles" title="Agent tools" link="/agents/tools" cta="Browse"> + Every tool a coding agent can call. + </ngmd-card> + <ngmd-card icon="compass" title="Browser overlay" link="/getting-started/overlay" cta="Set up"> + The script that reports the live page. + </ngmd-card> +</ngmd-card-grid> diff --git a/apps/docs/src/content/inspectors/ngrx-store.md b/apps/docs/src/content/inspectors/ngrx-store.md index 3f00759..8226808 100644 --- a/apps/docs/src/content/inspectors/ngrx-store.md +++ b/apps/docs/src/content/inspectors/ngrx-store.md @@ -3,45 +3,174 @@ title: NgRx Store description: Live NgRx signal stores and @ngrx/store state, with change logs, diffs and restore. --- +<ngmd-hero title="NgRx Store" logo="https://cdn.simpleicons.org/ngrx/BA2BD2" gradient> + Your NgRx state as it changes. Signal stores and the classic Store, with a log of every change, a diff per entry, and restore. +</ngmd-hero> + # NgRx Store -The Store tab shows your *NgRx state as it changes. It covers `@ngrx/signals` stores (`signalStore` and `signalState`) and the classic `@ngrx/store`. With the hub mounted, it lives in the **NgRx** dock. +The Store tab shows your *NgRx state live. It covers `@ngrx/signals` stores (`signalStore` and `signalState`) and the classic `@ngrx/store`. With the hub mounted, it lives in the **NgRx** dock. -## Where the data comes from +The tab has two sections. **Live stores** reads the running page. **Source declarations** lists what your files declare. + +## What it shows + +### Store list -- **Live**: the [overlay](/getting-started/overlay) finds stores in the page's injectors and component fields, and records every change. -- **Source**: the server scans your files for `signalStore` (with its `withState`, `withComputed`, `withMethods`, `withProps`, `withHooks`, `withEntities` and `rxMethod` members), `signalState`, `signalMethod`, and `createAction`, `createReducer`, `createEffect`, `createSelector` and `createFeature`. +Each store shows its label, its kind (**signalStore**, **signalState** or **@ngrx/store**), its scope and its change count. The scope is where the store lives: -## Live stores +- `root`, `platform` or another environment injector that provides it. +- `Owner (component)` for a store a component provides. +- `Owner (field)` for a store found only in a component field. -Each store in the list shows its label, kind (**signalStore**, **signalState** or **@ngrx/store**), scope and change count. The scope is the environment injector that provides it, or the component that owns it. +The classic Store shows with the label **Store**. Use the filter box to narrow stores, changes and declarations. When more than one page reports, a picker chooses the page. + +### Store detail Select a store to see: -- Its kind, scope and declaring file. -- The component fields that reference it (**Referenced by**). +- Its kind, scope and declaring file. The file appears when the store's state keys match a `signalStore` in your source. +- **Store DevTools on** or **read-only**, for the classic Store. +- **Referenced by**: the component fields that hold it. - **State**, **Computed** and **Methods**, with a call count per method. `rxMethod` members are tagged. -## Change log - -Signal stores get a **Change log**. `@ngrx/store` gets an **Action log**. Each entry shows its number, type, the number of changes and the time. Open an entry to see its arguments and a **State diff** with the value before and after each change. +### Change log -Method calls are logged even when they change nothing. The log keeps the last 200 entries. +Signal stores get a **Change log**. The classic Store gets an **Action log**. Each entry shows its number, its type, the number of changes and the time. -## Restore +Open an entry to see its arguments and a **State diff** with the value before and after each change. -Open an entry and click **Restore this state**, then **Restore** to confirm. +### Source declarations -- For a signal store, restore sets every state key back to its value right after that change. Components that read the store update at once, and a new **Restore** entry is added to the log. Call `registerNgrxSignals({ patchState })` once, so restore also notifies `watchState` listeners. See [Restore NgRx signal state](/guides/ngrx-signals-restore). -- For `@ngrx/store`, restore uses Store DevTools to jump to the state right after that action. New actions continue from there. Time travel needs `provideStoreDevtools()`. Without it the log is read-only. +The server scans your files for: -## For agents +- `signalStore` with its `withState`, `withComputed`, `withMethods`, `withProps`, `withHooks`, `withEntities` and `rxMethod` members. +- `signalState` and `signalMethod`. +- `createAction`, `createActionGroup`, `createReducer`, `createEffect`, `createSelector`, `createFeatureSelector` and `createFeature`. +- Store setup: `provideStore`, `provideState`, `provideEffects`, and the `StoreModule` and `EffectsModule` calls. -- `ng-devtools:get-ngrx-store` scans the source for NgRx declarations. -- The `ng-devtools:ngrx-store` resource holds the live stores and the change log. +Filter by kind with the chips. -## Tips +## Where the data comes from -- A `signalStore` is created the first time something injects it. Open a page that uses it, and it appears. -- For `@ngrx/store`, the Store must be provided in an environment injector (`provideStore()` or `StoreModule.forRoot()`). -- State keys with secret-looking names are replaced with `[redacted]`. See [Security](/security). +<ngmd-card-grid columns="2"> + <ngmd-card icon="zap" title="Live page"> + The overlay finds stores in the page's injectors and component fields, and records every change. + </ngmd-card> + <ngmd-card icon="file" title="Source scan"> + The server reads your <code>.ts</code> files for NgRx declarations. Specs are skipped. + </ngmd-card> +</ngmd-card-grid> + +### How changes are recorded + +The overlay wraps the state signals of each signal store and the store's methods. A method call becomes one log entry with its arguments. Nested method calls fold into the outer one. Writes made outside a method are batched and logged as `patchState`. + +For the classic Store, the overlay listens to the dispatched actions. + +### Development builds + +Stores are found through Angular's debug API, so the live section needs a development build. + +## How to use it + +### Find the change that broke the state + +<ngmd-workflow> + <ngmd-step title="Select the store"> + Pick it in the list. Filter by name if there are many. + </ngmd-step> + <ngmd-step title="Walk the log"> + Open entries from newest to oldest. Each <strong>State diff</strong> shows the keys that changed. + </ngmd-step> + <ngmd-step title="Read the arguments"> + The entry that set the wrong value shows the method and the arguments it got. + </ngmd-step> +</ngmd-workflow> + +### Restore an earlier state + +<ngmd-workflow> + <ngmd-step title="Open an entry"> + Pick the change you want to go back to. + </ngmd-step> + <ngmd-step title="Restore this state"> + Click <strong>Restore this state</strong>, then <strong>Restore</strong> to confirm. + </ngmd-step> + <ngmd-step title="Check the page"> + Components that read the store update at once. A new <code>Restore #N</code> entry is added to the log. + </ngmd-step> +</ngmd-workflow> + +### Restore modes + +<ngmd-tabs> + <ngmd-tab title="Signal store" icon="zap"> + Restore sets every state key that differs back to its value right after that change. Every state signal must be writable. + </ngmd-tab> + <ngmd-tab title="@ngrx/store" icon="box"> + Restore uses Store DevTools to jump to the state right after that action. New actions continue from there. It needs <code>provideStoreDevtools()</code>. Without it, the log is read-only. + </ngmd-tab> +</ngmd-tabs> + +## Agent tools + +| Tool or resource | Kind | What it does | +| ---------------------------- | -------- | ---------------------------------------------------------------------------------------- | +| `ng-devtools:get-ngrx-store` | tool | NgRx declarations from source, with the members of each `signalStore`. | +| `ng-devtools:ngrx-store` | resource | The live stores per page, with state, computeds, methods, references and the change log. | + +Agent access is read-only. No tool can restore a state. See [Tools](/agents/tools) and [Resources](/agents/resources). + +## Limits and gotchas + +<ngmd-callout type="warning" title="watchState needs registerNgrxSignals"> + Without it, restore writes the state signals directly. Components update, but <code>watchState</code> listeners do not run, and the log entry says so. Call <code>registerNgrxSignals({ patchState })</code> from <code>@santoshyadavdev/ng-devtools/overlay</code> once, and restore goes through <code>patchState</code>. This applies to <code>signalStore</code> only. A <code>signalState</code> restore always writes directly. See <a href="/guides/ngrx-signals-restore">Restore NgRx signal state</a>. +</ngmd-callout> + +<ngmd-callout type="info" title="Stores appear when they are created"> + A <code>signalStore</code> is created the first time something injects it. Open a page that uses it, and it appears. +</ngmd-callout> + +<ngmd-callout type="info" title="The classic Store must be in an environment injector"> + Use <code>provideStore()</code> or <code>StoreModule.forRoot()</code>. The overlay stops looking after a few tries, so reload the page if the Store is provided late. +</ngmd-callout> + +<ngmd-callout type="tip" title="Redaction"> + State keys with secret-looking names are replaced with <code>[redacted]</code>, at any depth. See <a href="/security">Security</a>. +</ngmd-callout> + +<ngmd-alert severity="helpful"> + The log keeps the last 200 entries. A method call that changes nothing is logged at most once per second. +</ngmd-alert> + +## FAQ + +<ngmd-accordion> + <ngmd-accordion-item title="Why can't I restore an @ngrx/store entry?"> + Store DevTools is not set up, so the log is read-only. The store detail shows <strong>read-only</strong>. Add <code>provideStoreDevtools()</code> to the app config. + </ngmd-accordion-item> + <ngmd-accordion-item title="Why does restore say this change is no longer in the page history?"> + The entry fell out of the 200-entry log in the page. Pick a newer entry. + </ngmd-accordion-item> + <ngmd-accordion-item title="Why does my store have no declaring file?"> + The file is matched by state keys. A store whose keys match no <code>signalStore</code> in the scanned source has none. + </ngmd-accordion-item> +</ngmd-accordion> + +## Related pages + +<ngmd-card-grid columns="2"> + <ngmd-card icon="wrench" title="Restore NgRx signal state" link="/guides/ngrx-signals-restore" cta="Guide"> + Register <code>patchState</code> so restore notifies <code>watchState</code>. + </ngmd-card> + <ngmd-card icon="zap" title="Signals" link="/inspectors/signals" cta="Open"> + The signal graph of the components that read the store. + </ngmd-card> + <ngmd-card icon="layers" title="Injectors" link="/inspectors/injectors" cta="Open"> + Where each store is provided. + </ngmd-card> + <ngmd-card icon="sparkles" title="Agent tools" link="/agents/tools" cta="Browse"> + Every tool a coding agent can call. + </ngmd-card> +</ngmd-card-grid> diff --git a/apps/docs/src/content/inspectors/pipes.md b/apps/docs/src/content/inspectors/pipes.md index 905c6f5..ebc669f 100644 --- a/apps/docs/src/content/inspectors/pipes.md +++ b/apps/docs/src/content/inspectors/pipes.md @@ -3,41 +3,46 @@ title: Pipes description: Custom and built-in pipes, where they are used, live instances, call recording and a pipe lint. --- +<ngmd-hero title="Pipes" gradient> + Your own pipes and the built-in ones in use. Where they live, which components use them on the page, what they last returned, and what to fix. +</ngmd-hero> + # Pipes -The Pipes tab lists the pipes your templates use: your own `@Pipe` classes and the built-in pipes from `@angular/common`. It shows where each one is declared and used, which components use it on the page, and what it last returned. +The Pipes tab lists your `@Pipe` classes and the built-in pipes from `@angular/common` that your templates use. It shows the live instances on the page, and records calls when you ask it to. -## Where the data comes from +## What it shows -- **Source**: the server scans your files for `@Pipe` classes (name, class, file, standalone and pure flags). Built-in pipes are listed when a template uses them, with every usage site. -- **Live**: the [overlay](/getting-started/overlay) finds pipe instances in the rendered views. This is read-only until you turn on recording. It needs a development build. -- **Lint**: the server checks your source for common pipe mistakes. +### Pipe list -## Pipe list +Search by pipe name, class or file. Narrow the list with **Show pipes**: **All pipes**, **Custom**, **Built-in**, **Impure** or **On the page**. -Search by pipe name, class or file, and narrow the list with **Show pipes**: **All pipes**, **Custom**, **Built-in**, **Impure** or **On the page**. Each row shows the pipe name, its class, and chips for **N live**, **built-in**, **NgModule** (not standalone), **pure** or **impure**. Hover a row to highlight the first component that uses it. +Each row shows the pipe name, its class, and chips: -## Details +- **N live**: instances on the page. +- **built-in**, and **NgModule** for pipes that are not standalone. +- **pure** or **impure**. +- **stale?** when recording caught a possible stale value. -Select a pipe to see: +Hover or focus a row to highlight the first component that uses it. -- **Declaration**: its class, whether it comes from `@angular/common` or your project, its file, whether it is standalone, and whether it is pure. A pure pipe reruns only when an input changes. An impure pipe reruns on every check. -- **Used in templates**: for built-in pipes, every usage site. -- **Live on the page**: the number of instances and the components that use them. Click, hover or focus a component chip to highlight it. +### Declaration -## Record calls +Select a pipe to see its class, whether it comes from `@angular/common` or your project, its file, and whether it is standalone and pure. A pure pipe reruns only when an argument changes. An impure pipe reruns on every check. -Click **Record calls** to count calls and keep the last input and output of each pipe. Recording patches each pipe's `transform` in the inspected page, on every connected tab. Click **Stop recording** when you are done. +### Used in templates -With recording on, the detail panel shows the call count, the last input and output, a per-instance breakdown and the last caller. +For built-in pipes, every template that uses it, with file and line. -A pure pipe only runs when Angular sees a changed argument. While recording, the tab can warn when a pure pipe got an argument whose contents changed while its reference stayed the same, so it may show a stale value. This warning is experimental. +### Live on the page -## Async subscriptions +The number of instances and the components that use them. Click, hover or focus a component chip to highlight it. With recording on, this block adds the call count, the last input and output, a per-instance breakdown and the last caller. -When templates use `| async`, the tab lists each subscription with its component and latest value. Each `| async` subscribes on its own. Two on the same source mean the work runs twice, so those rows are marked **duplicate subscription**. +### Async subscriptions -## Lint +When templates use `| async`, the tab lists each subscription with its component and latest value. This needs no recording. Each `| async` subscribes on its own. Two on the same source run the work twice, so those rows are marked **duplicate subscription**. + +### Lint | Rule | Severity | Finds | | -------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------- | @@ -45,8 +50,106 @@ When templates use `| async`, the tab lists each subscription with its component | `json-pipe-in-template` | info | `\| json` left in a template. It is a debugging aid. | | `signal-read-in-pure-pipe` | warning | A pure pipe whose `transform()` reads a signal. Its memoization only tracks its arguments, not the signals it reads. | -## For agents +## Where the data comes from -- `ng-devtools:get-pipes` lists custom pipes and the built-in pipes in use. -- `ng-devtools:lint-pipes` runs the checks above. -- `ng-devtools:explain-pipe` explains one pipe by name: where it is declared or used, purity, live counts, last input and output, the stale warning and lint findings. +<ngmd-card-grid columns="3"> + <ngmd-card icon="file" title="Source scan"> + <code>@Pipe</code> classes with name, class, file, standalone and pure flags. Built-in pipes with every usage site. + </ngmd-card> + <ngmd-card icon="zap" title="Live page"> + The overlay finds pipe instances in the rendered views. It is read-only until you record. + </ngmd-card> + <ngmd-card icon="search" title="Lint"> + The server checks your source for the three rules above. + </ngmd-card> +</ngmd-card-grid> + +### Built-in pipes + +The built-in list covers the `@angular/common` pipes: `async`, `currency`, `date`, `number`, `i18nPlural`, `i18nSelect`, `json`, `keyvalue`, `lowercase`, `percent`, `slice`, `titlecase` and `uppercase`. Pipes from other packages are not listed as built-in. + +### Live instances + +Live discovery walks the rendered views with `ng.getComponent` and related debug helpers. It needs a development build. + +### Recording + +Click **Record calls** to count calls and keep the last input and output of each pipe. Recording patches each pipe's `transform` in the inspected page, on every connected tab. It covers the pipes found on the page. Click **Stop recording** when you are done. + +## How to use it + +### Find a slow pipe + +<ngmd-workflow> + <ngmd-step title="Show impure pipes"> + Pick <strong>Impure</strong> in <strong>Show pipes</strong>. + </ngmd-step> + <ngmd-step title="Record calls"> + Click <strong>Record calls</strong>, then use the page for a moment. + </ngmd-step> + <ngmd-step title="Read the counts"> + A pipe with a high call count reruns on every check. Make it pure, or move the work into a <code>computed()</code>. + </ngmd-step> + <ngmd-step title="Stop recording"> + Click <strong>Stop recording</strong>. + </ngmd-step> +</ngmd-workflow> + +### Find a stale value + +<ngmd-workflow> + <ngmd-step title="Record calls"> + Recording turns on the stale check. + </ngmd-step> + <ngmd-step title="Look for the stale? chip"> + It marks a pure pipe that got an argument whose contents changed while its reference stayed the same. + </ngmd-step> + <ngmd-step title="Pass a new reference"> + Replace the object or array instead of mutating it, so the pipe reruns. + </ngmd-step> +</ngmd-workflow> + +### Remove duplicate subscriptions + +Open **Async subscriptions** and look for **duplicate subscription** rows. Subscribe once with `@let`, or turn the observable into a signal with `toSignal()`. + +## Agent tools + +| Tool | Live | What it does | +| -------------------------- | ------ | -------------------------------------------------------------------------------------------------------------------------------------- | +| `ng-devtools:get-pipes` | no | Custom pipes and the built-in pipes in use. | +| `ng-devtools:lint-pipes` | no | Runs the lint rules above. | +| `ng-devtools:explain-pipe` | partly | One pipe by `name`: where it is declared or used, purity, live counts, last input and output, the stale warning and the lint findings. | + +Agents cannot turn recording on. Click **Record calls** in the panel first to give `explain-pipe` call data. See [Tools](/agents/tools). + +## Limits and gotchas + +<ngmd-callout type="warning" title="The stale warning is experimental"> + It runs only while recording. It reads the template source, so it needs an unminified development build. When it cannot read the template, it stays quiet. +</ngmd-callout> + +<ngmd-callout type="info" title="Recording ends on reload"> + Recording is off by default and is not remembered. Reload the page and it is off again. +</ngmd-callout> + +<ngmd-callout type="danger" title="Values are not redacted"> + Pipe inputs, outputs and async values are sent as they are, cut to 200 characters. Keep the dev server on localhost. See <a href="/security">Security</a>. +</ngmd-callout> + +## Related pages + +<ngmd-card-grid columns="2"> + <ngmd-card icon="layers" title="Components" link="/inspectors/components" cta="Open"> + The components that use each pipe. + </ngmd-card> + <ngmd-card icon="zap" title="Signals" link="/inspectors/signals" cta="Open"> + Signals a pure pipe should not read. + </ngmd-card> + <ngmd-card icon="sparkles" title="Agent tools" link="/agents/tools" cta="Browse"> + Every tool a coding agent can call. + </ngmd-card> + <ngmd-card icon="shield" title="Security" link="/security" cta="Read"> + What the devtools redact, and what they don't. + </ngmd-card> +</ngmd-card-grid> diff --git a/apps/docs/src/content/inspectors/router.md b/apps/docs/src/content/inspectors/router.md index 5f80cc5..a5d9c9c 100644 --- a/apps/docs/src/content/inspectors/router.md +++ b/apps/docs/src/content/inspectors/router.md @@ -3,39 +3,212 @@ title: Router description: The live route, every navigation as a story, the live route config, router setup and a route lint. --- +<ngmd-hero title="Router" gradient> + The live route, every navigation as one story, the live route config, the router setup and a route lint. Plus the routes your source declares. +</ngmd-hero> + # 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 reads the running app's Router. The top section, **Live router**, has five views. The bottom section, **Source route config**, lists the routes your files declare. When more than one page is connected, a **Page** picker chooses which one you see. + +## What it shows + +### Current + +The route the page is on right now: + +- The URL, and the browser URL when the two differ. +- The navigation in flight, with an **Abort** button. +- The document title, query params and fragment. +- **Active routes**: each active route with its component, params, data, and guards and resolvers. Tags mark lazy routes, inherited params, and whether a data value is static, resolved or inherited. The title row says when the title is inherited. +- **Outlets**: the outlet tree, with the inputs the router binds to each component. + +### Navigations + +Every navigation as one story: + +- Where it came from, and who started it: a `RouterLink`, the code that called `navigate`, or back and forward. +- The extras, redirect chains and loops. +- A phase bar: recognize, guards, resolve, activate. +- Guards and resolvers, lazy loads, reused components, HTTP requests, scroll, and the title afterwards. +- Router warnings, and the cancel or error reason. NG04xxx errors are explained. + +Filter by URL, or check **Only problems**. Each row has **Replay** and **Copy repro** (a markdown repro). **Export JSON** saves the list. + +### Routes + +The live route config. Lazy children are merged in once they load, and the active branch is marked. + +- **Test a URL** and click **Predict** to see which route matches it, or the nearest ones. +- **Probe in app** runs the real matcher without navigating. +- Fill in the params of a route and click **Go** to navigate to it. +- **Read lazy** reads the routes of a lazy route that has not loaded. + +### Setup + +How the router is set up: `provideRouter` or `forRoot`, the effective options with **set** or **default** badges, the enabled features, the strategies, the base href and hydration. + +### Lint + +Route config mistakes, each with a fix: + +- Unreachable routes after `**`, and duplicate paths. +- A `:param` that shadows a literal path. +- Empty-path redirects without `pathMatch: 'full'`, and redirect cycles. +- Deprecated class guards and `canLoad`. +- Lazy chunks downloaded before a rejecting `canActivate`. +- Missing or duplicate titles, and param or input typos. +- `routerLinkActive` without `ariaCurrentWhenActive`. +- Emails in URLs, and return URLs taken from query params. + +Each finding says whether Angular throws, warns or does not warn. Lazy routes that have not loaded are skipped. Click **Check again** to rerun it. + +### Source route config + +The routes declared in your files: `*.routes.ts` and `*routing.module.ts` files, the files they lazy load, and Analog pages. Each row shows the path, the component or target, guards and resolvers, the title and the declaring file. Once the live config is available, this table is collapsed. **Show table** opens it. + +Components rendered by the router show their route and outlet in the [Components tab](/inspectors/components). + +## Where the data comes from + +<ngmd-card-grid columns="2"> + <ngmd-card icon="zap" title="Live page"> + The overlay finds the Router through Angular's debug API and reports the route, navigations, config and setup. + </ngmd-card> + <ngmd-card icon="file" title="Source scan"> + The server reads your route files for the source table, and <code>*.routes.server.ts</code> for render modes. + </ngmd-card> +</ngmd-card-grid> + +### Finding the Router + +The overlay reads the helper `provideRouter()` publishes (`ng.ɵgetRouterInstance`). For `RouterModule.forRoot()` apps, it looks for the `Router` token in the injectors instead. With several app roots, the router that has routes or has navigated wins. + +### Development builds + +The live views need `window.ng`, so they need a development build. In a production build no Router is found, and **Current** says **This page reports no Router**. + +When the debug API exists but lacks the provider helpers, the tab runs in events-only mode. The **Setup** view says so, and the config, lint and actions are limited. + +### Guard verdicts + +The router reports one result for all the guards of a navigation. To see each guard's verdict and time, the devtools wrap every guard and resolver in the live config. The result reads like `authGuard returned UrlTree /login`. + +Without that recording, the guards listed for a navigation are candidates: the `canDeactivate` guards of the page being left, and the `canActivate` and `canActivateChild` guards of the target. + +## How to use it + +### Find out why a navigation failed + +<ngmd-workflow> + <ngmd-step title="Open Navigations"> + Check <strong>Only problems</strong> to hide the navigations that succeeded. + </ngmd-step> + <ngmd-step title="Read the story"> + The phase bar shows where it stopped. The guard rows show which guard returned <code>false</code> or a <code>UrlTree</code>. + </ngmd-step> + <ngmd-step title="Replay it"> + Fix the code, then click <strong>Replay</strong> to run the same navigation again. + </ngmd-step> + <ngmd-step title="Share it"> + Click <strong>Copy repro</strong> to paste a markdown repro into an issue. + </ngmd-step> +</ngmd-workflow> + +### Check which route a URL hits + +<ngmd-workflow> + <ngmd-step title="Open Routes"> + Type the URL into <strong>Test a URL</strong>. + </ngmd-step> + <ngmd-step title="Predict"> + Click <strong>Predict</strong>. A miss lists the nearest routes. + </ngmd-step> + <ngmd-step title="Probe"> + Click <strong>Probe in app</strong> to confirm with the real matcher. It runs <code>canMatch</code> and may load lazy chunks. + </ngmd-step> +</ngmd-workflow> + +### Clean up the config + +<ngmd-workflow> + <ngmd-step title="Open Lint"> + Read the findings, most severe first. + </ngmd-step> + <ngmd-step title="Apply the fix"> + Each finding comes with a fix. Start with the ones where Angular stays silent. + </ngmd-step> + <ngmd-step title="Check again"> + Click <strong>Check again</strong> after the app reloads. + </ngmd-step> +</ngmd-workflow> + +## Agent tools -## Live router +| Tool or resource | What it does | +| --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| `ng-devtools:explain-navigation` | Why a navigation failed or redirected. Pass `url` or `id` to narrow it, `limit` for more than the last 5, or `perf` for the slowest ones. | +| `ng-devtools:inspect-route` | The route the page is on. Pass `selector` (a component class, tag or link text) for the route a component was rendered for, or a link state. | +| `ng-devtools:list-routes` | The live config with source files and example URLs. `match` predicts a URL, `audit` lists the guards of each page. | +| `ng-devtools:lint-routes` | The lint findings. | +| `ng-devtools:router-config` | The setup, including whether guard recording is on. | +| `ng-devtools:export-navigation` | A markdown repro. Defaults to the latest navigation that did not succeed. | +| `ng-devtools:explain-render-mode` | Which render mode a URL gets, from `*.routes.server.ts`. | +| `ng-devtools:get-routes` | Routes from your source files. | +| `ng-devtools:navigate` | Acts on the router: `navigate`, `abort`, `replay`, `probe`, `instrument` and `resolve-lazy`. | +| `ng-devtools:router` (resource) | The active route tree and recent navigations of each page. | -The live section has five views: +`navigate` only accepts same-origin relative URLs that start with `/`. `resolve-lazy` needs a `routeId`. See [Tools](/agents/tools). -- **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. +## Limits and gotchas -Components rendered by the router show the route and outlet in the [Components tab](/inspectors/components). +<ngmd-callout type="info" title="Guard recording is on by default"> + <strong>Record each guard and resolver</strong> in the Navigations view starts checked. Uncheck it to stop. Turning it off puts every original guard and resolver back. The choice is kept per browser tab, in <code>sessionStorage</code>, so it survives a reload. Agents use <code>navigate</code> with <code>action: "instrument"</code> and <code>on</code>. +</ngmd-callout> -## Source route config +<ngmd-callout type="warning" title="Abort and probe need Angular 20"> + Aborting and probing use router APIs that older versions lack. On those versions the action returns an error. +</ngmd-callout> -Below the live views, the tab lists the routes declared in your source files: `*.routes.ts` and `*routing.module.ts` files, the files they lazy load, and Analog pages. It shows each path, its component or target, guards and resolvers, title and the file that declares it. Once live config is available, this table is collapsed. +<ngmd-callout type="info" title="Navigations before the devtools connected"> + Only the last one is listed, marked <strong>before DevTools connected</strong>, without timing or guard details. +</ngmd-callout> -## Guard verdicts +<ngmd-callout type="tip" title="Redaction"> + Query, matrix and fragment values with secret-looking keys are replaced with <code>[redacted]</code>. So are tokens, <code>Bearer</code> values, and route params with secret-looking names such as <code>:token</code>. A navigation with a redacted URL cannot be replayed. See <a href="/security">Security</a>. +</ngmd-callout> -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. +<ngmd-alert severity="helpful"> + The page keeps the last 50 navigations and 50 preloads. The live config stops at 1000 routes. +</ngmd-alert> -## For agents +## FAQ -- `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. +<ngmd-accordion> + <ngmd-accordion-item title="Why does a navigation list several guards but no verdicts?"> + Guard recording is off for that tab. Check <strong>Record each guard and resolver</strong> and run the navigation again. + </ngmd-accordion-item> + <ngmd-accordion-item title="Does Probe in app change the URL?"> + No. It runs the real matcher with <code>skipLocationChange</code> and stops after recognition. <code>canActivate</code>, <code>canDeactivate</code> and resolvers do not run. + </ngmd-accordion-item> + <ngmd-accordion-item title="Why is the source table collapsed?"> + The live config is available, so it is the better source. Click <strong>Show table</strong> to open the source list. + </ngmd-accordion-item> +</ngmd-accordion> -## Privacy +## Related pages -Secret-looking query, matrix and fragment values, tokens and route params are replaced with `[redacted]` in URLs, params, data and messages. See [Security](/security). +<ngmd-card-grid columns="2"> + <ngmd-card icon="layers" title="Components" link="/inspectors/components" cta="Open"> + Routed components show their route and outlet. + </ngmd-card> + <ngmd-card icon="rocket" title="Analog" link="/inspectors/analog" cta="Open"> + File routes, server calls and render modes for Analog apps. + </ngmd-card> + <ngmd-card icon="shield" title="Security" link="/security" cta="Read"> + What is redacted, and how access is limited. + </ngmd-card> + <ngmd-card icon="sparkles" title="Agent tools" link="/agents/tools" cta="Browse"> + Every tool a coding agent can call. + </ngmd-card> +</ngmd-card-grid> diff --git a/apps/docs/src/content/inspectors/signals.md b/apps/docs/src/content/inspectors/signals.md index 56434f8..064f6f5 100644 --- a/apps/docs/src/content/inspectors/signals.md +++ b/apps/docs/src/content/inspectors/signals.md @@ -3,50 +3,166 @@ title: Signals description: The live signal graph of one component, with a value history per signal. --- -# Signals +<ngmd-hero title="Signals" gradient> + The live signal graph of one component: its signals, computeds, linked signals and effects, the edges between them, and a value history per signal. +</ngmd-hero> -The Signals tab shows the live signal graph of one component: its signal, computed, linkedSignal and effect nodes, and the edges between them. Only signals that a template or an effect has read appear. A signal nothing has read yet is not part of the graph. +# Signals -## Where the data comes from +The Signals tab shows the reactive graph of one component at a time. Only signals that a template or an effect has read appear. A signal nothing has read yet is not part of the graph. Without a live page, the tab lists the signal declarations in your source. -- **Live**: the [overlay](/getting-started/overlay) reads the graph with Angular's debug API. This needs Angular 19 or later and a development build. -- **Source**: without a live graph, the tab lists `signal()`, `computed()`, `linkedSignal()`, `effect()`, `toSignal()` and resource declarations found in your files, with signal inputs, models and queries. +## What it shows -## Pick a component +### Component picker -The **Component** picker at the top selects whose graph you see. +The **Component** picker at the top selects whose graph you see. It appears when a live component tree exists. -- **Follow the routed component** (the default) shows the component the router rendered. +- **Follow the routed component** is the default. It shows the deepest component rendered by a primary `<router-outlet>`. - Pick any live component to pin the graph to it. Duplicates are numbered, for example `#2`. - Without a routed component, the tab shows the first component that has signals. -A notice appears when the picked component is gone or has no graph, and the tab shows another one. The agent tool `ng-devtools:highlight` also switches the graph to the component it highlights. +A line under the picker names the component, its host path, and why it was chosen: **picked**, **rendered by the router** or **first component with signals**. + +### Node cards -## Nodes +Filter by name, or by kind with the chips. Each card shows: -Filter by name, or by kind with the chips. Each node card shows its kind, label, current value, epoch, dependencies and consumers, plus a **N changes** badge. +- Its kind (`signal`, `computed`, `linkedSignal` or `effect`) and label. Nodes without a name show **(unnamed)**. +- The current value. +- The epoch, and the number of dependencies and consumers. +- A **N changes** badge once the value has changed. -Expand a node to see: +### Node details + +Expand a card to see: - **Dependencies (producers)**: the nodes it reads. - **Consumers**: the nodes and effects that read it. - **Value history**: recent values, newest first, each with a time and a source tag. -| Source tag | Meaning | -| ---------- | ------------------------------------------- | -| set | The value was written. This entry is exact. | -| sampled | The overlay saw a new value while polling. | -| initial | The first value the overlay saw. | +### Value history + +| Tag | Meaning | +| ------- | ------------------------------------------- | +| set | The value was written. This entry is exact. | +| sampled | The overlay saw a new value while polling. | +| initial | The first value the overlay saw. | -The history keeps 50 changes per signal. When values change faster than the overlay polls, an entry says how many earlier values were not captured. +When values change faster than the overlay polls, an entry says how many earlier values were not captured. Effects have no history. -## For agents +### Source mode -- `ng-devtools:get-signals` lists signal declarations from source. -- `ng-devtools:inspect-signals` returns the graph the page reported, with history. -- The `ng-devtools:signal-graph` resource holds the live graph. +Without a live graph, the tab lists `signal()`, `computed()`, `linkedSignal()`, `effect()`, `toSignal()` and resource declarations found in your files. It also lists signal inputs, models and queries. Each card shows the file, line and component. -## Tips +## Where the data comes from -- If a signal is missing, check that something reads it. Signals join the graph when a template or effect reads them. -- The graph shows up to 400 nodes. +<ngmd-card-grid columns="2"> + <ngmd-card icon="zap" title="Live page"> + The overlay reads the graph of the chosen component and pushes it every 3 seconds. + </ngmd-card> + <ngmd-card icon="file" title="Source scan"> + The server scans your files for signal declarations. + </ngmd-card> +</ngmd-card-grid> + +### Debug APIs + +The live graph reads `ng.ɵgetSignalGraph` with the component's injector, from `ng.getInjector` and `ng.getComponent`. It needs a development build. The empty state asks for Angular 19 or later. + +### Exact and sampled values + +Exact **set** entries come from a hook on signal writes. A write is matched to a node by its label, so only signals with a `debugName` get exact entries. Everything else is **sampled** on each poll. + +<ngmd-callout type="tip" title="Name your signals"> + Pass a <code>debugName</code> to <code>signal()</code> to get exact history entries and a readable label on the card. +</ngmd-callout> + +## How to use it + +### See why a computed changed + +<ngmd-workflow> + <ngmd-step title="Pick the component"> + Leave the picker on the routed component, or pick the one you care about. + </ngmd-step> + <ngmd-step title="Open the computed"> + Expand its card and read <strong>Dependencies (producers)</strong>. + </ngmd-step> + <ngmd-step title="Compare the histories"> + Open each producer. The one with a change at the same time is the cause. + </ngmd-step> +</ngmd-workflow> + +### Find what reruns an effect + +<ngmd-workflow> + <ngmd-step title="Filter by kind"> + Click the <code>effect</code> chip. + </ngmd-step> + <ngmd-step title="Open the effect"> + Its producers are every signal it read on the last run. + </ngmd-step> + <ngmd-step title="Trim the reads"> + Wrap reads that should not rerun it in <code>untracked()</code>, then check the graph again. + </ngmd-step> +</ngmd-workflow> + +### Switch the graph from an agent + +The `ng-devtools:highlight` tool also switches the graph to the component it highlights. The picker does not show that choice. + +## Agent tools + +| Tool or resource | Kind | What it does | +| ----------------------------- | -------- | ------------------------------------------------------------------------------------------------- | +| `ng-devtools:get-signals` | tool | Signal declarations from source, with signal inputs, models and queries. | +| `ng-devtools:inspect-signals` | tool | The graph the page reported, with edges and history. Takes a host tag, class name or instance id. | +| `ng-devtools:highlight` | tool | Highlights a component and makes it the target of the graph. | +| `ng-devtools:signal-graph` | resource | The live graph per page. | + +`inspect-signals` returns the graph of the chosen component. Call `highlight` first to switch it. See [Tools](/agents/tools). + +## Limits and gotchas + +<ngmd-callout type="warning" title="Unread signals are missing"> + Signals join the graph when a template or an effect reads them. If a signal is missing, check that something reads it. +</ngmd-callout> + +<ngmd-callout type="info" title="Caps"> + The graph shows up to 400 nodes. Extra nodes are dropped without a notice. The history keeps 50 changes per signal. +</ngmd-callout> + +<ngmd-alert severity="helpful"> + A notice appears when the picked component is gone or has no graph. The tab then shows another one. +</ngmd-alert> + +## FAQ + +<ngmd-accordion> + <ngmd-accordion-item title="Why does the graph show a different component than I expected?"> + The default follows the deepest component in the primary router outlet. Named outlets are skipped. Pick the component yourself to pin it. + </ngmd-accordion-item> + <ngmd-accordion-item title="Why are all my history entries sampled?"> + Exact entries need a <code>debugName</code> on the signal. Without one, the overlay samples values on each poll. + </ngmd-accordion-item> + <ngmd-accordion-item title="Why is a value marked as not computed yet?"> + A computed that nothing has read yet has no value. It fills in after its first read. + </ngmd-accordion-item> +</ngmd-accordion> + +## Related pages + +<ngmd-card-grid columns="2"> + <ngmd-card icon="layers" title="Components" link="/inspectors/components" cta="Open"> + Each instance, with its live inputs. + </ngmd-card> + <ngmd-card icon="box" title="NgRx Store" link="/inspectors/ngrx-store" cta="Open"> + Signal store state, computeds and methods. + </ngmd-card> + <ngmd-card icon="sparkles" title="Agent tools" link="/agents/tools" cta="Browse"> + Every tool a coding agent can call. + </ngmd-card> + <ngmd-card icon="compass" title="Browser overlay" link="/getting-started/overlay" cta="Set up"> + The script that reports the live page. + </ngmd-card> +</ngmd-card-grid> diff --git a/apps/docs/src/content/inspectors/ssr-http.md b/apps/docs/src/content/inspectors/ssr-http.md index 8c48fa5..1bdf9e1 100644 --- a/apps/docs/src/content/inspectors/ssr-http.md +++ b/apps/docs/src/content/inspectors/ssr-http.md @@ -3,31 +3,204 @@ title: SSR & HTTP description: An HTTP timeline for SSR and client calls, fault injection, hydration stats and the TransferState payload. --- +<ngmd-hero title="SSR & HTTP" gradient> + Every HttpClient call made while rendering on the server and in the browser. Fault injection, hydration stats and the TransferState payload, in one tab. +</ngmd-hero> + # SSR & HTTP -The SSR & HTTP tab shows the HTTP calls your app makes while rendering on the server and in the browser, the hydration result, and the TransferState payload. It can also inject faults into requests. +The SSR & HTTP tab shows the HTTP calls your app makes during server rendering and in the browser. It also shows the hydration result and the TransferState payload, and it can inject faults into requests. Pick the page at the top. The tab shows that page's data. + +## Setup + +The timeline and fault rules need the interceptor. The hydration warnings need the provider. Add both to the app config, with `withNgDevtools()` before your own interceptors: + +```ts {8-9} +// app.config.ts +import {ApplicationConfig} from '@angular/core'; +import {provideHttpClient, withFetch, withInterceptors} from '@angular/common/http'; +import {provideNgDevtoolsHttp, withNgDevtools} from '@santoshyadavdev/ng-devtools/http'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideHttpClient(withFetch(), withNgDevtools(), withInterceptors([auth])), + provideNgDevtoolsHttp(), + ], +}; +``` + +<ngmd-callout type="warning" title="withNgDevtools() first"> + Put <code>withNgDevtools()</code> before your own interceptors. Then it records requests as the app makes them, and fault rules apply before anything else. The full setup is in the <a href="/guides/ssr-http">SSR & HTTP guide</a>. +</ngmd-callout> + +SSR must run in the same Node process as the devtools server, such as the Express server with the hub mounted, or the Vite dev server with the plugin. The [overlay](/getting-started/overlay) must be loaded, because client calls, hydration and the payload reach the tab through it. + +## What it shows + +### HTTP timeline + +Every `HttpClient` request, tagged **SSR** or **Client**. Each row shows the method, the URL, the page that made it, the status, the time, and notes: + +- **transfer cache**: the TransferState cache answered it. +- **faulted**: a fault rule matched it. + +Click a row for a response preview. The timeline shows the page's client calls and the SSR calls made while rendering its first URL. **Clear timeline** empties it. + +### Fault injection + +Add a rule with these fields: + +- **URL pattern**: a substring, or a glob where `*` matches anything. `/api/*` matches both relative and absolute URLs. +- **Method**: any, or one method. +- **Apply on**: SSR + client, SSR only, or client only. +- **Status**, **Delay (ms)** up to 10000, 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 rule with only a delay passes the request through. The first enabled rule that matches wins. + +### Hydration + +- Whether hydration is on. +- Hydrated components and nodes, skipped components, and incremental defer blocks. +- DOM nodes hydrated and skipped, and `ngSkipHydration` hosts. +- Mismatched components, with the expected and actual DOM. +- The hydration warnings (NG05xx) Angular logged in the browser. + +### TransferState payload + +Each entry in the page's `{APP_ID}-state` script, with its size. HttpClient and Analog cache entries are decoded to status, URL and body. `__nghData__` and `__nghDeferData__` are labelled as hydration annotations. + +## Where the data comes from + +<ngmd-card-grid columns="3"> + <ngmd-card icon="terminal" title="Server"> + The interceptor on the server hands SSR calls to the devtools through the shared Node process. + </ngmd-card> + <ngmd-card icon="zap" title="Browser"> + The overlay reports client calls, hydration stats and the payload. + </ngmd-card> + <ngmd-card icon="settings" title="Rules"> + Fault rules live on the devtools server and are sent to every page. + </ngmd-card> +</ngmd-card-grid> + +### What each part needs + +| Part | Needs | +| --------------------- | --------------------------------------- | +| HTTP timeline | `withNgDevtools()` and the overlay. | +| Fault injection | `withNgDevtools()`. | +| Hydration stats | The overlay. | +| Hydration warnings | `provideNgDevtoolsHttp()`. | +| TransferState payload | The overlay, on a server-rendered page. | + +### Development builds + +The interceptor works in development builds only. In production it passes requests through untouched. + +## How to use it + +### Test an error state + +<ngmd-workflow> + <ngmd-step title="Add a rule"> + Enter the URL pattern, pick <strong>Client only</strong>, and set the status to <code>500</code>. + </ngmd-step> + <ngmd-step title="Use the page"> + Trigger the request. The row is marked <strong>faulted</strong>. + </ngmd-step> + <ngmd-step title="Check the UI"> + Your error handling runs against a real <code>HttpErrorResponse</code>. + </ngmd-step> + <ngmd-step title="Remove the rule"> + Click <strong>Remove</strong> when you are done. + </ngmd-step> +</ngmd-workflow> + +### Test a slow API + +<ngmd-workflow> + <ngmd-step title="Add a delay-only rule"> + Leave the body empty and set a delay, for example 3000 ms. + </ngmd-step> + <ngmd-step title="Watch the loading state"> + The request still reaches the API, only later. + </ngmd-step> +</ngmd-workflow> + +### Check that TransferState works + +<ngmd-workflow> + <ngmd-step title="Load a server-rendered page"> + Use a route with <code>RenderMode.Server</code>. + </ngmd-step> + <ngmd-step title="Read the timeline"> + Each GET should show an SSR row, and a Client row marked <strong>transfer cache</strong>. + </ngmd-step> + <ngmd-step title="Read the payload"> + The matching entry should appear in <strong>TransferState payload</strong>. + </ngmd-step> +</ngmd-workflow> + +## Agent tools + +There is no dedicated tool for this tab. Agents read its data with the `devframe_state_read` tool and the `ng-devtools:http` key. See [Resources](/agents/resources). + +Two router tools cover related ground: -## Requirements +| Tool | What it does | +| --------------------------------- | ------------------------------------------------------------- | +| `ng-devtools:explain-render-mode` | Which render mode a URL gets, from `*.routes.server.ts`. | +| `ng-devtools:explain-navigation` | Each navigation's story, including the HTTP requests it made. | -The SSR & HTTP tab needs `withNgDevtools()` and `provideNgDevtoolsHttp()`, 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. The [SSR & HTTP guide](/guides/ssr-http) shows the setup. +## Limits and gotchas -## Sections +<ngmd-callout type="warning" title="Prerendered routes make no requests"> + Routes prerendered at build time make no requests at runtime and ignore SSR rules. Use <code>RenderMode.Server</code> in <code>app.routes.server.ts</code> for pages you want to test this way. +</ngmd-callout> -- **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. +<ngmd-callout type="info" title="SSR mocks are not transferred"> + SSR mocks are not written to TransferState, so the browser requests the URL again. Apply the rule on <strong>SSR + client</strong> to mock both. +</ngmd-callout> -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. +<ngmd-callout type="info" title="When rules apply"> + Client rules apply right away. SSR rules apply from the next page load. Client rules are also kept in <code>sessionStorage</code>, so they apply on reload before the overlay connects. Rules live in the devtools server's memory, so a server restart clears them. +</ngmd-callout> -## Limits +<ngmd-callout type="danger" title="Nothing is redacted here"> + Response previews and TransferState values are sent to the devtools server as they are. Don't expose the dev server beyond localhost. See <a href="/security">Security</a>. +</ngmd-callout> -The timeline keeps the last 200 calls. You can add up to 50 fault rules. Client rules are kept in `sessionStorage`, so they survive a reload of the same tab. +<ngmd-alert severity="helpful"> + The timeline keeps the last 200 SSR calls and the last 200 client calls per page. You can add up to 50 fault rules. +</ngmd-alert> -## For agents +## FAQ -There is no dedicated tool for this tab. Agents can read its data through the `devframe_state_read` tool with the `ng-devtools:http` key. See [Resources](/agents/resources). +<ngmd-accordion> + <ngmd-accordion-item title="Why are there no SSR rows?"> + SSR runs in a different process from the devtools, or the route is prerendered. Mount the hub in the same server, and use <code>RenderMode.Server</code>. + </ngmd-accordion-item> + <ngmd-accordion-item title="Why does Warnings say not captured?"> + <code>provideNgDevtoolsHttp()</code> is missing from the app providers. + </ngmd-accordion-item> + <ngmd-accordion-item title="Why did my client calls disappear?"> + Client calls live in the page. A reload starts a new list. SSR calls stay until <strong>Clear timeline</strong> or a server restart. + </ngmd-accordion-item> +</ngmd-accordion> -## Privacy +## Related pages -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. +<ngmd-card-grid columns="2"> + <ngmd-card icon="wrench" title="Set up SSR & HTTP" link="/guides/ssr-http" cta="Guide"> + The providers, their order, and the server setup. + </ngmd-card> + <ngmd-card icon="terminal" title="Angular CLI and Express" link="/getting-started/express" cta="Set up"> + Mount the hub in <code>server.ts</code>. + </ngmd-card> + <ngmd-card icon="compass" title="Router" link="/inspectors/router" cta="Open"> + HTTP requests per navigation, and render modes. + </ngmd-card> + <ngmd-card icon="shield" title="Security" link="/security" cta="Read"> + What is redacted, and what is not. + </ngmd-card> +</ngmd-card-grid> diff --git a/apps/docs/src/content/security.md b/apps/docs/src/content/security.md index 08eaaa1..fb71d91 100644 --- a/apps/docs/src/content/security.md +++ b/apps/docs/src/content/security.md @@ -3,15 +3,40 @@ title: Access and redaction description: Who can reach the devtools, and which values are redacted before they leave the page. --- +<ngmd-hero title="Access and redaction" gradient> + The devtools send what they read from your app to a server on your machine. Here is who can reach that server, and what is redacted on the way. +</ngmd-hero> + # Access and redaction The devtools read your running app and send what they find to a server on your machine. This page covers who can reach that server, and what is redacted on the way. +<ngmd-alert severity="critical"> + Don't expose the dev server beyond localhost. Some values, such as HTTP response previews, are sent as they are. +</ngmd-alert> + +## At a glance + +<ngmd-card-grid columns="2"> + <ngmd-card icon="zap" title="Vite plugin"> + Loopback requests only. Origins limited to loopback hosts, Chrome extensions, <code>allowedOrigins</code> and Vite's <code>server.allowedHosts</code>. + </ngmd-card> + <ngmd-card icon="layers" title="Express hub"> + A one-time code and a loopback origin check. Both on by default. + </ngmd-card> + <ngmd-card icon="terminal" title="Standalone CLI"> + Binds to <code>localhost</code> and asks for a one-time code by default. + </ngmd-card> + <ngmd-card icon="compass" title="Chrome extension"> + Connects only to pages served from <code>localhost</code> or <code>127.0.0.1</code>. + </ngmd-card> +</ngmd-card-grid> + ## Local-only access ### Vite plugin -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 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. In detail, a request to the devtools must: @@ -20,22 +45,28 @@ In detail, a request to the devtools must: Other requests get `403` with the message "ng-devtools only answers requests from this machine." WebSocket upgrades follow the same rules. -```ts +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 `allowedOrigins`: + +```ts {3-4} // vite.config.ts export default defineConfig({ - server: { allowedHosts: ['myapp.test'] }, - plugins: [analog(), ngDevtools({ allowedOrigins: ['https://tunnel.example'] })], + server: {allowedHosts: ['myapp.test']}, + plugins: [analog(), ngDevtools({allowedOrigins: ['https://tunnel.example']})], }); ``` +The Vite plugin turns the one-time code off. The loopback and origin checks take its place. + ### Express hub `initNgDevtoolsHub()` has two checks, both on by default: -- **One-time code** (`auth`): the server prints a code, and a browser can read data only after it exchanges that code. Pass `auth: false` to turn it off on a machine only you use. -- **Origin check** (`allowedOrigins`): only loopback origins can open the WebSocket. Pass a list to allow more origins. `false` turns the check off. +| Check | Option | What it does | +| ------------- | ---------------- | ------------------------------------------------------------------------------------ | +| One-time code | `auth` | The server prints a code. A browser can read data only after it exchanges that code. | +| Origin check | `allowedOrigins` | Only loopback origins can open the WebSocket. Pass a list to allow more origins. | -```ts +```ts {3-4} // server.ts const devtools = initNgDevtoolsHub({ auth: false, @@ -44,11 +75,17 @@ const devtools = initNgDevtoolsHub({ app.use(devtools.nodeMiddleware); ``` -The demo app in this repository sets `allowedOrigins: false` because it runs as a public demo. Keep the check on for your own apps. +<ngmd-callout type="warning" title="Turning the checks off"> + Pass <code>auth: false</code> only on a machine only you use. <code>allowedOrigins: false</code> turns the origin check off. The demo app in this repository sets it because it runs as a public demo. Keep the check on for your own apps. +</ngmd-callout> + +### Standalone CLI + +The CLI server binds to `localhost` and asks for a one-time code. `--host` changes the bind address and `--no-auth` turns the code off. See [Standalone CLI](/getting-started/cli). ### MCP endpoint -The HTTP MCP endpoint answers only requests from a loopback address that carry a loopback `Origin` header. See [MCP server](/agents/mcp-server#http). +The HTTP MCP endpoint answers only requests from a loopback address that carry a loopback `Origin` header. See [MCP server](/agents/mcp-server). ### Chrome extension @@ -56,19 +93,53 @@ The extension connects only to pages served from `localhost` or `127.0.0.1`. On ## What is redacted -Live values leave the page: they are sent to the devtools server, shown in the panel and returned to agents. Redacted values are replaced with `[redacted]`. +Live values leave the page. They are sent to the devtools server, shown in the panel and returned to agents. Redacted values are replaced with `[redacted]`. ### Forms -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. +A field's value is replaced with `[redacted]` when the field: + +- is a password field, +- has a password, one-time-code or credit-card `autocomplete`, +- sits inside `.sentry-mask`, `.rr-mask`, `[data-private]` or `[data-ng-devtools="mask"]`, or +- has a name that contains a secret word (password, token, card, cvv, apiKey and similar). + +Those values are also removed from error messages. DevTools never writes secret fields. Other values are sent as they are, so keep real credentials out of forms you inspect. -The secret words are: password, passwd, passphrase, passcode, pass, pwd, secret, token, otp, totp, pin, cvv, cvc, csc, ssn, iban, card, cc, credential and credentials. Names are split on camelCase and punctuation, so `userPassword` and `card_number` both match. The pairs apiKey, privateKey, secretKey, accessKey, ccNum, ccNumber and securityCode match as well. +### Opt fields in or out + +Mark a field in the template, or list keys on `window`: + +```html +<input name="nickname" data-ng-devtools="mask" /> +<input name="cardHolder" data-ng-devtools="unmask" /> +``` + +```ts +window.__NG_DEVTOOLS_FORMS__ = {mask: ['iban'], unmask: ['passport']}; +``` + +`[data-ng-devtools="unmask"]` opts a field back in. The `window` setting does the same by key. + +<ngmd-accordion> + <ngmd-accordion-item title="The full list of secret words"> + password, passwd, passphrase, passcode, pass, pwd, secret, token, otp, totp, pin, cvv, cvc, csc, ssn, iban, card, cc, credential and credentials. Names are split on camelCase and punctuation, so <code>userPassword</code> and <code>card_number</code> both match. The pairs apiKey, privateKey, secretKey, accessKey, ccNum, ccNumber and securityCode match as well. + </ngmd-accordion-item> +</ngmd-accordion> ### Router -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. +These are replaced with `[redacted]` in URLs, params, data and messages: + +- 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 and long opaque tokens, +- route params with secret-looking names. + +A secret route param is only known once the route is recognized or found in the config. A navigation that fails before that (for example inside a lazy route that failed to load) can still show it in its URL. -A navigation whose URL was redacted cannot be replayed. +<ngmd-alert severity="info"> + A navigation whose URL was redacted cannot be replayed. +</ngmd-alert> ### Components, signals and NgRx @@ -80,4 +151,32 @@ Server call previews and URLs are redacted: secret-looking keys in JSON bodies, ### Not redacted -Response previews and TransferState values in the [SSR & HTTP tab](/inspectors/ssr-http) are not redacted: they are sent to the devtools server as they are, so don't expose the dev server beyond localhost. +<ngmd-callout type="danger" title="SSR & HTTP values are sent as they are"> + Response previews and TransferState values in the <a href="/inspectors/ssr-http">SSR & HTTP tab</a> are not redacted. They reach the devtools server unchanged, so don't expose the dev server beyond localhost. +</ngmd-callout> + +## Checklist + +<ngmd-workflow> + <ngmd-step title="Keep it on your machine"> + Open the app on <code>localhost</code>. Add other hostnames or origins one by one, only when you need them. + </ngmd-step> + <ngmd-step title="Leave the checks on"> + Keep <code>auth</code> and the origin check on in the Express hub unless the machine is yours alone. + </ngmd-step> + <ngmd-step title="Use test data"> + Keep real credentials out of forms and API responses you inspect. + </ngmd-step> + <ngmd-step title="Mark extra secrets"> + Use <code>data-ng-devtools="mask"</code> or <code>window.__NG_DEVTOOLS_FORMS__</code> for fields the secret words miss. + </ngmd-step> +</ngmd-workflow> + +## Related pages + +<ngmd-pill-row> + <ngmd-pill href="/getting-started/vite" title="Vite and Analog"></ngmd-pill> + <ngmd-pill href="/getting-started/express" title="Angular CLI and Express"></ngmd-pill> + <ngmd-pill href="/inspectors/forms" title="Forms inspector"></ngmd-pill> + <ngmd-pill href="/agents/mcp-server" title="MCP server"></ngmd-pill> +</ngmd-pill-row> diff --git a/apps/docs/src/main.server.ts b/apps/docs/src/main.server.ts index c054210..e6ce09f 100644 --- a/apps/docs/src/main.server.ts +++ b/apps/docs/src/main.server.ts @@ -1,7 +1,7 @@ import '@angular/platform-server/init'; -import { render } from '@analogjs/router/server'; +import {render} from '@analogjs/router/server'; -import { App } from './app/app'; -import { config } from './app/app.config.server'; +import {App} from './app/app'; +import {config} from './app/app.config.server'; export default render(App, config); diff --git a/apps/docs/src/main.ts b/apps/docs/src/main.ts index f75ff45..2523ba0 100644 --- a/apps/docs/src/main.ts +++ b/apps/docs/src/main.ts @@ -1,6 +1,6 @@ -import { bootstrapApplication } from '@angular/platform-browser'; +import {bootstrapApplication} from '@angular/platform-browser'; -import { App } from './app/app'; -import { appConfig } from './app/app.config'; +import {App} from './app/app'; +import {appConfig} from './app/app.config'; bootstrapApplication(App, appConfig); diff --git a/apps/docs/src/marked-extensions/index.ts b/apps/docs/src/marked-extensions/index.ts index 30cdc15..6d6a7a8 100644 --- a/apps/docs/src/marked-extensions/index.ts +++ b/apps/docs/src/marked-extensions/index.ts @@ -1,7 +1,7 @@ -import type { MarkedExtension } from 'marked'; -import { ngmdRuntimeExtensions } from './runtime.ts'; +import type {MarkedExtension} from 'marked'; +import {ngmdRuntimeExtensions} from './runtime.ts'; -export { ngmdRuntimeExtensions }; +export {ngmdRuntimeExtensions}; /** * Marked extensions are split into two arrays. @@ -22,7 +22,7 @@ export { ngmdRuntimeExtensions }; // never resolves their `node:fs` / `shiki` imports. The async getter is // called by `vite.config.ts` (Node context) only. export async function getBuildExtensions(): Promise<MarkedExtension[]> { - const [{ ngmdCodeImportExtension }, { ngmdCodeGroupExtension }, { ngmdCodeHighlightExtension }] = + const [{ngmdCodeImportExtension}, {ngmdCodeGroupExtension}, {ngmdCodeHighlightExtension}] = await Promise.all([ import('./ngmd-code-import.ts'), import('./ngmd-code-group.ts'), diff --git a/apps/docs/src/marked-extensions/ngmd-code-group.ts b/apps/docs/src/marked-extensions/ngmd-code-group.ts index 39d3d29..bf12f6a 100644 --- a/apps/docs/src/marked-extensions/ngmd-code-group.ts +++ b/apps/docs/src/marked-extensions/ngmd-code-group.ts @@ -1,5 +1,5 @@ -import type { MarkedExtension } from 'marked'; -import { getHighlighter, LANGS, escapeHtml } from './shiki-shared.ts'; +import type {MarkedExtension} from 'marked'; +import {getHighlighter, LANGS, escapeHtml} from './shiki-shared.ts'; /** * Adjacent fenced code blocks tagged with `group="..."` merge into a tabbed @@ -49,7 +49,7 @@ async function renderCode(body: string, lang: string): Promise<string> { const highlighter = await getHighlighter(); return highlighter.codeToHtml(body, { lang: safeLang, - themes: { light: 'github-light', dark: 'github-dark' }, + themes: {light: 'github-light', dark: 'github-dark'}, defaultColor: false, }); } diff --git a/apps/docs/src/marked-extensions/ngmd-code-highlight.ts b/apps/docs/src/marked-extensions/ngmd-code-highlight.ts index 72ba594..5afcdee 100644 --- a/apps/docs/src/marked-extensions/ngmd-code-highlight.ts +++ b/apps/docs/src/marked-extensions/ngmd-code-highlight.ts @@ -1,5 +1,5 @@ -import type { MarkedExtension } from 'marked'; -import { getHighlighter, LANGS } from './shiki-shared.ts'; +import type {MarkedExtension} from 'marked'; +import {getHighlighter, LANGS} from './shiki-shared.ts'; /** * Fenced code blocks tagged with `{1,3-5}` get the matching lines visually @@ -61,8 +61,7 @@ export const ngmdCodeHighlightExtension: MarkedExtension = { // Quick negative check before scanning. if (!/^```[\w-]*[\t ]+\{[0-9,\-\s]+\}/m.test(markdown)) return markdown; - const matches: { start: number; end: number; lang: string; spec: string; body: string }[] = - []; + const matches: {start: number; end: number; lang: string; spec: string; body: string}[] = []; const re = new RegExp(FENCE_RE.source, FENCE_RE.flags); let m: RegExpExecArray | null; while ((m = re.exec(markdown)) !== null) { @@ -86,7 +85,7 @@ export const ngmdCodeHighlightExtension: MarkedExtension = { const safeLang = LANGS.includes(mt.lang) ? mt.lang : 'text'; const raw = highlighter.codeToHtml(mt.body, { lang: safeLang, - themes: { light: 'github-light', dark: 'github-dark' }, + themes: {light: 'github-light', dark: 'github-dark'}, defaultColor: false, }); return applyHighlights(raw, parseRanges(mt.spec)); diff --git a/apps/docs/src/marked-extensions/ngmd-code-import.ts b/apps/docs/src/marked-extensions/ngmd-code-import.ts index 35231ac..daadc00 100644 --- a/apps/docs/src/marked-extensions/ngmd-code-import.ts +++ b/apps/docs/src/marked-extensions/ngmd-code-import.ts @@ -1,7 +1,7 @@ -import { readFileSync } from 'node:fs'; -import { resolve } from 'node:path'; -import type { MarkedExtension } from 'marked'; -import { getHighlighter, LANGS, escapeHtml } from './shiki-shared.ts'; +import {readFileSync} from 'node:fs'; +import {resolve} from 'node:path'; +import type {MarkedExtension} from 'marked'; +import {getHighlighter, LANGS, escapeHtml} from './shiki-shared.ts'; import config from '../ngmd.config.ts'; /** @@ -27,7 +27,7 @@ import config from '../ngmd.config.ts'; const FENCE_RE = /^```([\w-]+)?[\t ]+file="([^"]+)"[^\n]*\n(?:([\s\S]*?)\n)?```$/gm; const IGNORE_LINE_RE = /^.*\/\/\s*ngmd-ignore-line\s*$/; -function loadFile(spec: string): { code: string; rangeFragment: string } { +function loadFile(spec: string): {code: string; rangeFragment: string} { const [path, range] = spec.split('#'); const full = resolve(process.cwd(), path); let content = readFileSync(full, 'utf8'); @@ -49,7 +49,7 @@ function loadFile(spec: string): { code: string; rangeFragment: string } { .filter((l) => !IGNORE_LINE_RE.test(l)) .join('\n'); - return { code: filtered.replace(/\n+$/, ''), rangeFragment }; + return {code: filtered.replace(/\n+$/, ''), rangeFragment}; } function githubBlobUrl(filePath: string, rangeFragment: string): string { @@ -79,7 +79,7 @@ export const ngmdCodeImportExtension: MarkedExtension = { const lang = m[1] ?? ''; const spec = m[2]; try { - const { code, rangeFragment } = loadFile(spec); + const {code, rangeFragment} = loadFile(spec); matches.push({ start: m.index, end: m.index + m[0].length, @@ -100,7 +100,7 @@ export const ngmdCodeImportExtension: MarkedExtension = { const safeLang = LANGS.includes(mt.lang) ? mt.lang : 'text'; const codeHtml = highlighter.codeToHtml(mt.code, { lang: safeLang, - themes: { light: 'github-light', dark: 'github-dark' }, + themes: {light: 'github-light', dark: 'github-dark'}, defaultColor: false, }); const headerLabel = mt.filePath + (mt.rangeFragment || ''); diff --git a/apps/docs/src/marked-extensions/ngmd-image.ts b/apps/docs/src/marked-extensions/ngmd-image.ts index d7a4185..0dc76ad 100644 --- a/apps/docs/src/marked-extensions/ngmd-image.ts +++ b/apps/docs/src/marked-extensions/ngmd-image.ts @@ -1,4 +1,4 @@ -import type { Tokens } from 'marked'; +import type {Tokens} from 'marked'; interface NgmdImageToken extends Tokens.Generic { type: 'ngmd-image'; diff --git a/apps/docs/src/marked-extensions/ngmd-keywords.ts b/apps/docs/src/marked-extensions/ngmd-keywords.ts index db2e351..f3d874b 100644 --- a/apps/docs/src/marked-extensions/ngmd-keywords.ts +++ b/apps/docs/src/marked-extensions/ngmd-keywords.ts @@ -1,4 +1,4 @@ -import type { MarkedExtension, Tokens } from 'marked'; +import type {MarkedExtension, Tokens} from 'marked'; import config from '../ngmd.config.ts'; /** diff --git a/apps/docs/src/marked-extensions/ngmd-video.ts b/apps/docs/src/marked-extensions/ngmd-video.ts index 35329dd..6e2e14f 100644 --- a/apps/docs/src/marked-extensions/ngmd-video.ts +++ b/apps/docs/src/marked-extensions/ngmd-video.ts @@ -1,4 +1,4 @@ -import type { Tokens } from 'marked'; +import type {Tokens} from 'marked'; interface NgmdVideoToken extends Tokens.Generic { type: 'ngmd-video'; diff --git a/apps/docs/src/marked-extensions/runtime.ts b/apps/docs/src/marked-extensions/runtime.ts index 9befe76..21cc3ff 100644 --- a/apps/docs/src/marked-extensions/runtime.ts +++ b/apps/docs/src/marked-extensions/runtime.ts @@ -1,7 +1,7 @@ -import type { MarkedExtension } from 'marked'; -import { ngmdVideoExtension } from './ngmd-video.ts'; -import { ngmdImageExtension } from './ngmd-image.ts'; -import { ngmdKeywordsExtension } from './ngmd-keywords.ts'; +import type {MarkedExtension} from 'marked'; +import {ngmdVideoExtension} from './ngmd-video.ts'; +import {ngmdImageExtension} from './ngmd-image.ts'; +import {ngmdKeywordsExtension} from './ngmd-keywords.ts'; export const ngmdRuntimeExtensions: MarkedExtension[] = [ { diff --git a/apps/docs/src/marked-extensions/shiki-shared.ts b/apps/docs/src/marked-extensions/shiki-shared.ts index 193eafa..d5b32ff 100644 --- a/apps/docs/src/marked-extensions/shiki-shared.ts +++ b/apps/docs/src/marked-extensions/shiki-shared.ts @@ -1,4 +1,4 @@ -import { createHighlighter, type Highlighter } from 'shiki'; +import {createHighlighter, type Highlighter} from 'shiki'; /** * Shared shiki highlighter instance used by every build-time fence extension diff --git a/apps/docs/src/ngmd.config.ts b/apps/docs/src/ngmd.config.ts index ab80d26..a346b6b 100644 --- a/apps/docs/src/ngmd.config.ts +++ b/apps/docs/src/ngmd.config.ts @@ -5,7 +5,7 @@ * Sidebar, command palette, breadcrumb, and header all read from here. */ -import type { BadgeVariant } from './types/badge.ts'; +import type {BadgeVariant} from './types/badge.ts'; export interface NavItem { label: string; @@ -133,9 +133,9 @@ const config: NgmdConfig = { }, sponsors: [ - { name: 'CodeRabbit', login: 'coderabbitai' }, - { name: 'umairhm', login: 'umairhm' }, - { name: 'Sonichigo', login: 'Sonichigo' }, + {name: 'CodeRabbit', login: 'coderabbitai'}, + {name: 'umairhm', login: 'umairhm'}, + {name: 'Sonichigo', login: 'Sonichigo'}, ], keywords: { @@ -151,65 +151,65 @@ const config: NgmdConfig = { { label: 'Getting Started', items: [ - { label: 'Introduction', href: '/getting-started/introduction' }, - { label: 'Installation', href: '/getting-started/installation' }, - { label: 'Angular CLI and Express', href: '/getting-started/express' }, - { label: 'Vite and Analog', href: '/getting-started/vite' }, - { label: 'Standalone CLI', href: '/getting-started/cli' }, - { label: 'Popup and hub', href: '/getting-started/popup-and-hub' }, - { label: 'Browser overlay', href: '/getting-started/overlay' }, - { label: 'Chrome extension', href: '/getting-started/chrome-extension' }, + {label: 'Introduction', href: '/getting-started/introduction'}, + {label: 'Installation', href: '/getting-started/installation'}, + {label: 'Angular CLI and Express', href: '/getting-started/express'}, + {label: 'Vite and Analog', href: '/getting-started/vite'}, + {label: 'Standalone CLI', href: '/getting-started/cli'}, + {label: 'Popup and hub', href: '/getting-started/popup-and-hub', status: 'new'}, + {label: 'Browser overlay', href: '/getting-started/overlay'}, + {label: 'Chrome extension', href: '/getting-started/chrome-extension'}, ], }, { label: 'Inspectors', items: [ - { label: 'Dashboard', href: '/inspectors/dashboard' }, - { label: 'Components', href: '/inspectors/components' }, - { label: 'Injectors', href: '/inspectors/injectors' }, - { label: 'Signals', href: '/inspectors/signals' }, - { label: 'NgRx Store', href: '/inspectors/ngrx-store' }, - { label: 'Forms', href: '/inspectors/forms' }, - { label: 'Router', href: '/inspectors/router' }, - { label: 'Pipes', href: '/inspectors/pipes' }, - { label: 'SSR & HTTP', href: '/inspectors/ssr-http' }, - { label: 'Analog', href: '/inspectors/analog' }, + {label: 'Dashboard', href: '/inspectors/dashboard'}, + {label: 'Components', href: '/inspectors/components'}, + {label: 'Injectors', href: '/inspectors/injectors', status: 'updated'}, + {label: 'Signals', href: '/inspectors/signals'}, + {label: 'NgRx Store', href: '/inspectors/ngrx-store'}, + {label: 'Forms', href: '/inspectors/forms', status: 'new'}, + {label: 'Router', href: '/inspectors/router', status: 'new'}, + {label: 'Pipes', href: '/inspectors/pipes', status: 'new'}, + {label: 'SSR & HTTP', href: '/inspectors/ssr-http', status: 'new'}, + {label: 'Analog', href: '/inspectors/analog', status: 'new'}, ], }, { label: 'Agent Tools', items: [ - { label: 'MCP server', href: '/agents/mcp-server' }, - { label: 'Tools', href: '/agents/tools' }, - { label: 'Resources', href: '/agents/resources' }, + {label: 'MCP server', href: '/agents/mcp-server'}, + {label: 'Tools', href: '/agents/tools'}, + {label: 'Resources', href: '/agents/resources'}, ], }, { label: 'Guides', items: [ - { label: 'Restore NgRx signal state', href: '/guides/ngrx-signals-restore' }, - { label: 'Set up SSR & HTTP', href: '/guides/ssr-http' }, - { label: 'Set up Analog', href: '/guides/analog' }, + {label: 'Restore NgRx signal state', href: '/guides/ngrx-signals-restore'}, + {label: 'Set up SSR & HTTP', href: '/guides/ssr-http'}, + {label: 'Set up Analog', href: '/guides/analog'}, ], }, { label: 'Security', - items: [{ label: 'Access and redaction', href: '/security' }], + items: [{label: 'Access and redaction', href: '/security'}], }, { label: 'Community', items: [ - { label: 'Get involved', href: '/community' }, - { label: 'Sponsors', href: '/sponsors' }, + {label: 'Get involved', href: '/community'}, + {label: 'Sponsors', href: '/sponsors'}, ], }, { label: 'Contributing', items: [ - { label: 'Development setup', href: '/contributing/development' }, - { label: 'Demo apps', href: '/contributing/demo-apps' }, - { label: 'Build the extension', href: '/contributing/chrome-extension' }, - { label: 'Publishing', href: '/contributing/publishing' }, + {label: 'Development setup', href: '/contributing/development'}, + {label: 'Demo apps', href: '/contributing/demo-apps'}, + {label: 'Build the extension', href: '/contributing/chrome-extension'}, + {label: 'Publishing', href: '/contributing/publishing'}, ], }, ], diff --git a/apps/docs/src/test-setup.ts b/apps/docs/src/test-setup.ts index b0f7cdc..73d65f2 100644 --- a/apps/docs/src/test-setup.ts +++ b/apps/docs/src/test-setup.ts @@ -1,6 +1,6 @@ import '@angular/compiler'; import '@analogjs/vitest-angular/setup-snapshots'; import '@analogjs/vitest-angular/setup-serializers'; -import { setupTestBed } from '@analogjs/vitest-angular/setup-testbed'; +import {setupTestBed} from '@analogjs/vitest-angular/setup-testbed'; setupTestBed(); diff --git a/apps/docs/src/vite-env.d.ts b/apps/docs/src/vite-env.d.ts index c9c6f39..0298696 100644 --- a/apps/docs/src/vite-env.d.ts +++ b/apps/docs/src/vite-env.d.ts @@ -9,12 +9,12 @@ declare module 'virtual:ngmd/page-meta' { } declare module 'virtual:ngmd/search-index' { - import type { IndexDoc } from './types/search'; + import type {IndexDoc} from './types/search'; export const searchIndex: IndexDoc[]; } declare module 'virtual:ngmd/api-index' { - import type { SymbolRecord } from './types/api'; + import type {SymbolRecord} from './types/api'; /** Every exported symbol discovered by the api-gen plugin from sources * matched by `ngmd.api.ts`. Empty array when `ngmd.api.ts` is absent. */ export const apiIndex: SymbolRecord[]; diff --git a/apps/docs/tsconfig.json b/apps/docs/tsconfig.json index ea8f635..5e79f72 100644 --- a/apps/docs/tsconfig.json +++ b/apps/docs/tsconfig.json @@ -27,5 +27,5 @@ "strictInputAccessModifiers": true, "strictTemplates": true }, - "references": [{ "path": "tsconfig.spec.json" }] + "references": [{"path": "tsconfig.spec.json"}] } diff --git a/apps/docs/vars.plugin.ts b/apps/docs/vars.plugin.ts index 8348d66..e0adadc 100644 --- a/apps/docs/vars.plugin.ts +++ b/apps/docs/vars.plugin.ts @@ -1,6 +1,6 @@ -import { readFileSync } from 'node:fs'; -import { join } from 'node:path'; -import type { Plugin } from 'vite'; +import {readFileSync} from 'node:fs'; +import {join} from 'node:path'; +import type {Plugin} from 'vite'; /** * Single source of truth for "the current published version" in markdown @@ -32,7 +32,7 @@ function readVars(root: string): Record<string, string> { } } catch {} } - memo = { 'ngmd-version': version }; + memo = {'ngmd-version': version}; return memo; } @@ -61,7 +61,7 @@ export function varsPlugin(): Plugin { if (!id.endsWith('.md')) return null; const out = substituteMdVars(code, root); if (out === code) return null; - return { code: out, map: null }; + return {code: out, map: null}; }, }; } diff --git a/apps/docs/vite.config.ts b/apps/docs/vite.config.ts index d408f0d..e60b7f9 100644 --- a/apps/docs/vite.config.ts +++ b/apps/docs/vite.config.ts @@ -1,17 +1,17 @@ /// <reference types="vitest" /> -import { defineConfig, type Plugin } from 'vite'; +import {defineConfig, type Plugin} from 'vite'; import analog from '@analogjs/platform'; import tailwindcss from '@tailwindcss/vite'; -import { readFileSync } from 'node:fs'; -import { getBuildExtensions } from './src/marked-extensions/index.ts'; -import { pageMetaPlugin } from './page-meta.plugin.ts'; -import { internalLinkGuard } from './link-guard.plugin.ts'; -import { sitemapPlugin } from './sitemap.plugin.ts'; -import { searchIndexPlugin } from './search-index.plugin.ts'; -import { rawMdPlugin } from './raw-md.plugin.ts'; -import { varsPlugin } from './vars.plugin.ts'; -import { apiGenPlugin } from './api-gen.plugin.ts'; +import {readFileSync} from 'node:fs'; +import {getBuildExtensions} from './src/marked-extensions/index.ts'; +import {pageMetaPlugin} from './page-meta.plugin.ts'; +import {internalLinkGuard} from './link-guard.plugin.ts'; +import {sitemapPlugin} from './sitemap.plugin.ts'; +import {searchIndexPlugin} from './search-index.plugin.ts'; +import {rawMdPlugin} from './raw-md.plugin.ts'; +import {varsPlugin} from './vars.plugin.ts'; +import {apiGenPlugin} from './api-gen.plugin.ts'; import config from './src/ngmd.config.ts'; /** @@ -76,8 +76,8 @@ export default defineConfig(async () => ({ varsPlugin(), externalLinkGuard(), internalLinkGuard(), - pageMetaPlugin({ repoUrl: config.site.githubUrl, branch: 'main', dir: 'apps/docs' }), - sitemapPlugin({ siteUrl: config.site.url }), + pageMetaPlugin({repoUrl: config.site.githubUrl, branch: 'main', dir: 'apps/docs'}), + sitemapPlugin({siteUrl: config.site.url}), rawMdPlugin(), searchIndexPlugin(), apiGenPlugin(), @@ -90,7 +90,7 @@ export default defineConfig(async () => ({ }, shikiOptions: { highlight: { - themes: { light: 'github-light', dark: 'github-dark' }, + themes: {light: 'github-light', dark: 'github-dark'}, defaultColor: false, }, highlighter: { From 72a0da8eab98d69229c156fc9d64b6e1b797167c Mon Sep 17 00:00:00 2001 From: Kam <erkamyaman35@gmail.com> Date: Tue, 29 Sep 2026 09:07:27 +0300 Subject: [PATCH 03/13] fix(docs): address review findings Escape image and video attributes, accept only YouTube and Vimeo embeds, and keep file= imports inside the docs app. Move focus into the mobile drawer, give each tab its own panel, and skip fenced code when reading headings. Correct the tool count, extension permissions, overlay, Vite origin, tunnel and unmask docs. --- README.md | 2 +- apps/docs/api-gen.plugin.ts | 4 +- apps/docs/link-guard.plugin.ts | 17 +++++--- apps/docs/page-meta.plugin.ts | 20 +++++---- apps/docs/plugin-utils.ts | 14 +++++++ apps/docs/search-index.plugin.ts | 5 ++- apps/docs/src/app/app.ts | 41 +++++++++++++++++-- apps/docs/src/app/pages/index.page.ts | 9 +++- apps/docs/src/app/theme.ts | 5 ++- apps/docs/src/app/ui/alert.ts | 11 +++-- apps/docs/src/app/ui/tabs.ts | 20 +++++---- apps/docs/src/app/ui/video.ts | 2 +- .../src/app/utils/enhance-on-navigation.ts | 8 +--- apps/docs/src/content/agents/tools.md | 4 +- .../content/contributing/chrome-extension.md | 6 +-- .../getting-started/chrome-extension.md | 4 +- .../src/content/getting-started/express.md | 2 +- .../src/content/getting-started/overlay.md | 2 +- apps/docs/src/content/guides/ssr-http.md | 6 +-- apps/docs/src/content/inspectors/forms.md | 4 +- apps/docs/src/content/security.md | 17 +++++--- .../docs/src/marked-extensions/escape-html.ts | 7 ++++ .../src/marked-extensions/ngmd-code-group.ts | 13 +++--- .../marked-extensions/ngmd-code-highlight.ts | 10 ++--- .../src/marked-extensions/ngmd-code-import.ts | 20 +++++---- apps/docs/src/marked-extensions/ngmd-image.ts | 9 ++-- apps/docs/src/marked-extensions/ngmd-video.ts | 7 ++-- .../src/marked-extensions/shiki-shared.ts | 8 ---- apps/docs/src/styles.css | 1 + 29 files changed, 183 insertions(+), 95 deletions(-) create mode 100644 apps/docs/src/marked-extensions/escape-html.ts diff --git a/README.md b/README.md index 9583e52..2b3bf57 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ The full documentation lives in [`apps/docs`](./apps/docs/src/content/getting-st - [Security](./apps/docs/src/content/security.md): local-only access and what is redacted - [Contributing](./apps/docs/src/content/contributing/development.md): development setup, demo apps, the extension and publishing -Run the docs site locally with `pnpm docs:dev`. +Links inside these pages point to docs site routes, so they don't work when you read the files on GitHub. To follow them, run the docs site locally with `pnpm docs:dev`. ## Quick start diff --git a/apps/docs/api-gen.plugin.ts b/apps/docs/api-gen.plugin.ts index 4cb2c2f..9f4c517 100644 --- a/apps/docs/api-gen.plugin.ts +++ b/apps/docs/api-gen.plugin.ts @@ -151,7 +151,9 @@ export function apiGenPlugin(): Plugin { handleHotUpdate({file, server}) { // Invalidate the project cache when any source under scope changes. // Cheap because `Project` re-uses TypeScript's incremental machinery. - if (file.endsWith('.ts') || file.endsWith('ngmd.api.ts')) { + const configPath = join(root, 'ngmd.api.ts'); + if (!existsSync(configPath)) return undefined; + if (file === configPath || project?.getSourceFile(file)) { project = null; recordsMemo = null; // Clearing the memo isn't enough — Vite caches the virtual module's diff --git a/apps/docs/link-guard.plugin.ts b/apps/docs/link-guard.plugin.ts index e044497..6c2fab6 100644 --- a/apps/docs/link-guard.plugin.ts +++ b/apps/docs/link-guard.plugin.ts @@ -1,7 +1,13 @@ import {readFileSync, statSync} from 'node:fs'; import {join, relative} from 'node:path'; import type {Plugin} from 'vite'; -import {routeFromPagePath, slugify, walkContentFiles, walkPageFiles} from './plugin-utils.ts'; +import { + fenceTracker, + routeFromPagePath, + slugify, + walkContentFiles, + walkPageFiles, +} from './plugin-utils.ts'; /** * Build-time guard that errors on broken internal links inside markdown files. @@ -22,10 +28,11 @@ import {routeFromPagePath, slugify, walkContentFiles, walkPageFiles} from './plu function extractHeadings(markdown: string): Set<string> { const slugs = new Set<string>(); - const headingRe = /^#{1,6}\s+(.+?)\s*$/gm; - let m; - while ((m = headingRe.exec(markdown)) !== null) { - slugs.add(slugify(m[1])); + const inFence = fenceTracker(); + for (const line of markdown.split(/\r?\n/)) { + if (inFence(line)) continue; + const m = /^#{1,6}\s+(.+?)\s*$/.exec(line); + if (m) slugs.add(slugify(m[1])); } return slugs; } diff --git a/apps/docs/page-meta.plugin.ts b/apps/docs/page-meta.plugin.ts index 0dd27e8..0a8e45f 100644 --- a/apps/docs/page-meta.plugin.ts +++ b/apps/docs/page-meta.plugin.ts @@ -50,14 +50,18 @@ export function pageMetaPlugin(opts: {repoUrl: string; branch?: string; dir?: st const map: Record<string, PageMeta> = {}; // .page.ts → route - 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}/${prefix}${rel}`, - lastUpdated: gitDate(rel, root), - }; + 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}/${prefix}${rel}`, + lastUpdated: gitDate(rel, root), + }; + } + } catch { + // src/app/pages missing — skip } // src/content/**/*.md → route (mirrors the [...slug] catch-all) diff --git a/apps/docs/plugin-utils.ts b/apps/docs/plugin-utils.ts index 5263e85..b66ca84 100644 --- a/apps/docs/plugin-utils.ts +++ b/apps/docs/plugin-utils.ts @@ -102,3 +102,17 @@ export function slugify(s: string): string { .replace(/[^a-z0-9]+/g, '-') .replace(/^-|-$/g, ''); } + +export function fenceTracker(): (line: string) => boolean { + let open = ''; + return (line) => { + const m = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line); + if (!open) { + if (!m) 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; + }; +} diff --git a/apps/docs/search-index.plugin.ts b/apps/docs/search-index.plugin.ts index b3267b6..39711aa 100644 --- a/apps/docs/search-index.plugin.ts +++ b/apps/docs/search-index.plugin.ts @@ -2,7 +2,7 @@ import {readFileSync, statSync} from 'node:fs'; import {join} from 'node:path'; import type {Plugin} from 'vite'; import type {IndexDoc, SearchHitKind} from './src/types/search.ts'; -import {slugify, walkContentFiles} from './plugin-utils.ts'; +import {fenceTracker, slugify, walkContentFiles} from './plugin-utils.ts'; /** * Build-time search index. Walks `src/content/**\/*.md` and emits a flat list @@ -71,8 +71,9 @@ 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) { - const m = line.match(/^(##+)\s+(.+?)\s*$/); + const m = !inFence(line) && line.match(/^(##+)\s+(.+?)\s*$/); if (m) { if (current.heading || current.body.trim()) sections.push(current); current = {heading: m[2], body: ''}; diff --git a/apps/docs/src/app/app.ts b/apps/docs/src/app/app.ts index 6228781..cba2bbc 100644 --- a/apps/docs/src/app/app.ts +++ b/apps/docs/src/app/app.ts @@ -1,4 +1,15 @@ -import {Component, DestroyRef, computed, inject, OnInit, signal} from '@angular/core'; +import { + Component, + DestroyRef, + ElementRef, + Injector, + afterNextRender, + computed, + inject, + OnInit, + signal, + viewChild, +} from '@angular/core'; import {Router, RouterLink, RouterOutlet} from '@angular/router'; import { LucideDynamicIcon, @@ -61,8 +72,9 @@ import {VersionSwitcher} from './components/version-switcher'; > @if (showSidebar()) { <button + #menuButton type="button" - (click)="drawerOpen.set(!drawerOpen())" + (click)="toggleDrawer()" class="lg:hidden rounded p-1.5 hover:bg-zinc-100 dark:hover:bg-zinc-900" [attr.aria-label]="drawerOpen() ? 'Close menu' : 'Open menu'" > @@ -185,10 +197,12 @@ import {VersionSwitcher} from './components/version-switcher'; [class.opacity-0]="!drawerOpen()" [class.opacity-100]="drawerOpen()" [class.pointer-events-none]="!drawerOpen()" - (click)="drawerOpen.set(false)" + (click)="closeDrawer()" aria-hidden="true" ></div> <aside + #drawer + (keydown.escape)="closeDrawer()" class="lg:hidden fixed left-0 top-[57px] bottom-0 z-40 w-64 overflow-y-auto border-r border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-950 p-4 transform transition-transform duration-200 ease-out" [class.-translate-x-full]="!drawerOpen()" [class.translate-x-0]="drawerOpen()" @@ -258,6 +272,9 @@ export class App implements OnInit { private readonly destroyRef = inject(DestroyRef); protected readonly layout = inject(LayoutMode); private readonly routeUrl = inject(RouteUrlService); + private readonly injector = inject(Injector); + private readonly menuButton = viewChild<ElementRef<HTMLButtonElement>>('menuButton'); + private readonly drawer = viewChild<ElementRef<HTMLElement>>('drawer'); readonly menuIcon = LucideMenu; readonly closeIcon = LucideX; @@ -281,6 +298,24 @@ export class App implements OnInit { readonly showToc = this.isDocsRoute; readonly showFooter = this.isDocsRoute; + toggleDrawer(): void { + if (this.drawerOpen()) { + this.closeDrawer(); + return; + } + this.drawerOpen.set(true); + afterNextRender( + () => this.drawer()?.nativeElement.querySelector<HTMLElement>('a[href], button')?.focus(), + {injector: this.injector}, + ); + } + + closeDrawer(): void { + if (!this.drawerOpen()) return; + this.drawerOpen.set(false); + this.menuButton()?.nativeElement.focus(); + } + ngOnInit(): void { this.theme.initFromStorage(); onNavigation(this.router, this.destroyRef, () => { diff --git a/apps/docs/src/app/pages/index.page.ts b/apps/docs/src/app/pages/index.page.ts index 3215b67..b4ea66c 100644 --- a/apps/docs/src/app/pages/index.page.ts +++ b/apps/docs/src/app/pages/index.page.ts @@ -2,6 +2,7 @@ import { AfterViewInit, Component, computed, + DestroyRef, ElementRef, inject, signal, @@ -382,6 +383,7 @@ app.use(devtools.nodeMiddleware); }) export default class Home implements AfterViewInit { private readonly toast = inject(ToastService); + private copyTimer: ReturnType<typeof setTimeout> | undefined; readonly hero = viewChild<ElementRef<HTMLElement>>('hero'); readonly angularGradient = @@ -398,6 +400,10 @@ export default class Home implements AfterViewInit { readonly copied = signal(''); + constructor() { + inject(DestroyRef).onDestroy(() => clearTimeout(this.copyTimer)); + } + readonly installCommands = [ { pm: 'npm', @@ -432,7 +438,8 @@ export default class Home implements AfterViewInit { return; } this.copied.set(cmd); - setTimeout(() => this.copied.set(''), 1500); + clearTimeout(this.copyTimer); + this.copyTimer = setTimeout(() => this.copied.set(''), 1500); } ngAfterViewInit(): void { diff --git a/apps/docs/src/app/theme.ts b/apps/docs/src/app/theme.ts index 216804a..fdc7398 100644 --- a/apps/docs/src/app/theme.ts +++ b/apps/docs/src/app/theme.ts @@ -22,9 +22,12 @@ export class ThemeService { this.cycle(); } + private listening = false; + initFromStorage() { this.apply(this.mode()); - if (this.isBrowser) { + if (this.isBrowser && !this.listening) { + this.listening = true; matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { if (this.mode() === 'auto') this.apply('auto'); }); diff --git a/apps/docs/src/app/ui/alert.ts b/apps/docs/src/app/ui/alert.ts index 011be6b..b1bdd09 100644 --- a/apps/docs/src/app/ui/alert.ts +++ b/apps/docs/src/app/ui/alert.ts @@ -70,8 +70,11 @@ export class NgmdAlert { /** Optional label override. Defaults to the severity name, uppercased. */ readonly label = input<string>(''); - protected readonly iconImg = computed(() => ICON_MAP[this.severity()]); - protected readonly accentClass = computed(() => ACCENT[this.severity()]); - protected readonly boxClass = computed(() => `${BOX} ${STRIPE[this.severity()]}`); - protected readonly tag = computed(() => (this.label() || this.severity()).toUpperCase()); + private readonly resolved = computed<AlertSeverity>(() => + Object.hasOwn(ICON_MAP, this.severity()) ? this.severity() : 'info', + ); + protected readonly iconImg = computed(() => ICON_MAP[this.resolved()]); + protected readonly accentClass = computed(() => ACCENT[this.resolved()]); + protected readonly boxClass = computed(() => `${BOX} ${STRIPE[this.resolved()]}`); + protected readonly tag = computed(() => (this.label() || this.resolved()).toUpperCase()); } diff --git a/apps/docs/src/app/ui/tabs.ts b/apps/docs/src/app/ui/tabs.ts index 37db1e9..2b8296f 100644 --- a/apps/docs/src/app/ui/tabs.ts +++ b/apps/docs/src/app/ui/tabs.ts @@ -29,6 +29,8 @@ import { LucideZap, } from '@lucide/angular'; +let idCounter = 0; + const ICON_MAP: Record<string, LucideIcon> = { book: LucideBook, box: LucideBox, @@ -81,6 +83,7 @@ const ICON_MAP: Record<string, LucideIcon> = { @Component({ selector: 'ngmd-tab', + host: {role: 'tabpanel', '[hidden]': '!active()'}, template: ` <div class="p-5 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0" [hidden]="!active()"> <ng-content></ng-content> @@ -118,9 +121,9 @@ export class NgmdTab { <button type="button" role="tab" - [id]="'ngmd-tab-' + tab.key" + [id]="tab.key + '-tab'" [attr.aria-selected]="active() === tab.key" - [attr.aria-controls]="'ngmd-tabpanel-' + tab.key" + [attr.aria-controls]="tab.key + '-panel'" [tabindex]="active() === tab.key ? 0 : -1" (click)="setActive(tab.key)" (keydown)="onKey($event, i)" @@ -141,11 +144,7 @@ export class NgmdTab { </button> } </div> - <div - role="tabpanel" - [attr.aria-labelledby]="'ngmd-tab-' + active()" - [id]="'ngmd-tabpanel-' + active()" - > + <div> <ng-content></ng-content> </div> </div> @@ -153,6 +152,7 @@ export class NgmdTab { }) export class NgmdTabs implements AfterViewInit { private readonly host: ElementRef<HTMLElement> = inject(ElementRef); + private readonly uid = ++idCounter; readonly tabs = signal< { key: string; @@ -173,12 +173,16 @@ export class NgmdTabs implements AfterViewInit { this.host.nativeElement.querySelectorAll<HTMLElement>(':scope ngmd-tab'), ); const list = els.map((el, i) => ({ - key: `tab-${i}`, + key: `ngmd-tabs-${this.uid}-${i}`, label: el.getAttribute('title') ?? '', image: el.getAttribute('image') ?? '', iconImg: ICON_MAP[el.getAttribute('icon') ?? ''] ?? null, el, })); + for (const tab of list) { + tab.el.id = `${tab.key}-panel`; + tab.el.setAttribute('aria-labelledby', `${tab.key}-tab`); + } this.tabs.set(list); if (list[0]) this.setActive(list[0].key); } diff --git a/apps/docs/src/app/ui/video.ts b/apps/docs/src/app/ui/video.ts index fb15220..2bc9405 100644 --- a/apps/docs/src/app/ui/video.ts +++ b/apps/docs/src/app/ui/video.ts @@ -36,6 +36,6 @@ export class NgmdVideo { if (ytShort) return `https://www.youtube.com/embed/${ytShort[1]}`; const vm = src.match(/vimeo\.com\/(\d+)/); if (vm) return `https://player.vimeo.com/video/${vm[1]}`; - return src; + return 'about:blank'; }); } diff --git a/apps/docs/src/app/utils/enhance-on-navigation.ts b/apps/docs/src/app/utils/enhance-on-navigation.ts index b557763..4740ff0 100644 --- a/apps/docs/src/app/utils/enhance-on-navigation.ts +++ b/apps/docs/src/app/utils/enhance-on-navigation.ts @@ -35,12 +35,8 @@ export function enhanceOnNavigation( const run = (attempt = 0): void => { if (typeof document === 'undefined' || attempt > maxAttempts) return; - const nodes = document.querySelectorAll<HTMLElement>(selector); - if (nodes.length === 0) { - setTimeout(() => run(attempt + 1), delayMs); - return; - } - nodes.forEach(enhanceEach); + document.querySelectorAll<HTMLElement>(selector).forEach(enhanceEach); + setTimeout(() => run(attempt + 1), delayMs); }; run(); diff --git a/apps/docs/src/content/agents/tools.md b/apps/docs/src/content/agents/tools.md index 96c2b73..83eb237 100644 --- a/apps/docs/src/content/agents/tools.md +++ b/apps/docs/src/content/agents/tools.md @@ -4,7 +4,7 @@ description: Every agent tool the devtools expose, grouped by inspector, with wh --- <ngmd-hero title="Tools" logo="https://cdn.simpleicons.org/modelcontextprotocol/71717A" gradient> - Forty-three tools, grouped by inspector. Each one answers a question you would otherwise answer by clicking through the panel. + Forty-four tools, grouped by inspector. Each one answers a question you would otherwise answer by clicking through the panel. </ngmd-hero> # Tools @@ -170,7 +170,7 @@ Markers let an agent check its own work: read the marker, act, then call `form-d ### Act on a form -Both tools are action tools and need a development build. Secret, hidden and readonly fields are never written. +Both tools are action tools and need a development build. Secret fields are not written unless you unmask them. See [Access and redaction](/security#opt-fields-in-or-out). For Signal Forms, hidden and readonly fields are not written either. | Tool | What it does | Arguments | | ------------- | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | diff --git a/apps/docs/src/content/contributing/chrome-extension.md b/apps/docs/src/content/contributing/chrome-extension.md index ff72d79..8abc45b 100644 --- a/apps/docs/src/content/contributing/chrome-extension.md +++ b/apps/docs/src/content/contributing/chrome-extension.md @@ -15,7 +15,7 @@ The Chrome extension lives in `extension/`. It detects Angular pages, creates th ```text extension/ - manifest.json # Manifest V3, host access to localhost and 127.0.0.1 + manifest.json # Manifest V3, host permissions for localhost and 127.0.0.1 background.js # Tracks which tabs run Angular content-script.js # Relays the detection result to the background worker detect-angular.js # Runs in the page, looks for ng-version or window.ng @@ -33,8 +33,8 @@ extension/ <ngmd-card icon="shield" title="No permissions"> <code>permissions</code> is empty. </ngmd-card> - <ngmd-card icon="compass" title="Loopback hosts only"> - Host access to <code>localhost</code> and <code>127.0.0.1</code>, over HTTP and HTTPS. + <ngmd-card icon="compass" title="Loopback host permissions"> + Host permissions for <code>localhost</code> and <code>127.0.0.1</code>, over HTTP and HTTPS. The content scripts still run on every page. </ngmd-card> <ngmd-card icon="settings" title="Chrome 111 or later"> Set by <code>minimum_chrome_version</code>. diff --git a/apps/docs/src/content/getting-started/chrome-extension.md b/apps/docs/src/content/getting-started/chrome-extension.md index 0e75e71..28af63b 100644 --- a/apps/docs/src/content/getting-started/chrome-extension.md +++ b/apps/docs/src/content/getting-started/chrome-extension.md @@ -93,11 +93,11 @@ When the inspected page navigates, the panel looks for the server again. ### Host access -The extension requests no permissions beyond host access to `localhost` and `127.0.0.1`, over HTTP and HTTPS. +The manifest asks for no `permissions`. Its host permissions cover only `localhost` and `127.0.0.1`, over HTTP and HTTPS. ### Content scripts -Content scripts run on every page to detect Angular, but the panel only connects to local dev servers. The Vite plugin accepts requests from Chrome extension origins. See [Access and redaction](/security). +The content scripts are wider. Two of them run on every page. They check for an `ng-version` attribute or `window.ng`, and pass the Angular version to the extension. The panel only connects to local dev servers. The Vite plugin accepts requests from Chrome extension origins. See [Access and redaction](/security). ## FAQ diff --git a/apps/docs/src/content/getting-started/express.md b/apps/docs/src/content/getting-started/express.md index 0fd0e30..4675daf 100644 --- a/apps/docs/src/content/getting-started/express.md +++ b/apps/docs/src/content/getting-started/express.md @@ -116,7 +116,7 @@ const devtools = initNgDevtoolsHub({ app.use(devtools.nodeMiddleware); ``` -It turns the origin check off because it runs as a public demo. Keep the check on for your own apps. +It turns the one-time code off unless `NG_DEVTOOLS_AUTH` is `true`, and it turns the origin check off because it runs as a public demo. Don't copy these two settings. Keep both checks on for your own apps. <ngmd-alert severity="warning"> <code>initNgDevtoolsHub()</code> has no production switch of its own. If your <code>server.ts</code> also runs in production, decide there whether to mount it. diff --git a/apps/docs/src/content/getting-started/overlay.md b/apps/docs/src/content/getting-started/overlay.md index 6ed16b6..a50e5a0 100644 --- a/apps/docs/src/content/getting-started/overlay.md +++ b/apps/docs/src/content/getting-started/overlay.md @@ -105,7 +105,7 @@ const dispose = await initOverlay({baseURL: '/__my-devtools/'}); ### Avoid two overlays <ngmd-callout type="danger" title="Importing the module already starts one"> - By the time you call <code>initOverlay</code>, the import has already started an overlay on the default URLs. Dispose of that one before starting another. Otherwise the page ends up with two connections and two polling intervals. + The import starts an overlay on the default URLs, and it does not hand you a function to stop it. When the devtools live only at your custom path, that overlay finds no connection, logs an error and stops. Your <code>initOverlay</code> call is then the only one running. If the devtools also answer on a default URL, don't call <code>initOverlay</code>. Otherwise the page ends up with two connections and two polling intervals. </ngmd-callout> ## NgRx signal stores diff --git a/apps/docs/src/content/guides/ssr-http.md b/apps/docs/src/content/guides/ssr-http.md index 5f4b07f..d9ec0b5 100644 --- a/apps/docs/src/content/guides/ssr-http.md +++ b/apps/docs/src/content/guides/ssr-http.md @@ -90,7 +90,7 @@ A call counts as a transfer cache hit when the cached response comes back right The interceptor on the server hands its calls to the devtools through the Node process. So SSR and the devtools middleware must run in the same Express process. -```ts {4,9-14} +```ts {4,9-12} // src/server.ts import {AngularNodeAppEngine, createNodeRequestHandler} from '@angular/ssr/node'; import express from 'express'; @@ -101,8 +101,6 @@ const angularApp = new AngularNodeAppEngine(); const devtools = initNgDevtoolsHub({ ws: {sidecar: true}, - auth: false, - allowedOrigins: false, }); app.use(devtools.nodeMiddleware); @@ -111,7 +109,7 @@ app.use(devtools.nodeMiddleware); export const reqHandler = createNodeRequestHandler(app); ``` -This is adapted from the demo app's `src/server.ts`. See [Angular CLI and Express](/getting-started/express) for every option. +This is adapted from the demo app's `src/server.ts`. It keeps the one-time code and the origin check on, which are the defaults. See [Angular CLI and Express](/getting-started/express) for every option. ## Step 4: Render the pages you test on the server diff --git a/apps/docs/src/content/inspectors/forms.md b/apps/docs/src/content/inspectors/forms.md index 9431c47..923756d 100644 --- a/apps/docs/src/content/inspectors/forms.md +++ b/apps/docs/src/content/inspectors/forms.md @@ -179,8 +179,8 @@ Agents can loop: inspect, act, `wait-for-form`, then `form-diff` from the marker In the tab, the button turns into <strong>Confirm reset</strong>, <strong>Confirm submit</strong> or <strong>Confirm restore</strong>. Click again to run it. Agents pass <code>confirm: true</code> for the same actions, and for <code>fill-form</code> with <code>submit</code>. </ngmd-callout> -<ngmd-callout type="info" title="Fields that are never written"> - Secret fields are never written. For Signal Forms, hidden, readonly and rule-disabled fields are skipped too. Disabled reactive fields are written only with <code>force</code>. +<ngmd-callout type="info" title="Fields that are not written"> + Secret fields are not written unless you unmask them. See <a href="/security#opt-fields-in-or-out">Access and redaction</a>. For Signal Forms, hidden, readonly and rule-disabled fields are skipped too. Disabled reactive fields are written only with <code>force</code>. </ngmd-callout> <ngmd-callout type="info" title="Snapshots"> diff --git a/apps/docs/src/content/security.md b/apps/docs/src/content/security.md index fb71d91..bd894b1 100644 --- a/apps/docs/src/content/security.md +++ b/apps/docs/src/content/security.md @@ -19,7 +19,7 @@ The devtools read your running app and send what they find to a server on your m <ngmd-card-grid columns="2"> <ngmd-card icon="zap" title="Vite plugin"> - Loopback requests only. Origins limited to loopback hosts, Chrome extensions, <code>allowedOrigins</code> and Vite's <code>server.allowedHosts</code>. + Loopback requests only. A request that sends an <code>Origin</code> must come from a loopback host, a Chrome extension, <code>allowedOrigins</code> or Vite's <code>server.allowedHosts</code>. </ngmd-card> <ngmd-card icon="layers" title="Express hub"> A one-time code and a loopback origin check. Both on by default. @@ -36,7 +36,7 @@ The devtools read your running app and send what they find to a server on your m ### Vite plugin -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. +The devtools only answer requests from this machine. When a request carries an `Origin` header, that origin must be a loopback host, the Chrome extension or an origin you allowed. Requests without an `Origin` header pass the origin check. Browsers leave the header out of some cross-site requests, such as image loads and link clicks, so the origin check alone does not stop every request from another website. In detail, a request to the devtools must: @@ -57,6 +57,10 @@ export default defineConfig({ The Vite plugin turns the one-time code off. The loopback and origin checks take its place. +<ngmd-callout type="warning" title="Tunnels look local"> + A tunnel client runs on your machine, so the requests it forwards come from a loopback address. Anyone who can reach the tunnel can then reach the devtools. Only allow a tunnel origin that only you can reach. +</ngmd-callout> + ### Express hub `initNgDevtoolsHub()` has two checks, both on by default: @@ -66,17 +70,16 @@ The Vite plugin turns the one-time code off. The loopback and origin checks take | One-time code | `auth` | The server prints a code. A browser can read data only after it exchanges that code. | | Origin check | `allowedOrigins` | Only loopback origins can open the WebSocket. Pass a list to allow more origins. | -```ts {3-4} +```ts {3} // server.ts const devtools = initNgDevtoolsHub({ - auth: false, allowedOrigins: ['https://tunnel.example'], }); app.use(devtools.nodeMiddleware); ``` <ngmd-callout type="warning" title="Turning the checks off"> - Pass <code>auth: false</code> only on a machine only you use. <code>allowedOrigins: false</code> turns the origin check off. The demo app in this repository sets it because it runs as a public demo. Keep the check on for your own apps. + Pass <code>auth: false</code> only on a machine only you use. Keep it on when you allow a tunnel origin: the origin check does not tell who is on the other end of the tunnel. <code>allowedOrigins: false</code> turns the origin check off. The demo app in this repository sets it because it runs as a public demo. Keep the check on for your own apps. </ngmd-callout> ### Standalone CLI @@ -104,7 +107,7 @@ A field's value is replaced with `[redacted]` when the field: - sits inside `.sentry-mask`, `.rr-mask`, `[data-private]` or `[data-ng-devtools="mask"]`, or - has a name that contains a secret word (password, token, card, cvv, apiKey and similar). -Those values are also removed from error messages. DevTools never writes secret fields. Other values are sent as they are, so keep real credentials out of forms you inspect. +Those values are also removed from error messages. DevTools does not write secret fields unless you unmask them, see below. Other values are sent as they are, so keep real credentials out of forms you inspect. ### Opt fields in or out @@ -121,6 +124,8 @@ window.__NG_DEVTOOLS_FORMS__ = {mask: ['iban'], unmask: ['passport']}; `[data-ng-devtools="unmask"]` opts a field back in. The `window` setting does the same by key. +Unmasking also changes what DevTools can write. A key listed in `unmask` on `window` can be written. The element marker only lifts the checks that come from the element (password type, `autocomplete` and mask markers), so a field with a secret-looking name is still not written. + <ngmd-accordion> <ngmd-accordion-item title="The full list of secret words"> password, passwd, passphrase, passcode, pass, pwd, secret, token, otp, totp, pin, cvv, cvc, csc, ssn, iban, card, cc, credential and credentials. Names are split on camelCase and punctuation, so <code>userPassword</code> and <code>card_number</code> both match. The pairs apiKey, privateKey, secretKey, accessKey, ccNum, ccNumber and securityCode match as well. diff --git a/apps/docs/src/marked-extensions/escape-html.ts b/apps/docs/src/marked-extensions/escape-html.ts new file mode 100644 index 0000000..1f605ca --- /dev/null +++ b/apps/docs/src/marked-extensions/escape-html.ts @@ -0,0 +1,7 @@ +export function escapeHtml(s: string): string { + return s + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"'); +} diff --git a/apps/docs/src/marked-extensions/ngmd-code-group.ts b/apps/docs/src/marked-extensions/ngmd-code-group.ts index bf12f6a..2bffd4f 100644 --- a/apps/docs/src/marked-extensions/ngmd-code-group.ts +++ b/apps/docs/src/marked-extensions/ngmd-code-group.ts @@ -1,5 +1,6 @@ import type {MarkedExtension} from 'marked'; -import {getHighlighter, LANGS, escapeHtml} from './shiki-shared.ts'; +import {getHighlighter, LANGS} from './shiki-shared.ts'; +import {escapeHtml} from './escape-html.ts'; /** * Adjacent fenced code blocks tagged with `group="..."` merge into a tabbed @@ -25,7 +26,7 @@ import {getHighlighter, LANGS, escapeHtml} from './shiki-shared.ts'; let groupCounter = 0; const FENCE_WITH_GROUP_RE = - /^```([\w-]+)?[\t ]+([^\n]*?\bgroup="([^"]+)"[^\n]*)\n([\s\S]*?)\n```$/gm; + /^(`{3,})([\w-]+)?[\t ]+([^\n]*?\bgroup="([^"]+)"[^\n]*)\n([\s\S]*?)\n\1`*$/gm; interface Fence { start: number; @@ -64,10 +65,10 @@ export const ngmdCodeGroupExtension: MarkedExtension = { fences.push({ start: m.index, end: m.index + m[0].length, - lang: m[1] ?? '', - attrs: m[2], - group: m[3], - body: m[4], + lang: m[2] ?? '', + attrs: m[3], + group: m[4], + body: m[5], }); } if (fences.length === 0) return markdown; diff --git a/apps/docs/src/marked-extensions/ngmd-code-highlight.ts b/apps/docs/src/marked-extensions/ngmd-code-highlight.ts index 5afcdee..442bca1 100644 --- a/apps/docs/src/marked-extensions/ngmd-code-highlight.ts +++ b/apps/docs/src/marked-extensions/ngmd-code-highlight.ts @@ -26,7 +26,7 @@ import {getHighlighter, LANGS} from './shiki-shared.ts'; // Capture: lang, line ranges in {}, body. Skips fences whose info string // contains `group=` or `file=` so those routes own the fence. -const FENCE_RE = /^```([\w-]+)?[\t ]+\{([0-9,\-\s]+)\}[\t ]*\n([\s\S]*?)\n```$/gm; +const FENCE_RE = /^(`{3,})([\w-]+)?[\t ]+\{([0-9,\-\s]+)\}[\t ]*\n([\s\S]*?)\n\1`*$/gm; function parseRanges(spec: string): Set<number> { const lines = new Set<number>(); @@ -59,7 +59,7 @@ export const ngmdCodeHighlightExtension: MarkedExtension = { hooks: { async preprocess(markdown: string): Promise<string> { // Quick negative check before scanning. - if (!/^```[\w-]*[\t ]+\{[0-9,\-\s]+\}/m.test(markdown)) return markdown; + if (!/^`{3,}[\w-]*[\t ]+\{[0-9,\-\s]+\}/m.test(markdown)) return markdown; const matches: {start: number; end: number; lang: string; spec: string; body: string}[] = []; const re = new RegExp(FENCE_RE.source, FENCE_RE.flags); @@ -72,9 +72,9 @@ export const ngmdCodeHighlightExtension: MarkedExtension = { matches.push({ start: m.index, end: m.index + m[0].length, - lang: m[1] ?? '', - spec: m[2], - body: m[3], + lang: m[2] ?? '', + spec: m[3], + body: m[4], }); } if (matches.length === 0) return markdown; diff --git a/apps/docs/src/marked-extensions/ngmd-code-import.ts b/apps/docs/src/marked-extensions/ngmd-code-import.ts index daadc00..73d2d1f 100644 --- a/apps/docs/src/marked-extensions/ngmd-code-import.ts +++ b/apps/docs/src/marked-extensions/ngmd-code-import.ts @@ -1,7 +1,8 @@ import {readFileSync} from 'node:fs'; -import {resolve} from 'node:path'; +import {isAbsolute, relative, resolve} from 'node:path'; import type {MarkedExtension} from 'marked'; -import {getHighlighter, LANGS, escapeHtml} from './shiki-shared.ts'; +import {getHighlighter, LANGS} from './shiki-shared.ts'; +import {escapeHtml} from './escape-html.ts'; import config from '../ngmd.config.ts'; /** @@ -24,12 +25,17 @@ import config from '../ngmd.config.ts'; * self-contained HTML block — marked never sees the inner fence. */ -const FENCE_RE = /^```([\w-]+)?[\t ]+file="([^"]+)"[^\n]*\n(?:([\s\S]*?)\n)?```$/gm; +const FENCE_RE = /^(`{3,})([\w-]+)?[\t ]+file="([^"]+)"[^\n]*\n(?:([\s\S]*?)\n)?\1`*$/gm; const IGNORE_LINE_RE = /^.*\/\/\s*ngmd-ignore-line\s*$/; function loadFile(spec: string): {code: string; rangeFragment: string} { const [path, range] = spec.split('#'); - const full = resolve(process.cwd(), path); + const root = process.cwd(); + const full = resolve(root, path); + const rel = relative(root, full); + if (rel.startsWith('..') || isAbsolute(rel)) { + throw new Error('path resolves outside the project root'); + } let content = readFileSync(full, 'utf8'); let rangeFragment = ''; @@ -62,7 +68,7 @@ function githubBlobUrl(filePath: string, rangeFragment: string): string { export const ngmdCodeImportExtension: MarkedExtension = { hooks: { async preprocess(markdown: string): Promise<string> { - if (!/^```[\w-]*[\t ]+file="/m.test(markdown)) return markdown; + if (!/^`{3,}[\w-]*[\t ]+file="/m.test(markdown)) return markdown; const matches: { start: number; @@ -76,8 +82,8 @@ export const ngmdCodeImportExtension: MarkedExtension = { const re = new RegExp(FENCE_RE.source, FENCE_RE.flags); let m: RegExpExecArray | null; while ((m = re.exec(markdown)) !== null) { - const lang = m[1] ?? ''; - const spec = m[2]; + const lang = m[2] ?? ''; + const spec = m[3]; try { const {code, rangeFragment} = loadFile(spec); matches.push({ diff --git a/apps/docs/src/marked-extensions/ngmd-image.ts b/apps/docs/src/marked-extensions/ngmd-image.ts index 0dc76ad..6d253dd 100644 --- a/apps/docs/src/marked-extensions/ngmd-image.ts +++ b/apps/docs/src/marked-extensions/ngmd-image.ts @@ -1,4 +1,5 @@ import type {Tokens} from 'marked'; +import {escapeHtml} from './escape-html.ts'; interface NgmdImageToken extends Tokens.Generic { type: 'ngmd-image'; @@ -37,9 +38,9 @@ export const ngmdImageExtension = { }; }, renderer(token: NgmdImageToken) { - const widthAttr = token.width ? token.width.replace(/"/g, '') : ''; - const alt = token.alt.replace(/"/g, '"'); - const caption = token.caption ? token.caption.replace(/"/g, '"') : ''; - return `<div class="ngmd-image" data-image-src="${token.src}" data-image-alt="${alt}" data-image-caption="${caption}" data-image-width="${widthAttr}"></div>`; + const widthAttr = escapeHtml(token.width ?? ''); + const alt = escapeHtml(token.alt); + const caption = escapeHtml(token.caption ?? ''); + return `<div class="ngmd-image" data-image-src="${escapeHtml(token.src)}" data-image-alt="${alt}" data-image-caption="${caption}" data-image-width="${widthAttr}"></div>`; }, }; diff --git a/apps/docs/src/marked-extensions/ngmd-video.ts b/apps/docs/src/marked-extensions/ngmd-video.ts index 6e2e14f..a8f76fe 100644 --- a/apps/docs/src/marked-extensions/ngmd-video.ts +++ b/apps/docs/src/marked-extensions/ngmd-video.ts @@ -1,4 +1,5 @@ import type {Tokens} from 'marked'; +import {escapeHtml} from './escape-html.ts'; interface NgmdVideoToken extends Tokens.Generic { type: 'ngmd-video'; @@ -22,7 +23,7 @@ function buildEmbedUrl(src: string): string { if (ytShort) return `https://www.youtube.com/embed/${ytShort[1]}`; const vm = src.match(/vimeo\.com\/(\d+)/); if (vm) return `https://player.vimeo.com/video/${vm[1]}`; - return src; + return 'about:blank'; } export const ngmdVideoExtension = { @@ -46,7 +47,7 @@ export const ngmdVideoExtension = { }, renderer(token: NgmdVideoToken) { const url = buildEmbedUrl(token.src); - const title = (token.title ?? 'Video player').replace(/"/g, '"'); - return `<div class="ngmd-video" data-video-src="${url}" data-video-title="${title}"></div>`; + const title = escapeHtml(token.title ?? 'Video player'); + return `<div class="ngmd-video" data-video-src="${escapeHtml(url)}" data-video-title="${title}"></div>`; }, }; diff --git a/apps/docs/src/marked-extensions/shiki-shared.ts b/apps/docs/src/marked-extensions/shiki-shared.ts index d5b32ff..9e29541 100644 --- a/apps/docs/src/marked-extensions/shiki-shared.ts +++ b/apps/docs/src/marked-extensions/shiki-shared.ts @@ -32,11 +32,3 @@ export function getHighlighter(): Promise<Highlighter> { } return highlighterPromise; } - -export function escapeHtml(s: string): string { - return s - .replace(/&/g, '&') - .replace(/</g, '<') - .replace(/>/g, '>') - .replace(/"/g, '"'); -} diff --git a/apps/docs/src/styles.css b/apps/docs/src/styles.css index 0a4d9a2..43c53b8 100644 --- a/apps/docs/src/styles.css +++ b/apps/docs/src/styles.css @@ -159,6 +159,7 @@ :is(analog-markdown, analog-markdown-route) ngmd-pill:not(:defined), :is(analog-markdown, analog-markdown-route) ngmd-pill-row:not(:defined), :is(analog-markdown, analog-markdown-route) ngmd-step:not(:defined), +:is(analog-markdown, analog-markdown-route) ngmd-tab:not(:defined), :is(analog-markdown, analog-markdown-route) ngmd-tabs:not(:defined), :is(analog-markdown, analog-markdown-route) ngmd-video:not(:defined), :is(analog-markdown, analog-markdown-route) ngmd-workflow:not(:defined) { From 41756562d01f67cf84c42166168d522cbb687a91 Mon Sep 17 00:00:00 2001 From: Kam <erkamyaman35@gmail.com> Date: Tue, 29 Sep 2026 10:13:17 +0300 Subject: [PATCH 04/13] feat(docs): update to NgMd 0.4 Take the NgMd 0.4 framework code: unique heading ids shared by the TOC, link guard and search index, a dev link guard that warns instead of failing every page, and config-driven header, Discord and sponsor links. Follow symlinks before checking code imports, and match CommonMark when detecting fences. --- apps/docs/link-guard.plugin.ts | 29 ++++++++--- apps/docs/page-meta.plugin.ts | 2 +- apps/docs/plugin-utils.spec.ts | 28 +++++++++++ apps/docs/plugin-utils.ts | 25 +++------- apps/docs/search-index.plugin.ts | 12 +++-- apps/docs/src/app/app.ts | 48 ++++++++++-------- apps/docs/src/app/components/site-footer.ts | 26 +++++++--- apps/docs/src/app/components/sponsor-list.ts | 50 ++++++++++--------- apps/docs/src/app/components/toc.spec.ts | 44 ++++++++++++++++ apps/docs/src/app/components/toc.ts | 35 +++++++------ apps/docs/src/app/ui/callout.ts | 2 +- apps/docs/src/app/utils/heading-slug.spec.ts | 44 ++++++++++++++++ apps/docs/src/app/utils/heading-slug.ts | 50 +++++++++++++++++++ .../marked-extensions.spec.ts | 38 ++++++++++++++ .../src/marked-extensions/ngmd-code-import.ts | 6 +-- apps/docs/src/ngmd.config.ts | 17 ++++++- apps/docs/src/styles.css | 7 +++ 17 files changed, 361 insertions(+), 102 deletions(-) create mode 100644 apps/docs/plugin-utils.spec.ts create mode 100644 apps/docs/src/app/components/toc.spec.ts create mode 100644 apps/docs/src/app/utils/heading-slug.spec.ts create mode 100644 apps/docs/src/app/utils/heading-slug.ts create mode 100644 apps/docs/src/marked-extensions/marked-extensions.spec.ts diff --git a/apps/docs/link-guard.plugin.ts b/apps/docs/link-guard.plugin.ts index 6c2fab6..65ea3bd 100644 --- a/apps/docs/link-guard.plugin.ts +++ b/apps/docs/link-guard.plugin.ts @@ -1,9 +1,11 @@ import {readFileSync, statSync} from 'node:fs'; import {join, relative} from 'node:path'; -import type {Plugin} from 'vite'; +import type {Plugin, ViteDevServer} from 'vite'; import { + createSlugger, fenceTracker, routeFromPagePath, + headingText, slugify, walkContentFiles, walkPageFiles, @@ -29,10 +31,13 @@ import { function extractHeadings(markdown: string): Set<string> { const slugs = new Set<string>(); const inFence = fenceTracker(); + const slug = createSlugger(); for (const line of markdown.split(/\r?\n/)) { if (inFence(line)) continue; - const m = /^#{1,6}\s+(.+?)\s*$/.exec(line); - if (m) slugs.add(slugify(m[1])); + const m = /^(#{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; } @@ -44,6 +49,7 @@ export function internalLinkGuard(): Plugin { // route → source file (relative path) const routes = new Map<string, string>(); let primed = false; + let server: ViteDevServer | undefined; function prime(): void { if (primed) return; @@ -82,6 +88,15 @@ export function internalLinkGuard(): Plugin { configResolved(cfg) { root = cfg.root; }, + configureServer(devServer) { + server = devServer; + }, + watchChange(id) { + if (!id.endsWith('.md') && !id.endsWith('.page.ts')) return; + primed = false; + routes.clear(); + headingsByRoute.clear(); + }, transform(_code, id) { // Vite may append `?import` / `?raw` query suffixes const cleanId = id.split('?')[0]; @@ -133,10 +148,12 @@ export function internalLinkGuard(): Plugin { } if (issues.length > 0) { - this.error( + 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.`, - ); + `Fix the link target, or update the heading slug it points to.`; + if (!server) this.error(message); + this.warn(message); + server.ws.send({type: 'error', err: {message, stack: ''}}); } return null; diff --git a/apps/docs/page-meta.plugin.ts b/apps/docs/page-meta.plugin.ts index 0a8e45f..bb2e9c9 100644 --- a/apps/docs/page-meta.plugin.ts +++ b/apps/docs/page-meta.plugin.ts @@ -61,7 +61,7 @@ export function pageMetaPlugin(opts: {repoUrl: string; branch?: string; dir?: st }; } } catch { - // src/app/pages missing — skip + // src/app/pages missing, skip } // src/content/**/*.md → route (mirrors the [...slug] catch-all) diff --git a/apps/docs/plugin-utils.spec.ts b/apps/docs/plugin-utils.spec.ts new file mode 100644 index 0000000..d37b17a --- /dev/null +++ b/apps/docs/plugin-utils.spec.ts @@ -0,0 +1,28 @@ +import {fenceTracker} 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']); + }); +}); diff --git a/apps/docs/plugin-utils.ts b/apps/docs/plugin-utils.ts index b66ca84..5b7ccab 100644 --- a/apps/docs/plugin-utils.ts +++ b/apps/docs/plugin-utils.ts @@ -2,6 +2,8 @@ import {execFileSync} from 'node:child_process'; import {readdirSync, statSync} from 'node:fs'; import {join, relative} from 'node:path'; +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` @@ -13,8 +15,9 @@ import {join, relative} from 'node:path'; * - `.page.ts` under `src/app/pages/`: `home/index.page.ts` → `/home`, * `index.page.ts` → `/`, dynamic / catch-all (`[...slug].page.ts`) → '' (skipped) * - * The `slugify` rule matches the runtime TOC's heading-id algorithm so - * build-time link validation and runtime fragments stay aligned. + * `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`. */ @@ -87,28 +90,12 @@ export function gitDate(file: string, cwd: string, mtimeFallback: () => string = } } -/** - * Heading slug. Matches the algorithm `toc.ts` uses at runtime to - * overwrite every rendered heading id, and the one `search-index.plugin.ts` - * uses to anchor search snippets, so all three stay in sync. - * - * Lowercase, collapse every run of non-alphanumeric characters (including - * `.`, `_`, `*`, spaces, etc.) into a single `-`, then trim outer hyphens. - */ -export function slugify(s: string): string { - return s - .toLowerCase() - .trim() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-|-$/g, ''); -} - export function fenceTracker(): (line: string) => boolean { let open = ''; return (line) => { const m = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line); if (!open) { - if (!m) return false; + if (!m || (m[1][0] === '`' && m[2].includes('`'))) return false; open = m[1]; return true; } diff --git a/apps/docs/search-index.plugin.ts b/apps/docs/search-index.plugin.ts index 39711aa..cd04fcc 100644 --- a/apps/docs/search-index.plugin.ts +++ b/apps/docs/search-index.plugin.ts @@ -2,7 +2,12 @@ import {readFileSync, statSync} from 'node:fs'; import {join} from 'node:path'; import type {Plugin} from 'vite'; import type {IndexDoc, SearchHitKind} from './src/types/search.ts'; -import {fenceTracker, slugify, walkContentFiles} from './plugin-utils.ts'; +import { + createSlugger, + fenceTracker, + headingText as headingTextOf, + walkContentFiles, +} from './plugin-utils.ts'; /** * Build-time search index. Walks `src/content/**\/*.md` and emits a flat list @@ -173,10 +178,11 @@ export function searchIndexPlugin(): Plugin { // 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); - const anchor = slugify(headingText); + const headingText = stripMarkdown(headingTextOf(section.heading)); + const anchor = anchorFor(headingText); docs.push({ id: `section:${url}#${anchor}`, url, diff --git a/apps/docs/src/app/app.ts b/apps/docs/src/app/app.ts index cba2bbc..9a0d86c 100644 --- a/apps/docs/src/app/app.ts +++ b/apps/docs/src/app/app.ts @@ -90,26 +90,29 @@ import {VersionSwitcher} from './components/version-switcher'; {{ siteName }} </a> - <nav class="hidden sm:flex items-center gap-1 text-sm"> - <a - routerLink="/getting-started/introduction" - class="rounded px-3 py-1.5 hover:bg-zinc-100 dark:hover:bg-zinc-900" - > - Docs - </a> - <a - routerLink="/inspectors/dashboard" - class="rounded px-3 py-1.5 hover:bg-zinc-100 dark:hover:bg-zinc-900" - > - Inspectors - </a> - <a - routerLink="/agents/mcp-server" - class="rounded px-3 py-1.5 hover:bg-zinc-100 dark:hover:bg-zinc-900" - > - Agents - </a> - </nav> + @if (headerNav.length > 0) { + <nav class="hidden sm:flex items-center gap-1 text-sm"> + @for (item of headerNav; track item.href) { + @if (isExternal(item.href)) { + <a + [href]="item.href" + target="_blank" + rel="noopener noreferrer" + class="rounded px-3 py-1.5 hover:bg-zinc-100 dark:hover:bg-zinc-900" + > + {{ item.label }} + </a> + } @else { + <a + [routerLink]="item.href" + class="rounded px-3 py-1.5 hover:bg-zinc-100 dark:hover:bg-zinc-900" + > + {{ item.label }} + </a> + } + } + </nav> + } <div class="ml-auto flex items-center gap-2"> <app-version-switcher></app-version-switcher> @@ -286,6 +289,7 @@ export class App implements OnInit { readonly siteName = siteConfig.site.name; readonly githubUrl = siteConfig.site.githubUrl; readonly discordUrl = siteConfig.site.links?.discord; + readonly headerNav = siteConfig.headerNav ?? []; readonly drawerOpen = signal(false); @@ -298,6 +302,10 @@ export class App implements OnInit { readonly showToc = this.isDocsRoute; readonly showFooter = this.isDocsRoute; + isExternal(href: string): boolean { + return /^https?:\/\//.test(href); + } + toggleDrawer(): void { if (this.drawerOpen()) { this.closeDrawer(); diff --git a/apps/docs/src/app/components/site-footer.ts b/apps/docs/src/app/components/site-footer.ts index b0dc77c..dcb9bc9 100644 --- a/apps/docs/src/app/components/site-footer.ts +++ b/apps/docs/src/app/components/site-footer.ts @@ -1,26 +1,34 @@ import {Component} from '@angular/core'; -import {RouterLink} from '@angular/router'; import {GithubIcon} from '../ui/github-icon'; import {DiscordIcon} from '../ui/discord-icon'; import siteConfig from '../../ngmd.config'; @Component({ selector: 'app-site-footer', - imports: [RouterLink, GithubIcon, DiscordIcon], + imports: [GithubIcon, DiscordIcon], template: ` <footer - class="border-t border-zinc-200 dark:border-zinc-800 py-6 px-4 sm:px-6 text-sm text-zinc-600 dark:text-zinc-400" + class="border-t border-zinc-200 dark:border-zinc-800 py-6 px-4 sm:px-6 text-sm text-zinc-500 dark:text-zinc-400" > <div class="mx-auto max-w-7xl flex flex-col sm:flex-row items-center justify-between gap-3"> <span>© {{ year }} {{ name }} contributors. Released under the MIT License.</span> <nav class="flex flex-wrap items-center gap-4" aria-label="Project links"> - <a routerLink="/sponsors" class="hover:text-zinc-900 dark:hover:text-zinc-200">Sponsor</a> + @if (sponsorUrl) { + <a + [href]="sponsorUrl" + target="_blank" + rel="noopener noreferrer" + class="hover:text-zinc-700 dark:hover:text-zinc-300" + > + Sponsor + </a> + } @if (discordUrl) { <a [href]="discordUrl" target="_blank" rel="noopener noreferrer" - class="inline-flex items-center gap-1.5 hover:text-zinc-900 dark:hover:text-zinc-200" + class="inline-flex items-center gap-1.5 hover:text-zinc-700 dark:hover:text-zinc-300" > <svg ngmdDiscordIcon class="size-4"></svg> Discord @@ -30,7 +38,8 @@ import siteConfig from '../../ngmd.config'; [href]="githubUrl" target="_blank" rel="noopener noreferrer" - class="inline-flex items-center gap-1.5 hover:text-zinc-900 dark:hover:text-zinc-200" + class="inline-flex items-center gap-1.5 hover:text-zinc-700 dark:hover:text-zinc-300" + aria-label="GitHub repository" > <svg ngmdGithubIcon class="size-4"></svg> {{ repo }} @@ -41,9 +50,10 @@ import siteConfig from '../../ngmd.config'; `, }) export class SiteFooter { - readonly year = new Date().getFullYear(); readonly name = siteConfig.site.name; readonly githubUrl = siteConfig.site.githubUrl; - readonly discordUrl = siteConfig.site.links?.discord; + readonly year = new Date().getFullYear(); readonly repo = new URL(siteConfig.site.githubUrl).pathname.replace(/^\/+/, ''); + readonly sponsorUrl = siteConfig.site.links?.sponsor; + readonly discordUrl = siteConfig.site.links?.discord; } diff --git a/apps/docs/src/app/components/sponsor-list.ts b/apps/docs/src/app/components/sponsor-list.ts index 5628800..7feb455 100644 --- a/apps/docs/src/app/components/sponsor-list.ts +++ b/apps/docs/src/app/components/sponsor-list.ts @@ -4,30 +4,32 @@ import siteConfig from '../../ngmd.config'; @Component({ selector: 'app-sponsor-list', template: ` - <ul class="flex flex-wrap gap-4 list-none p-0 m-0"> - @for (sponsor of sponsors; track sponsor.login) { - <li class="m-0"> - <a - [href]="'https://github.com/' + sponsor.login" - target="_blank" - rel="noopener noreferrer" - class="flex flex-col items-center gap-2 rounded-lg p-2 no-underline! hover:bg-zinc-100 dark:hover:bg-zinc-900" - > - <img - [src]="'https://github.com/' + sponsor.login + '.png?size=' + size() * 2" - [width]="size()" - [height]="size()" - [alt]="sponsor.name" - loading="lazy" - class="rounded-full border border-zinc-200 dark:border-zinc-800" - /> - @if (showNames()) { - <span class="text-sm font-medium">{{ sponsor.name }}</span> - } - </a> - </li> - } - </ul> + @if (sponsors.length > 0) { + <ul class="flex flex-wrap gap-4 list-none p-0 m-0"> + @for (sponsor of sponsors; track sponsor.login) { + <li class="m-0"> + <a + [href]="'https://github.com/' + sponsor.login" + target="_blank" + rel="noopener noreferrer" + class="flex flex-col items-center gap-2 rounded-lg p-2 no-underline! hover:bg-zinc-100 dark:hover:bg-zinc-900" + > + <img + [src]="'https://github.com/' + sponsor.login + '.png?size=' + size() * 2" + [width]="size()" + [height]="size()" + [alt]="sponsor.name" + loading="lazy" + class="rounded-full border border-zinc-200 dark:border-zinc-800" + /> + @if (showNames()) { + <span class="text-sm font-medium">{{ sponsor.name }}</span> + } + </a> + </li> + } + </ul> + } `, }) export class SponsorList { diff --git a/apps/docs/src/app/components/toc.spec.ts b/apps/docs/src/app/components/toc.spec.ts new file mode 100644 index 0000000..9b5ef20 --- /dev/null +++ b/apps/docs/src/app/components/toc.spec.ts @@ -0,0 +1,44 @@ +import {TestBed} from '@angular/core/testing'; +import {provideRouter} from '@angular/router'; +import {Toc} from './toc'; + +describe('Toc', () => { + beforeEach(() => { + vi.stubGlobal( + 'IntersectionObserver', + class { + observe(): void {} + disconnect(): void {} + }, + ); + TestBed.configureTestingModule({providers: [provideRouter([])]}); + }); + + afterEach(async () => { + await new Promise((resolve) => setTimeout(resolve, 50)); + vi.unstubAllGlobals(); + document.body.innerHTML = ''; + }); + + it('gives repeated headings unique ids and lists only h2 and h3', async () => { + document.body.innerHTML = ` + <main><analog-markdown> + <h2>Install</h2><h3>Flags</h3><h4>Flags</h4> + <h2>Run</h2><h3>Flags</h3> + <h2>Setup <ngmd-badge>New</ngmd-badge></h2> + </analog-markdown></main>`; + const fixture = TestBed.createComponent(Toc); + await fixture.whenStable(); + + const ids = Array.from(document.querySelectorAll('h2, h3, h4')).map((h) => h.id); + expect(ids).toEqual(['install', 'flags', 'flags-1', 'run', 'flags-2', 'setup']); + expect(fixture.componentInstance.headings().map((h) => h.id)).toEqual([ + 'install', + 'flags', + 'run', + 'flags-2', + 'setup', + ]); + fixture.destroy(); + }); +}); diff --git a/apps/docs/src/app/components/toc.ts b/apps/docs/src/app/components/toc.ts index 699f08f..29649c0 100644 --- a/apps/docs/src/app/components/toc.ts +++ b/apps/docs/src/app/components/toc.ts @@ -1,6 +1,7 @@ import {AfterViewInit, Component, DestroyRef, inject, input, signal} from '@angular/core'; import {Router} from '@angular/router'; import {onNavigation} from '../utils/enhance-on-navigation'; +import {createSlugger} from '../utils/heading-slug'; interface Heading { id: string; @@ -44,6 +45,7 @@ export class Toc implements AfterViewInit { readonly active = signal<string>(''); private observer?: IntersectionObserver; private contentObserver?: MutationObserver; + private retryTimer?: ReturnType<typeof setTimeout>; isActive(id: string): boolean { return this.showActive() && this.active() === id; @@ -76,6 +78,7 @@ export class Toc implements AfterViewInit { } this.destroyRef.onDestroy(() => { + clearTimeout(this.retryTimer); this.contentObserver?.disconnect(); this.observer?.disconnect(); }); @@ -103,6 +106,7 @@ export class Toc implements AfterViewInit { // Reset any prior observer before scanning. Navigation churn would // otherwise leave a stale observer firing on the wrong route's <main>. this.contentObserver?.disconnect(); + clearTimeout(this.retryTimer); // TS-driven pages render synchronously: the headings are in the DOM // by the time AfterViewInit fires. Try once, succeed immediately. @@ -117,7 +121,7 @@ export class Toc implements AfterViewInit { if (!main) { // <main> not in DOM yet (very early in the lifecycle). One micro-delay // and we'll find it. - setTimeout(() => this.scanWithRetry(), 50); + this.retryTimer = setTimeout(() => this.scanWithRetry(), 50); return; } this.contentObserver = new MutationObserver(() => { @@ -142,23 +146,22 @@ export class Toc implements AfterViewInit { } private scan(content: Element): void { - const nodes = Array.from(content.querySelectorAll('h2, h3')); - const result: Heading[] = nodes.map((node) => { - const text = node.textContent?.trim() ?? ''; + const slug = createSlugger(); + const nodes: HTMLElement[] = []; + const result: Heading[] = []; + for (const node of Array.from(content.querySelectorAll<HTMLElement>('h2, h3, h4, h5, h6'))) { + const copy = node.cloneNode(true) as HTMLElement; + copy.querySelectorAll('ngmd-badge').forEach((badge) => badge.remove()); + const text = copy.textContent?.trim() ?? ''; // Always overwrite the id with a clean slug so palette deep-links match. - const id = text - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-|-$/g, ''); - node.id = id; - return { - id, - text, - level: parseInt(node.tagName.substring(1), 10), - }; - }); + node.id = slug(text); + const level = parseInt(node.tagName.substring(1), 10); + if (level > 3) continue; + nodes.push(node); + result.push({id: node.id, text, level}); + } this.headings.set(result); - this.setupObserver(nodes as HTMLElement[]); + this.setupObserver(nodes); } private setupObserver(nodes: HTMLElement[]): void { diff --git a/apps/docs/src/app/ui/callout.ts b/apps/docs/src/app/ui/callout.ts index 636e0ff..f3198ae 100644 --- a/apps/docs/src/app/ui/callout.ts +++ b/apps/docs/src/app/ui/callout.ts @@ -15,7 +15,7 @@ const BOX = template: ` @switch (type()) { @case ('tip') { - <div [class]="box + ' border-l-teal-500'"> + <div [class]="box + ' border-l-[color:var(--accent)]'"> <ng-container *ngTemplateOutlet="body"></ng-container> </div> } diff --git a/apps/docs/src/app/utils/heading-slug.spec.ts b/apps/docs/src/app/utils/heading-slug.spec.ts new file mode 100644 index 0000000..2511135 --- /dev/null +++ b/apps/docs/src/app/utils/heading-slug.spec.ts @@ -0,0 +1,44 @@ +import {createSlugger, headingText, slugify} from './heading-slug'; + +describe('slugify', () => { + it('lowercases and collapses non-alphanumerics into single hyphens', () => { + expect(slugify(' Connect over HTTP ')).toBe('connect-over-http'); + expect(slugify('`ngmd.config.ts` > nav')).toBe('ngmd-config-ts-nav'); + }); +}); + +describe('createSlugger', () => { + it('suffixes repeated headings in document order', () => { + const slug = createSlugger(); + expect(slug('Flags')).toBe('flags'); + expect(slug('Usage')).toBe('usage'); + expect(slug('Flags')).toBe('flags-1'); + expect(slug('flags')).toBe('flags-2'); + }); + + it('skips suffixes already taken by a literal heading', () => { + const slug = createSlugger(); + expect(slug('Flags 1')).toBe('flags-1'); + expect(slug('Flags')).toBe('flags'); + expect(slug('Flags')).toBe('flags-2'); + }); + + it('keeps separate state per slugger', () => { + expect(createSlugger()('Flags')).toBe('flags'); + expect(createSlugger()('Flags')).toBe('flags'); + }); +}); + +describe('headingText', () => { + it('matches what the rendered heading shows', () => { + expect(headingText('Prerequisites <ngmd-badge variant="stable">MIT</ngmd-badge>')).toBe( + 'Prerequisites', + ); + expect(headingText('Read [the guide](/guide) and ![logo](/logo.svg)')).toBe( + 'Read the guide and logo', + ); + expect(headingText('Install <code>@scope/pkg</code> & run')).toBe( + 'Install @scope/pkg & run', + ); + }); +}); diff --git a/apps/docs/src/app/utils/heading-slug.ts b/apps/docs/src/app/utils/heading-slug.ts new file mode 100644 index 0000000..3ea8961 --- /dev/null +++ b/apps/docs/src/app/utils/heading-slug.ts @@ -0,0 +1,50 @@ +/** + * Heading slug. Matches the algorithm `toc.ts` uses at runtime to + * overwrite every rendered heading id, and the one `search-index.plugin.ts` + * uses to anchor search snippets, so all three stay in sync. + * + * Lowercase, collapse every run of non-alphanumeric characters (including + * `.`, `_`, `*`, spaces, etc.) into a single `-`, then trim outer hyphens. + */ +export function slugify(s: string): string { + return s + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, ''); +} + +export function createSlugger(): (text: string) => string { + const seen = new Set<string>(); + return (text) => { + const base = slugify(text); + let slug = base; + for (let n = 1; seen.has(slug); n++) slug = `${base}-${n}`; + seen.add(slug); + return slug; + }; +} + +const ENTITIES: Record<string, string> = { + amp: '&', + lt: '<', + gt: '>', + quot: '"', + apos: "'", + nbsp: ' ', +}; + +/** + * Plain text of a raw markdown heading, as the TOC reads it from the + * rendered page: badges dropped, links and images reduced to their text, + * other tags removed and entities decoded. + */ +export function headingText(markdown: string): string { + return markdown + .replace(/<ngmd-badge\b[^>]*>[\s\S]*?<\/ngmd-badge>/g, '') + .replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1') + .replace(/<[^>]+>/g, '') + .replace(/&#(\d+);/g, (_, n: string) => String.fromCharCode(Number(n))) + .replace(/&(amp|lt|gt|quot|apos|nbsp);/g, (_, e: string) => ENTITIES[e]) + .trim(); +} diff --git a/apps/docs/src/marked-extensions/marked-extensions.spec.ts b/apps/docs/src/marked-extensions/marked-extensions.spec.ts new file mode 100644 index 0000000..535be60 --- /dev/null +++ b/apps/docs/src/marked-extensions/marked-extensions.spec.ts @@ -0,0 +1,38 @@ +import {Marked} from 'marked'; +import {ngmdImageExtension} from './ngmd-image'; +import {ngmdVideoExtension} from './ngmd-video'; + +function render(markdown: string): string { + return new Marked({extensions: [ngmdImageExtension, ngmdVideoExtension]}).parse( + markdown, + ) as string; +} + +describe('ngmd-image', () => { + it('escapes every attribute it writes', () => { + const html = render( + '<ngmd-image src="/a.png?x=1&y=2" alt="<b" caption="Tom & Jerry" width="3<"></ngmd-image>', + ); + expect(html).toContain('data-image-src="/a.png?x=1&y=2"'); + expect(html).toContain('data-image-alt="<b"'); + expect(html).toContain('data-image-caption="Tom & Jerry"'); + expect(html).toContain('data-image-width="3<"'); + }); +}); + +describe('ngmd-video', () => { + it('builds embed URLs for YouTube and Vimeo', () => { + expect(render('<ngmd-video src="https://youtu.be/abc123"></ngmd-video>')).toContain( + 'data-video-src="https://www.youtube.com/embed/abc123"', + ); + expect(render('<ngmd-video src="https://vimeo.com/42"></ngmd-video>')).toContain( + 'data-video-src="https://player.vimeo.com/video/42"', + ); + }); + + it('refuses other URLs and escapes the title', () => { + const html = render('<ngmd-video src="javascript:alert(1)" title="a <b"></ngmd-video>'); + expect(html).toContain('data-video-src="about:blank"'); + expect(html).toContain('data-video-title="a <b"'); + }); +}); diff --git a/apps/docs/src/marked-extensions/ngmd-code-import.ts b/apps/docs/src/marked-extensions/ngmd-code-import.ts index 73d2d1f..ce4aed4 100644 --- a/apps/docs/src/marked-extensions/ngmd-code-import.ts +++ b/apps/docs/src/marked-extensions/ngmd-code-import.ts @@ -1,4 +1,4 @@ -import {readFileSync} from 'node:fs'; +import {readFileSync, realpathSync} from 'node:fs'; import {isAbsolute, relative, resolve} from 'node:path'; import type {MarkedExtension} from 'marked'; import {getHighlighter, LANGS} from './shiki-shared.ts'; @@ -30,8 +30,8 @@ const IGNORE_LINE_RE = /^.*\/\/\s*ngmd-ignore-line\s*$/; function loadFile(spec: string): {code: string; rangeFragment: string} { const [path, range] = spec.split('#'); - const root = process.cwd(); - const full = resolve(root, path); + const root = realpathSync(process.cwd()); + const full = realpathSync(resolve(root, path)); const rel = relative(root, full); if (rel.startsWith('..') || isAbsolute(rel)) { throw new Error('path resolves outside the project root'); diff --git a/apps/docs/src/ngmd.config.ts b/apps/docs/src/ngmd.config.ts index a346b6b..28bf9d9 100644 --- a/apps/docs/src/ngmd.config.ts +++ b/apps/docs/src/ngmd.config.ts @@ -72,7 +72,8 @@ export interface SiteConfig { * "view source" link on API symbol pages). Defaults to `main` when * omitted. Set this if the repo's default branch isn't `main`. */ githubBranch?: string; - /** Optional social / community links rendered in the header. */ + /** Optional community links. `discord` adds an icon to the header and a + * link to the footer; `sponsor` adds a "Sponsor" link to the footer. */ links?: { twitter?: string; discord?: string; @@ -92,12 +93,20 @@ export interface SiteConfig { } export interface Sponsor { + /** Display name, also used as the avatar's alt text. */ name: string; + /** GitHub login. Drives the avatar and the profile link. */ login: string; } export interface NgmdConfig { site: SiteConfig; + /** Links rendered in the header next to the brand, in order. Internal + * paths route in-app; `http(s)` URLs open in a new tab. Leave undefined + * for no header links. */ + headerNav?: NavItem[]; + /** Sponsors listed by `<ngmd-sponsors>`. Leave undefined to render + * nothing. */ sponsors?: Sponsor[]; /** Sidebar sections, in render order. */ nav: NavSection[]; @@ -132,6 +141,12 @@ const config: NgmdConfig = { }, }, + headerNav: [ + {label: 'Docs', href: '/getting-started/introduction'}, + {label: 'Inspectors', href: '/inspectors/dashboard'}, + {label: 'Agents', href: '/agents/mcp-server'}, + ], + sponsors: [ {name: 'CodeRabbit', login: 'coderabbitai'}, {name: 'umairhm', login: 'umairhm'}, diff --git a/apps/docs/src/styles.css b/apps/docs/src/styles.css index 43c53b8..afddea3 100644 --- a/apps/docs/src/styles.css +++ b/apps/docs/src/styles.css @@ -507,6 +507,13 @@ border-width: 1px; } + :is(analog-markdown, analog-markdown-route, .ngmd-prose) + :is(ngmd-card, ngmd-callout) + code:not(pre code) { + background: color-mix(in srgb, var(--fg) 7%, transparent); + border-color: transparent; + } + analog-markdown pre, analog-markdown-route pre { margin: 1.5rem 0; From 51c3c812a1d721e8f0f30853f73121396eb3e966 Mon Sep 17 00:00:00 2001 From: Kam <erkamyaman35@gmail.com> Date: Tue, 29 Sep 2026 10:36:35 +0300 Subject: [PATCH 05/13] fix(docs): keep code text in heading anchors and test the import path check Keep code span text when reading heading text, build search anchors from the unstripped heading, and move the file= path check into a tested resolveInside helper. --- apps/docs/plugin-utils.spec.ts | 33 ++++++++++++++++++- apps/docs/plugin-utils.ts | 18 ++++++++-- apps/docs/search-index.plugin.ts | 2 +- apps/docs/src/app/utils/heading-slug.spec.ts | 2 ++ apps/docs/src/app/utils/heading-slug.ts | 6 ++++ .../src/marked-extensions/ngmd-code-import.ts | 12 ++----- 6 files changed, 60 insertions(+), 13 deletions(-) diff --git a/apps/docs/plugin-utils.spec.ts b/apps/docs/plugin-utils.spec.ts index d37b17a..6f88eff 100644 --- a/apps/docs/plugin-utils.spec.ts +++ b/apps/docs/plugin-utils.spec.ts @@ -1,4 +1,7 @@ -import {fenceTracker} from './plugin-utils'; +import {mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {fenceTracker, resolveInside} from './plugin-utils'; function outsideFences(markdown: string): string[] { const inFence = fenceTracker(); @@ -26,3 +29,31 @@ describe('fenceTracker', () => { 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'); + }); +}); diff --git a/apps/docs/plugin-utils.ts b/apps/docs/plugin-utils.ts index 5b7ccab..7cb307a 100644 --- a/apps/docs/plugin-utils.ts +++ b/apps/docs/plugin-utils.ts @@ -1,6 +1,6 @@ import {execFileSync} from 'node:child_process'; -import {readdirSync, statSync} from 'node:fs'; -import {join, relative} from 'node:path'; +import {readdirSync, realpathSync, statSync} from 'node:fs'; +import {isAbsolute, join, relative, resolve} from 'node:path'; export {createSlugger, headingText, slugify} from './src/app/utils/heading-slug.ts'; @@ -103,3 +103,17 @@ export function fenceTracker(): (line: string) => boolean { return true; }; } + +/** + * 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/search-index.plugin.ts b/apps/docs/search-index.plugin.ts index cd04fcc..0bbd9b7 100644 --- a/apps/docs/search-index.plugin.ts +++ b/apps/docs/search-index.plugin.ts @@ -182,7 +182,7 @@ export function searchIndexPlugin(): Plugin { for (const section of splitSections(body)) { if (section.heading) { const headingText = stripMarkdown(headingTextOf(section.heading)); - const anchor = anchorFor(headingText); + const anchor = anchorFor(headingTextOf(section.heading)); docs.push({ id: `section:${url}#${anchor}`, url, diff --git a/apps/docs/src/app/utils/heading-slug.spec.ts b/apps/docs/src/app/utils/heading-slug.spec.ts index 2511135..a9dae3c 100644 --- a/apps/docs/src/app/utils/heading-slug.spec.ts +++ b/apps/docs/src/app/utils/heading-slug.spec.ts @@ -37,6 +37,8 @@ describe('headingText', () => { expect(headingText('Read [the guide](/guide) and ![logo](/logo.svg)')).toBe( 'Read the guide and logo', ); + expect(headingText('Use `<router-outlet>` here')).toBe('Use <router-outlet> here'); + expect(slugify(headingText('Use `<router-outlet>`'))).toBe('use-router-outlet'); expect(headingText('Install <code>@scope/pkg</code> & run')).toBe( 'Install @scope/pkg & run', ); diff --git a/apps/docs/src/app/utils/heading-slug.ts b/apps/docs/src/app/utils/heading-slug.ts index 3ea8961..5c67302 100644 --- a/apps/docs/src/app/utils/heading-slug.ts +++ b/apps/docs/src/app/utils/heading-slug.ts @@ -40,11 +40,17 @@ const ENTITIES: Record<string, string> = { * other tags removed and entities decoded. */ export function headingText(markdown: string): string { + const code: string[] = []; return markdown + .replace(/(`+)([\s\S]*?)\1/g, (_, _ticks: string, inner: string) => { + code.push(inner); + return `\u0000${code.length - 1}\u0000`; + }) .replace(/<ngmd-badge\b[^>]*>[\s\S]*?<\/ngmd-badge>/g, '') .replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1') .replace(/<[^>]+>/g, '') .replace(/&#(\d+);/g, (_, n: string) => String.fromCharCode(Number(n))) .replace(/&(amp|lt|gt|quot|apos|nbsp);/g, (_, e: string) => ENTITIES[e]) + .replace(/\u0000(\d+)\u0000/g, (_, i: string) => code[Number(i)]) .trim(); } diff --git a/apps/docs/src/marked-extensions/ngmd-code-import.ts b/apps/docs/src/marked-extensions/ngmd-code-import.ts index ce4aed4..8dedb51 100644 --- a/apps/docs/src/marked-extensions/ngmd-code-import.ts +++ b/apps/docs/src/marked-extensions/ngmd-code-import.ts @@ -1,8 +1,8 @@ -import {readFileSync, realpathSync} from 'node:fs'; -import {isAbsolute, relative, resolve} from 'node:path'; +import {readFileSync} from 'node:fs'; import type {MarkedExtension} from 'marked'; import {getHighlighter, LANGS} from './shiki-shared.ts'; import {escapeHtml} from './escape-html.ts'; +import {resolveInside} from '../../plugin-utils.ts'; import config from '../ngmd.config.ts'; /** @@ -30,13 +30,7 @@ const IGNORE_LINE_RE = /^.*\/\/\s*ngmd-ignore-line\s*$/; function loadFile(spec: string): {code: string; rangeFragment: string} { const [path, range] = spec.split('#'); - const root = realpathSync(process.cwd()); - const full = realpathSync(resolve(root, path)); - const rel = relative(root, full); - if (rel.startsWith('..') || isAbsolute(rel)) { - throw new Error('path resolves outside the project root'); - } - let content = readFileSync(full, 'utf8'); + let content = readFileSync(resolveInside(process.cwd(), path), 'utf8'); let rangeFragment = ''; if (range) { From edae1446e1492c5b02f153f5499d7143716c4f93 Mon Sep 17 00:00:00 2001 From: Kam <erkamyaman35@gmail.com> Date: Tue, 29 Sep 2026 12:28:32 +0300 Subject: [PATCH 06/13] docs: fact-check every page, add a writing guide and a kitchen sink Check every page, the README and CONTRIBUTING against the code and fix what was wrong. Add the Write documentation page and the devtools-docs skill, audit every page against them, and slim the README to a pointer to the docs. Add a kitchen sink page under Contributing, keep the active TOC item in view on long pages, leave noIndex pages out of the sitemap, and make the dev link guard a terminal warning. --- .claude/skills/devtools-docs/SKILL.md | 143 +++++++++ AGENTS.md | 7 +- CONTRIBUTING.md | 28 +- README.md | 56 +--- apps/docs/link-guard.plugin.ts | 11 +- apps/docs/sitemap.plugin.ts | 9 +- apps/docs/src/app/components/toc.ts | 35 ++- apps/docs/src/app/pages/index.page.ts | 11 +- apps/docs/src/content/agents/mcp-server.md | 22 +- apps/docs/src/content/agents/resources.md | 4 +- apps/docs/src/content/agents/tools.md | 34 +- apps/docs/src/content/community.md | 4 +- .../content/contributing/chrome-extension.md | 6 +- .../src/content/contributing/demo-apps.md | 24 +- .../src/content/contributing/development.md | 12 +- .../src/content/contributing/kitchen-sink.md | 294 ++++++++++++++++++ .../src/content/contributing/publishing.md | 4 +- .../src/content/contributing/writing-docs.md | 229 ++++++++++++++ .../getting-started/chrome-extension.md | 6 +- apps/docs/src/content/getting-started/cli.md | 20 +- .../src/content/getting-started/express.md | 50 +-- .../content/getting-started/installation.md | 14 +- .../content/getting-started/introduction.md | 10 +- .../src/content/getting-started/overlay.md | 52 ++-- .../content/getting-started/popup-and-hub.md | 20 +- apps/docs/src/content/getting-started/vite.md | 30 +- apps/docs/src/content/guides/analog.md | 17 +- .../content/guides/ngrx-signals-restore.md | 7 +- apps/docs/src/content/guides/ssr-http.md | 21 +- apps/docs/src/content/inspectors/analog.md | 49 +-- .../docs/src/content/inspectors/components.md | 41 +-- apps/docs/src/content/inspectors/dashboard.md | 80 ++--- apps/docs/src/content/inspectors/forms.md | 50 +-- apps/docs/src/content/inspectors/injectors.md | 47 +-- .../docs/src/content/inspectors/ngrx-store.md | 56 ++-- apps/docs/src/content/inspectors/pipes.md | 35 ++- apps/docs/src/content/inspectors/router.md | 46 +-- apps/docs/src/content/inspectors/signals.md | 44 +-- apps/docs/src/content/inspectors/ssr-http.md | 43 +-- apps/docs/src/content/security.md | 37 ++- apps/docs/src/ngmd.config.ts | 2 + 41 files changed, 1242 insertions(+), 468 deletions(-) create mode 100644 .claude/skills/devtools-docs/SKILL.md create mode 100644 apps/docs/src/content/contributing/kitchen-sink.md create mode 100644 apps/docs/src/content/contributing/writing-docs.md diff --git a/.claude/skills/devtools-docs/SKILL.md b/.claude/skills/devtools-docs/SKILL.md new file mode 100644 index 0000000..52e1957 --- /dev/null +++ b/.claude/skills/devtools-docs/SKILL.md @@ -0,0 +1,143 @@ +--- +name: devtools-docs +description: Writing guide for the Angular DevTools documentation site in apps/docs (NgMd). Covers audience, voice, style rules, page types and structure, NgMd authoring components, code samples, checking claims against the code, and the build checks. You MUST use this skill any time you create, edit or review files in apps/docs/src/content, the docs home page, or README.md. +--- + +# Angular DevTools docs writing guide + +The human-readable version of this guide is `apps/docs/src/content/contributing/writing-docs.md`. Keep the two in sync when rules change. + +The docs are an NgMd site (AnalogJS + Angular + Tailwind + marked). Pages are markdown files in `apps/docs/src/content`. The file path is the URL. The sidebar is `nav` in `apps/docs/src/ngmd.config.ts`. + +## 1. Audience and voice + +- Readers are Angular developers who have built at least one app. Don't explain TypeScript, the CLI, components, signals or DI basics. Do explain Devframe, MCP and how this project works. +- Orient each page around what the reader wants to do. +- Second person and imperative. Present tense. Active voice. +- One idea per sentence. Short, plain sentences. +- Condition first: "If X, do Y." +- Sentence case headings. +- UI labels in **bold**. Code, files, commands and options in `code`. +- Descriptive link text, never "here". + +## 2. Hard rules + +Reviewers reject changes that break these. + +1. **No em dashes.** Use a period, comma or parentheses. +2. **No first person** ("we", "our"). +3. **No future tense** ("will"). +4. **No time-relative claims** ("new", "recently", "upcoming", "now supports"). Use a sidebar `status` badge in `ngmd.config.ts` and the changelog instead. +5. **No comparisons with other devtools products.** Describe this project on its own terms. +6. **No marketing words**: powerful, seamless, blazingly fast, simply, just, easy. +7. **No invented features.** Every name, label, option, default, tool and argument must exist in the code. +8. **Lists with more than one attribute per item are tables.** +9. **One subject per page.** Link to angular.dev or MDN for background. + +## 3. Page types + +| Section | Job | Shape | +| --------------- | ------------------------------------------- | ------------------------------------------------------------------------------ | +| getting-started | Get one setup running. | Intro, workflow of steps, code per setup, gotchas as callouts. | +| inspectors | Explain one tab completely. | What it shows, where data comes from, how to use it, agent tools, limits, FAQ. | +| agents | Reference for MCP server, tools, resources. | Tables of names, arguments, results, grouped by inspector. | +| guides | One task end to end. | Workflow of steps with full working code. | +| contributing | Working on the repo. | Commands, tables, checklists. | + +Don't mix explainer and tutorial content on one page. + +## 4. Page skeleton + +```md +--- +title: Router +description: One sentence for search and link previews. +--- + +<ngmd-hero title="Router" gradient> + One or two sentences on what the page covers. +</ngmd-hero> + +# Router + +Short intro. + +## Section + +### Subsection + +## Where to next + +<ngmd-pill-row> + <ngmd-pill href="/agents/tools" title="Agent tools"></ngmd-pill> +</ngmd-pill-row> +``` + +- One `#` heading, matching `title`. `##` sections with `###` subsections so the TOC has depth. Don't skip levels. +- Headings are unique within a page. +- Before renaming a heading, grep `apps/docs/src/content` for its anchor. Anchors are the slug of the heading text (lowercase, non-alphanumerics to `-`), badges excluded. +- Add new pages to `nav` in `ngmd.config.ts`. +- `<ngmd-hero logo="...">` only on pages about one external tool (NgRx, Analog, Vite, Express, Chrome, MCP, Nx). + +## 5. Components + +Raw HTML in markdown. Always write explicit closing tags; never self-close custom elements. + +| Component | Use for | Attributes | +| ---------------------------------------- | ---------------------------------------- | ---------------------------------------------------------------------- | +| `ngmd-hero` | Page opener, once, before `#`. | `title`, `gradient`, `logo` | +| `ngmd-callout` | Short aside. | `type`: info, tip, success, warning, danger. `title` | +| `ngmd-alert` | One point the reader must not miss. | `severity`: info, helpful, important, warning, critical. `label` | +| `ngmd-card-grid` > `ngmd-card` | Related pages, requirements, overviews. | grid `columns`. card `title`, `link`, `cta`, `icon`, `image`, `avatar` | +| `ngmd-workflow` > `ngmd-step` | Ordered steps. | step `title` | +| `ngmd-accordion` > `ngmd-accordion-item` | FAQ. | item `title`, `open` | +| `ngmd-pill-row` > `ngmd-pill` | Related links at the end of a page. | pill `href`, `title` | +| `ngmd-badge` | Status chip next to a heading. | `variant`: new, updated, alpha, beta, stable, deprecated | +| `ngmd-tabs` > `ngmd-tab` | Alternatives a code group can't express. | tab `title`, `icon`, `image` | +| `ngmd-image`, `ngmd-video` | Screenshots, YouTube or Vimeo. | image `src`, `alt`, `caption`, `width`. video `src`, `title` | + +Card icons: book, box, code, compass, file, layers, lightbulb, palette, rocket, search, settings, shield, sparkles, terminal, wrench, zap. + +Rules: + +- Callouts and alerts are rare. Never adjacent, never inside a card, table cell or other component. +- Only nest the parent > child pairs above. +- If the page doesn't make sense without it, it's not a callout. +- End with a pill row or a card grid, not both. +- Inside components use HTML (`<code>`, `<strong>`, `<a>`); 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 2b3bf57..e8c327f 100644 --- a/README.md +++ b/README.md @@ -2,58 +2,28 @@ 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. -## Documentation - -The full documentation lives in [`apps/docs`](./apps/docs/src/content/getting-started/introduction.md): - -- [Getting started](./apps/docs/src/content/getting-started/introduction.md): install, Express SSR, Vite and Analog, the CLI, the popup and hub, the overlay, and the Chrome extension -- [Inspectors](./apps/docs/src/content/inspectors/dashboard.md): Dashboard, Components, Injectors, Signals, NgRx Store, Forms, Router, Pipes, SSR & HTTP, and Analog -- [Agent tools](./apps/docs/src/content/agents/mcp-server.md): the MCP server, every tool and resource -- [Security](./apps/docs/src/content/security.md): local-only access and what is redacted -- [Contributing](./apps/docs/src/content/contributing/development.md): development setup, demo apps, the extension and publishing - -Links inside these pages point to docs site routes, so they don't work when you read the files on GitHub. To follow them, run the docs site locally with `pnpm docs:dev`. - -## Quick start +## Get started ```sh npm install @santoshyadavdev/ng-devtools devframe ``` -**Angular app with SSR (Express)**: mount the devtools hub in your server. - -```ts -// server.ts -import { initNgDevtoolsHub } from '@santoshyadavdev/ng-devtools/hub'; - -const devtools = initNgDevtoolsHub({ ws: false }); -app.use(devtools.nodeMiddleware); -``` +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`. -**Analog (Vite)**: add the plugin next to `analog()`. - -```ts -// vite.config.ts -import analog from '@analogjs/platform'; -import ngDevtools from '@santoshyadavdev/ng-devtools/vite'; -import { defineConfig } from 'vite'; +## Documentation -export default defineConfig({ - plugins: [analog(), ngDevtools()], -}); -``` +The docs live in [`apps/docs`](./apps/docs). Run them locally with `pnpm docs:dev`. -Then load the overlay in development, and a floating button appears on your page: +- [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) -```ts -import '@santoshyadavdev/ng-devtools/overlay'; -``` +## Maintainers -**Coding agents**: run the MCP server. - -```sh -npx @santoshyadavdev/ng-devtools mcp -``` +- [Santosh Yadav](https://github.com/santoshyadavdev) +- [Erkam Yaman](https://github.com/erkamyaman) ## Community @@ -63,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: <!-- sponsors --> diff --git a/apps/docs/link-guard.plugin.ts b/apps/docs/link-guard.plugin.ts index 65ea3bd..9741a86 100644 --- a/apps/docs/link-guard.plugin.ts +++ b/apps/docs/link-guard.plugin.ts @@ -1,6 +1,6 @@ import {readFileSync, statSync} from 'node:fs'; import {join, relative} from 'node:path'; -import type {Plugin, ViteDevServer} from 'vite'; +import type {Plugin} from 'vite'; import { createSlugger, fenceTracker, @@ -49,7 +49,7 @@ export function internalLinkGuard(): Plugin { // route → source file (relative path) const routes = new Map<string, string>(); let primed = false; - let server: ViteDevServer | undefined; + let isBuild = true; function prime(): void { if (primed) return; @@ -87,9 +87,7 @@ export function internalLinkGuard(): Plugin { enforce: 'pre', configResolved(cfg) { root = cfg.root; - }, - configureServer(devServer) { - server = devServer; + isBuild = cfg.command === 'build'; }, watchChange(id) { if (!id.endsWith('.md') && !id.endsWith('.page.ts')) return; @@ -151,9 +149,8 @@ export function internalLinkGuard(): Plugin { 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 (!server) this.error(message); + if (isBuild) this.error(message); this.warn(message); - server.ws.send({type: 'error', err: {message, stack: ''}}); } return null; diff --git a/apps/docs/sitemap.plugin.ts b/apps/docs/sitemap.plugin.ts index 8ddaac8..5e2743a 100644 --- a/apps/docs/sitemap.plugin.ts +++ b/apps/docs/sitemap.plugin.ts @@ -1,4 +1,4 @@ -import {statSync} from 'node:fs'; +import {readFileSync, statSync} from 'node:fs'; import {join} from 'node:path'; import type {Plugin} from 'vite'; import {gitDate, routeFromPagePath, walkContentFiles, walkPageFiles} from './plugin-utils.ts'; @@ -56,6 +56,13 @@ export function sitemapPlugin(opts: {siteUrl: string}): Plugin { try { statSync(contentDir); for (const [rel, route] of walkContentFiles(contentDir, root)) { + if ( + /^---[\s\S]*?^noIndex:\s*(true|yes|1)\s*$[\s\S]*?^---/m.test( + readFileSync(join(root, rel), 'utf8'), + ) + ) { + continue; + } entries.set(route, gitDate(rel, root, today)); } } catch { diff --git a/apps/docs/src/app/components/toc.ts b/apps/docs/src/app/components/toc.ts index 29649c0..7369f9d 100644 --- a/apps/docs/src/app/components/toc.ts +++ b/apps/docs/src/app/components/toc.ts @@ -1,4 +1,13 @@ -import {AfterViewInit, Component, DestroyRef, inject, input, signal} from '@angular/core'; +import { + AfterViewInit, + Component, + DestroyRef, + effect, + ElementRef, + inject, + input, + signal, +} from '@angular/core'; import {Router} from '@angular/router'; import {onNavigation} from '../utils/enhance-on-navigation'; import {createSlugger} from '../utils/heading-slug'; @@ -43,6 +52,30 @@ export class Toc implements AfterViewInit { readonly showActive = input<boolean>(true); readonly headings = signal<Heading[]>([]); readonly active = signal<string>(''); + private readonly host = inject<ElementRef<HTMLElement>>(ElementRef); + + constructor() { + effect(() => { + const id = this.active(); + if (!this.showActive() || !id) return; + this.revealActive(id); + }); + } + + private revealActive(id: string): void { + const link = this.host.nativeElement.querySelector<HTMLElement>(`a[href="#${CSS.escape(id)}"]`); + let box = this.host.nativeElement.parentElement; + while (box && box.scrollHeight <= box.clientHeight) box = box.parentElement; + if (!link || !box || box === document.documentElement || box === document.body) return; + const linkRect = link.getBoundingClientRect(); + const boxRect = box.getBoundingClientRect(); + const margin = 48; + if (linkRect.top < boxRect.top + margin) { + box.scrollTop -= boxRect.top + margin - linkRect.top; + } else if (linkRect.bottom > boxRect.bottom - margin) { + box.scrollTop += linkRect.bottom - (boxRect.bottom - margin); + } + } private observer?: IntersectionObserver; private contentObserver?: MutationObserver; private retryTimer?: ReturnType<typeof setTimeout>; diff --git a/apps/docs/src/app/pages/index.page.ts b/apps/docs/src/app/pages/index.page.ts index b4ea66c..ce8e642 100644 --- a/apps/docs/src/app/pages/index.page.ts +++ b/apps/docs/src/app/pages/index.page.ts @@ -172,7 +172,7 @@ import {writeToClipboard} from '../utils/clipboard'; > <div class="mx-auto max-w-5xl px-6 py-20"> <div class="text-center mb-10"> - <h2 class="text-3xl sm:text-4xl font-bold tracking-tight">Two lines to a live panel</h2> + <h2 class="text-3xl sm:text-4xl font-bold tracking-tight">A few lines to a live panel</h2> <p class="mt-3 text-zinc-600 dark:text-zinc-400 max-w-xl mx-auto"> Mount the hub where your app runs, load the overlay, and open the panel on your page. </p> @@ -196,7 +196,7 @@ import {writeToClipboard} from '../utils/clipboard'; app.use(devtools.nodeMiddleware); <span class="text-zinc-400">// src/main.ts</span> -<span class="text-[color:var(--accent)] font-semibold">import</span> '@santoshyadavdev/ng-devtools/overlay';</code></pre> +<span class="text-[color:var(--accent)] font-semibold">if</span> (typeof ngDevMode === 'undefined' || ngDevMode) <span class="text-[color:var(--accent)] font-semibold">import</span>('@santoshyadavdev/ng-devtools/overlay');</code></pre> </div> <div class="rounded-xl border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-950 overflow-hidden" @@ -298,7 +298,7 @@ app.use(devtools.nodeMiddleware); <div class="mx-auto max-w-3xl px-6 py-20 text-center"> <h2 class="text-3xl sm:text-4xl font-bold tracking-tight">Sponsors</h2> <p class="mt-3 text-zinc-600 dark:text-zinc-400"> - Thanks to our current sponsors. Your support keeps development going. + Thanks to the current sponsors. Your support keeps development going. </p> <div class="mt-8 flex justify-center"> <app-sponsor-list /> @@ -556,9 +556,10 @@ export default class Home implements AfterViewInit { }, { icon: LucideShieldCheck, - title: 'Local only', + title: 'Access and redaction', link: '/security', - description: 'The devtools answer only your machine, and secret-looking values are redacted.', + description: + 'The Vite plugin answers only your machine, the Express hub asks for a one-time code, and secret-looking values are redacted.', }, ]; } diff --git a/apps/docs/src/content/agents/mcp-server.md b/apps/docs/src/content/agents/mcp-server.md index df4fc28..84f2a53 100644 --- a/apps/docs/src/content/agents/mcp-server.md +++ b/apps/docs/src/content/agents/mcp-server.md @@ -92,6 +92,8 @@ The path depends on how you mount the devtools. Use the port your server actuall | [Vite plugin](/getting-started/vite) | `http://localhost:5173/__devframes/__mcp` | | [Standalone CLI](/getting-started/cli) | `http://localhost:9999/__mcp` | +The standalone CLI uses port 9999 by default. If that port is taken and you did not pass `--port`, it picks a free port. Use the URL it prints. + If you mount the devtools panel without the hub, at `/__ng-devtools/`, the endpoint is `/__ng-devtools/__mcp`. ### Send an Origin header @@ -154,25 +156,25 @@ The live tools read what the page reports. Without an open page, they have nothi ### Tool names -Tools are registered with a colon, as `ng-devtools:get-routes`. MCP clients see them with an underscore, as `ng-devtools_get-routes`. Calls with either form work. +The server registers tools with a colon, as `ng-devtools:get-routes`. MCP clients see them with an underscore, as `ng-devtools_get-routes`. Calls with either form work. ### Read and action tools -Read-only tools are marked read-only for your client. Five tools act on the app, so they are not: +The server marks read-only tools as read-only for your client. Five tools act on the app, so the server does not mark them: -<ngmd-pill-row> - <ngmd-pill href="/agents/tools#components-signals-and-di" title="highlight"></ngmd-pill> - <ngmd-pill href="/agents/tools#act-on-the-router" title="navigate"></ngmd-pill> - <ngmd-pill href="/agents/tools#act-on-a-form" title="form-action"></ngmd-pill> - <ngmd-pill href="/agents/tools#act-on-a-form" title="fill-form"></ngmd-pill> - <ngmd-pill href="/agents/tools#call-a-server-route" title="analog-call-api"></ngmd-pill> -</ngmd-pill-row> +| Tool | Reference | +| ----------------- | --------------------------------------------------------------------- | +| `highlight` | [Components, signals and DI](/agents/tools#components-signals-and-di) | +| `navigate` | [Act on the router](/agents/tools#act-on-the-router) | +| `form-action` | [Act on a form](/agents/tools#act-on-a-form) | +| `fill-form` | [Act on a form](/agents/tools#act-on-a-form) | +| `analog-call-api` | [Call a server route](/agents/tools#call-a-server-route) | Your client can ask you before it runs them. ### Pages and tabs -Each browser tab reports on its own and gets a page id. Tools that read live data use the most recent page by default. Pass `page` (or `pageId` for `inspect-providers`) to pick another tab. Pages that stop reporting are dropped after a short time. +Each browser tab reports on its own and gets a page id. Tools that read live data use the most recent page by default. Pass `page` (or `pageId` for `inspect-providers`) to pick another tab. The server drops pages that stop reporting after a short time. ## Where to next diff --git a/apps/docs/src/content/agents/resources.md b/apps/docs/src/content/agents/resources.md index 394a79a..d2a6c05 100644 --- a/apps/docs/src/content/agents/resources.md +++ b/apps/docs/src/content/agents/resources.md @@ -78,7 +78,7 @@ The devtools keep their live data in shared-state keys. Every key is also listed ### Keys -This covers data without its own resource. +This table covers the data that has no resource of its own. | Key | Content | | ------------------------ | ------------------------------------------------------------------------------ | @@ -87,7 +87,7 @@ This covers data without its own resource. | `ng-devtools:analog` | Analog page data and the server call log | | `ng-devtools:routes` | Declared but not filled. Use `get-routes` or `list-routes` instead. | -The keys behind the six resources above (`ng-devtools:component-tree`, `ng-devtools:forms`, and so on) are listed too. +The list also includes the keys behind the six resources above (`ng-devtools:component-tree`, `ng-devtools:forms`, and so on). ### Read a key with a tool diff --git a/apps/docs/src/content/agents/tools.md b/apps/docs/src/content/agents/tools.md index 83eb237..30de4a2 100644 --- a/apps/docs/src/content/agents/tools.md +++ b/apps/docs/src/content/agents/tools.md @@ -55,13 +55,13 @@ These seven tools take no arguments. They all read your source. | `get-providers` | DI providers: `@Injectable` services, `inject()` calls and `providers` arrays, with token, file and where each one is provided. | | `get-ngrx-store` | NgRx declarations: `@ngrx/store` actions, reducers, effects, selectors, features and store setup, and `@ngrx/signals` `signalStore` (with its members), `signalState` and `signalMethod`. | | `get-pipes` | Custom `@Pipe` classes, and built-in pipes from `@angular/common` in use in templates, with purity, standalone status, and where each is declared or used. | -| `build-meta` | The Angular and TypeScript versions and SSR status. | +| `build-meta` | The project name, the Angular and TypeScript versions, SSR status, the Analog version in Analog apps, and a `builtAt` timestamp. | ## Components, signals and DI -### highlight +### highlight <ngmd-badge variant="alpha">Action</ngmd-badge> -Highlights a component in the page and makes it the target of `inspect-signals`. Reads: page. <ngmd-badge variant="alpha">Action</ngmd-badge> +Highlights a component in the page and makes it the target of `inspect-signals`. Reads: page. | Argument | Required | Value | | ---------- | -------- | ------------------------------------------------------------------------------------------------------------- | @@ -115,13 +115,15 @@ Use `explain-navigation` for "why was I redirected". Pass `perf: true` for "why `list-routes` takes three optional arguments: -- `match`: a URL such as `/users/42`. The tool predicts which route it hits, or the nearest routes when it hits none. -- `audit`: set to `true` to list the guards that protect each page. -- `filter`: only routes whose path or component contains this text. +| Argument | Value | +| -------- | ---------------------------------------------------------------------------------------------------------- | +| `match` | A URL such as `/users/42`. The tool predicts which route it hits, or the nearest routes when it hits none. | +| `audit` | Set to `true` to list the guards that protect each page. | +| `filter` | Only routes whose path or component contains this text. | -### Act on the router +### Act on the router <ngmd-badge variant="alpha">Action</ngmd-badge> -`navigate` acts on the running app's router, in development only. Reads: page. <ngmd-badge variant="alpha">Action</ngmd-badge> +`navigate` acts on the running app's router, in development only. Reads: page. | Action | What it does | Arguments | | -------------- | ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------- | @@ -140,8 +142,10 @@ All forms tools read the page. They cover Signal Forms, reactive forms and templ Two arguments come up in almost every tool: -- `form`: a form id (like `Checkout.form@ab12`) or part of its label (`Component.property`). -- `path`: a dotted field path, like `address.city` or `items.0.qty`. Empty for the form itself. +| Argument | Value | +| -------- | ------------------------------------------------------------------------------------- | +| `form` | A form id (like `Checkout.form@ab12`) or part of its label (`Component.property`). | +| `path` | A dotted field path, like `address.city` or `items.0.qty`. Empty for the form itself. | ### Read form state @@ -150,11 +154,11 @@ Two arguments come up in almost every tool: | `inspect-forms` | Without arguments, each form with its status and error count. With `form`, its field tree: value, status, touched, dirty and errors. | `form`, `path`, `onlyInvalid`, `includeValues` | | `explain-form-invalid` | Which fields make a form invalid, and why: the failing validator, its message, the value and whether it was touched. Without `form`, every invalid or pending form. | `form` | | `explain-field` | One field: where each error comes from, why validation is skipped, the binding and DOM facts like the label and visible error text. | `form`, `path`, or `selector` (a CSS selector) | -| `explain-submit` | What submit will do, and why it might do nothing. | `form` | +| `explain-submit` | What submit does, and why it might do nothing. | `form` | | `form-payload` | What the form sends: `value` against `getRawValue()`, fields that are sent without validation, and which fields the user changed. | `form` | | `explain-custom-control` | How a field is bound to its element, and what is wrong with the binding, such as value drift or a missing `setDisabledState`. | `form`, `path` | -For "why is this form invalid", call `explain-form-invalid` first. Password and other secret-looking values are redacted. +For "why is this form invalid", call `explain-form-invalid` first. The tools redact passwords and other secret-looking values. ### Track changes @@ -170,7 +174,7 @@ Markers let an agent check its own work: read the marker, act, then call `form-d ### Act on a form -Both tools are action tools and need a development build. Secret fields are not written unless you unmask them. See [Access and redaction](/security#opt-fields-in-or-out). For Signal Forms, hidden and readonly fields are not written either. +Both tools are action tools and need a development build. They don't write secret fields unless you unmask them. See [Opt fields in or out](/security#opt-fields-in-or-out). For Signal Forms, they don't write hidden, readonly or disabled fields either. | Tool | What it does | Arguments | | ------------- | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | @@ -228,9 +232,9 @@ These tools cover *Analog apps. Most read your source. Two read what the Vite pl | `analog-render-modes` | For each page: server rendered, prerendered, or client only, and what the last request actually did. | none | | `analog-prerender-plan` | `prerender.routes` compared with the page files and the build output. | none | -### Call a server route +### Call a server route <ngmd-badge variant="alpha">Action</ngmd-badge> -`analog-call-api` sends a request to a route on the running dev server, like `GET /api/v1/hello`, and returns the status, time and body. Reads: Vite plugin. <ngmd-badge variant="alpha">Action</ngmd-badge> +`analog-call-api` sends a request to a route on the running dev server, like `GET /api/v1/hello`, and returns the status, time and body. Reads: Vite plugin. | Argument | Required | Value | | --------- | -------- | ------------------------------------------------------------- | diff --git a/apps/docs/src/content/community.md b/apps/docs/src/content/community.md index ba5690a..151e992 100644 --- a/apps/docs/src/content/community.md +++ b/apps/docs/src/content/community.md @@ -22,7 +22,7 @@ Angular DevTools is open source under the MIT license. Here is where to reach th </ngmd-card> </ngmd-card-grid> -## Talk to us <ngmd-badge variant="stable">MIT</ngmd-badge> +## Get in touch <ngmd-card-grid columns="2"> <ngmd-card icon="sparkles" title="Discord" link="https://discord.gg/YRTyJd6Qx" cta="Join"> @@ -79,7 +79,7 @@ If the devtools help your work, please consider [sponsoring the project on GitHu ### Current sponsors -Thanks to everyone who sponsors the project. See the [sponsors](/sponsors) page for the current list. +Thanks to everyone who sponsors the project. The [Sponsors page](/sponsors) lists the current sponsors. ## Where to next diff --git a/apps/docs/src/content/contributing/chrome-extension.md b/apps/docs/src/content/contributing/chrome-extension.md index 8abc45b..4a3e116 100644 --- a/apps/docs/src/content/contributing/chrome-extension.md +++ b/apps/docs/src/content/contributing/chrome-extension.md @@ -60,13 +60,13 @@ This builds the devtools UI (`pnpm devtools:build`), then replaces `extension/ui Go to <code>chrome://extensions</code>. </ngmd-step> <ngmd-step title="Turn on Developer mode"> - Use the toggle in the top right corner. + Turn on the <strong>Developer mode</strong> toggle in the top right corner. </ngmd-step> <ngmd-step title="Load it unpacked"> Click <strong>Load unpacked</strong> and select the <code>extension/</code> directory. </ngmd-step> <ngmd-step title="Open DevTools on an Angular app"> - Start a demo app and open DevTools. The <strong>Angular DevTools</strong> panel appears once the page is detected as Angular. + Start a demo app and open DevTools. The <strong>Angular DevTools</strong> panel appears once the extension detects Angular on the page. </ngmd-step> </ngmd-workflow> @@ -78,7 +78,7 @@ After a rebuild, click the reload icon on the extension card, then reopen DevToo pnpm extension:zip ``` -This runs `extension:build`, then writes `dist/ng-devtools-extension.zip`. `.DS_Store` files are left out. +This runs `extension:build`, then writes `dist/ng-devtools-extension.zip`. The zip leaves out `.DS_Store` files. ### Upload diff --git a/apps/docs/src/content/contributing/demo-apps.md b/apps/docs/src/content/contributing/demo-apps.md index 1348909..86f6c2c 100644 --- a/apps/docs/src/content/contributing/demo-apps.md +++ b/apps/docs/src/content/contributing/demo-apps.md @@ -26,12 +26,14 @@ The repository has two demo apps. Use them to try a change against a real app be ### What's inside -- **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, forms, pipes and HTTP. -- **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 errors. +| Area | What it covers | +| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| **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, forms, pipes and HTTP. | +| **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 errors. | Destination photos are from Unsplash, credited in `public/destinations/CREDITS.md`. @@ -72,9 +74,11 @@ It listens on port 4000, or on `PORT` when set. `src/app/app.routes.server.ts` sets a render mode per route, so the SSR tools have something to compare: -- `destinations`, `destinations/:id` and `examples/http` render on the server per request. -- `book/:id`, `trips`, `sign-in` and some `examples/routes` pages render on the client only. -- Everything else is prerendered. +| Routes | Render mode | +| ------------------------------------------------------------ | -------------------------- | +| `destinations`, `destinations/:id`, `examples/http` | On the server, per request | +| `book/:id`, `trips`, `sign-in`, some `examples/routes` pages | Client only | +| Everything else | Prerendered | ## Analog demo @@ -94,7 +98,7 @@ It listens on port 4000, or on `PORT` when set. pnpm analog:dev ``` -The script builds the devtools package, then starts the Vite dev server. The devtools and the MCP endpoint are served by that dev server. +The script builds the devtools package, then starts the Vite dev server. That dev server also serves the devtools and the MCP endpoint. ### Build and preview diff --git a/apps/docs/src/content/contributing/development.md b/apps/docs/src/content/contributing/development.md index e458176..f3d1fd4 100644 --- a/apps/docs/src/content/contributing/development.md +++ b/apps/docs/src/content/contributing/development.md @@ -106,11 +106,11 @@ pnpm exec nx affected -t test build ### Ports -| Command | Port | Notes | -| ---------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------------------------- | -| `pnpm start` | 4200 | `ng serve` with SSR and hot reload. The popup and live data work without a separate server. | -| `pnpm build --configuration development && node dist/angular-devtools/server/server.mjs` | 4000 | The demo app as an SSR server. | -| `pnpm devtools:dev` | 5173 | The devtools UI with hot reload. It needs the SSR server running for live data. | +| Command | Port | Notes | +| ---------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| `pnpm start` | 4200 | `ng serve` with SSR and hot reload. The popup and live data work without a separate server. | +| `pnpm build --configuration development && node dist/angular-devtools/server/server.mjs` | 4000 | The demo app as an SSR server. | +| `pnpm devtools:dev` | 5173 | The devtools UI with hot reload and its own RPC. Source-scan data only; live tabs need an app page connected, so use the SSR server for those. | <ngmd-callout type="warning" title="Refresh the bundled UI"> The SSR server serves the UI built into <code>packages/ng-devtools/dist/public</code>. Run <code>pnpm devtools:build-pkg</code> to refresh it after you change <code>app/</code>. @@ -169,7 +169,7 @@ pnpm extension:build # Chrome extension ### Add an agent tool -Add `agent: { description }` to an RPC function, or call `ctx.agent.registerTool()` in the devframe setup. List the new tool on the [Tools](/agents/tools) page. +Add `agent: { description }` to an RPC function, or call `ctx.agent.registerTool()` in the devframe setup. List the tool on the [Tools](/agents/tools) page. <ngmd-callout type="tip" title="Changed app/?"> Run <code>pnpm extension:build</code> and commit <code>extension/ui</code>. CI fails when it is stale. See <a href="/contributing/chrome-extension">Build the extension</a>. diff --git a/apps/docs/src/content/contributing/kitchen-sink.md b/apps/docs/src/content/contributing/kitchen-sink.md new file mode 100644 index 0000000..f120ad9 --- /dev/null +++ b/apps/docs/src/content/contributing/kitchen-sink.md @@ -0,0 +1,294 @@ +--- +title: Kitchen sink +description: Every NgMd component and markdown feature this site uses, on one page, for checking styles and behaviour. +noIndex: true +--- + +<ngmd-hero title="Kitchen sink" logo="/logo-mark.svg" gradient> + Every component and markdown feature on one page. Use it to check styles, dark mode and spacing after a change. It is left out of search and the sitemap. +</ngmd-hero> + +# Kitchen sink + +This page follows the [writing guide](/contributing/writing-docs). Each section shows one feature with its common options. + +## Text + +### Inline formatting + +Plain text, **bold**, _italic_, `inline code`, ~~strikethrough~~ and a **UI label** like **Record**. A line with a [site link](/inspectors/router), an [anchor link](#tables), an [external link](https://angular.dev) and keyword links: *Angular, *Analog, *Devframe, *NgRx, *MCP and *Vite. + +### Lists + +- Unordered item +- Another item with `code` + - Nested item + +1. First step +2. Second step +3. Third step + +### Tables + +| Column | Type | Notes | +| -------- | --------- | --------------------------------------- | +| `name` | `string` | Short text. | +| `count` | `number` | Right after the name. | +| `active` | `boolean` | A longer note that wraps on small screens to check the cell layout. | + +### Blockquote + +> A quoted line for the rare case a page needs one. + +### Headings with extras + +#### A fourth level heading + +Headings from `####` down get anchors but don't appear in "On this page". + +### Heading with a badge <ngmd-badge variant="beta">Beta</ngmd-badge> + +The badge is left out of the anchor, so this heading links as `#heading-with-a-badge`. + +### Heading with `code` + +## Code + +### Plain fence + +```ts +// src/app/app.config.ts +import {ApplicationConfig} from '@angular/core'; + +export const appConfig: ApplicationConfig = { + providers: [], +}; +``` + +### Line highlights + +```ts {2,5-7} +// src/main.ts +import {bootstrapApplication} from '@angular/platform-browser'; +import {App} from './app/app'; +import {appConfig} from './app/app.config'; + +bootstrapApplication(App, appConfig).then(() => { + if (typeof ngDevMode === 'undefined' || ngDevMode) { + return import('@santoshyadavdev/ng-devtools/overlay'); + } + return undefined; +}); +``` + +### Code group + +```bash group="install" name="pnpm" image="https://cdn.simpleicons.org/pnpm/F69220" active +pnpm add @santoshyadavdev/ng-devtools devframe +``` + +```bash group="install" name="npm" image="https://cdn.simpleicons.org/npm/CB3837" +npm install @santoshyadavdev/ng-devtools devframe +``` + +```bash group="install" name="yarn" image="https://cdn.simpleicons.org/yarn/2C8EBB" +yarn add @santoshyadavdev/ng-devtools devframe +``` + +```bash group="install" name="bun" image="https://bun.sh/logo.svg" +bun add @santoshyadavdev/ng-devtools devframe +``` + +### File import + +```ts file="src/ngmd.config.ts#L1-L12" +``` + +### Other languages + +```json +{ + "mcpServers": { + "ng-devtools": {"command": "npx", "args": ["@santoshyadavdev/ng-devtools", "mcp"]} + } +} +``` + +```html +<button type="button" aria-label="Open devtools">Open</button> +``` + +```css +:root { + --accent: #b45309; +} +``` + +## Callouts + +<ngmd-callout type="info" title="Info"> + Context the reader may need, with <code>code</code> and a <a href="/security">link</a>. +</ngmd-callout> + +Callouts are never adjacent on real pages. The text between them here keeps that rule. + +<ngmd-callout type="tip" title="Tip"> + A shortcut or a better way to do something. +</ngmd-callout> + +Text between callouts. + +<ngmd-callout type="success" title="Success"> + Confirms a result. +</ngmd-callout> + +Text between callouts. + +<ngmd-callout type="warning" title="Warning"> + Something that can go wrong. +</ngmd-callout> + +Text between callouts. + +<ngmd-callout type="danger" title="Danger"> + Data loss or a security risk. +</ngmd-callout> + +## Alerts + +<ngmd-alert severity="info"> + An info alert. +</ngmd-alert> + +Text between alerts. + +<ngmd-alert severity="helpful"> + A helpful alert. +</ngmd-alert> + +Text between alerts. + +<ngmd-alert severity="important"> + An important alert. +</ngmd-alert> + +Text between alerts. + +<ngmd-alert severity="warning"> + A warning alert. +</ngmd-alert> + +Text between alerts. + +<ngmd-alert severity="critical" label="Custom label"> + A critical alert with a custom label. +</ngmd-alert> + +## Badges + +<ngmd-badge variant="new">New</ngmd-badge> <ngmd-badge variant="updated">Updated</ngmd-badge> <ngmd-badge variant="alpha">Alpha</ngmd-badge> <ngmd-badge variant="beta">Beta</ngmd-badge> <ngmd-badge variant="stable">Stable</ngmd-badge> <ngmd-badge variant="deprecated">Deprecated</ngmd-badge> + +## Cards + +### Three columns with icons + +<ngmd-card-grid columns="3"> + <ngmd-card icon="box" title="With a link" link="/inspectors/components" cta="Open"> + A card that links inside the site. + </ngmd-card> + <ngmd-card icon="shield" title="External link" link="https://angular.dev" cta="Visit"> + A card that opens another site in a new tab. + </ngmd-card> + <ngmd-card icon="zap" title="No link"> + A card with inline <code>code</code> and no link. + </ngmd-card> +</ngmd-card-grid> + +### Two columns with images + +<ngmd-card-grid columns="2"> + <ngmd-card image="https://cdn.simpleicons.org/ngrx/BA2BD2" title="Brand image"> + A logo instead of an icon. + </ngmd-card> + <ngmd-card avatar image="https://github.com/santoshyadavdev.png?size=96" title="Avatar" link="https://github.com/santoshyadavdev" cta="GitHub"> + A round profile photo. + </ngmd-card> +</ngmd-card-grid> + +### Every icon + +<ngmd-card-grid columns="4"> + <ngmd-card icon="book" title="book"></ngmd-card> + <ngmd-card icon="box" title="box"></ngmd-card> + <ngmd-card icon="code" title="code"></ngmd-card> + <ngmd-card icon="compass" title="compass"></ngmd-card> + <ngmd-card icon="file" title="file"></ngmd-card> + <ngmd-card icon="layers" title="layers"></ngmd-card> + <ngmd-card icon="lightbulb" title="lightbulb"></ngmd-card> + <ngmd-card icon="palette" title="palette"></ngmd-card> + <ngmd-card icon="rocket" title="rocket"></ngmd-card> + <ngmd-card icon="search" title="search"></ngmd-card> + <ngmd-card icon="settings" title="settings"></ngmd-card> + <ngmd-card icon="shield" title="shield"></ngmd-card> + <ngmd-card icon="sparkles" title="sparkles"></ngmd-card> + <ngmd-card icon="terminal" title="terminal"></ngmd-card> + <ngmd-card icon="wrench" title="wrench"></ngmd-card> + <ngmd-card icon="zap" title="zap"></ngmd-card> +</ngmd-card-grid> + +## Workflow + +<ngmd-workflow> + <ngmd-step title="Install the package"> + Run <code>pnpm add @santoshyadavdev/ng-devtools devframe</code>. + </ngmd-step> + <ngmd-step title="Mount the hub"> + Add the hub to your server. + </ngmd-step> + <ngmd-step title="Load the overlay"> + Import the overlay in development builds. + </ngmd-step> +</ngmd-workflow> + +## Tabs + +<ngmd-tabs> + <ngmd-tab title="Express" icon="terminal"> + Content of the first tab. + </ngmd-tab> + <ngmd-tab title="Vite" image="https://cdn.simpleicons.org/vite/646CFF"> + Content of the second tab, with an image instead of an icon. + </ngmd-tab> + <ngmd-tab title="Plain"> + A tab with no icon. + </ngmd-tab> +</ngmd-tabs> + +## Accordion + +<ngmd-accordion> + <ngmd-accordion-item title="Open by default" open> + This item starts open. + </ngmd-accordion-item> + <ngmd-accordion-item title="Closed by default"> + This item starts closed. + </ngmd-accordion-item> +</ngmd-accordion> + +## Media + +### Image + +<ngmd-image src="/og.png" alt="The Angular DevTools social card" caption="An image with a caption." width="640"></ngmd-image> + +### Video + +<ngmd-video src="https://www.youtube.com/watch?v=Ata9cSC2WpM" title="Angular in 100 seconds"></ngmd-video> + +## Where to next + +<ngmd-pill-row> + <ngmd-pill href="/contributing/writing-docs" title="Writing guide"></ngmd-pill> + <ngmd-pill href="/getting-started/introduction" title="Introduction"></ngmd-pill> + <ngmd-pill href="https://angular.dev" title="angular.dev"></ngmd-pill> +</ngmd-pill-row> diff --git a/apps/docs/src/content/contributing/publishing.md b/apps/docs/src/content/contributing/publishing.md index 0e2d161..711a013 100644 --- a/apps/docs/src/content/contributing/publishing.md +++ b/apps/docs/src/content/contributing/publishing.md @@ -58,9 +58,7 @@ pnpm devtools:build-pkg ### 3. Refresh the extension UI -<ngmd-callout type="warning" title="extension/ui must be fresh"> - If <code>app/</code> changed since the last release, run <code>pnpm extension:build</code> and commit <code>extension/ui</code> before you publish. CI fails when the committed copy is stale. -</ngmd-callout> +If `app/` changed since the last release, run `pnpm extension:build` and commit `extension/ui` before you publish. CI fails when the committed copy is stale. ### 4. Publish diff --git a/apps/docs/src/content/contributing/writing-docs.md b/apps/docs/src/content/contributing/writing-docs.md new file mode 100644 index 0000000..9715030 --- /dev/null +++ b/apps/docs/src/content/contributing/writing-docs.md @@ -0,0 +1,229 @@ +--- +title: Write documentation +description: How to write and review pages for this site. Audience, voice, page structure, NgMd components, code samples and the checks every change must pass. +--- + +<ngmd-hero title="Write documentation" gradient> + How to write pages for this site: who they are for, how they read, how they are built, and how to check them against the code. +</ngmd-hero> + +# Write documentation + +These docs live in `apps/docs` and are built with [NgMd](https://github.com/erkamyaman/ngmd). Every page is a markdown file under `apps/docs/src/content`. The path is the URL: `inspectors/router.md` is served at `/inspectors/router`. + +The rules below follow the Angular documentation guidelines and Google's technical writing courses, with a few additions for this project. Read [Tech Writing One](https://developers.google.com/tech-writing/one) and [Tech Writing Two](https://developers.google.com/tech-writing/two) if you haven't. + +<ngmd-callout type="tip" title="Using a coding agent?"> + The repository ships a <code>devtools-docs</code> skill in <code>.claude/skills</code> with the same rules, so agents follow this page when they edit docs. +</ngmd-callout> + +## Audience and voice + +### Who you write for + +Write for Angular developers who have built at least one app. Assume they know TypeScript, HTML, the Angular CLI and the basics of components, signals and DI. Don't assume they know Devframe, MCP or how this project works inside. + +Orient every page around what the reader is trying to do. Ask: _what does the developer want to find out or get working?_ + +### How pages read + +- Use second person and the imperative: "Open the Router tab", not "We can open the Router tab". +- Use present tense: "The tab shows", not "The tab will show". +- Use active voice: "The overlay reads the page every 3 seconds", not "The page is read every 3 seconds". +- One idea per sentence. Keep sentences short and plain. +- Put the condition first: "If the tab is empty, check that the app runs in development mode." +- Use sentence case for headings. Capitalize only the first word and proper nouns. +- Put UI labels in **bold**, and code, file names, commands and option names in `code`. +- Use descriptive link text. Never "click here". + +## Style rules + +These are the mistakes reviewers flag most often. + +| Rule | Why | Avoid | Prefer | +| -------------------------------------- | ----------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | -------------------------------------------------------------- | +| **No first person** | The reader is the subject. | "We use the hub to mount the panel." | "Mount the panel with the hub." | +| **No future tense** | Docs describe what the code does now. | "The tool will return the tree." | "The tool returns the tree." | +| **No time-relative claims** | "New" and "recent" go stale. Release notes belong in the changelog, status belongs in sidebar badges. | "The recently added Pipes tab..." | "The Pipes tab..." | +| **No em dashes** | Project style. Use a period, comma or parentheses. | "The hub — mounted once — serves every tool." | "The hub is mounted once and serves every tool." | +| **No comparisons with other products** | Describe this project on its own terms. | "Unlike other devtools, ..." | Describe the feature directly. | +| **No marketing words** | They carry no information. | "powerful", "seamless", "blazingly fast", "simply", "just", "easy" | Say what it does. | +| **No invented features** | Every claim must match the code. | A button, option or tool that doesn't exist. | Check the source before you write it. | +| **Lists that should be tables** | Items with more than one attribute read better as rows. | A bullet list of tools, each with arguments and a purpose. | A table with name, purpose and arguments columns. | +| **Scope creep** | Each page covers one subject. Link out for the rest. | Explaining how Angular DI works on the Injectors page. | One sentence and a link to [angular.dev](https://angular.dev). | + +## Page types + +Each section of the site has one job. Keep a page to its type. + +| Section | Purpose | Shape | +| ------------------- | --------------------------------------------------------------- | ---------------------------------------------------------------------------------- | +| **Getting started** | Get the devtools running in one setup. | Short intro, a workflow of steps, code for each setup, gotchas as callouts. | +| **Inspectors** | Explain one tab completely. Readers jump to the part they need. | What it shows, where the data comes from, how to use it, agent tools, limits, FAQ. | +| **Agents** | Reference for the MCP server, every tool and every resource. | Tables of names, arguments and results, grouped by inspector. | +| **Guides** | Walk through one task end to end. | A workflow of steps with full, working code samples. | +| **Contributing** | How to work on the repository. | Commands, tables of scripts and ports, checklists. | + +Don't mix an explainer and a tutorial on one page. If a reference page needs a walkthrough, write a guide and link to it. + +## Page structure + +Every page follows the same skeleton. + +```md +--- +title: Router +description: One sentence for search results and link previews. +--- + +<ngmd-hero title="Router" gradient> + One or two sentences on what the page covers. +</ngmd-hero> + +# Router + +A short intro: what the tab is and when you open it. + +## What it shows + +### Navigations + +... + +## Where to next + +<ngmd-pill-row> + <ngmd-pill href="/agents/tools" title="Agent tools"></ngmd-pill> +</ngmd-pill-row> +``` + +### Frontmatter + +`title` is the browser tab and sidebar title. `description` is used for search, the page meta tags and the sitemap. Keep it to one sentence. + +### Headings + +- One `#` heading per page, matching the title. +- Use `##` for sections and `###` for subsections. The "On this page" list shows both, so a page with only `##` headings gets a flat, short table of contents. +- Don't skip levels. +- Keep heading text unique within a page. Repeated headings get `-1`, `-2` anchors, which are hard to link to. +- Other pages link to headings by anchor. Before you rename a heading, search `apps/docs/src/content` for `#old-anchor`. The build fails on broken anchors. + +### Add a page to the sidebar + +Add an entry to `nav` in `apps/docs/src/ngmd.config.ts`. Pages that aren't listed still build, but readers can't find them. Use `status: 'new'` or `status: 'updated'` for a sidebar badge instead of saying "new" in the text. + +## Components + +The site uses NgMd's authoring components. Write them as raw HTML inside the markdown. The full reference is the components page of the [NgMd documentation](https://ngmd.netlify.app/concepts/components). + +| Component | Use it for | Attributes | +| -------------------------------------------- | ----------------------------------------------------------------- | --------------------------------------------------------------------------- | +| `<ngmd-hero>` | The page opener. Once per page, before the `#` heading. | `title`, `gradient`, `logo` (only on pages about one external tool, such as NgRx, Analog, Vite, Express, Chrome, MCP or Nx) | +| `<ngmd-callout>` | A short aside with context the reader may need. | `type` (`info`, `tip`, `success`, `warning`, `danger`), `title` | +| `<ngmd-alert>` | One short point the reader must not miss. | `severity` (`info`, `helpful`, `important`, `warning`, `critical`), `label` | +| `<ngmd-card-grid>` + `<ngmd-card>` | Links to related pages, requirements, feature overviews. | grid: `columns`. card: `title`, `link`, `cta`, `icon`, `image`, `avatar` | +| `<ngmd-workflow>` + `<ngmd-step>` | Ordered steps. | step: `title` | +| `<ngmd-accordion>` + `<ngmd-accordion-item>` | FAQ sections. | item: `title`, `open` | +| `<ngmd-pill-row>` + `<ngmd-pill>` | A row of related links at the end of a page. | pill: `href`, `title` | +| `<ngmd-badge>` | A status chip next to a heading. | `variant` (`new`, `updated`, `alpha`, `beta`, `stable`, `deprecated`) | +| `<ngmd-tabs>` + `<ngmd-tab>` | The same content in several forms, when a code group doesn't fit. | tab: `title`, `icon`, `image` | +| `<ngmd-image>`, `<ngmd-video>` | Screenshots and YouTube or Vimeo videos. | image: `src`, `alt`, `caption`, `width`. video: `src`, `title` | + +Card icons come from a fixed set: `book`, `box`, `code`, `compass`, `file`, `layers`, `lightbulb`, `palette`, `rocket`, `search`, `settings`, `shield`, `sparkles`, `terminal`, `wrench`, `zap`. + +### When to use which + +- Use callouts and alerts sparingly. Never put two next to each other, and never put one inside a card, table cell or another component. +- Don't nest components, except for the parent and child pairs in the table. +- A callout is an aside. If the page doesn't make sense without it, it belongs in the text. +- End a page with a pill row or a card grid that points to the next pages, not both. +- Always write a closing tag, such as `<ngmd-pill ...></ngmd-pill>`. HTML doesn't honour self-closing custom elements, so the next element ends up nested inside. +- Write external links in raw HTML with `target="_blank" rel="noopener noreferrer"`, or the build fails. Markdown links get both automatically. +- Components that contain HTML use HTML for inline formatting: `<code>`, `<strong>` and `<a>`. Markdown doesn't render inside them. Write `@` as `@` inside components, because the renderer can read a bare `@` as Angular template syntax. + +### Keyword links + +Write `*Angular`, `*Analog`, `*Devframe`, `*NgRx`, `*MCP` or `*Vite` in prose to link the word to its site. The list is `keywords` in `ngmd.config.ts`. The asterisk is not a typo, so don't remove it. + +## Code samples + +### Fences + +Always set the language. When the code belongs in a specific file, put the path in a comment on the first line. Highlight the lines that matter with `{...}` after the language. + +````md +```ts {6} +// src/app/app.config.ts +import {ApplicationConfig} from '@angular/core'; +import {provideNgDevtoolsHttp} from '@santoshyadavdev/ng-devtools/http'; + +export const appConfig: ApplicationConfig = { + providers: [provideNgDevtoolsHttp()], +}; +``` +```` + +To show a real file from the repository, import it with `file="..."` instead of pasting it. Add `#L5-L20` for a line range. The path is relative to `apps/docs`, and files outside it can't be imported. + +For install commands, use a code group so readers pick their package manager. List pnpm, npm, yarn and bun, in that order: + +````md +```bash group="install" name="pnpm" active +pnpm add @santoshyadavdev/ng-devtools devframe +``` + +```bash group="install" name="npm" +npm install @santoshyadavdev/ng-devtools devframe +``` +```` + +### Rules for samples + +- Samples must run. Include every import, and match the real exports and option names in `packages/ng-devtools`. +- Prefer the demo apps as the source. The Angular Travel demo is in `src/` and the Analog demo is in `examples/analog`. +- Use realistic names: `TripSearch`, `authGuard`, `bookingForm`. Avoid `Foo`, `Example` or `prop1`. +- Load the overlay only in development builds, with the `ngDevMode` check the installation page uses. +- Keep examples secure by default. Don't show `auth: false` or `allowedOrigins: false` in code readers copy, unless the page explains why. +- Comments explain why, not what. Most samples need none. +- UI code in samples follows accessibility basics: labels on controls, alt text on images. + +## Check every claim against the code + +The docs describe what the code does today. Before you write a claim, find it in the source. + +| Page | Source of truth | +| ------------------------- | -------------------------------------------------------------------------------------------------------- | +| Inspector pages | The tab in `app/src/pages/` and its collector in `packages/ng-devtools/src/` | +| Agent tools and resources | `packages/ng-devtools/src/devframe.ts`, `rpc/*.ts` and `rpc/analog-register.ts` | +| Setup pages | `packages/ng-devtools/package.json` exports, `hub.ts`, `vite.ts`, `overlay.ts`, `popup.ts` and the demos | +| Security | `hub.ts`, `vite.ts` and the redaction code, such as `forms-privacy.ts` | +| Contributing | Root `package.json` scripts, `nx.json`, `project.json` files and `.github/workflows` | + +Check names exactly: labels, buttons, tool names, arguments, option names and defaults. When the code changes, update the page in the same pull request. + +## Before you open a pull request + +<ngmd-workflow> + <ngmd-step title="Run the dev server"> + Run <code>pnpm docs:dev</code> and open every page you changed. Check the "On this page" list, the links and dark mode. + </ngmd-step> + <ngmd-step title="Build with the link guards"> + Run <code>pnpm docs:build</code>. The build fails on a broken internal link or anchor, and on an external raw HTML link without <code>target="_blank"</code>. + </ngmd-step> + <ngmd-step title="Format"> + Run <code>pnpm exec prettier --check "apps/docs/**/*.{ts,json,css,html}"</code>. Markdown in <code>src/content</code> is not reformatted, so check tables and line breaks by eye. + </ngmd-step> + <ngmd-step title="Reread against the rules"> + Check the style rules above, and that every claim you added matches the code. Search your changes for em dashes, "will", "we", "new", "recently", "simply" and "just". + </ngmd-step> +</ngmd-workflow> + +## Where to next + +<ngmd-pill-row> + <ngmd-pill href="/contributing/development" title="Development setup"></ngmd-pill> + <ngmd-pill href="/contributing/kitchen-sink" title="Kitchen sink"></ngmd-pill> + <ngmd-pill href="/contributing/demo-apps" title="Demo apps"></ngmd-pill> + <ngmd-pill href="https://github.com/erkamyaman/ngmd" title="NgMd on GitHub"></ngmd-pill> +</ngmd-pill-row> diff --git a/apps/docs/src/content/getting-started/chrome-extension.md b/apps/docs/src/content/getting-started/chrome-extension.md index 28af63b..3723ba2 100644 --- a/apps/docs/src/content/getting-started/chrome-extension.md +++ b/apps/docs/src/content/getting-started/chrome-extension.md @@ -12,7 +12,7 @@ description: Open the devtools as a panel inside Chrome DevTools. The Chrome extension adds a panel named **Angular DevTools** to Chrome DevTools. The panel loads the devtools UI and connects it to the dev server of the page you are inspecting. <ngmd-callout type="info" title="An extra, not a setup"> - The page still needs the devtools mounted on its server and the <a href="/getting-started/overlay">overlay</a> loaded. The extension replaces the floating button, not the setup. Start with <a href="/getting-started/express">Angular CLI and Express</a> or <a href="/getting-started/vite">Vite and Analog</a>. + The page still needs the devtools mounted on its server and the <a href="/getting-started/overlay">overlay</a> loaded. The extension is one more way to open the devtools. It does not replace the setup. Start with <a href="/getting-started/express">Angular CLI and Express</a> or <a href="/getting-started/vite">Vite and Analog</a>. </ngmd-callout> ## Before you start @@ -66,7 +66,7 @@ pnpm extension:build ### Angular detection -A content script checks each page for Angular: an `ng-version` attribute or a `window.ng` global. It checks once, then retries for a few seconds for apps that bootstrap late. The panel is created only on Angular pages. +A content script checks each page for Angular: an `ng-version` attribute or a `window.ng` global. It checks once, then retries for a few seconds for apps that bootstrap late. The extension creates the panel only on Angular pages. ### Finding the server @@ -113,7 +113,7 @@ The content scripts are wider. Two of them run on every page. They check for an </ngmd-accordion-item> </ngmd-accordion> -## Next steps +## Where to next <ngmd-card-grid columns="2"> <ngmd-card icon="wrench" title="Build the extension" link="/contributing/chrome-extension" cta="Build and package"> diff --git a/apps/docs/src/content/getting-started/cli.md b/apps/docs/src/content/getting-started/cli.md index 4787507..bcda20e 100644 --- a/apps/docs/src/content/getting-started/cli.md +++ b/apps/docs/src/content/getting-started/cli.md @@ -61,7 +61,7 @@ npx @santoshyadavdev/ng-devtools dev --port 9999 --open | Flag | What it does | | --------------------- | ----------------------------------------------------------------------------------- | -| `--port <port>` | Port to listen on. The default is 9999. If it is taken, the next free port is used. | +| `--port <port>` | Port to listen on. The default is 9999. If it is taken, a random free port is used. | | `--host <host>` | Host to bind to. The default is `localhost`. | | `--open`, `--no-open` | Open the browser on start, or not. | | `--no-auth` | Turn off the one-time code the server asks for. | @@ -75,14 +75,12 @@ npx @santoshyadavdev/ng-devtools dev --port 9999 --open No page is connected to the CLI server. The tabs show what your source declares: -<ngmd-pill-row> - <ngmd-pill href="/inspectors/components" title="Components"></ngmd-pill> - <ngmd-pill href="/inspectors/router" title="Routes"></ngmd-pill> - <ngmd-pill href="/inspectors/signals" title="Signals"></ngmd-pill> - <ngmd-pill href="/inspectors/injectors" title="Providers"></ngmd-pill> - <ngmd-pill href="/inspectors/ngrx-store" title="NgRx declarations"></ngmd-pill> - <ngmd-pill href="/inspectors/pipes" title="Pipes"></ngmd-pill> -</ngmd-pill-row> +- [Components](/inspectors/components) +- [Routes](/inspectors/router) +- [Signals](/inspectors/signals) +- [Providers](/inspectors/injectors) +- [NgRx declarations](/inspectors/ngrx-store) +- [Pipes](/inspectors/pipes) <ngmd-alert severity="helpful"> For live data, mount the devtools in your app's own server. See <a href="/getting-started/express">Angular CLI and Express</a> or <a href="/getting-started/vite">Vite and Analog</a>. @@ -135,11 +133,11 @@ The stdio server has no page connected, so only the source scan tools return dat The root of your Angular workspace. The scan starts from the current directory. </ngmd-accordion-item> <ngmd-accordion-item title="Port 9999 is taken"> - The server moves to the next free port. Pass <code>--port</code> to choose one yourself. + Without <code>--port</code>, the server picks a random free port. Pass <code>--port</code> to choose one yourself. </ngmd-accordion-item> </ngmd-accordion> -## Next steps +## Where to next <ngmd-card-grid columns="2"> <ngmd-card icon="sparkles" title="MCP server" link="/agents/mcp-server" cta="Connect an agent"> diff --git a/apps/docs/src/content/getting-started/express.md b/apps/docs/src/content/getting-started/express.md index 4675daf..532322c 100644 --- a/apps/docs/src/content/getting-started/express.md +++ b/apps/docs/src/content/getting-started/express.md @@ -36,10 +36,12 @@ In an *Angular app with server-side rendering, the devtools run inside your Expr ### Add the middleware -```ts {4-7} -// server.ts +```ts {3,6-7} +// src/server.ts +import express from 'express'; import {initNgDevtoolsHub} from '@santoshyadavdev/ng-devtools/hub'; +const app = express(); const devtools = initNgDevtoolsHub({ws: false}); app.use(devtools.nodeMiddleware); ``` @@ -51,7 +53,7 @@ The full-page viewer is at `http://localhost:4000/__devframes/`. The hub is buil Mount the middleware before `express.static` and the Angular SSR handler, so the devtools routes answer first. ```ts -// server.ts +// src/server.ts const app = express(); const devtools = initNgDevtoolsHub({ws: false}); app.use(devtools.nodeMiddleware); // devtools first @@ -72,16 +74,18 @@ The middleware only handles requests under its base path (`/__devframes/` by def The browser talks to the hub over server-sent events or a WebSocket. Pick one with the `ws` option: ```ts group="transport" name="Server-sent events" active -// No WebSocket. The browser connects over SSE on the same port. +// src/server.ts const devtools = initNgDevtoolsHub({ws: false}); ``` ```ts group="transport" name="WebSocket side-car" -// The WebSocket runs on its own port, picked automatically. +// src/server.ts const devtools = initNgDevtoolsHub({ws: {sidecar: true}}); ``` -`ws: false` is the simplest choice. Every request goes through your Express server, including under `ng serve`. +With `ws: false` there is no WebSocket, and the browser connects over SSE on the same port. It is the simplest choice: every request goes through your Express server, including under `ng serve`. + +With `ws: {sidecar: true}`, the WebSocket runs on its own port, picked automatically. ### Hub options @@ -97,9 +101,7 @@ const devtools = initNgDevtoolsHub({ws: {sidecar: true}}); ### Access control -<ngmd-callout type="warning" title="One-time code"> - The hub protects its connection with a one-time code by default. The server prints the code, and a browser can read data only after it exchanges that code. On a machine only you use, pass <code>auth: false</code> to turn the gate off. -</ngmd-callout> +The hub protects its connection with a one-time code by default. The server prints the code, and a browser can read data only after it exchanges that code. On a machine only you use, pass `auth: false` to turn the gate off. The origin check is on by default too. Only loopback origins can open the WebSocket. [Access and redaction](/security) covers both checks. @@ -116,7 +118,7 @@ const devtools = initNgDevtoolsHub({ app.use(devtools.nodeMiddleware); ``` -It turns the one-time code off unless `NG_DEVTOOLS_AUTH` is `true`, and it turns the origin check off because it runs as a public demo. Don't copy these two settings. Keep both checks on for your own apps. +It turns the one-time code off unless `NG_DEVTOOLS_AUTH` is `true`, and it turns the origin check off. Don't copy these two settings. Keep both checks on for your own apps. <ngmd-alert severity="warning"> <code>initNgDevtoolsHub()</code> has no production switch of its own. If your <code>server.ts</code> also runs in production, decide there whether to mount it. @@ -128,8 +130,12 @@ It turns the one-time code off unless `NG_DEVTOOLS_AUTH` is `true`, and it turns The [overlay](/getting-started/overlay) collects live data from the page. Import it after bootstrap, in development only: -```ts {4-6} -// main.ts +```ts {8-10} +// src/main.ts +import {bootstrapApplication} from '@angular/platform-browser'; +import {App} from './app/app'; +import {appConfig} from './app/app.config'; + bootstrapApplication(App, appConfig) .then(() => { if (typeof ngDevMode === 'undefined' || ngDevMode) { @@ -151,8 +157,8 @@ A floating button appears on your page. It opens the devtools with one dock entr | Angular | Dashboard, components, routes, signals, injectors, forms, pipes, and SSR & HTTP | | 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 | +| NativeScript | A **Coming Soon** placeholder | +| Capacitor | A **Coming Soon** placeholder | [Popup and hub](/getting-started/popup-and-hub) covers the panel, its dock modes and deep links. @@ -187,9 +193,11 @@ To test the real Express process, build with the development configuration and s To fill the SSR & HTTP tab, add the interceptor and hydration hooks to your app config: -```ts {2,7-8} -// app.config.ts +```ts {5,10-11} +// src/app/app.config.ts import {provideHttpClient, withFetch} from '@angular/common/http'; +import {ApplicationConfig} from '@angular/core'; +import {provideClientHydration} from '@angular/platform-browser'; import {provideNgDevtoolsHttp, withNgDevtools} from '@santoshyadavdev/ng-devtools/http'; export const appConfig: ApplicationConfig = { @@ -203,11 +211,9 @@ export const appConfig: ApplicationConfig = { `withNgDevtools()` records requests and applies fault rules. `provideNgDevtoolsHttp()` captures hydration warnings before the overlay loads. In production builds the interceptor passes requests through untouched. -### Put withNgDevtools first +### Put `withNgDevtools` first -<ngmd-callout type="tip" title="Interceptor order"> - Register <code>withNgDevtools()</code> before your own interceptors, for example <code>provideHttpClient(withNgDevtools(), withInterceptors([auth]))</code>. It then records requests as the app makes them, and fault rules apply before anything else. -</ngmd-callout> +Register `withNgDevtools()` before your own interceptors, for example `provideHttpClient(withNgDevtools(), withInterceptors([authInterceptor]))`. It then records requests as the app makes them, and fault rules apply before anything else. ### Run SSR in the same process @@ -220,7 +226,7 @@ The [SSR & HTTP guide](/guides/ssr-http) covers interceptor order and fault inje To mount only the devtools panel without the dock, use `initDevframe()` from `devframe/initiate`: ```ts -// server.ts +// src/server.ts import {initDevframe} from 'devframe/initiate'; import ngDevtools from '@santoshyadavdev/ng-devtools/devframe'; @@ -247,7 +253,7 @@ The overlay looks for `/__ng-devtools/` too. Without the hub, every tab sits in </ngmd-accordion-item> </ngmd-accordion> -## Next steps +## Where to next <ngmd-card-grid columns="2"> <ngmd-card icon="zap" title="Browser overlay" link="/getting-started/overlay" cta="How it connects"> diff --git a/apps/docs/src/content/getting-started/installation.md b/apps/docs/src/content/getting-started/installation.md index 5205e5c..0e3b9ec 100644 --- a/apps/docs/src/content/getting-started/installation.md +++ b/apps/docs/src/content/getting-started/installation.md @@ -76,9 +76,11 @@ Every setup has two parts: Pick the tab that matches your app: ```ts group="setup" name="Angular CLI + Express" image="https://cdn.simpleicons.org/express/71717A" active -// server.ts +// src/server.ts +import express from 'express'; import {initNgDevtoolsHub} from '@santoshyadavdev/ng-devtools/hub'; +const app = express(); const devtools = initNgDevtoolsHub({ws: false}); app.use(devtools.nodeMiddleware); ``` @@ -104,7 +106,11 @@ npx @santoshyadavdev/ng-devtools Load the overlay after bootstrap, in development only. The check depends on your build tool: ```ts group="overlay" name="Angular CLI" image="https://cdn.simpleicons.org/angular/DD0031" active -// main.ts +// src/main.ts +import {bootstrapApplication} from '@angular/platform-browser'; +import {App} from './app/app'; +import {appConfig} from './app/app.config'; + bootstrapApplication(App, appConfig) .then(() => { if (typeof ngDevMode === 'undefined' || ngDevMode) { @@ -117,6 +123,10 @@ bootstrapApplication(App, appConfig) ```ts group="overlay" name="Analog (Vite)" image="https://cdn.simpleicons.org/vite/646CFF" // src/main.ts +import {bootstrapApplication} from '@angular/platform-browser'; +import {App} from './app/app'; +import {appConfig} from './app/app.config'; + bootstrapApplication(App, appConfig).then(() => { if (import.meta.env.DEV) void import('@santoshyadavdev/ng-devtools/overlay'); }); diff --git a/apps/docs/src/content/getting-started/introduction.md b/apps/docs/src/content/getting-started/introduction.md index 1a7834d..c52074a 100644 --- a/apps/docs/src/content/getting-started/introduction.md +++ b/apps/docs/src/content/getting-started/introduction.md @@ -52,8 +52,10 @@ These tabs read the running page through Angular's debug API. They need a develo ### Project overview -- **Dashboard**: the Angular and TypeScript versions, SSR status and a count for each inspector. See [Dashboard](/inspectors/dashboard). -- **Analog**: file routes, server calls, render modes, content and lint for *Analog apps. See [Analog](/inspectors/analog). +| Tab | What it shows | +| ---------------------------------- | ------------------------------------------------------------------------------- | +| [Dashboard](/inspectors/dashboard) | The Angular and TypeScript versions, SSR status and a count for each inspector. | +| [Analog](/inspectors/analog) | File routes, server calls, render modes, content and lint for *Analog apps. | ### Source scan @@ -101,7 +103,7 @@ The devtools are a <a href="https://devfra.me" target="_blank" rel="noopener nor <ngmd-card icon="zap" title="RPC and shared state"> The UI talks to the server over Devframe RPC, and live data sits in shared state that the UI and agents both read. </ngmd-card> - <ngmd-card icon="sparkles" title="Agent tools for free"> + <ngmd-card icon="sparkles" title="Agent tools from RPC functions"> An RPC function marked for agents becomes an MCP tool, and shared state is exposed as MCP resources. </ngmd-card> </ngmd-card-grid> @@ -141,7 +143,7 @@ The devtools are a <a href="https://devfra.me" target="_blank" rel="noopener nor </ngmd-accordion-item> </ngmd-accordion> -## Next steps +## Where to next <ngmd-card-grid columns="2"> <ngmd-card icon="box" title="Install" link="/getting-started/installation" cta="Install the package"> diff --git a/apps/docs/src/content/getting-started/overlay.md b/apps/docs/src/content/getting-started/overlay.md index a50e5a0..70aa704 100644 --- a/apps/docs/src/content/getting-started/overlay.md +++ b/apps/docs/src/content/getting-started/overlay.md @@ -9,11 +9,7 @@ description: The script that runs in your page and sends live data to the devtoo # Browser overlay -The overlay runs inside your *Angular page. It reads Angular's debug API and sends live data to the devtools server. Importing the module starts it, so in most apps that import is all that is needed: - -```ts -import '@santoshyadavdev/ng-devtools/overlay'; -``` +The overlay runs inside your *Angular page. It reads Angular's debug API and sends live data to the devtools server. Importing the module starts it, so in most apps one dynamic import in `main.ts` is all you need. ## Load it in development @@ -22,7 +18,11 @@ import '@santoshyadavdev/ng-devtools/overlay'; Load the overlay after bootstrap, with a dynamic import that only runs in development: ```ts group="overlay" name="Angular CLI" image="https://cdn.simpleicons.org/angular/DD0031" active -// main.ts +// src/main.ts +import {bootstrapApplication} from '@angular/platform-browser'; +import {App} from './app/app'; +import {appConfig} from './app/app.config'; + bootstrapApplication(App, appConfig) .then(() => { if (typeof ngDevMode === 'undefined' || ngDevMode) { @@ -35,6 +35,10 @@ bootstrapApplication(App, appConfig) ```ts group="overlay" name="Analog (Vite)" image="https://cdn.simpleicons.org/vite/646CFF" // src/main.ts +import {bootstrapApplication} from '@angular/platform-browser'; +import {App} from './app/app'; +import {appConfig} from './app/app.config'; + bootstrapApplication(App, appConfig).then(() => { if (import.meta.env.DEV) void import('@santoshyadavdev/ng-devtools/overlay'); }); @@ -42,9 +46,7 @@ bootstrapApplication(App, appConfig).then(() => { ### Why development only -<ngmd-callout type="warning" title="Production builds have nothing to read"> - The overlay reads <code>window.ng</code>, Angular's debug API. Production builds remove it, so the overlay has nothing to read there. The dynamic import keeps it out of your production bundle. -</ngmd-callout> +The overlay reads `window.ng`, Angular's debug API. Production builds remove it, so the overlay has nothing to read there. The dynamic import keeps the overlay out of your production bundle. ## What it sends @@ -90,30 +92,42 @@ Each browser tab gets its own page id, kept in `sessionStorage`. The devtools us ## A custom mount path -### Call initOverlay +### Call `initOverlay` -`initOverlay` is exported for a devtools mounted somewhere else: +If you mount the devtools somewhere else, call `initOverlay` with that path: ```ts -import {initOverlay} from '@santoshyadavdev/ng-devtools/overlay'; +// src/main.ts +import {bootstrapApplication} from '@angular/platform-browser'; +import {App} from './app/app'; +import {appConfig} from './app/app.config'; -const dispose = await initOverlay({baseURL: '/__my-devtools/'}); +bootstrapApplication(App, appConfig).then(async () => { + if (typeof ngDevMode === 'undefined' || ngDevMode) { + const {initOverlay} = await import('@santoshyadavdev/ng-devtools/overlay'); + const dispose = await initOverlay({baseURL: '/__my-devtools/'}); + } +}); ``` `baseURL` takes one path or a list of paths to try in order. `initOverlay` resolves to a function that stops the overlay and removes its hooks. ### Avoid two overlays -<ngmd-callout type="danger" title="Importing the module already starts one"> - The import starts an overlay on the default URLs, and it does not hand you a function to stop it. When the devtools live only at your custom path, that overlay finds no connection, logs an error and stops. Your <code>initOverlay</code> call is then the only one running. If the devtools also answer on a default URL, don't call <code>initOverlay</code>. Otherwise the page ends up with two connections and two polling intervals. -</ngmd-callout> +Importing the module already starts an overlay on the default URLs, and it does not hand you a function to stop it. When the devtools live only at your custom path, that overlay finds no connection, logs an error and stops. Your `initOverlay` call is then the only one running. + +If the devtools also answer on a default URL, don't call `initOverlay`. Otherwise the page ends up with two connections and two polling intervals. ## NgRx signal stores The overlay also exports `registerNgrxSignals`. Call it once with `patchState` so that restoring a store's state also notifies `watchState` listeners: -```ts {4-7} -// main.ts +```ts {8-11} +// src/main.ts +import {bootstrapApplication} from '@angular/platform-browser'; +import {App} from './app/app'; +import {appConfig} from './app/app.config'; + bootstrapApplication(App, appConfig).then(() => { if (typeof ngDevMode === 'undefined' || ngDevMode) { return Promise.all([ @@ -145,7 +159,7 @@ When you hover a component in the devtools, the overlay draws an amber box aroun </ngmd-accordion-item> </ngmd-accordion> -## Next steps +## Where to next <ngmd-pill-row> <ngmd-pill href="/getting-started/popup-and-hub" title="Popup and hub"></ngmd-pill> diff --git a/apps/docs/src/content/getting-started/popup-and-hub.md b/apps/docs/src/content/getting-started/popup-and-hub.md index 1135ac0..4154eb4 100644 --- a/apps/docs/src/content/getting-started/popup-and-hub.md +++ b/apps/docs/src/content/getting-started/popup-and-hub.md @@ -9,7 +9,7 @@ description: The floating button, the panel and its dock modes, the hub rail and # Popup and hub -When the overlay loads, a floating button appears in the bottom-right corner of your page. Click it to open the devtools in a panel on top of your app. No browser extension is needed. +When the overlay loads, a floating button appears in the bottom-right corner of your page. Click it to open the devtools in a panel on top of your app. You don't need a browser extension. ## The floating button @@ -22,12 +22,13 @@ Importing the [overlay](/getting-started/overlay) adds the button. The overlay f Most apps never call the popup API. To add the button without the overlay, call `createDevtoolsPopup()`: ```ts +// src/main.ts import {createDevtoolsPopup} from '@santoshyadavdev/ng-devtools/popup'; createDevtoolsPopup(); ``` -It adds the button and opens the full devtools UI in an iframe. Calling it again returns the same popup. +It adds the button and opens the full devtools UI in an iframe. Calling it again returns the same popup. Importing the popup module in the browser also adds the button on its own. <ngmd-alert severity="helpful"> The popup alone sends no live data. Load the overlay for that. @@ -38,6 +39,7 @@ It adds the button and opens the full devtools UI in an iframe. Calling it again The button reads CSS variables from your page. Set them on `:root` to match your app: ```css +/* src/styles.css */ :root { --ng-devtools-accent: #f5a524; /* button background */ --ng-devtools-accent-ink: #1c1300; /* button icon */ @@ -63,7 +65,7 @@ The values above are the defaults. </ngmd-card> </ngmd-card-grid> -Only the floating panel can be dragged. Switch modes from the buttons in the panel toolbar. +You can drag only the floating panel. Switch modes from the buttons in the panel toolbar. ### Keyboard and mouse @@ -76,7 +78,7 @@ Only the floating panel can be dragged. Switch modes from the buttons in the pan ### Saved layout -Position, size and dock mode are saved in `localStorage` under `ng-devtools-popup`. The panel keeps its layout across reloads. Clear that key to reset it. +The panel saves its position, size and dock mode in `localStorage` under `ng-devtools-popup`, so it keeps its layout across reloads. Clear that key to reset it. ## The hub @@ -89,8 +91,8 @@ When the page's server mounts the hub (`/__devframes/`), the button opens the wh | Angular | Dashboard, Components, Routes, Signals, Injectors, Forms, Pipes, and SSR & HTTP | | NgRx | The Store tab | | Analog | The Analog tab, or a notice in apps that do not use Analog | -| NativeScript | Coming soon | -| Capacitor | Coming soon | +| NativeScript | A **Coming Soon** placeholder | +| Capacitor | A **Coming Soon** placeholder | ### Full-page viewer @@ -121,9 +123,7 @@ The URL hash selects a tab. Open `/__devframes/ng-devtools/#tab=signals` to land ### Limits -<ngmd-callout type="info" title="The tab must exist on load"> - A hash only works for a tab that exists when the panel opens. The Analog tab appears after the server confirms the app is an Analog app, so <code>#tab=analog</code> does not select it on load. Inside the Angular dock, the Store and Analog tabs live in their own docks. -</ngmd-callout> +A hash only works for a tab that exists when the panel opens. The Analog tab appears after the server confirms the app is an Analog app, so `#tab=analog` does not select it on load. Inside the Angular dock, the Store and Analog tabs live in their own docks. ## Connection status @@ -151,7 +151,7 @@ If the panel cannot reach the server, check that the dev server is running, then </ngmd-accordion-item> </ngmd-accordion> -## Next steps +## Where to next <ngmd-pill-row> <ngmd-pill href="/getting-started/overlay" title="Browser overlay"></ngmd-pill> diff --git a/apps/docs/src/content/getting-started/vite.md b/apps/docs/src/content/getting-started/vite.md index 6feaccb..2879ddf 100644 --- a/apps/docs/src/content/getting-started/vite.md +++ b/apps/docs/src/content/getting-started/vite.md @@ -30,7 +30,7 @@ For *Analog apps, add the *Vite plugin next to `analog()` and load the overlay i ## Add the plugin -### Register it in vite.config.ts +### Register it in `vite.config.ts` ```ts {3,7} // vite.config.ts @@ -47,8 +47,12 @@ export default defineConfig({ The plugin does not inject the overlay. Your app imports it in `main.ts`: -```ts {3} +```ts {7} // src/main.ts +import {bootstrapApplication} from '@angular/platform-browser'; +import {App} from './app/app'; +import {appConfig} from './app/app.config'; + bootstrapApplication(App, appConfig).then(() => { if (import.meta.env.DEV) void import('@santoshyadavdev/ng-devtools/overlay'); }); @@ -80,15 +84,14 @@ It records Analog page renders, `load()` fetches, server functions and API calls ### Answers only your machine -<ngmd-callout type="warning" title="Loopback only"> - The plugin only answers requests from a loopback address (<code>127.0.0.1</code> or <code>::1</code>). Other requests to the devtools get <code>403</code> with the message "ng-devtools only answers requests from this machine." WebSocket upgrades follow the same rules. See <a href="/security">Access and redaction</a>. -</ngmd-callout> +The plugin only answers requests from a loopback address (`127.0.0.1` or `::1`). Other requests to the devtools get `403` with the message "ng-devtools only answers requests from this machine." WebSocket upgrades follow the same rules. -The Vite plugin turns the one-time code off. The loopback and origin checks take its place. +The Vite plugin turns the one-time code off. The loopback and origin checks take its place. [Access and redaction](/security) covers both checks. ## Options ```ts +// vite.config.ts ngDevtools({ base: '/__devframes/', apiPrefix: 'api', @@ -102,15 +105,15 @@ ngDevtools({ | `apiPrefix` | Analog's `apiPrefix`, or `'api'` | The prefix of your server routes, used to classify API calls. | | `allowedOrigins` | none | Extra exact origins allowed to reach the devtools, for example a tunnel. | -### base +### `base` Change `base` if `/__devframes/` clashes with a route of your own. The overlay looks for `/__devframes/ng-devtools/` and `/__ng-devtools/` by default, so a custom base also needs a custom overlay path. See [A custom mount path](/getting-started/overlay#a-custom-mount-path). -### apiPrefix +### `apiPrefix` The plugin reads `apiPrefix` from your Analog config. Set it here only when the detection is wrong. -### allowedOrigins +### `allowedOrigins` Each entry is an exact origin, such as `https://tunnel.example`. The request itself must still come from a loopback address. @@ -120,8 +123,12 @@ Each entry is an exact origin, such as `https://tunnel.example`. The request its 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`. The devtools trust it too. -```ts {3} +```ts {7} // vite.config.ts +import analog from '@analogjs/platform'; +import ngDevtools from '@santoshyadavdev/ng-devtools/vite'; +import {defineConfig} from 'vite'; + export default defineConfig({ server: {allowedHosts: ['myapp.test']}, plugins: [analog(), ngDevtools()], @@ -133,6 +140,7 @@ export default defineConfig({ Add other origins with `allowedOrigins`: ```ts +// vite.config.ts ngDevtools({allowedOrigins: ['https://tunnel.example']}); ``` @@ -156,7 +164,7 @@ ngDevtools({allowedOrigins: ['https://tunnel.example']}); </ngmd-accordion-item> </ngmd-accordion> -## Next steps +## Where to next <ngmd-card-grid columns="2"> <ngmd-card icon="rocket" title="Set up Analog" link="/guides/analog" cta="Read the guide"> diff --git a/apps/docs/src/content/guides/analog.md b/apps/docs/src/content/guides/analog.md index fda25ac..0df7fe4 100644 --- a/apps/docs/src/content/guides/analog.md +++ b/apps/docs/src/content/guides/analog.md @@ -11,7 +11,7 @@ description: Add the devtools to an Analog app, step by step. This guide adds the devtools to an *Analog app. Everything runs on the *Vite dev server, so there is no separate server to start. -## What you'll get +## What you get <ngmd-card-grid columns="2"> <ngmd-card icon="layers" title="Angular inspectors"> @@ -55,6 +55,14 @@ pnpm add @santoshyadavdev/ng-devtools devframe npm install @santoshyadavdev/ng-devtools devframe ``` +```bash group="install" name="yarn" image="https://cdn.simpleicons.org/yarn/2C8EBB" +yarn add @santoshyadavdev/ng-devtools devframe +``` + +```bash group="install" name="bun" image="https://bun.sh/logo.svg" +bun add @santoshyadavdev/ng-devtools devframe +``` + ## Step 2: Add the Vite plugin Add the plugin after `analog()`: @@ -119,10 +127,13 @@ Open the **Analog** dock to see file routes, server calls, render modes, content ## Optional: record HttpClient calls -Analog's own `load()` fetches and API calls show in the Analog dock without extra setup. To also record `HttpClient` calls in the SSR & HTTP tab, add the devtools providers to your app config: +Analog's own `load()` fetches and API calls show in the Analog dock without extra setup. To also record `HttpClient` calls in the **SSR & HTTP** tab, add the devtools providers to your app config: -```ts {2,7-8} +```ts {5,10-11} // src/app/app.config.ts +import {provideHttpClient, withFetch} from '@angular/common/http'; +import {ApplicationConfig} from '@angular/core'; +import {provideFileRouter} from '@analogjs/router'; import {provideNgDevtoolsHttp, withNgDevtools} from '@santoshyadavdev/ng-devtools/http'; export const appConfig: ApplicationConfig = { diff --git a/apps/docs/src/content/guides/ngrx-signals-restore.md b/apps/docs/src/content/guides/ngrx-signals-restore.md index 24695d0..a59cd9b 100644 --- a/apps/docs/src/content/guides/ngrx-signals-restore.md +++ b/apps/docs/src/content/guides/ngrx-signals-restore.md @@ -96,7 +96,7 @@ The Angular CLI version is the demo app's `src/main.ts`. It loads the overlay an ### What happens -Every state key of the store goes back to its value right after that change. Components that read the store update at once. A new **Restore** entry is added to the log. +Every state key of the store goes back to its value right after that change. Components that read the store update at once. The log gets a **Restore** entry. <ngmd-callout type="info" title="Without registerNgrxSignals"> Restore still works, but the log entry says that <code>watchState</code> listeners were not notified. @@ -106,8 +106,8 @@ Every state key of the store goes back to its value right after that change. Com ### Signal stores -- Restore needs every state key to be writable. A read-only store state cannot be restored. -- The log keeps the last 200 entries per page. Older changes cannot be restored. +- Restore needs every state key to be writable. It can't restore a read-only store state. +- The log keeps the last 200 entries per page. You can't restore older changes. ### @ngrx/store @@ -115,6 +115,7 @@ For `@ngrx/store`, restore uses Store DevTools instead. Add `provideStoreDevtool ```ts {6} // src/app/app.config.ts +import {ApplicationConfig} from '@angular/core'; import {provideStoreDevtools} from '@ngrx/store-devtools'; export const appConfig: ApplicationConfig = { diff --git a/apps/docs/src/content/guides/ssr-http.md b/apps/docs/src/content/guides/ssr-http.md index d9ec0b5..b84c6a2 100644 --- a/apps/docs/src/content/guides/ssr-http.md +++ b/apps/docs/src/content/guides/ssr-http.md @@ -11,7 +11,7 @@ description: Add the interceptor and hydration hooks, in the right order, to fil The [SSR & HTTP tab](/inspectors/ssr-http) records every `HttpClient` call during server rendering and in the browser. It needs three things: an interceptor, a hydration hook, and SSR running next to the devtools. -## What you'll set up +## What you set up <ngmd-card-grid columns="3"> <ngmd-card icon="zap" title="Interceptor"> @@ -78,8 +78,21 @@ export const appConfig: ApplicationConfig = { Register `withNgDevtools()` before your own interceptors. Then it records requests as the app makes them, and fault rules apply before anything else. -```ts -provideHttpClient(withFetch(), withNgDevtools(), withInterceptors([auth])); +```ts {11} +// src/app/app.config.ts +import {provideHttpClient, withFetch, withInterceptors} from '@angular/common/http'; +import {ApplicationConfig} from '@angular/core'; +import {provideClientHydration} from '@angular/platform-browser'; +import {provideNgDevtoolsHttp, withNgDevtools} from '@santoshyadavdev/ng-devtools/http'; +import {authInterceptor} from './auth.interceptor'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideClientHydration(), + provideHttpClient(withFetch(), withNgDevtools(), withInterceptors([authInterceptor])), + provideNgDevtoolsHttp(), + ], +}; ``` ### How transfer cache hits are detected @@ -133,7 +146,7 @@ export const serverRoutes: ServerRoute[] = [ <ngmd-workflow> <ngmd-step title="Open Fault injection"> - Open the SSR & HTTP tab and go to <strong>Fault injection</strong>. + Open the <strong>SSR & HTTP</strong> tab and go to <strong>Fault injection</strong>. </ngmd-step> <ngmd-step title="Match a URL"> Enter a URL pattern, for example <code>/api/*</code>. diff --git a/apps/docs/src/content/inspectors/analog.md b/apps/docs/src/content/inspectors/analog.md index fc9454f..f59afb2 100644 --- a/apps/docs/src/content/inspectors/analog.md +++ b/apps/docs/src/content/inspectors/analog.md @@ -15,6 +15,8 @@ The Analog dock is always in the rail. In other apps it shows a **This app doesn ## Setup +### Add the plugin + Add the Vite plugin next to `analog()` and load the overlay. See [Vite and Analog](/getting-started/vite) and the [Analog guide](/guides/analog). ```ts {3,7} @@ -30,6 +32,10 @@ export default defineConfig({ The plugin runs on the dev server only. The app counts as Analog when its `package.json` depends on `@analogjs/platform` or `@analogjs/router`. +### Try the demo + +The demo lives in `examples/analog`. It uses Analog 2.7 on Angular 22. Run it with `pnpm analog:dev`. + ## What it shows ### Summary @@ -46,7 +52,7 @@ Type a URL into **Test a URL** and click **Explain** to see which files render i Page renders (server rendered or client only), `load()` fetches, server functions and API calls. Each row shows the status, the time, who called it, and a redacted response preview. Filter by kind, and click **Clear calls** to empty the list. -A `load()` that runs during server rendering and again in the browser right after is flagged. It means TransferState did not serve the server result. +The tab flags a `load()` that runs during server rendering and again in the browser right after. It means TransferState did not serve the server result. The **API routes** table lists your server routes. Click **Try** to open one in the **Request playground**, which sends real requests to your dev server. @@ -58,7 +64,7 @@ The **Prerender plan** compares `prerender.routes` with your pages and the build ### Content -Markdown files under `src/content`, with title, URL, slug, date and file. Files with frontmatter errors are marked. +Markdown files under `src/content`, with title, URL, slug, date and file. The tab marks files with frontmatter errors. ### Lint @@ -71,7 +77,7 @@ Checks grouped by rule, each with a fix: - API method suffixes, duplicate API routes, and routes outside the API prefix. - Prerender entries that match nothing. - Frontmatter errors, duplicate slugs, and content that shadows a page. -- From the live page: `load()` fetched twice, hydration errors, API routes not found, and new pages that need a restart. +- From the live page: `load()` fetched twice, hydration errors, API routes not found, and added pages that need a restart. ## Where the data comes from @@ -109,14 +115,14 @@ A page is **Client only** when `routeRules` or the `ssr` option turns SSR off fo </ngmd-step> </ngmd-workflow> -### Fix a load() that runs twice +### Fix a `load()` that runs twice <ngmd-workflow> <ngmd-step title="Open Server"> A warning at the top names the route. </ngmd-step> <ngmd-step title="Check TransferState"> - Open the SSR & HTTP tab and look for the Analog entry in the payload. + Open the <a href="/inspectors/ssr-http">SSR & HTTP tab</a> and look for the Analog entry in the payload. </ngmd-step> <ngmd-step title="Reload and compare"> After the fix, the browser should not fetch the route's <code>load()</code> again. @@ -156,27 +162,30 @@ A page is **Client only** when `routeRules` or the `ssr` option turns SSR off fo ## Limits and gotchas -<ngmd-callout type="warning" title="analog-call-api changes real data"> - It sends a real request to your dev server. Methods other than GET, HEAD and OPTIONS need <code>confirm: true</code>. It works only through the Vite plugin. -</ngmd-callout> +### `analog-call-api` changes real data -<ngmd-callout type="info" title="New pages need a restart"> - The running router does not know page files added after the dev server started. The lint flags them. Restart the dev server. -</ngmd-callout> +It sends a real request to your dev server. Methods other than GET, HEAD and OPTIONS need `confirm: true`. It works only through the Vite plugin. -<ngmd-callout type="tip" title="Redaction"> - Response previews and <code>load()</code> data redact secret-looking keys, tokens, <code>Bearer</code> values and secret query parameters. See <a href="/security">Security</a>. -</ngmd-callout> +### Redaction -<ngmd-alert severity="helpful"> - The server keeps the last 200 calls. Previews are cut to 1000 characters, and page renders have no preview. -</ngmd-alert> +Response previews and `load()` data redact secret-looking keys, tokens, `Bearer` values and secret query parameters. See [what the devtools redact](/security). -## Try the demo +### Call history size -The demo lives in `examples/analog`. It uses Analog 2.7 on Angular 22. Run it with `pnpm analog:dev`. +The server keeps the last 200 calls. It cuts previews to 1000 characters, and page renders have no preview. + +## FAQ + +<ngmd-accordion> + <ngmd-accordion-item title="Why doesn't the router know a page I added?"> + The running router does not know page files added after the dev server started. The lint flags them. Restart the dev server. + </ngmd-accordion-item> + <ngmd-accordion-item title="Why is there no Analog tab?"> + Without the hub, the Analog tab appears only in Analog apps. The app counts as Analog when its <code>package.json</code> depends on <code>@analogjs/platform</code> or <code>@analogjs/router</code>. + </ngmd-accordion-item> +</ngmd-accordion> -## Related pages +## Where to next <ngmd-card-grid columns="2"> <ngmd-card icon="wrench" title="Set up Analog" link="/guides/analog" cta="Guide"> diff --git a/apps/docs/src/content/inspectors/components.md b/apps/docs/src/content/inspectors/components.md index 0c1613a..53eae7d 100644 --- a/apps/docs/src/content/inspectors/components.md +++ b/apps/docs/src/content/inspectors/components.md @@ -9,7 +9,7 @@ description: Every component instance on the page, with live inputs, outputs and # Components -The Components tab lists each rendered component instance as a tree. It starts at the app root and walks the page in document order, including shadow roots. When no page is connected, it lists what your source declares instead. +The Components tab lists each rendered component instance as a tree. It walks each app root in document order, including shadow roots, then the components outside the app root, such as overlays. When no page is connected, it lists what your source declares instead. ## What it shows @@ -22,13 +22,13 @@ Each row shows the class name and the host tag. Routed components get a chip wit - **Hover or focus** a row to highlight its host element in the page. - **Click** a row, or press Enter or Space, to select it. Click it again to clear the selection. -The tree shows up to 2000 components, and up to 256 levels of nesting. Past either limit, a notice says the page has more components than the tree shows. +The tree shows up to 2000 components, and walks up to 256 levels of DOM nesting. Past either limit, a notice says the page has more components than the tree shows. ### Detail header The header of the selected instance shows the class name, the host tag, and the source file and line. The file and line come from the source scan, matched by class name. They are missing when the scan has no match. -When a form in the same source file exists, a **Show … in Forms** button opens it in the [Forms tab](/inspectors/forms). +When a form exists in the same source file, a **Show … in Forms** button opens it in the [Forms tab](/inspectors/forms). ### Facts @@ -48,7 +48,7 @@ A fact shows **Unknown** when Angular does not report it. ### Injected services -**Injected** lists each token the component class injects, with its flags and the injector that provided it. A token nobody provides is marked **not provided**. Tokens injected by the host directives are not listed here. Use the [Injectors tab](/inspectors/injectors) for those. +**Injected** lists each token the component class injects, with its flags and the injector that provided it. The block marks a token nobody provides as **not provided**. The block leaves out tokens that host directives inject. Use the [Injectors tab](/inspectors/injectors) for those. ### Source mode @@ -60,7 +60,7 @@ A notice at the top says why you see the source list: no page is connected, or t <ngmd-card-grid columns="2"> <ngmd-card icon="zap" title="Live page"> - The overlay walks the page with Angular's debug API and pushes the tree every 3 seconds. Unchanged trees are skipped. + The overlay walks the page with Angular's debug API every 3 seconds. It resends an unchanged tree only every fourth time. </ngmd-card> <ngmd-card icon="file" title="Source scan"> The server scans your files for <code>@Component</code> and <code>@Directive</code> classes. It also supplies the file and line in the detail header. @@ -75,12 +75,13 @@ The live tree reads `window.ng`, which only development builds expose. It uses t | -------------------------------------------------------------------------- | ---------------------------------------------------- | | `ng.getComponent`, `ng.getDirectives` | Finding instances and the directives on each host. | | `ng.getDirectiveMetadata` | Inputs, outputs, change detection and encapsulation. | +| `ng.isSignal` | Unwrapping signal inputs. | | `ng.getListeners` | Output listeners and DOM listeners. | | `ng.getInjector`, `ɵgetDependenciesFromInjectable`, `ɵgetInjectorMetadata` | The **Injected** block. | ### Refresh rate -The page pushes every 3 seconds. The detail block is read only for the selected instance, on each push. The server drops a page after 15 seconds without a report. +The page reads the tree every 3 seconds, and at once when you select an instance. It reads the detail block only for the selected instance. The server drops a page after 15 seconds without a report. ## How to use it @@ -139,21 +140,21 @@ See [Tools](/agents/tools) and [Resources](/agents/resources). ## Limits and gotchas -<ngmd-callout type="warning" title="Development builds only"> - Live data reads <code>window.ng</code>. Production builds remove it, so the tab falls back to the source list. -</ngmd-callout> +### Development builds only -<ngmd-callout type="info" title="Values are shortened"> - Input values stop at 3 levels of nesting, 30 keys or items, and 300 characters. Past that, the value is cut and marked. -</ngmd-callout> +Live data reads `window.ng`. Production builds remove it, so the tab falls back to the source list. -<ngmd-callout type="tip" title="Secrets are redacted"> - Inputs with secret-looking names are replaced with <code>[redacted]</code>. Tokens and <code>Bearer</code> values inside strings are redacted too. See <a href="/security">Security</a>. -</ngmd-callout> +### Values are shortened -<ngmd-alert severity="helpful"> - Instance ids change on every page load. Don't store them between sessions. -</ngmd-alert> +Input values stop at 3 levels of nesting, 30 keys or items, and 300 characters. Past that, the devtools cut the value and mark it. Each block lists up to 60 inputs, outputs or listeners. + +### Secrets are redacted + +The devtools replace inputs with secret-looking names with `[redacted]`. They also redact JWTs and `Bearer` values inside strings. See [what the devtools redact](/security). + +### Instance ids change on reload + +Instance ids change on every page load. Don't store them between sessions. ## FAQ @@ -161,7 +162,7 @@ See [Tools](/agents/tools) and [Resources](/agents/resources). <ngmd-accordion-item title="Why do I see the source list instead of the tree?"> No page is connected, or the connected page is a production build. Open the app in a development build with the overlay loaded. </ngmd-accordion-item> - <ngmd-accordion-item title="Why is the file and line missing?"> + <ngmd-accordion-item title="Why are the file and line missing?"> The detail header matches the class name against the source scan. Classes outside the scanned folders, or from libraries, have no match. </ngmd-accordion-item> <ngmd-accordion-item title="Why does a component show +2?"> @@ -169,7 +170,7 @@ See [Tools](/agents/tools) and [Resources](/agents/resources). </ngmd-accordion-item> </ngmd-accordion> -## Related pages +## Where to next <ngmd-card-grid columns="2"> <ngmd-card icon="layers" title="Injectors" link="/inspectors/injectors" cta="Open"> diff --git a/apps/docs/src/content/inspectors/dashboard.md b/apps/docs/src/content/inspectors/dashboard.md index 7fc26fb..6e1131d 100644 --- a/apps/docs/src/content/inspectors/dashboard.md +++ b/apps/docs/src/content/inspectors/dashboard.md @@ -11,59 +11,39 @@ description: Project metadata and a count for each inspector. The Dashboard opens by default. The top block describes your workspace. The cards below count what each inspector found, and each card opens its tab. -<ngmd-pill-row> - <ngmd-pill href="/inspectors/components" title="Components"></ngmd-pill> - <ngmd-pill href="/inspectors/signals" title="Signals"></ngmd-pill> - <ngmd-pill href="/inspectors/injectors" title="Injectors"></ngmd-pill> - <ngmd-pill href="/inspectors/router" title="Router"></ngmd-pill> - <ngmd-pill href="/inspectors/pipes" title="Pipes"></ngmd-pill> -</ngmd-pill-row> - ## What it shows ### Project block The top block shows the project name and a chip for each of these: -<ngmd-card-grid columns="2"> - <ngmd-card icon="box" title="Angular"> - The installed Angular version. - </ngmd-card> - <ngmd-card icon="code" title="TypeScript"> - The installed TypeScript version. - </ngmd-card> - <ngmd-card icon="layers" title="SSR"> - <code>On</code> or <code>Off</code>. - </ngmd-card> - <ngmd-card icon="rocket" title="Analog"> - The Analog version. Shown in Analog apps only. - </ngmd-card> -</ngmd-card-grid> +| Chip | Shows | +| -------------- | ---------------------------------------------- | +| **Angular** | The installed Angular version. | +| **TypeScript** | The installed TypeScript version. | +| **SSR** | **On** or **Off**. | +| **Analog** | The Analog version. Shown in Analog apps only. | ### Inspector cards -Each card counts what one inspector found. Click a card to open its tab. +Each card counts what one inspector found. Click a card to open its tab. When the hub is mounted, the NgRx card opens the **NgRx** dock. -| Card | Counts | -| ----------------- | ----------------------------------------------------------------------------------------------------------- | -| Components | Components in source, plus the number of directives. | -| Routes | Navigable page paths in source, plus the number of redirects. | -| Signals | Signal nodes live on the page, plus the declarations in source. Without a page, the declarations in source. | -| Injectors | Live injectors on the page, plus their providers. Without a page, the provider declarations in source. | -| NgRx declarations | NgRx declarations in source, broken down by kind. | -| Pipes | Custom pipes in source, plus the built-in pipes in use. | +| Card | Counts | +| --------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | +| [Components](/inspectors/components) | Components in source, plus the number of directives. | +| [Routes](/inspectors/router) | Navigable page paths in source, plus the number of redirects. | +| [Signals](/inspectors/signals) | Nodes in the live signal graph, plus the declarations in source. Without a page, the declarations in source. | +| [Injectors](/inspectors/injectors) | Live injectors on the page, plus their providers. Without a page, the provider declarations in source. | +| [NgRx declarations](/inspectors/ngrx-store) | NgRx declarations in source, broken down by kind. | +| [Pipes](/inspectors/pipes) | Custom pipes in source, plus the built-in pipes in use. | ### Card states -A card shows **Counting…** while it loads. It shows **Count unavailable** when its data could not be read. - -<ngmd-alert severity="helpful"> - The NgRx card opens the <strong>NgRx</strong> dock when the hub is mounted. -</ngmd-alert> +A card shows **Counting…** while it loads. It shows **Count unavailable** when the tab can't read its data. ## Where the data comes from -Most of the Dashboard reads your workspace, not the running page. It works before the app has even loaded in a browser. +Most of the Dashboard reads your workspace, not the running page. It works before the app loads in a browser. ### Versions and project name @@ -77,7 +57,7 @@ SSR is **On** when the build options set `ssr` or `server`. For *Analog apps, SS ### Counts -The Components, Routes, NgRx and Pipes cards count the source scan. The Signals and Injectors cards use the live page when one is connected, and the source scan otherwise. +The Components, Routes, NgRx and Pipes cards count the source scan. The Signals and Injectors cards use the live page when one is connected, and the source scan otherwise. The live Signals count covers the graph of the one component the [Signals tab](/inspectors/signals) shows, and counts its signals, computeds, linked signals and effects. ## How to use it @@ -95,23 +75,25 @@ The Components, Routes, NgRx and Pipes cards count the source scan. The Signals ## Agent tools -| Tool | What it returns | -| ------------------------ | ----------------------------------------------------------------- | -| `ng-devtools:build-meta` | Angular and TypeScript versions, the project name and SSR status. | +| Tool | What it returns | +| ------------------------ | ------------------------------------------------------------------------------------------------------ | +| `ng-devtools:build-meta` | Angular and TypeScript versions, the project name, SSR status and, in Analog apps, the Analog version. | -The same data is baked into [static reports](/getting-started/cli). See [Tools](/agents/tools) for every tool. +[Static reports](/getting-started/cli) include the same data. See [Tools](/agents/tools) for every tool. ## Limits and gotchas -<ngmd-callout type="warning" title="Project details unavailable"> - If the project block says <strong>Project details unavailable</strong>, check that the dev server is running, then reload the panel. -</ngmd-callout> +If the project block says **Project details unavailable**, check that the dev server is running, then reload the panel. + +## FAQ -<ngmd-callout type="info" title="Source counts do not need a page"> - The source-based cards fill in from the workspace scan. Only the Signals and Injectors cards change when a page connects. -</ngmd-callout> +<ngmd-accordion> + <ngmd-accordion-item title="Do the cards need the app open in a browser?"> + No. The source-based cards fill in from the workspace scan. Only the Signals and Injectors cards change when a page connects. + </ngmd-accordion-item> +</ngmd-accordion> -## Related pages +## Where to next <ngmd-card-grid columns="2"> <ngmd-card icon="layers" title="Components" link="/inspectors/components" cta="Open"> diff --git a/apps/docs/src/content/inspectors/forms.md b/apps/docs/src/content/inspectors/forms.md index 923756d..fa7f3ee 100644 --- a/apps/docs/src/content/inspectors/forms.md +++ b/apps/docs/src/content/inspectors/forms.md @@ -9,7 +9,7 @@ description: Every form on the page with each field's state and errors, a change # Forms -The Forms tab reads the forms of the running page, in development builds only. It covers Signal Forms, reactive forms and template-driven forms. Actions you run from the tab are sent back to the page and run there. +The Forms tab reads the forms of the running page, in development builds only. It covers Signal Forms, reactive forms and template-driven forms. Actions you run from the tab go back to the page and run there. ## What it shows @@ -23,7 +23,7 @@ Select a form to see its status, whether it is dirty or touched, whether it was Each field shows its value, status, touched and dirty state, and errors. Extra facts depend on the kind: -- **Signal Forms**: constraints (`min`, `max`, `minLength`, `maxLength`, `pattern`), a pending `debounce`, `submitting`, and disabled reasons. +- **Signal Forms**: constraints (`min`, `max`, `minLength`, `maxLength`, `pattern`), `required`, `readonly` and `hidden`, a pending `debounce`, and disabled reasons. - **Reactive and template-driven**: whether validators and async validators are attached, the value `reset()` goes back to, `updateOn`, and the bound `ControlValueAccessor`. Filter by path, or with the **Invalid**, **Dirty**, **Touched**, **Disabled** and **Error not shown** chips. Hover a field to highlight its input in the page. @@ -45,17 +45,17 @@ Each error says where it comes from: ### Field details -Click a field to open its details. There you can set a value, **Focus**, **Touch** or **Revalidate** the field, or **Store as global**. That stores the form as `$form`, and the field as `$control`, in the page console. +Click a field to open its details. From there, set a value, or click **Focus**, **Touch**, **Revalidate** or **Store as global**. **Store as global** stores the form as `$form`, and the field as `$control`, in the page console. ### Timeline view -Recent changes, newest first, each tagged with its origin: user, code or devtools. Array items are tracked by identity, so moves show as moves. Async validation times show as **pending** tags. +Recent changes, newest first, each tagged with its origin: user, code or devtools. Filter the list by origin. The timeline tracks array items by identity, so moves show as moves. Async validation times show as **pending** tags. Check **Record details** to add the calling code of each change, validator changes, and component renders per keystroke. It is off by default and applies to the whole page. ### Submit view -What submit will do, and why it might do nothing. It also shows what the form sends. **Copy test fixture** copies a fixture for your tests. +What submit does, and why it might do nothing. It also shows what the form sends. **Copy test fixture** copies a fixture for your tests. ### Lint view @@ -67,7 +67,7 @@ The actions bar works on the selected form: - **Touch all**, **Revalidate** and **Focus first invalid**. - **Pick field on page**: click a field in the app to select it. Esc cancels. -- **Snapshot** saves the form's values. **Restore s1**, **Restore s2** and so on put them back. +- **Snapshot** saves the form's values as `s1`, `s2` and so on. **Restore** puts back the latest one. The button shows its name, like **Restore s2**. - **Reset** and **Submit**. ## Where the data comes from @@ -83,16 +83,16 @@ The actions bar works on the selected form: ### When the page reports -The overlay pushes the forms every 3 seconds, and shortly after each `input`, `change`, `focusout`, `submit` or `reset` event. Reactive and template-driven forms also report each change through `control.events`. +The overlay reads the forms every 3 seconds and pushes them when they change. It also pushes shortly after each `input`, `change`, `focusout`, `submit` or `reset` event. Reactive and template-driven forms also report each change through `control.events`. ### Validators run only when needed -To tell where each error comes from, the devtools run the sync validators of reactive and template-driven fields themselves. They do this only for enabled leaf fields. The result is reused for up to 5 seconds while the value and the validators stay the same. With **Record details** on, they run on every report. +To tell where each error comes from, the devtools run the sync validators of reactive and template-driven fields themselves. They do this only for enabled leaf fields. They reuse the result for up to 5 seconds while the value and the validators stay the same. With **Record details** on, they run on every report. -Async validators are never run by the devtools. The probe emits no form events, so it does not show up in the timeline. +The devtools never run async validators. The probe emits no form events, so it does not show up in the timeline. <ngmd-callout type="warning" title="Validators with side effects"> - The devtools call your sync validators. A validator that logs, counts or changes state will see extra calls while the Forms tab is open. + The devtools call your sync validators. A validator that logs, counts or changes state sees extra calls while the Forms tab is open. </ngmd-callout> ## How to use it @@ -118,7 +118,7 @@ Async validators are never run by the devtools. The probe emits no form events, <ngmd-workflow> <ngmd-step title="Open Submit"> - It explains what submit will do. + It explains what submit does. </ngmd-step> <ngmd-step title="Read the payload"> Compare the value with what your API expects. @@ -155,7 +155,7 @@ You can also open a form from its component in the [Components tab](/inspectors/ | `ng-devtools:explain-form-invalid` | Start here. Every invalid or pending form, with each failing field's value, validator, message and touched state. | | `ng-devtools:inspect-forms` | The forms with status and error counts. With `form`, the field tree. Narrow with `path` or `onlyInvalid`. | | `ng-devtools:explain-field` | One field: error sources, skip reasons, pending values, binding, visible errors, and source lines. | -| `ng-devtools:explain-submit` | What submit will do, and why it might do nothing. | +| `ng-devtools:explain-submit` | What submit does, and why it might do nothing. | | `ng-devtools:form-payload` | What the form sends: value against raw value, and unvalidated fields. | | `ng-devtools:form-history` | The change timeline with origins. Returns a marker. | | `ng-devtools:form-diff` | The net change since a marker. | @@ -175,21 +175,21 @@ Agents can loop: inspect, act, `wait-for-form`, then `form-diff` from the marker ## Limits and gotchas -<ngmd-callout type="warning" title="Reset, submit and restore ask first"> - In the tab, the button turns into <strong>Confirm reset</strong>, <strong>Confirm submit</strong> or <strong>Confirm restore</strong>. Click again to run it. Agents pass <code>confirm: true</code> for the same actions, and for <code>fill-form</code> with <code>submit</code>. +<ngmd-callout type="danger" title="Form values leave the page"> + The devtools send values to the devtools server, show them in the tab and return them to agents. They replace password fields and fields with secret-looking names with <code>[redacted]</code>. To mask or unmask a field, see <a href="/security">Security</a>. </ngmd-callout> -<ngmd-callout type="info" title="Fields that are not written"> - Secret fields are not written unless you unmask them. See <a href="/security#opt-fields-in-or-out">Access and redaction</a>. For Signal Forms, hidden, readonly and rule-disabled fields are skipped too. Disabled reactive fields are written only with <code>force</code>. -</ngmd-callout> +### Reset, submit and restore ask first -<ngmd-callout type="info" title="Snapshots"> - Up to 20 snapshots are kept. They are lost on reload. Restore fails when the form's shape has changed, and it keeps the current value of secret fields. -</ngmd-callout> +In the tab, the button turns into **Confirm reset**, **Confirm submit** or **Confirm restore**. Click again to run it. Agents pass `confirm: true` for the same actions, and for `fill-form` with `submit`. -<ngmd-callout type="danger" title="Form values leave the page"> - Values are sent to the devtools server, shown in the tab and returned to agents. Password fields and fields with secret-looking names are replaced with <code>[redacted]</code>. See <a href="/security">Security</a> to mask or unmask a field. -</ngmd-callout> +### Fields that are not written + +The actions don't write secret fields unless you unmask them. See [Access and redaction](/security#opt-fields-in-or-out). For Signal Forms, they skip hidden, readonly and rule-disabled fields too. They write disabled reactive fields only with `force`. + +### Snapshot limits + +The page keeps up to 20 snapshots, and a reload clears them. Restore fails when the form's shape has changed, and it keeps the current value of secret fields. ## FAQ @@ -198,14 +198,14 @@ Agents can loop: inspect, act, `wait-for-form`, then `form-diff` from the marker The current tab has no form yet. Click <strong>Show forms from all pages</strong> to see forms from other tabs. </ngmd-accordion-item> <ngmd-accordion-item title="Why are there no callers in the timeline?"> - Callers are recorded only with <strong>Record details</strong> checked. + The timeline records callers only with <strong>Record details</strong> checked. </ngmd-accordion-item> <ngmd-accordion-item title="Does the tab change my form when I only look at it?"> No. It reads state and runs sync validators without emitting events. Only the actions write. </ngmd-accordion-item> </ngmd-accordion> -## Related pages +## Where to next <ngmd-card-grid columns="2"> <ngmd-card icon="layers" title="Components" link="/inspectors/components" cta="Open"> diff --git a/apps/docs/src/content/inspectors/injectors.md b/apps/docs/src/content/inspectors/injectors.md index 3aa5c5c..4ec3a2d 100644 --- a/apps/docs/src/content/inspectors/injectors.md +++ b/apps/docs/src/content/inspectors/injectors.md @@ -13,14 +13,14 @@ When a component asks for a token, Angular walks up the element injectors, then ## What it shows -### Elements and Environment +### View switch A switch at the top picks the view: - **Elements**: one node per host element that has a component or a directive. - **Environment**: the environment injectors, such as the root and platform injectors. -Each row shows a kind letter (`C`, `D` or `E`), the tag or injector name, and icons with the number of injected and provided tokens. +Each row shows a kind letter (`C`, `D` or `E`), the tag or injector name, the component or directive classes, and icons with the number of injected and provided tokens. ### Search and filters @@ -35,7 +35,7 @@ Select an injector to see the **Lookup path**: the injectors Angular asks, in or ### Injected here -For element injectors, **Injected here** lists each token requested at this level and the injector that answered. A token that nobody provides is marked **not provided anywhere**. When the element has more than one class, each row says which class asked. +For element injectors, **Injected here** lists each token requested at this level and the injector that answered. The block marks a token that nobody provides as **not provided anywhere**. When the element has more than one class, each row says which class asked. ### Provides @@ -45,12 +45,12 @@ For element injectors, **Injected here** lists each token requested at this leve Without a live tree, the tab lists DI found in your source files, in four groups: -| Group | Lists | -| ------------------------------ | --------------------------------------------------------------------------------------------------- | -| **Root Providers (provide\*)** | Calls to known Angular `provide*()` functions, such as `provideRouter()` and `provideHttpClient()`. | -| **Injectable Services** | `@Injectable` classes, plus `signalStore` and `InjectionToken` declarations with `providedIn`. | -| **inject() Calls** | `inject(T)` field initializers and `@Inject(T)` parameters. | -| **Component Providers** | Any `providers` or `viewProviders` array, in components, routes, app config or NgModules. | +| Group | Lists | +| ------------------------------ | ------------------------------------------------------------------------------------------------------------- | +| **Root Providers (provide\*)** | Calls to known Angular `provide*()` functions, such as `provideRouter()` and `provideHttpClient()`. | +| **Injectable Services** | `@Injectable` and `@Service` classes, plus `signalStore` and `InjectionToken` declarations with `providedIn`. | +| **inject() Calls** | `x = inject(T)` assignments and `@Inject(T)` parameters. | +| **Component Providers** | Any `providers` or `viewProviders` array, in components, routes, app config or NgModules. | ## Where the data comes from @@ -103,7 +103,7 @@ The source-mode notice says to connect the overlay on Angular 17 or later for th ### Keyboard -Arrow keys, Home and End move the selection through the tree. The first row is selected when nothing else is. +Arrow keys, Home and End move the selection through the tree. The right arrow expands a row or moves to its first child. The left arrow collapses a row or moves to its parent. The first row is selected when nothing else is. ## Agent tools @@ -117,19 +117,26 @@ See [Tools](/agents/tools) and [Resources](/agents/resources). ## Limits and gotchas -<ngmd-callout type="warning" title="Up to 2000 element injectors"> - The Elements view stops at 2000 injectors, without a notice. Environment injectors are not capped. -</ngmd-callout> +### Up to 2000 element injectors -<ngmd-callout type="info" title="Source mode only knows some provide functions"> - The <strong>Root Providers</strong> group matches a fixed list of Angular <code>provide*()</code> functions. Your own provider functions are not listed there. -</ngmd-callout> +The **Elements** view stops at 2000 injectors, without a notice. Environment injectors have no cap. -<ngmd-alert severity="helpful"> - Constructor parameters without <code>@Inject()</code> are not found by the source scan. The live tree has them. -</ngmd-alert> +### Source mode only knows some provide functions -## Related pages +The **Root Providers** group matches a fixed list of Angular `provide*()` functions. It doesn't list your own provider functions. + +## FAQ + +<ngmd-accordion> + <ngmd-accordion-item title="Why is a constructor-injected service missing from source mode?"> + The source scan doesn't find constructor parameters without <code>@Inject()</code>. The live tree has them. + </ngmd-accordion-item> + <ngmd-accordion-item title="Why does the tab show source mode instead of the live tree?"> + No live tree has reached the tab. The live tree needs the overlay, a development build and Angular 17 or later. + </ngmd-accordion-item> +</ngmd-accordion> + +## Where to next <ngmd-card-grid columns="2"> <ngmd-card icon="layers" title="Components" link="/inspectors/components" cta="Open"> diff --git a/apps/docs/src/content/inspectors/ngrx-store.md b/apps/docs/src/content/inspectors/ngrx-store.md index 8226808..a303fd9 100644 --- a/apps/docs/src/content/inspectors/ngrx-store.md +++ b/apps/docs/src/content/inspectors/ngrx-store.md @@ -23,22 +23,22 @@ Each store shows its label, its kind (**signalStore**, **signalState** or **@ngr - `Owner (component)` for a store a component provides. - `Owner (field)` for a store found only in a component field. -The classic Store shows with the label **Store**. Use the filter box to narrow stores, changes and declarations. When more than one page reports, a picker chooses the page. +The tab labels a signal store with the matching declaration name from your source. Without a match, it uses the first component field that holds it, then its class name. The classic Store shows with the label **Store**. Use the filter box to narrow stores, changes and declarations. When more than one page reports, a picker chooses the page. ### Store detail Select a store to see: -- Its kind, scope and declaring file. The file appears when the store's state keys match a `signalStore` in your source. +- Its kind, scope and declaring file. The file appears when the store's state keys match a `signalStore` or `signalState` in your source. - **Store DevTools on** or **read-only**, for the classic Store. - **Referenced by**: the component fields that hold it. -- **State**, **Computed** and **Methods**, with a call count per method. `rxMethod` members are tagged. +- **State**, **Computed** and **Methods**, with a call count per method. The tab tags `rxMethod` members. ### Change log Signal stores get a **Change log**. The classic Store gets an **Action log**. Each entry shows its number, its type, the number of changes and the time. -Open an entry to see its arguments and a **State diff** with the value before and after each change. +Open an entry to see its arguments, or the action for the classic Store, and a **State diff** with the value before and after each change. The diff lists up to 50 changes. ### Source declarations @@ -58,19 +58,19 @@ Filter by kind with the chips. The overlay finds stores in the page's injectors and component fields, and records every change. </ngmd-card> <ngmd-card icon="file" title="Source scan"> - The server reads your <code>.ts</code> files for NgRx declarations. Specs are skipped. + The server reads your <code>.ts</code> files for NgRx declarations, skipping specs. </ngmd-card> </ngmd-card-grid> ### How changes are recorded -The overlay wraps the state signals of each signal store and the store's methods. A method call becomes one log entry with its arguments. Nested method calls fold into the outer one. Writes made outside a method are batched and logged as `patchState`. +The overlay wraps the state signals of each signal store and the store's methods. A method call becomes one log entry with its arguments. Nested method calls fold into the outer one. The overlay batches writes made outside a method and logs them as `patchState`. For the classic Store, the overlay listens to the dispatched actions. ### Development builds -Stores are found through Angular's debug API, so the live section needs a development build. +The overlay finds stores through Angular's debug API, so the live section needs a development build. ## How to use it @@ -98,7 +98,7 @@ Stores are found through Angular's debug API, so the live section needs a develo Click <strong>Restore this state</strong>, then <strong>Restore</strong> to confirm. </ngmd-step> <ngmd-step title="Check the page"> - Components that read the store update at once. A new <code>Restore #N</code> entry is added to the log. + Components that read the store update at once. The log gets a <code>Restore #N</code> entry. </ngmd-step> </ngmd-workflow> @@ -108,8 +108,8 @@ Stores are found through Angular's debug API, so the live section needs a develo <ngmd-tab title="Signal store" icon="zap"> Restore sets every state key that differs back to its value right after that change. Every state signal must be writable. </ngmd-tab> - <ngmd-tab title="@ngrx/store" icon="box"> - Restore uses Store DevTools to jump to the state right after that action. New actions continue from there. It needs <code>provideStoreDevtools()</code>. Without it, the log is read-only. + <ngmd-tab title="@ngrx/store" icon="box"> + Restore uses Store DevTools to jump to the state right after that action. Later actions continue from there. It needs <code>provideStoreDevtools()</code>. Without it, the log is read-only. </ngmd-tab> </ngmd-tabs> @@ -124,41 +124,41 @@ Agent access is read-only. No tool can restore a state. See [Tools](/agents/tool ## Limits and gotchas -<ngmd-callout type="warning" title="watchState needs registerNgrxSignals"> - Without it, restore writes the state signals directly. Components update, but <code>watchState</code> listeners do not run, and the log entry says so. Call <code>registerNgrxSignals({ patchState })</code> from <code>@santoshyadavdev/ng-devtools/overlay</code> once, and restore goes through <code>patchState</code>. This applies to <code>signalStore</code> only. A <code>signalState</code> restore always writes directly. See <a href="/guides/ngrx-signals-restore">Restore NgRx signal state</a>. -</ngmd-callout> +### `watchState` needs `registerNgrxSignals` -<ngmd-callout type="info" title="Stores appear when they are created"> - A <code>signalStore</code> is created the first time something injects it. Open a page that uses it, and it appears. -</ngmd-callout> +Without it, restore writes the state signals directly. Components update, but `watchState` listeners do not run, and the log entry says so. Call `registerNgrxSignals({ patchState })` from `@santoshyadavdev/ng-devtools/overlay` once, and restore goes through `patchState`. This applies to `signalStore` only. A `signalState` restore always writes directly. See [Restore NgRx signal state](/guides/ngrx-signals-restore). -<ngmd-callout type="info" title="The classic Store must be in an environment injector"> - Use <code>provideStore()</code> or <code>StoreModule.forRoot()</code>. The overlay stops looking after a few tries, so reload the page if the Store is provided late. -</ngmd-callout> +### Stores appear when they are created -<ngmd-callout type="tip" title="Redaction"> - State keys with secret-looking names are replaced with <code>[redacted]</code>, at any depth. See <a href="/security">Security</a>. -</ngmd-callout> +Angular creates a `signalStore` the first time something injects it. Open a page that uses it, and it appears. -<ngmd-alert severity="helpful"> - The log keeps the last 200 entries. A method call that changes nothing is logged at most once per second. -</ngmd-alert> +### The classic Store must be in an environment injector + +Use `provideStore()` or `StoreModule.forRoot()`. The overlay stops looking after a few tries, so if you provide the Store late, reload the page. + +### Redaction + +The devtools replace state keys with secret-looking names with `[redacted]`, at any depth. See [what the devtools redact](/security). + +### Log size + +The log keeps the last 200 entries. The overlay logs a method call that changes nothing at most once per second. ## FAQ <ngmd-accordion> - <ngmd-accordion-item title="Why can't I restore an @ngrx/store entry?"> + <ngmd-accordion-item title="Why can't I restore an @ngrx/store entry?"> Store DevTools is not set up, so the log is read-only. The store detail shows <strong>read-only</strong>. Add <code>provideStoreDevtools()</code> to the app config. </ngmd-accordion-item> <ngmd-accordion-item title="Why does restore say this change is no longer in the page history?"> The entry fell out of the 200-entry log in the page. Pick a newer entry. </ngmd-accordion-item> <ngmd-accordion-item title="Why does my store have no declaring file?"> - The file is matched by state keys. A store whose keys match no <code>signalStore</code> in the scanned source has none. + The tab matches the file by state keys. A store whose keys match no <code>signalStore</code> or <code>signalState</code> in the scanned source has none. </ngmd-accordion-item> </ngmd-accordion> -## Related pages +## Where to next <ngmd-card-grid columns="2"> <ngmd-card icon="wrench" title="Restore NgRx signal state" link="/guides/ngrx-signals-restore" cta="Guide"> diff --git a/apps/docs/src/content/inspectors/pipes.md b/apps/docs/src/content/inspectors/pipes.md index ebc669f..9cad3ad 100644 --- a/apps/docs/src/content/inspectors/pipes.md +++ b/apps/docs/src/content/inspectors/pipes.md @@ -40,7 +40,7 @@ The number of instances and the components that use them. Click, hover or focus ### Async subscriptions -When templates use `| async`, the tab lists each subscription with its component and latest value. This needs no recording. Each `| async` subscribes on its own. Two on the same source run the work twice, so those rows are marked **duplicate subscription**. +When templates use `| async`, the tab lists each subscription with its component and latest value. This needs no recording. Each `| async` subscribes on its own. Two on the same source run the work twice, so the tab marks those rows **duplicate subscription**. ### Lint @@ -104,7 +104,7 @@ Click **Record calls** to count calls and keep the last input and output of each <ngmd-step title="Look for the stale? chip"> It marks a pure pipe that got an argument whose contents changed while its reference stayed the same. </ngmd-step> - <ngmd-step title="Pass a new reference"> + <ngmd-step title="Replace the reference"> Replace the object or array instead of mutating it, so the pipe reruns. </ngmd-step> </ngmd-workflow> @@ -121,23 +121,34 @@ Open **Async subscriptions** and look for **duplicate subscription** rows. Subsc | `ng-devtools:lint-pipes` | no | Runs the lint rules above. | | `ng-devtools:explain-pipe` | partly | One pipe by `name`: where it is declared or used, purity, live counts, last input and output, the stale warning and the lint findings. | -Agents cannot turn recording on. Click **Record calls** in the panel first to give `explain-pipe` call data. See [Tools](/agents/tools). +Agents can't turn recording on. To give `explain-pipe` call data, click **Record calls** in the panel first. See [Tools](/agents/tools). ## Limits and gotchas -<ngmd-callout type="warning" title="The stale warning is experimental"> - It runs only while recording. It reads the template source, so it needs an unminified development build. When it cannot read the template, it stays quiet. +<ngmd-callout type="danger" title="Values are not redacted"> + The devtools send pipe inputs, outputs and async values as they are, cut to 200 characters. Keep the dev server on localhost. See <a href="/security">Security</a>. </ngmd-callout> -<ngmd-callout type="info" title="Recording ends on reload"> - Recording is off by default and is not remembered. Reload the page and it is off again. -</ngmd-callout> +### The stale warning is experimental -<ngmd-callout type="danger" title="Values are not redacted"> - Pipe inputs, outputs and async values are sent as they are, cut to 200 characters. Keep the dev server on localhost. See <a href="/security">Security</a>. -</ngmd-callout> +It runs only while recording. It reads the template source, so it needs an unminified development build. When it can't read the template, it stays quiet. + +### Recording ends on reload + +Recording is off by default. Reload the page and it is off again. + +## FAQ + +<ngmd-accordion> + <ngmd-accordion-item title="Why does explain-pipe return no call data?"> + Recording is off. Agents can't turn it on. Click <strong>Record calls</strong> in the panel first. + </ngmd-accordion-item> + <ngmd-accordion-item title="Why isn't a pipe from another package listed as built-in?"> + The built-in list covers the <code>@angular/common</code> pipes only. + </ngmd-accordion-item> +</ngmd-accordion> -## Related pages +## Where to next <ngmd-card-grid columns="2"> <ngmd-card icon="layers" title="Components" link="/inspectors/components" cta="Open"> diff --git a/apps/docs/src/content/inspectors/router.md b/apps/docs/src/content/inspectors/router.md index a5d9c9c..a7e52b4 100644 --- a/apps/docs/src/content/inspectors/router.md +++ b/apps/docs/src/content/inspectors/router.md @@ -15,7 +15,7 @@ The Routes tab reads the running app's Router. The top section, **Live router**, ### Current -The route the page is on right now: +The route the page is on: - The URL, and the browser URL when the two differ. - The navigation in flight, with an **Abort** button. @@ -31,13 +31,13 @@ Every navigation as one story: - The extras, redirect chains and loops. - A phase bar: recognize, guards, resolve, activate. - Guards and resolvers, lazy loads, reused components, HTTP requests, scroll, and the title afterwards. -- Router warnings, and the cancel or error reason. NG04xxx errors are explained. +- Router warnings, and the cancel or error reason. The tab explains NG04xxx errors. Filter by URL, or check **Only problems**. Each row has **Replay** and **Copy repro** (a markdown repro). **Export JSON** saves the list. ### Routes -The live route config. Lazy children are merged in once they load, and the active branch is marked. +The live route config. The tab merges lazy children in once they load, and marks the active branch. - **Test a URL** and click **Predict** to see which route matches it, or the nearest ones. - **Probe in app** runs the real matcher without navigating. @@ -61,11 +61,11 @@ Route config mistakes, each with a fix: - `routerLinkActive` without `ariaCurrentWhenActive`. - Emails in URLs, and return URLs taken from query params. -Each finding says whether Angular throws, warns or does not warn. Lazy routes that have not loaded are skipped. Click **Check again** to rerun it. +Each finding says whether Angular throws, warns or does not warn. The lint skips lazy routes that have not loaded. Click **Check again** to rerun it. ### Source route config -The routes declared in your files: `*.routes.ts` and `*routing.module.ts` files, the files they lazy load, and Analog pages. Each row shows the path, the component or target, guards and resolvers, the title and the declaring file. Once the live config is available, this table is collapsed. **Show table** opens it. +The routes declared in your files: `*.routes.ts` and `*routing.module.ts` files, the files they lazy load, and Analog pages. Each row shows the path, the component or target, guards and resolvers, the title and the declaring file. Once the live config is available, the tab collapses this table. **Show table** opens it. Components rendered by the router show their route and outlet in the [Components tab](/inspectors/components). @@ -86,13 +86,13 @@ The overlay reads the helper `provideRouter()` publishes (`ng.ɵgetRouterInstanc ### Development builds -The live views need `window.ng`, so they need a development build. In a production build no Router is found, and **Current** says **This page reports no Router**. +The live views need `window.ng`, so they need a development build. In a production build the overlay finds no Router, and **Current** says **This page reports no Router**. When the debug API exists but lacks the provider helpers, the tab runs in events-only mode. The **Setup** view says so, and the config, lint and actions are limited. ### Guard verdicts -The router reports one result for all the guards of a navigation. To see each guard's verdict and time, the devtools wrap every guard and resolver in the live config. The result reads like `authGuard returned UrlTree /login`. +The router reports one result for all the guards of a navigation. To see each guard's verdict and time, the devtools wrap every guard and resolver in the live config. Each row shows the guard, the route, its result (such as `UrlTree /login`) and its time. Without that recording, the guards listed for a navigation are candidates: the `canDeactivate` guards of the page being left, and the `canActivate` and `canActivateChild` guards of the target. @@ -162,25 +162,25 @@ Without that recording, the guards listed for a navigation are candidates: the ` ## Limits and gotchas -<ngmd-callout type="info" title="Guard recording is on by default"> - <strong>Record each guard and resolver</strong> in the Navigations view starts checked. Uncheck it to stop. Turning it off puts every original guard and resolver back. The choice is kept per browser tab, in <code>sessionStorage</code>, so it survives a reload. Agents use <code>navigate</code> with <code>action: "instrument"</code> and <code>on</code>. -</ngmd-callout> +### Guard recording is on by default -<ngmd-callout type="warning" title="Abort and probe need Angular 20"> - Aborting and probing use router APIs that older versions lack. On those versions the action returns an error. -</ngmd-callout> +**Record each guard and resolver** in the **Navigations** view starts checked. Uncheck it to stop. Turning it off puts every original guard and resolver back. The page keeps the choice per browser tab, in `sessionStorage`, so it survives a reload. Agents use `navigate` with `action: "instrument"` and `on`. -<ngmd-callout type="info" title="Navigations before the devtools connected"> - Only the last one is listed, marked <strong>before DevTools connected</strong>, without timing or guard details. -</ngmd-callout> +### Abort and probe need Angular 20.2 -<ngmd-callout type="tip" title="Redaction"> - Query, matrix and fragment values with secret-looking keys are replaced with <code>[redacted]</code>. So are tokens, <code>Bearer</code> values, and route params with secret-looking names such as <code>:token</code>. A navigation with a redacted URL cannot be replayed. See <a href="/security">Security</a>. -</ngmd-callout> +Aborting and probing use the `currentNavigation` signal and `Navigation.abort()`, which older versions lack. On those versions the action returns an error. -<ngmd-alert severity="helpful"> - The page keeps the last 50 navigations and 50 preloads. The live config stops at 1000 routes. -</ngmd-alert> +### Navigations before the devtools connected + +The tab lists only the last one, marked **before DevTools connected**, without timing or guard details. It also lists a navigation still running at that moment. + +### Redaction + +The devtools replace query, matrix and fragment values with secret-looking keys with `[redacted]`. They also redact tokens, `Bearer` values, and route params with secret-looking names such as `:token`. You can't replay a navigation with a redacted URL. See [what the devtools redact](/security). + +### History and config caps + +The page keeps the last 50 navigations and 50 preloads. The live config stops at 1000 routes. ## FAQ @@ -196,7 +196,7 @@ Without that recording, the guards listed for a navigation are candidates: the ` </ngmd-accordion-item> </ngmd-accordion> -## Related pages +## Where to next <ngmd-card-grid columns="2"> <ngmd-card icon="layers" title="Components" link="/inspectors/components" cta="Open"> diff --git a/apps/docs/src/content/inspectors/signals.md b/apps/docs/src/content/inspectors/signals.md index 064f6f5..b4e8268 100644 --- a/apps/docs/src/content/inspectors/signals.md +++ b/apps/docs/src/content/inspectors/signals.md @@ -18,8 +18,8 @@ The Signals tab shows the reactive graph of one component at a time. Only signal The **Component** picker at the top selects whose graph you see. It appears when a live component tree exists. - **Follow the routed component** is the default. It shows the deepest component rendered by a primary `<router-outlet>`. -- Pick any live component to pin the graph to it. Duplicates are numbered, for example `#2`. -- Without a routed component, the tab shows the first component that has signals. +- Pick any live component to pin the graph to it. The picker numbers duplicates, for example `#2`. +- Without a routed component, the tab shows the first component that has signals, among the first 50 on the page. A line under the picker names the component, its host path, and why it was chosen: **picked**, **rendered by the router** or **first component with signals**. @@ -27,7 +27,7 @@ A line under the picker names the component, its host path, and why it was chose Filter by name, or by kind with the chips. Each card shows: -- Its kind (`signal`, `computed`, `linkedSignal` or `effect`) and label. Nodes without a name show **(unnamed)**. +- Its kind and label. Kinds come from Angular, such as `signal`, `computed`, `linkedSignal`, `effect` and `template`. Nodes without a name show **(unnamed)**. - The current value. - The epoch, and the number of dependencies and consumers. - A **N changes** badge once the value has changed. @@ -42,17 +42,17 @@ Expand a card to see: ### Value history -| Tag | Meaning | -| ------- | ------------------------------------------- | -| set | The value was written. This entry is exact. | -| sampled | The overlay saw a new value while polling. | -| initial | The first value the overlay saw. | +| Tag | Meaning | +| ----------- | ---------------------------------------------- | +| **set** | A write set the value. This entry is exact. | +| **sampled** | The overlay saw a changed value while polling. | +| **initial** | The first value the overlay saw. | -When values change faster than the overlay polls, an entry says how many earlier values were not captured. Effects have no history. +When values change faster than the overlay polls, an entry says how many earlier values were not captured. Only `signal`, `computed` and `linkedSignal` nodes have a history. ### Source mode -Without a live graph, the tab lists `signal()`, `computed()`, `linkedSignal()`, `effect()`, `toSignal()` and resource declarations found in your files. It also lists signal inputs, models and queries. Each card shows the file, line and component. +Without a live graph, the tab lists `signal()`, `computed()`, `linkedSignal()`, `effect()`, `toSignal()` and resource declarations found in your files. It also lists signal inputs, models and queries. Each card shows the file and line, and the component when the scan finds one. ## Where the data comes from @@ -71,7 +71,7 @@ The live graph reads `ng.ɵgetSignalGraph` with the component's injector, from ` ### Exact and sampled values -Exact **set** entries come from a hook on signal writes. A write is matched to a node by its label, so only signals with a `debugName` get exact entries. Everything else is **sampled** on each poll. +Exact **set** entries come from a hook on signal writes. The overlay matches a write to a node by its label, so only signals with a `debugName` get exact entries. It samples everything else on each poll, as **sampled** entries. <ngmd-callout type="tip" title="Name your signals"> Pass a <code>debugName</code> to <code>signal()</code> to get exact history entries and a readable label on the card. @@ -124,23 +124,23 @@ The `ng-devtools:highlight` tool also switches the graph to the component it hig ## Limits and gotchas -<ngmd-callout type="warning" title="Unread signals are missing"> - Signals join the graph when a template or an effect reads them. If a signal is missing, check that something reads it. -</ngmd-callout> +### Unread signals are missing -<ngmd-callout type="info" title="Caps"> - The graph shows up to 400 nodes. Extra nodes are dropped without a notice. The history keeps 50 changes per signal. -</ngmd-callout> +Signals join the graph when a template or an effect reads them. If a signal is missing, check that something reads it. + +### Graph and history caps + +The graph shows up to 400 nodes, and drops extra nodes without a notice. The history keeps 50 changes per signal. + +### Picked component is gone -<ngmd-alert severity="helpful"> - A notice appears when the picked component is gone or has no graph. The tab then shows another one. -</ngmd-alert> +When the picked component is gone or has no graph, a notice appears and the tab shows another one. ## FAQ <ngmd-accordion> <ngmd-accordion-item title="Why does the graph show a different component than I expected?"> - The default follows the deepest component in the primary router outlet. Named outlets are skipped. Pick the component yourself to pin it. + The default follows the deepest component in the primary router outlet. It skips named outlets. Pick the component yourself to pin it. </ngmd-accordion-item> <ngmd-accordion-item title="Why are all my history entries sampled?"> Exact entries need a <code>debugName</code> on the signal. Without one, the overlay samples values on each poll. @@ -150,7 +150,7 @@ The `ng-devtools:highlight` tool also switches the graph to the component it hig </ngmd-accordion-item> </ngmd-accordion> -## Related pages +## Where to next <ngmd-card-grid columns="2"> <ngmd-card icon="layers" title="Components" link="/inspectors/components" cta="Open"> diff --git a/apps/docs/src/content/inspectors/ssr-http.md b/apps/docs/src/content/inspectors/ssr-http.md index 1bdf9e1..5c55489 100644 --- a/apps/docs/src/content/inspectors/ssr-http.md +++ b/apps/docs/src/content/inspectors/ssr-http.md @@ -15,15 +15,16 @@ The SSR & HTTP tab shows the HTTP calls your app makes during server rendering a The timeline and fault rules need the interceptor. The hydration warnings need the provider. Add both to the app config, with `withNgDevtools()` before your own interceptors: -```ts {8-9} -// app.config.ts +```ts {9-10} +// src/app/app.config.ts import {ApplicationConfig} from '@angular/core'; import {provideHttpClient, withFetch, withInterceptors} from '@angular/common/http'; import {provideNgDevtoolsHttp, withNgDevtools} from '@santoshyadavdev/ng-devtools/http'; +import {authInterceptor} from './auth.interceptor'; export const appConfig: ApplicationConfig = { providers: [ - provideHttpClient(withFetch(), withNgDevtools(), withInterceptors([auth])), + provideHttpClient(withFetch(), withNgDevtools(), withInterceptors([authInterceptor])), provideNgDevtoolsHttp(), ], }; @@ -67,7 +68,7 @@ A status of 400 or more fails the request with an `HttpErrorResponse`. A lower s ### TransferState payload -Each entry in the page's `{APP_ID}-state` script, with its size. HttpClient and Analog cache entries are decoded to status, URL and body. `__nghData__` and `__nghDeferData__` are labelled as hydration annotations. +Each entry in the page's `{APP_ID}-state` script, with its size. The tab decodes HttpClient and Analog cache entries to status, URL and body. It labels `__nghData__` and `__nghDeferData__` as hydration annotations. ## Where the data comes from @@ -79,7 +80,7 @@ Each entry in the page's `{APP_ID}-state` script, with its size. HttpClient and The overlay reports client calls, hydration stats and the payload. </ngmd-card> <ngmd-card icon="settings" title="Rules"> - Fault rules live on the devtools server and are sent to every page. + Fault rules live on the devtools server, which sends them to every page. </ngmd-card> </ngmd-card-grid> @@ -154,25 +155,25 @@ Two router tools cover related ground: ## Limits and gotchas -<ngmd-callout type="warning" title="Prerendered routes make no requests"> - Routes prerendered at build time make no requests at runtime and ignore SSR rules. Use <code>RenderMode.Server</code> in <code>app.routes.server.ts</code> for pages you want to test this way. +<ngmd-callout type="danger" title="Nothing is redacted here"> + The devtools send response previews and TransferState values to the devtools server as they are. Don't expose the dev server beyond localhost. See <a href="/security">Security</a>. </ngmd-callout> -<ngmd-callout type="info" title="SSR mocks are not transferred"> - SSR mocks are not written to TransferState, so the browser requests the URL again. Apply the rule on <strong>SSR + client</strong> to mock both. -</ngmd-callout> +### Prerendered routes make no requests -<ngmd-callout type="info" title="When rules apply"> - Client rules apply right away. SSR rules apply from the next page load. Client rules are also kept in <code>sessionStorage</code>, so they apply on reload before the overlay connects. Rules live in the devtools server's memory, so a server restart clears them. -</ngmd-callout> +Routes prerendered at build time make no requests at runtime and ignore SSR rules. For pages you want to test this way, use `RenderMode.Server` in `app.routes.server.ts`. -<ngmd-callout type="danger" title="Nothing is redacted here"> - Response previews and TransferState values are sent to the devtools server as they are. Don't expose the dev server beyond localhost. See <a href="/security">Security</a>. -</ngmd-callout> +### SSR mocks are not transferred + +The devtools don't write SSR mocks to TransferState, so the browser requests the URL again. To mock both, apply the rule on **SSR + client**. + +### When rules apply + +Client rules apply right away. SSR rules apply from the next page load. The page also keeps client rules in `sessionStorage`, so they apply on reload before the overlay connects. Rules live in the devtools server's memory, so a server restart clears them. + +### Timeline and rule caps -<ngmd-alert severity="helpful"> - The timeline keeps the last 200 SSR calls and the last 200 client calls per page. You can add up to 50 fault rules. -</ngmd-alert> +The timeline keeps the last 200 SSR calls in total, and the last 200 client calls of each page. You can add up to 50 fault rules. ## FAQ @@ -184,11 +185,11 @@ Two router tools cover related ground: <code>provideNgDevtoolsHttp()</code> is missing from the app providers. </ngmd-accordion-item> <ngmd-accordion-item title="Why did my client calls disappear?"> - Client calls live in the page. A reload starts a new list. SSR calls stay until <strong>Clear timeline</strong> or a server restart. + Client calls live in the page, so a reload clears them. SSR calls stay until <strong>Clear timeline</strong> or a server restart. </ngmd-accordion-item> </ngmd-accordion> -## Related pages +## Where to next <ngmd-card-grid columns="2"> <ngmd-card icon="wrench" title="Set up SSR & HTTP" link="/guides/ssr-http" cta="Guide"> diff --git a/apps/docs/src/content/security.md b/apps/docs/src/content/security.md index bd894b1..fe29ad2 100644 --- a/apps/docs/src/content/security.md +++ b/apps/docs/src/content/security.md @@ -40,15 +40,19 @@ The devtools only answer requests from this machine. When a request carries an ` In detail, a request to the devtools must: -- come from a loopback address (`127.0.0.1` or `::1`), and +- come from a loopback address (any `127.x.x.x` address or `::1`), and - have no `Origin` header, or an origin that is a loopback host, a Chrome extension, an entry in `allowedOrigins`, or a host that Vite's `server.allowedHosts` accepts. Other requests get `403` with the message "ng-devtools only answers requests from this machine." WebSocket upgrades follow the same rules. -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 `allowedOrigins`: +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. Add other origins with `allowedOrigins`: -```ts {3-4} +```ts {7-8} // vite.config.ts +import analog from '@analogjs/platform'; +import ngDevtools from '@santoshyadavdev/ng-devtools/vite'; +import {defineConfig} from 'vite'; + export default defineConfig({ server: {allowedHosts: ['myapp.test']}, plugins: [analog(), ngDevtools({allowedOrigins: ['https://tunnel.example']})], @@ -65,13 +69,18 @@ The Vite plugin turns the one-time code off. The loopback and origin checks take `initNgDevtoolsHub()` has two checks, both on by default: -| Check | Option | What it does | -| ------------- | ---------------- | ------------------------------------------------------------------------------------ | -| One-time code | `auth` | The server prints a code. A browser can read data only after it exchanges that code. | -| Origin check | `allowedOrigins` | Only loopback origins can open the WebSocket. Pass a list to allow more origins. | +| Check | Option | What it does | +| ------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------- | +| One-time code | `auth` | The server prints a code. A browser can read data only after it exchanges that code. | +| Origin check | `allowedOrigins` | Only loopback origins, or clients that send no `Origin`, can open the WebSocket. Pass a list to allow more origins. | + +```ts {8} +// src/server.ts +import {initNgDevtoolsHub} from '@santoshyadavdev/ng-devtools/hub'; +import express from 'express'; + +const app = express(); -```ts {3} -// server.ts const devtools = initNgDevtoolsHub({ allowedOrigins: ['https://tunnel.example'], }); @@ -79,7 +88,7 @@ app.use(devtools.nodeMiddleware); ``` <ngmd-callout type="warning" title="Turning the checks off"> - Pass <code>auth: false</code> only on a machine only you use. Keep it on when you allow a tunnel origin: the origin check does not tell who is on the other end of the tunnel. <code>allowedOrigins: false</code> turns the origin check off. The demo app in this repository sets it because it runs as a public demo. Keep the check on for your own apps. + Pass <code>auth: false</code> only on a machine only you use. Keep it on when you allow a tunnel origin: the origin check does not tell who is on the other end of the tunnel. <code>allowedOrigins: false</code> turns the origin check off. The demo app in this repository sets it. Keep the check on for your own apps. </ngmd-callout> ### Standalone CLI @@ -107,7 +116,7 @@ A field's value is replaced with `[redacted]` when the field: - sits inside `.sentry-mask`, `.rr-mask`, `[data-private]` or `[data-ng-devtools="mask"]`, or - has a name that contains a secret word (password, token, card, cvv, apiKey and similar). -Those values are also removed from error messages. DevTools does not write secret fields unless you unmask them, see below. Other values are sent as they are, so keep real credentials out of forms you inspect. +Those values are also removed from error messages. The devtools don't write secret fields unless you unmask them (see [Opt fields in or out](#opt-fields-in-or-out)). Other values are sent as they are, so keep real credentials out of forms you inspect. ### Opt fields in or out @@ -124,7 +133,7 @@ window.__NG_DEVTOOLS_FORMS__ = {mask: ['iban'], unmask: ['passport']}; `[data-ng-devtools="unmask"]` opts a field back in. The `window` setting does the same by key. -Unmasking also changes what DevTools can write. A key listed in `unmask` on `window` can be written. The element marker only lifts the checks that come from the element (password type, `autocomplete` and mask markers), so a field with a secret-looking name is still not written. +Unmasking also changes what the devtools can write. A key listed in `unmask` on `window` can be written. The element marker only lifts the checks that come from the element (password type, `autocomplete` and mask markers), so a field with a secret-looking name is still not written. <ngmd-accordion> <ngmd-accordion-item title="The full list of secret words"> @@ -156,9 +165,7 @@ Server call previews and URLs are redacted: secret-looking keys in JSON bodies, ### Not redacted -<ngmd-callout type="danger" title="SSR & HTTP values are sent as they are"> - Response previews and TransferState values in the <a href="/inspectors/ssr-http">SSR & HTTP tab</a> are not redacted. They reach the devtools server unchanged, so don't expose the dev server beyond localhost. -</ngmd-callout> +Response previews and TransferState values in the [SSR & HTTP tab](/inspectors/ssr-http) are not redacted. They reach the devtools server unchanged, so don't expose the dev server beyond localhost. ## Checklist diff --git a/apps/docs/src/ngmd.config.ts b/apps/docs/src/ngmd.config.ts index 28bf9d9..af4584b 100644 --- a/apps/docs/src/ngmd.config.ts +++ b/apps/docs/src/ngmd.config.ts @@ -225,6 +225,8 @@ const config: NgmdConfig = { {label: 'Demo apps', href: '/contributing/demo-apps'}, {label: 'Build the extension', href: '/contributing/chrome-extension'}, {label: 'Publishing', href: '/contributing/publishing'}, + {label: 'Write documentation', href: '/contributing/writing-docs'}, + {label: 'Kitchen sink', href: '/contributing/kitchen-sink'}, ], }, ], From c4955fa5c89e5b9873ab076dd4870c28304bbabc Mon Sep 17 00:00:00 2001 From: Kam <erkamyaman35@gmail.com> Date: Tue, 29 Sep 2026 14:55:53 +0300 Subject: [PATCH 07/13] feat(docs): update to NgMd 0.5 Take the NgMd 0.5 framework code: accessibility fixes across every component, a scroll-driven TOC, a keyboard-friendly command palette, stricter markdown and link handling, and the github-*-default code themes. Point edit and source links at apps/docs with githubDir, and add a photo to the kitchen sink. --- apps/docs/api-gen.plugin.spec.ts | 151 ++++++++ apps/docs/api-gen.plugin.ts | 223 +++++++----- apps/docs/build-extensions.spec.ts | 69 ++++ apps/docs/build-plugins.spec.ts | 142 ++++++++ apps/docs/link-guard.plugin.ts | 44 ++- apps/docs/page-meta.plugin.ts | 22 +- apps/docs/plugin-utils.spec.ts | 75 +++- apps/docs/plugin-utils.ts | 60 +++- apps/docs/public/images/cats.jpg | Bin 0 -> 296164 bytes apps/docs/raw-md.plugin.ts | 56 ++- apps/docs/search-index.plugin.ts | 65 ++-- apps/docs/sitemap.plugin.ts | 17 +- apps/docs/src/app/app.spec.ts | 37 +- apps/docs/src/app/app.ts | 61 +++- .../src/app/components/breadcrumb.spec.ts | 62 ++++ apps/docs/src/app/components/breadcrumb.ts | 63 ++-- apps/docs/src/app/components/code-copy.ts | 44 ++- .../src/app/components/code-group.spec.ts | 64 ++++ apps/docs/src/app/components/code-group.ts | 83 ++++- .../app/components/command-palette.spec.ts | 109 ++++++ .../src/app/components/command-palette.ts | 335 +++++++++++++----- .../src/app/components/content-banners.ts | 7 +- .../docs/src/app/components/external-links.ts | 2 +- .../src/app/components/heading-anchors.ts | 24 +- apps/docs/src/app/components/llm-actions.ts | 84 ++++- .../docs/src/app/components/media-enhancer.ts | 2 +- apps/docs/src/app/components/page-footer.ts | 4 +- apps/docs/src/app/components/sidebar.spec.ts | 25 ++ apps/docs/src/app/components/sidebar.ts | 59 ++- apps/docs/src/app/components/site-footer.ts | 9 +- apps/docs/src/app/components/sponsor-list.ts | 2 +- apps/docs/src/app/components/toaster.spec.ts | 19 + apps/docs/src/app/components/toaster.ts | 49 ++- apps/docs/src/app/components/toc.spec.ts | 98 ++++- apps/docs/src/app/components/toc.ts | 105 +++--- .../app/components/version-switcher.spec.ts | 26 +- .../src/app/components/version-switcher.ts | 30 +- apps/docs/src/app/pages/[...slug].page.ts | 19 +- apps/docs/src/app/pages/index.page.ts | 85 +++-- .../route-url/route-url.service.spec.ts | 14 + .../services/route-url/route-url.service.ts | 7 +- .../services/search/algolia-provider.spec.ts | 46 +++ .../app/services/search/algolia-provider.ts | 32 +- .../src/app/services/search/escape-html.ts | 7 + .../services/search/orama-provider.spec.ts | 64 ++++ .../src/app/services/search/orama-provider.ts | 51 ++- .../services/search/search.service.spec.ts | 83 +++++ .../src/app/services/search/search.service.ts | 40 ++- apps/docs/src/app/theme.ts | 12 +- apps/docs/src/app/title-strategy.ts | 13 +- apps/docs/src/app/ui/accordion.ts | 4 +- apps/docs/src/app/ui/alert.ts | 10 +- apps/docs/src/app/ui/badge.ts | 6 +- apps/docs/src/app/ui/card-grid.ts | 2 +- apps/docs/src/app/ui/card.ts | 14 +- apps/docs/src/app/ui/code-block.ts | 6 +- apps/docs/src/app/ui/hero.ts | 4 +- apps/docs/src/app/ui/image.ts | 9 +- apps/docs/src/app/ui/pill.ts | 15 +- apps/docs/src/app/ui/tabs.ts | 32 +- apps/docs/src/app/ui/ui.spec.ts | 144 ++++++++ apps/docs/src/app/utils/heading-slug.spec.ts | 12 + apps/docs/src/app/utils/heading-slug.ts | 17 +- .../src/content/contributing/kitchen-sink.md | 6 +- .../src/content/contributing/writing-docs.md | 6 +- apps/docs/src/content/getting-started/vite.md | 2 +- apps/docs/src/marked-extensions/fences.ts | 69 ++++ apps/docs/src/marked-extensions/index.ts | 18 +- .../marked-extensions.spec.ts | 62 ++++ .../src/marked-extensions/ngmd-code-group.ts | 74 ++-- .../marked-extensions/ngmd-code-highlight.ts | 70 ++-- .../src/marked-extensions/ngmd-code-import.ts | 67 ++-- .../src/marked-extensions/ngmd-keywords.ts | 15 +- apps/docs/src/marked-extensions/runtime.ts | 1 + .../src/marked-extensions/shiki-shared.ts | 14 +- apps/docs/src/ngmd.config.ts | 7 +- apps/docs/src/styles.css | 18 +- apps/docs/src/types/api.ts | 4 + apps/docs/src/types/badge.ts | 2 +- apps/docs/vars.plugin.ts | 2 +- apps/docs/vite.config.ts | 16 +- 81 files changed, 2722 insertions(+), 806 deletions(-) create mode 100644 apps/docs/api-gen.plugin.spec.ts create mode 100644 apps/docs/build-extensions.spec.ts create mode 100644 apps/docs/build-plugins.spec.ts create mode 100644 apps/docs/public/images/cats.jpg create mode 100644 apps/docs/src/app/components/breadcrumb.spec.ts create mode 100644 apps/docs/src/app/components/code-group.spec.ts create mode 100644 apps/docs/src/app/components/command-palette.spec.ts create mode 100644 apps/docs/src/app/components/sidebar.spec.ts create mode 100644 apps/docs/src/app/components/toaster.spec.ts create mode 100644 apps/docs/src/app/services/route-url/route-url.service.spec.ts create mode 100644 apps/docs/src/app/services/search/algolia-provider.spec.ts create mode 100644 apps/docs/src/app/services/search/escape-html.ts create mode 100644 apps/docs/src/app/services/search/orama-provider.spec.ts create mode 100644 apps/docs/src/app/services/search/search.service.spec.ts create mode 100644 apps/docs/src/app/ui/ui.spec.ts create mode 100644 apps/docs/src/marked-extensions/fences.ts 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<string, string> = { + '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<T, U>( + items: readonly T[], + fn: (item: T) => U, +): U[] { + return items.map(fn); +} + +function Deco(): ClassDecorator { + return () => {}; +} + +/** A box. */ +@Deco() +export class Box<T> extends Array<T> { + 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<T, U>(\n items: readonly T[],\n fn: (item: T) => U,\n): U[]', + ); + expect(byName(list, 'Box').signature).toBe('class Box<T> extends Array<T>'); + 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 index 9f4c517..38cb331 100644 --- a/apps/docs/api-gen.plugin.ts +++ b/apps/docs/api-gen.plugin.ts @@ -1,7 +1,7 @@ import {existsSync} from 'node:fs'; -import {join, relative} from 'node:path'; -import type {Plugin} from 'vite'; -import {Project, ts} from 'ts-morph'; +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'; /** @@ -31,11 +31,18 @@ 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 { - const path = join(root, 'ngmd.api.ts'); + if (configMemo === undefined) configMemo = readConfig(); + return configMemo; + } + + function readConfig(): ApiConfig | null { + const path = configPath(); if (!existsSync(path)) return null; try { const proj = new Project({ @@ -70,13 +77,10 @@ export function apiGenPlugin(): Plugin { : undefined, skipAddingFilesFromTsConfig: true, }); - for (const pattern of config.scope) { - project.addSourceFilesAtPaths(join(root, pattern)); - } - for (const pattern of config.exclude ?? []) { - const matches = project.getSourceFiles(pattern); - for (const f of matches) project.removeSourceFile(f); - } + project.addSourceFilesAtPaths([ + ...config.scope.map((pattern) => posix.join(root, pattern)), + ...(config.exclude ?? []).map((pattern) => '!' + posix.join(root, pattern)), + ]); return project; } @@ -84,44 +88,49 @@ export function apiGenPlugin(): Plugin { if (recordsMemo) return recordsMemo; const proj = ensureProject(config); const records: SymbolRecord[] = []; + const seen = new Set<string>(); const badgeTags = new Set(config.badgesFromJsDoc ?? []); for (const sourceFile of proj.getSourceFiles()) { - const filePath = relative(root, sourceFile.getFilePath()); - const group = groupNameFor(filePath, config.groupBy ?? 'directory'); - - for (const [name, declarations] of sourceFile.getExportedDeclarations()) { - const first = declarations[0]; + for (const [exportName, declarations] of sourceFile.getExportedDeclarations()) { + const decls = declarations.filter((d) => symbolKindOf(d)); + const first = decls[0]; if (!first) continue; - const kind = symbolKindOf(first); - if (!kind) continue; - // `VariableDeclaration` nodes don't expose `getJsDocs()`; the JSDoc - // is attached to the enclosing `VariableStatement`. Resolve the - // right host so `export const` symbols pick up their description - // and `@deprecated`/`@experimental`/`@beta` badges. - const jsDocHost = jsDocHostFor(first); - const jsDoc = ( - jsDocHost && 'getJsDocs' in jsDocHost - ? (jsDocHost as {getJsDocs: () => unknown[]}).getJsDocs() - : [] - ) as Array<{ - getDescription: () => string; - getTags: () => Array<{getTagName: () => string}>; - }>; - const description = jsDoc[0]?.getDescription().trim() ?? ''; - const tags = jsDoc.flatMap((d) => d.getTags().map((t) => t.getTagName())); - const badges = tags.filter((t) => badgeTags.has(t)); - const signature = first.getText().split('\n')[0]?.trim() ?? name; + 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, + signature: signatureOf(decls), description, badges, - group, + group: groupNameFor(filePath, config.groupBy ?? 'directory', kind), }); } } @@ -136,6 +145,7 @@ export function apiGenPlugin(): Plugin { root = cfg.root; project = null; recordsMemo = null; + configMemo = undefined; }, resolveId(id) { if (id === VIRTUAL_INDEX_ID) return RESOLVED_INDEX_ID; @@ -148,67 +158,112 @@ export function apiGenPlugin(): Plugin { const records = extractRecords(config); return `export const apiIndex = ${JSON.stringify(records, null, 2)};\n`; }, - handleHotUpdate({file, server}) { - // Invalidate the project cache when any source under scope changes. - // Cheap because `Project` re-uses TypeScript's incremental machinery. - const configPath = join(root, 'ngmd.api.ts'); - if (!existsSync(configPath)) return undefined; - if (file === configPath || project?.getSourceFile(file)) { - project = null; - recordsMemo = null; - // Clearing the memo isn't enough — Vite caches the virtual module's - // `load()` result, so invalidate it explicitly and return it so the - // client gets a fresh `apiIndex` without a full reload. - const mod = server.moduleGraph.getModuleById(RESOLVED_INDEX_ID); - if (mod) { - server.moduleGraph.invalidateModule(mod); - return [mod]; - } - } - return undefined; + 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 symbolKindOf(decl: unknown): SymbolKind | null { - const kindGetter = (decl as {getKindName?: () => string}).getKindName; - const kindName = typeof kindGetter === 'function' ? kindGetter.call(decl) : ''; - switch (kindName) { - case 'ClassDeclaration': - return 'class'; - case 'InterfaceDeclaration': - return 'interface'; - case 'FunctionDeclaration': - return 'function'; - case 'VariableDeclaration': - return 'const'; - case 'TypeAliasDeclaration': - return 'type'; - case 'EnumDeclaration': - return 'enum'; - default: - return null; + 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: unknown): unknown { - const node = decl as { - getKindName?: () => string; - getFirstAncestorByKind?: (kind: ts.SyntaxKind) => unknown; - }; - if (typeof node.getKindName === 'function' && node.getKindName() === 'VariableDeclaration') { - return node.getFirstAncestorByKind?.(ts.SyntaxKind.VariableStatement) ?? node; +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)); } - return node; + 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<ApiConfig['groupBy']>): string { - if (strategy === 'kind') return 'symbols'; +function groupNameFor( + filePath: string, + strategy: NonNullable<ApiConfig['groupBy']>, + kind: SymbolKind, +): string { + if (strategy === 'kind') return kind; if (strategy === 'package') { const match = filePath.match(/^packages\/([^/]+)\//); return match?.[1] ?? 'root'; 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<string> { + return (extension.hooks!.preprocess as (markdown: string) => Promise<string>)(markdown); +} + +async function renderBuild(markdown: string, extension: MarkedExtension): Promise<string> { + 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="<b>&" 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>&</button>'); + expect(html).toContain('data-active="false">active tab</button>'); + expect(html).toContain('<code class="language-bash">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</a>'); + 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<string, string>): 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<string, string> { + const out = new Map<string, string>(); + 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)', + '![logo](/logo.svg) [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) ![i](/missing.png) [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<Record<string, string>> { + 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 \`<router-outlet>\`\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 <router-outlet>'); + 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>(.*)<\/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/link-guard.plugin.ts b/apps/docs/link-guard.plugin.ts index 9741a86..d46a47f 100644 --- a/apps/docs/link-guard.plugin.ts +++ b/apps/docs/link-guard.plugin.ts @@ -1,14 +1,16 @@ -import {readFileSync, statSync} from 'node:fs'; +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'; /** @@ -34,7 +36,7 @@ function extractHeadings(markdown: string): Set<string> { const slug = createSlugger(); for (const line of markdown.split(/\r?\n/)) { if (inFence(line)) continue; - const m = /^(#{1,6})\s+(.+?)\s*$/.exec(line); + 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)); @@ -42,12 +44,21 @@ function extractHeadings(markdown: string): Set<string> { 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<string, Set<string>>(); // route → source file (relative path) const routes = new Map<string, string>(); + const dynamicRoutes: RegExp[] = []; let primed = false; let isBuild = true; @@ -73,6 +84,8 @@ export function internalLinkGuard(): Plugin { 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); @@ -93,6 +106,7 @@ export function internalLinkGuard(): Plugin { if (!id.endsWith('.md') && !id.endsWith('.page.ts')) return; primed = false; routes.clear(); + dynamicRoutes.length = 0; headingsByRoute.clear(); }, transform(_code, id) { @@ -109,11 +123,12 @@ export function internalLinkGuard(): Plugin { const validate = (href: string, label: string) => { if (!href) return; // external / mail / relative — skip - if (/^(https?:|mailto:|tel:|#)/.test(href) === false && !href.startsWith('/')) return; - if (/^(https?:|mailto:|tel:)/.test(href)) return; + if (!href.startsWith('#') && (!href.startsWith('/') || href.startsWith('//'))) return; - const [path, fragment] = href.split('#'); - if (path === '') { + 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`); @@ -121,9 +136,17 @@ export function internalLinkGuard(): Plugin { return; } - // absolute route: must be a known route + const path = decode(rawPath).replace(/(.)\/+$/, '$1'); if (!routes.has(path)) { - issues.push(` ${label} → "${path}" is not a known route`); + 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) { @@ -135,13 +158,14 @@ export function internalLinkGuard(): Plugin { } }; + const scanned = withoutCode(content); const mdLinkRe = /\[([^\]]+)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g; const htmlAnchorRe = /<a\s[^>]*href=["']([^"']+)["']/g; let m: RegExpExecArray | null; - while ((m = mdLinkRe.exec(content)) !== null) { + while ((m = mdLinkRe.exec(scanned)) !== null) { validate(m[2], `[${m[1]}](${m[2]})`); } - while ((m = htmlAnchorRe.exec(content)) !== null) { + while ((m = htmlAnchorRe.exec(scanned)) !== null) { validate(m[1], `<a href="${m[1]}">`); } diff --git a/apps/docs/page-meta.plugin.ts b/apps/docs/page-meta.plugin.ts index bb2e9c9..685941a 100644 --- a/apps/docs/page-meta.plugin.ts +++ b/apps/docs/page-meta.plugin.ts @@ -1,6 +1,6 @@ import {statSync} from 'node:fs'; import {join} from 'node:path'; -import type {Plugin} from 'vite'; +import type {Plugin, ViteDevServer} from 'vite'; import {gitDate, routeFromPagePath, walkContentFiles, walkPageFiles} from './plugin-utils.ts'; /** @@ -26,20 +26,22 @@ const RESOLVED_ID = '\0' + VIRTUAL_ID; export function pageMetaPlugin(opts: {repoUrl: string; branch?: string; dir?: string}): Plugin { const branch = opts.branch ?? 'main'; - const prefix = opts.dir ? `${opts.dir.replace(/\/+$/, '')}/` : ''; + 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; }, - /** Invalidate the virtual module when any markdown file changes so - * `lastUpdated` reflows without a full restart. */ - handleHotUpdate(ctx) { - if (!ctx.file.endsWith('.md')) return; - const mod = ctx.server.moduleGraph.getModuleById(RESOLVED_ID); - if (mod) ctx.server.moduleGraph.invalidateModule(mod); + 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; @@ -56,7 +58,7 @@ export function pageMetaPlugin(opts: {repoUrl: string; branch?: string; dir?: st const route = routeFromPagePath(rel); if (!route) continue; map[route] = { - editUrl: `${opts.repoUrl}/edit/${branch}/${prefix}${rel}`, + editUrl: `${opts.repoUrl}/edit/${branch}/${dir}${rel}`, lastUpdated: gitDate(rel, root), }; } @@ -73,7 +75,7 @@ export function pageMetaPlugin(opts: {repoUrl: string; branch?: string; dir?: st if (!date) continue; // .md edit URL wins when present (more useful for prose pages) map[route] = { - editUrl: `${opts.repoUrl}/edit/${branch}/${prefix}${rel}`, + editUrl: `${opts.repoUrl}/edit/${branch}/${dir}${rel}`, lastUpdated: date, }; } diff --git a/apps/docs/plugin-utils.spec.ts b/apps/docs/plugin-utils.spec.ts index 6f88eff..1de4f89 100644 --- a/apps/docs/plugin-utils.spec.ts +++ b/apps/docs/plugin-utils.spec.ts @@ -1,7 +1,16 @@ import {mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync} from 'node:fs'; import {tmpdir} from 'node:os'; import {join} from 'node:path'; -import {fenceTracker, resolveInside} from './plugin-utils'; +import { + fenceTracker, + isNoIndex, + pageRouteMatcher, + parseFrontmatter, + resolveInside, + routeFromPagePath, + walkContentFiles, + withoutCode, +} from './plugin-utils'; function outsideFences(markdown: string): string[] { const inFence = fenceTracker(); @@ -57,3 +66,67 @@ describe('resolveInside', () => { 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 index 7cb307a..2f499b0 100644 --- a/apps/docs/plugin-utils.ts +++ b/apps/docs/plugin-utils.ts @@ -1,6 +1,7 @@ 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'; @@ -50,7 +51,10 @@ export function walkContentFiles( 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$/, ''); + const fromContent = relative(baseDir, full) + .replace(/\\/g, '/') + .replace(/\.md$/, '') + .replace(/(^|\/)index$/, ''); out.push([rel, '/' + fromContent]); } } @@ -60,10 +64,27 @@ export function walkContentFiles( /** `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 trimmed = rel.replace(/^src\/app\/pages\//, '').replace(/\.page\.ts$/, ''); - if (trimmed === 'index') return '/'; - if (trimmed.startsWith('[')) return ''; - return '/' + trimmed; + 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)); } /** @@ -90,6 +111,26 @@ export function gitDate(file: string, cwd: string, mtimeFallback: () => string = } } +export function parseFrontmatter(text: string): { + attributes: Record<string, unknown>; + body: string; +} { + try { + const {attributes, body} = frontMatter<unknown>(text); + return { + attributes: + attributes && typeof attributes === 'object' ? (attributes as Record<string, unknown>) : {}, + body, + }; + } catch { + return {attributes: {}, body: text}; + } +} + +export function isNoIndex(attributes: Record<string, unknown>): boolean { + return /^(true|yes|1)$/i.test(String(attributes['noIndex'] ?? '')); +} + export function fenceTracker(): (line: string) => boolean { let open = ''; return (line) => { @@ -104,6 +145,15 @@ export function fenceTracker(): (line: string) => boolean { }; } +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. diff --git a/apps/docs/public/images/cats.jpg b/apps/docs/public/images/cats.jpg new file mode 100644 index 0000000000000000000000000000000000000000..90f8ab617602259b8cdbc1b5184d4cf937954862 GIT binary patch literal 296164 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 gf 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%0A3~sAh}9m;V5m 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%0Ad_zSz(@yM1Xl)8LJan^WX4nu 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 CAu!+o literal 0 HcmV?d00001 diff --git a/apps/docs/raw-md.plugin.ts b/apps/docs/raw-md.plugin.ts index 51d4829..b2d38b0 100644 --- a/apps/docs/raw-md.plugin.ts +++ b/apps/docs/raw-md.plugin.ts @@ -1,6 +1,7 @@ -import {readdirSync, readFileSync, statSync} from 'node:fs'; -import {extname, join, relative} from 'node:path'; +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'; /** @@ -26,18 +27,16 @@ export function rawMdPlugin(): Plugin { const route = rawPath.slice(0, -3).replace(/^\//, ''); if (!route) return null; if (route.includes('..')) return null; - const abs = join(root, 'src/content', `${route}.md`); - try { - const s = statSync(abs); - if (!s.isFile()) return null; - } catch { - return null; - } - try { - return substituteMdVars(readFileSync(abs, 'utf8'), root); - } catch { - 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 { @@ -70,30 +69,23 @@ export function rawMdPlugin(): Plugin { // Emit one `<route>.md` asset per markdown source so the same URL // works in production. Mirrors the dev middleware. const contentDir = join(root, 'src/content'); - - const walk = (dir: string): string[] => { - const out: string[] = []; - for (const entry of readdirSync(dir, {withFileTypes: true})) { - const full = join(dir, entry.name); - if (entry.isDirectory()) out.push(...walk(full)); - else if (entry.isFile() && entry.name.endsWith('.md')) out.push(full); - } - return out; - }; - try { statSync(contentDir); } catch { return; } - for (const file of walk(contentDir)) { - const route = relative(contentDir, file).replace(/\\/g, '/'); - const body = substituteMdVars(readFileSync(file, 'utf8'), root); - this.emitFile({ - type: 'asset', - fileName: route, - source: body, - }); + 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 index 0bbd9b7..3ff0783 100644 --- a/apps/docs/search-index.plugin.ts +++ b/apps/docs/search-index.plugin.ts @@ -1,11 +1,13 @@ import {readFileSync, statSync} from 'node:fs'; import {join} from 'node:path'; -import type {Plugin} from 'vite'; +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'; @@ -25,26 +27,6 @@ import { const VIRTUAL_ID = 'virtual:ngmd/search-index'; const RESOLVED_ID = '\0' + VIRTUAL_ID; -interface Frontmatter { - title?: string; - noIndex?: boolean; -} - -function parseFrontmatter(text: string): {fm: Frontmatter; body: string} { - const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/); - if (!match) return {fm: {}, body: text}; - const fm: Frontmatter = {}; - for (const line of match[1].split(/\r?\n/)) { - const m = line.match(/^\s*(\w+)\s*:\s*(.+?)\s*$/); - if (!m) continue; - const key = m[1]; - const raw = m[2].replace(/^['"]|['"]$/g, ''); - if (key === 'title') fm.title = raw; - if (key === 'noIndex') fm.noIndex = /^(true|yes|1)$/i.test(raw); - } - return {fm, body: match[2]}; -} - const ENTITIES: Record<string, string> = { '<': '<', '>': '>', @@ -58,13 +40,17 @@ const ENTITIES: Record<string, string> = { /** Strip markdown syntax so search hits show clean prose, not markup. */ function stripMarkdown(s: string): string { + const code: string[] = []; return s - .replace(/```[\s\S]*?```/g, ' ') - .replace(/`([^`]+)`/g, '$1') + .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, '$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(); } @@ -78,7 +64,8 @@ function splitSections(body: string): Array<{heading: string; body: string}> { let current: {heading: string; body: string} = {heading: '', body: ''}; const inFence = fenceTracker(); for (const line of lines) { - const m = !inFence(line) && line.match(/^(##+)\s+(.+?)\s*$/); + 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: ''}; @@ -110,25 +97,27 @@ function chunkBody(body: string, target = 280): string[] { } const piece = body.slice(i, end).trim(); if (piece) chunks.push(piece); - i = end < body.length ? end + 1 : end; + 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; }, - /** Markdown edits invalidate the virtual module so HMR rebuilds the - * index without a server restart. */ - handleHotUpdate(ctx) { - if (!ctx.file.endsWith('.md')) return; - const mod = ctx.server.moduleGraph.getModuleById(RESOLVED_ID); - if (mod) ctx.server.moduleGraph.invalidateModule(mod); + 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; @@ -151,12 +140,14 @@ export function searchIndexPlugin(): Plugin { } catch { continue; } - const {fm, body} = parseFrontmatter(raw); - if (fm.noIndex) continue; + const {attributes, body} = parseFrontmatter(raw); + if (isNoIndex(attributes)) continue; const slug = url.split('/').pop() || ''; const pageTitle = - fm.title ?? slug.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); + 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 @@ -181,7 +172,9 @@ export function searchIndexPlugin(): Plugin { const anchorFor = createSlugger(); for (const section of splitSections(body)) { if (section.heading) { - const headingText = stripMarkdown(headingTextOf(section.heading)); + const headingText = stripMarkdown( + section.heading.replace(/<ngmd-badge\b[^>]*>[\s\S]*?<\/ngmd-badge>/g, ''), + ); const anchor = anchorFor(headingTextOf(section.heading)); docs.push({ id: `section:${url}#${anchor}`, diff --git a/apps/docs/sitemap.plugin.ts b/apps/docs/sitemap.plugin.ts index 5e2743a..674ab5e 100644 --- a/apps/docs/sitemap.plugin.ts +++ b/apps/docs/sitemap.plugin.ts @@ -1,7 +1,14 @@ import {readFileSync, statSync} from 'node:fs'; import {join} from 'node:path'; import type {Plugin} from 'vite'; -import {gitDate, routeFromPagePath, walkContentFiles, walkPageFiles} from './plugin-utils.ts'; +import { + gitDate, + isNoIndex, + parseFrontmatter, + routeFromPagePath, + walkContentFiles, + walkPageFiles, +} from './plugin-utils.ts'; /** * Emits `sitemap.xml` and `robots.txt` into the client build output. @@ -56,11 +63,7 @@ export function sitemapPlugin(opts: {siteUrl: string}): Plugin { try { statSync(contentDir); for (const [rel, route] of walkContentFiles(contentDir, root)) { - if ( - /^---[\s\S]*?^noIndex:\s*(true|yes|1)\s*$[\s\S]*?^---/m.test( - readFileSync(join(root, rel), 'utf8'), - ) - ) { + if (isNoIndex(parseFrontmatter(readFileSync(join(root, rel), 'utf8')).attributes)) { continue; } entries.set(route, gitDate(rel, root, today)); @@ -72,7 +75,7 @@ export function sitemapPlugin(opts: {siteUrl: string}): Plugin { const urls = [...entries.entries()] .sort(([a], [b]) => a.localeCompare(b)) .map(([route, lastmod]) => { - const loc = escapeXml(`${siteUrl}${route}`); + const loc = escapeXml(`${siteUrl}${encodeURI(route)}`); return ` <url>\n <loc>${loc}</loc>\n <lastmod>${lastmod}</lastmod>\n </url>`; }) .join('\n'); diff --git a/apps/docs/src/app/app.spec.ts b/apps/docs/src/app/app.spec.ts index eb31557..095a591 100644 --- a/apps/docs/src/app/app.spec.ts +++ b/apps/docs/src/app/app.spec.ts @@ -1,14 +1,19 @@ +import {Component} from '@angular/core'; import {TestBed} from '@angular/core/testing'; -import {provideRouter} from '@angular/router'; +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([]), provideLocationMocks()], + providers: [provideRouter([{path: '**', component: Blank}]), provideLocationMocks()], }).compileComponents(); }); @@ -17,4 +22,32 @@ describe('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<HTMLButtonElement>('button[aria-label="Open menu"]')!; + menu.click(); + await fixture.whenStable(); + const drawer = el.querySelector<HTMLElement>('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<HTMLElement>('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 index 9a0d86c..f31b42a 100644 --- a/apps/docs/src/app/app.ts +++ b/apps/docs/src/app/app.ts @@ -5,11 +5,13 @@ import { 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, @@ -41,9 +43,13 @@ 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, @@ -64,11 +70,12 @@ import {VersionSwitcher} from './components/version-switcher'; SiteFooter, Toaster, VersionSwitcher, + ContentBanners, ], template: ` <div class="min-h-screen flex flex-col"> <header - class="sticky top-0 z-30 flex items-center gap-4 border-b border-zinc-200/60 dark:border-zinc-800/60 backdrop-blur-sm px-4 py-3" + class="sticky top-0 z-30 flex items-center gap-2 sm:gap-4 border-b border-zinc-200/60 dark:border-zinc-800/60 backdrop-blur-sm px-4 py-3" > @if (showSidebar()) { <button @@ -91,7 +98,7 @@ import {VersionSwitcher} from './components/version-switcher'; </a> @if (headerNav.length > 0) { - <nav class="hidden sm:flex items-center gap-1 text-sm"> + <nav aria-label="Main" class="hidden sm:flex items-center gap-1 text-sm"> @for (item of headerNav; track item.href) { @if (isExternal(item.href)) { <a @@ -114,12 +121,12 @@ import {VersionSwitcher} from './components/version-switcher'; </nav> } - <div class="ml-auto flex items-center gap-2"> + <div class="ml-auto flex items-center gap-1 sm:gap-2"> <app-version-switcher></app-version-switcher> <button type="button" (click)="palette.toggle()" - class="hidden lg:inline-flex items-center gap-2 rounded-lg border border-zinc-200 dark:border-zinc-800 bg-zinc-50 dark:bg-zinc-900 px-3 py-1.5 text-sm text-zinc-500 hover:bg-zinc-100 dark:hover:bg-zinc-800 min-w-56" + class="hidden lg:inline-flex items-center gap-2 rounded-lg border border-zinc-200 dark:border-zinc-800 bg-zinc-50 dark:bg-zinc-900 px-3 py-1.5 text-sm text-zinc-500 dark:text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800 min-w-56" > <svg [lucideIcon]="searchIcon" class="size-4"></svg> <span class="flex-1 text-left">Search documentation...</span> @@ -144,7 +151,7 @@ import {VersionSwitcher} from './components/version-switcher'; > <svg [lucideIcon]="searchIcon" class="size-5"></svg> </button> - <span class="h-4 w-px bg-zinc-300/60 dark:bg-zinc-700/60"></span> + <span class="hidden sm:block h-4 w-px bg-zinc-300/60 dark:bg-zinc-700/60"></span> <a [href]="githubUrl" target="_blank" @@ -165,7 +172,7 @@ import {VersionSwitcher} from './components/version-switcher'; <svg ngmdDiscordIcon class="size-5"></svg> </a> } - <span class="h-4 w-px bg-zinc-300/60 dark:bg-zinc-700/60"></span> + <span class="hidden sm:block h-4 w-px bg-zinc-300/60 dark:bg-zinc-700/60"></span> <button type="button" (click)="theme.cycle()" @@ -186,6 +193,7 @@ import {VersionSwitcher} from './components/version-switcher'; <div class="flex flex-1"> @if (showSidebar()) { <aside + aria-label="Sidebar" class="ngmd-scroll-track-mini hidden lg:flex w-64 shrink-0 flex-col border-r border-zinc-200 dark:border-zinc-800 p-4 overflow-y-auto sticky top-[57px] self-start h-[calc(100vh-57px)]" > <app-sidebar /> @@ -205,7 +213,7 @@ import {VersionSwitcher} from './components/version-switcher'; ></div> <aside #drawer - (keydown.escape)="closeDrawer()" + aria-label="Documentation menu" class="lg:hidden fixed left-0 top-[57px] bottom-0 z-40 w-64 overflow-y-auto border-r border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-950 p-4 transform transition-transform duration-200 ease-out" [class.-translate-x-full]="!drawerOpen()" [class.translate-x-0]="drawerOpen()" @@ -223,9 +231,11 @@ import {VersionSwitcher} from './components/version-switcher'; } @if (showFooter()) { <app-source-actions /> + <app-content-banners class="block mx-auto max-w-3xl px-4 sm:px-8" /> } @if (showToc()) { <details + #tocDetails class="xl:hidden mx-4 sm:mx-6 mt-4 rounded-lg border border-zinc-200 dark:border-zinc-800 bg-zinc-50 dark:bg-zinc-900 group" > <summary @@ -234,7 +244,7 @@ import {VersionSwitcher} from './components/version-switcher'; On this page <span class="text-zinc-400 transition-transform group-open:rotate-180">▾</span> </summary> - <div class="px-4 pb-4"> + <div class="px-4 pb-4" (click)="tocDetails.open = false"> <app-toc [showActive]="false" /> </div> </details> @@ -251,6 +261,7 @@ import {VersionSwitcher} from './components/version-switcher'; @if (showToc()) { <aside + aria-label="On this page" class="ngmd-scroll-track-mini hidden xl:block w-56 shrink-0 border-l border-zinc-200 dark:border-zinc-800 p-6 sticky top-[57px] self-start max-h-[calc(100vh-57px)] overflow-y-auto" > <p class="mb-3 text-sm font-semibold">On this page</p> @@ -276,6 +287,7 @@ export class App implements OnInit { protected readonly layout = inject(LayoutMode); private readonly routeUrl = inject(RouteUrlService); private readonly injector = inject(Injector); + private readonly document = inject(DOCUMENT); private readonly menuButton = viewChild<ElementRef<HTMLButtonElement>>('menuButton'); private readonly drawer = viewChild<ElementRef<HTMLElement>>('drawer'); @@ -293,6 +305,19 @@ export class App implements OnInit { 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(); @@ -324,6 +349,26 @@ export class App implements OnInit { 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<HTMLElement>('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, () => { 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 index e4cd02a..77eb6f3 100644 --- a/apps/docs/src/app/components/breadcrumb.ts +++ b/apps/docs/src/app/components/breadcrumb.ts @@ -1,6 +1,7 @@ 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 { @@ -8,19 +9,14 @@ interface Crumb { href: string; } -const LABELS: Record<string, string> = { - '': 'Home', - welcome: 'Introduction', - 'getting-started': 'Getting Started', - installation: 'Installation', - 'quick-start': 'Quick Start', - introduction: 'Introduction', - about: 'About & Credits', - concepts: 'Core Concepts', - 'markdown-routes': 'Markdown Routes', - theming: 'Theming', - components: 'Components', -}; +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', @@ -28,19 +24,30 @@ const LABELS: Record<string, string> = { template: ` @if (crumbs().length > 0) { <nav - class="flex items-center gap-1.5 px-6 py-3 text-sm border-b border-zinc-200 dark:border-zinc-800" + aria-label="Breadcrumb" + class="px-6 py-3 text-sm border-b border-zinc-200 dark:border-zinc-800" > - <a routerLink="/" class="text-zinc-500 hover:text-zinc-900 dark:hover:text-zinc-50"> - <svg [lucideIcon]="home" class="size-4"></svg> - </a> - @for (crumb of crumbs(); track crumb.href; let last = $last) { - <svg [lucideIcon]="chevron" class="size-3.5 text-zinc-400"></svg> - @if (last) { - <span class="font-medium">{{ crumb.label }}</span> - } @else { - <span class="text-zinc-500">{{ crumb.label }}</span> + <ol class="flex flex-wrap items-center gap-1.5"> + <li class="flex"> + <a + routerLink="/" + aria-label="Home" + class="text-zinc-500 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-50" + > + <svg [lucideIcon]="home" class="size-4"></svg> + </a> + </li> + @for (crumb of crumbs(); track crumb.href; let last = $last) { + <li class="flex items-center gap-1.5"> + <svg [lucideIcon]="chevron" class="size-3.5 text-zinc-400"></svg> + @if (last) { + <span class="font-medium" aria-current="page">{{ crumb.label }}</span> + } @else { + <span class="text-zinc-500 dark:text-zinc-400">{{ crumb.label }}</span> + } + </li> } - } + </ol> </nav> } `, @@ -54,10 +61,10 @@ export class Breadcrumb { const segments = this.cleanUrl() .split('/') .filter((s) => s.length > 0); - return segments.map((segment, i) => ({ - label: LABELS[segment] ?? this.humanize(segment), - href: '/' + segments.slice(0, i + 1).join('/'), - })); + 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 { diff --git a/apps/docs/src/app/components/code-copy.ts b/apps/docs/src/app/components/code-copy.ts index 5560c1e..6cc209c 100644 --- a/apps/docs/src/app/components/code-copy.ts +++ b/apps/docs/src/app/components/code-copy.ts @@ -34,22 +34,16 @@ export class CodeCopy implements AfterViewInit { 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 focus:opacity-100'; - button.innerHTML = ` - <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> - <rect width="14" height="14" x="8" y="8" rx="2" ry="2"/> - <path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/> - </svg> - `; - - pre.addEventListener('mouseenter', () => (button.style.opacity = '1')); - pre.addEventListener('mouseleave', () => (button.style.opacity = '0')); + '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<typeof setTimeout> | undefined; button.addEventListener('click', async (e) => { e.stopPropagation(); const code = pre.querySelector('code')?.textContent ?? pre.textContent ?? ''; @@ -58,21 +52,25 @@ export class CodeCopy implements AfterViewInit { this.toast.error('Could not copy code.'); return; } - button.innerHTML = ` - <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> - <polyline points="20 6 9 17 4 12"/> - </svg> - `; - setTimeout(() => { - button.innerHTML = ` - <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> - <rect width="14" height="14" x="8" y="8" rx="2" ry="2"/> - <path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/> - </svg> - `; - }, 1500); + 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 = ` + <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> + <rect width="14" height="14" x="8" y="8" rx="2" ry="2"/> + <path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/> + </svg> +`; + +const CHECK_ICON = ` + <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> + <polyline points="20 6 9 17 4 12"/> + </svg> +`; 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) => + `<button type="button" class="ngmd-code-group__tab" data-target="${id}-${i}" data-active="${i === 0}">${l}</button>`, + ) + .join(''); + const panels = labels + .map( + (_, i) => + `<div class="ngmd-code-group__panel" data-id="${id}-${i}" data-active="${i === 0}"><pre>${i}</pre></div>`, + ) + .join(''); + return `<div class="ngmd-code-group" data-group="${id}"><div class="ngmd-code-group__tabs">${tabs}</div>${panels}</div>`; +} + +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<HTMLButtonElement>('[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<HTMLButtonElement>('[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 index 287db21..6465fea 100644 --- a/apps/docs/src/app/components/code-group.ts +++ b/apps/docs/src/app/components/code-group.ts @@ -34,18 +34,81 @@ export class CodeGroup implements AfterViewInit { private enhance(group: HTMLElement): void { group.setAttribute('data-enhanced', 'true'); - const tabs = group.querySelectorAll<HTMLButtonElement>('.ngmd-code-group__tab'); - const panels = group.querySelectorAll<HTMLElement>('.ngmd-code-group__panel'); + const tabs = [...group.querySelectorAll<HTMLButtonElement>('.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<HTMLElement>(`[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; - tabs.forEach((tab) => { tab.addEventListener('click', () => { - const target = tab.getAttribute('data-target'); - if (!target) return; - tabs.forEach((t) => t.setAttribute('data-active', t === tab ? 'true' : 'false')); - panels.forEach((p) => - p.setAttribute('data-active', p.getAttribute('data-id') === target ? 'true' : 'false'), - ); + selectTab(tab); + const label = tabLabel(tab); + try { + localStorage.setItem(STORAGE_KEY, label); + } catch {} + for (const other of document.querySelectorAll<HTMLButtonElement>( + '.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<HTMLButtonElement>('.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<HTMLElement>('.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 index 765371b..4c61765 100644 --- a/apps/docs/src/app/components/command-palette.ts +++ b/apps/docs/src/app/components/command-palette.ts @@ -1,18 +1,19 @@ import { Component, ElementRef, - HostListener, computed, effect, inject, signal, + untracked, viewChild, } from '@angular/core'; -import {NgTemplateOutlet} from '@angular/common'; +import {NgTemplateOutlet, ViewportScroller} from '@angular/common'; import {Router} from '@angular/router'; import { LucideDynamicIcon, LucideArrowRight, + LucideBraces, LucideClock, LucideFileText, LucideHash, @@ -22,21 +23,23 @@ import { LucideX, } from '@lucide/angular'; import type {SearchHit} from '../../types/search'; -import {SearchService} from '../services/search/search.service'; +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. Typing kicks the - * service (debounced) and renders highlighted hits. Hover highlights a - * row, click navigates and records the visit. Esc closes. Keyboard - * navigation (arrow + Enter) is intentionally not wired yet — planned - * for a future polish pass. + * 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()) { <div @@ -44,70 +47,92 @@ import {SearchService} from '../services/search/search.service'; (click)="close()" > <div + #dialog + role="dialog" + aria-modal="true" + aria-label="Search docs" class="w-full max-w-2xl rounded-2xl border border-zinc-200 bg-white shadow-2xl dark:border-zinc-800 dark:bg-zinc-950 overflow-hidden" (click)="$event.stopPropagation()" + (keydown)="onDialogKeydown($event)" > <div class="flex items-center gap-3 px-5 py-4"> <svg [lucideIcon]="searchIcon" class="size-6 text-zinc-400"></svg> <input #input type="text" + role="combobox" placeholder="Search docs" aria-label="Search docs" - class="flex-1 bg-transparent text-lg outline-none placeholder:text-zinc-400" + aria-autocomplete="list" + autocomplete="off" + spellcheck="false" + class="flex-1 min-w-0 bg-transparent text-lg outline-none placeholder:text-zinc-500 dark:placeholder:text-zinc-400" + [attr.aria-expanded]="expanded()" + [attr.aria-controls]="expanded() ? listboxId : null" + [attr.aria-activedescendant]="expanded() && active() >= 0 ? optionId(active()) : null" [value]="search.query()" (input)="onInput($event)" + (keydown)="onInputKeydown($event)" /> @if (search.loading()) { - <span class="text-xs text-zinc-400">…</span> + <span class="text-xs text-zinc-500 dark:text-zinc-400" aria-hidden="true">…</span> } </div> <div + tabindex="0" class="ngmd-scroll-track-mini border-t border-zinc-200 dark:border-zinc-800 max-h-[60vh] overflow-y-auto p-3" > @if (showingHistory()) { @if (search.favorites().length) { - <div class="px-4 py-2 text-xs uppercase tracking-wider text-zinc-500"> + <div + id="ngmd-search-favourites" + class="px-4 py-2 text-xs uppercase tracking-wider text-zinc-500 dark:text-zinc-400" + > Favourites </div> - @for (item of search.favorites(); track item.url) { - <ng-container - *ngTemplateOutlet="historyRow; context: {$implicit: item, favorite: true}" - /> - } + <ul aria-labelledby="ngmd-search-favourites"> + @for (item of search.favorites(); track item.url) { + <ng-container + *ngTemplateOutlet="historyRow; context: {$implicit: item, favorite: true}" + /> + } + </ul> } @if (search.recents().length) { <div - class="flex items-center justify-between px-4 py-2 text-xs uppercase tracking-wider text-zinc-500" + class="flex items-center justify-between px-4 py-2 text-xs uppercase tracking-wider text-zinc-500 dark:text-zinc-400" > - <span>Recent</span> + <span id="ngmd-search-recents">Recent</span> <button type="button" class="inline-flex items-center gap-1 hover:text-zinc-700 dark:hover:text-zinc-300" - (click)="search.clearRecents()" + (click)="clearRecents()" > <svg [lucideIcon]="trashIcon" class="size-3"></svg> Clear </button> </div> - @for (item of search.recents(); track item.url) { - <ng-container - *ngTemplateOutlet="historyRow; context: {$implicit: item, favorite: false}" - /> - } + <ul aria-labelledby="ngmd-search-recents"> + @for (item of search.recents(); track item.url) { + <ng-container + *ngTemplateOutlet="historyRow; context: {$implicit: item, favorite: false}" + /> + } + </ul> } <ng-template #historyRow let-item let-favorite="favorite"> - <div - class="group flex w-full cursor-pointer items-center gap-2 rounded-lg pr-2 text-left" + <li + class="group flex w-full items-center gap-2 rounded-lg pr-2 text-left focus-within:bg-[color:var(--accent-soft)]" [class]="hoverUrl() === item.url ? 'bg-[color:var(--accent-soft)]' : ''" (mouseenter)="hoverUrl.set(item.url)" (mouseleave)="hoverUrl.set(null)" > <button type="button" - class="flex flex-1 min-w-0 items-center gap-4 px-4 py-3 text-left" + data-history-item + class="flex flex-1 min-w-0 cursor-pointer items-center gap-4 rounded-lg px-4 py-3 text-left" (click)="selectHistory(item)" > <svg @@ -122,7 +147,7 @@ import {SearchService} from '../services/search/search.service'; ></div> @if (item.subLabelHtml) { <div - class="text-sm text-zinc-500 truncate" + class="text-sm text-zinc-600 dark:text-zinc-400 truncate" [innerHTML]="item.subLabelHtml" ></div> } @@ -131,70 +156,74 @@ import {SearchService} from '../services/search/search.service'; @if (!favorite) { <button type="button" - class="rounded p-1.5 text-zinc-400 hover:text-amber-500 opacity-0 group-hover:opacity-100 transition-opacity" + class="rounded p-1.5 text-zinc-500 dark:text-zinc-400 hover:text-amber-500 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity" aria-label="Pin to favourites" - (click)="search.toggleFavorite(item.url)" + (click)="toggleFavorite(item.url)" > <svg [lucideIcon]="starIcon" class="size-4"></svg> </button> } <button type="button" - class="rounded p-1.5 text-zinc-400 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-opacity" + class="rounded p-1.5 text-zinc-500 dark:text-zinc-400 hover:text-red-500 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity" aria-label="Remove from history" (click)="removeAt(item.url)" > <svg [lucideIcon]="closeIcon" class="size-4"></svg> </button> - </div> + </li> </ng-template> } @else if (search.loading() && !search.results().length) { - <div class="p-3 text-zinc-500"> + <div class="p-3 text-zinc-500 dark:text-zinc-400"> <span>Searching docs…</span> </div> } @else if (search.hasNoResults()) { - <div class="p-3 text-zinc-500"> + <div class="p-3 text-zinc-500 dark:text-zinc-400"> <span>No results found</span> </div> } @else if (search.results().length) { - @for (item of search.results(); track item.id; let i = $index) { - <button - type="button" - class="flex w-full cursor-pointer items-start gap-4 rounded-lg px-4 py-3 text-left" - [class]="i === active() ? 'bg-[color:var(--accent-soft)]' : ''" - (mouseenter)="active.set(i)" - (click)="select(item)" - > - <svg [lucideIcon]="iconFor(item)" class="mt-0.5 size-5 text-zinc-400"></svg> - <div class="flex-1 min-w-0"> - <div - class="text-base font-semibold truncate" - [innerHTML]="item.labelHtml" - ></div> - @if (item.subLabelHtml) { - <div - class="text-sm text-zinc-500 truncate" - [innerHTML]="item.subLabelHtml" - ></div> - } - @if (item.contentHtml) { + <div role="listbox" aria-label="Search results" [id]="listboxId"> + @for (item of search.results(); track item.id; let i = $index) { + <div + role="option" + class="flex w-full cursor-pointer items-start gap-4 rounded-lg px-4 py-3 text-left" + [id]="optionId(i)" + [class]="i === active() ? 'bg-[color:var(--accent-soft)]' : ''" + [attr.aria-selected]="i === active()" + (mousemove)="active.set(i)" + (click)="select(item)" + > + <svg [lucideIcon]="iconFor(item)" class="mt-0.5 size-5 text-zinc-400"></svg> + <div class="flex-1 min-w-0"> <div - class="mt-1 text-sm text-zinc-500 line-clamp-2" - [innerHTML]="item.contentHtml" + class="text-base font-semibold truncate" + [innerHTML]="item.labelHtml" ></div> - } + @if (item.subLabelHtml) { + <div + class="text-sm text-zinc-600 dark:text-zinc-400 truncate" + [innerHTML]="item.subLabelHtml" + ></div> + } + @if (item.contentHtml) { + <div + class="mt-1 text-sm text-zinc-600 dark:text-zinc-400 line-clamp-2" + [innerHTML]="item.contentHtml" + ></div> + } + </div> </div> - </button> - } + } + </div> } @else if (!search.query().trim() && !search.history().length) { - <div class="p-3 text-zinc-500"> + <div class="p-3 text-zinc-500 dark:text-zinc-400"> <span>Start typing to see results</span> </div> } </div> - + <div role="status" class="sr-only">{{ status() }}</div> <div - class="flex items-center justify-between border-t border-zinc-200 dark:border-zinc-800 px-4 py-2 text-xs text-zinc-500" + class="flex items-center justify-between border-t border-zinc-200 dark:border-zinc-800 px-4 py-2 text-xs text-zinc-500 dark:text-zinc-400" > <span class="flex items-center gap-3"> <kbd class="rounded border border-zinc-200 dark:border-zinc-700 px-1.5">esc</kbd> @@ -279,21 +308,24 @@ import {SearchService} from '../services/search/search.service'; }) export class CommandPalette { private readonly router = inject(Router); + private readonly scroller = inject(ViewportScroller); protected readonly search = inject(SearchService); private readonly input = viewChild<ElementRef<HTMLInputElement>>('input'); + private readonly dialog = viewChild<ElementRef<HTMLElement>>('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); - /** Mouse-hover highlight only. Arrow-key keyboard nav is intentionally - * not wired yet; the focus/scroll polish wasn't worth shipping rough. */ + /** 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 @@ -302,20 +334,35 @@ export class CommandPalette { * as two lists with independent indices. */ readonly hoverUrl = signal<string | null>(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' : ''; }); - // Clear hover highlight whenever the visible list changes. effect(() => { - this.search.results(); - this.search.history(); - this.active.set(-1); + 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 @@ -323,21 +370,23 @@ export class CommandPalette { effect(() => { const tick = this.search.openTick(); if (tick === 0) return; - this.open.set(true); - this.active.set(-1); - queueMicrotask(() => this.input()?.nativeElement.focus()); + 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; } - @HostListener('document:keydown', ['$event']) onKeydown(event: KeyboardEvent) { - if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') { + if ((event.metaKey || event.ctrlKey) && event.key?.toLowerCase() === 'k') { event.preventDefault(); this.toggle(); return; @@ -349,28 +398,87 @@ export class CommandPalette { } toggle() { - this.open.update((v) => !v); if (this.open()) { - this.search.query.set(''); - this.active.set(-1); - queueMicrotask(() => this.input()?.nativeElement.focus()); + 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: {id: string; url: string; labelHtml: string; subLabelHtml: string}) { + selectHistory(item: HistoryItem) { // Re-record so a re-visited recent moves to the top of the list. this.search.recordVisit({ id: item.id, @@ -382,38 +490,77 @@ export class CommandPalette { 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<HTMLElement>('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(); - const [path, hash] = url.split('#'); - const samePath = this.router.url.split('#')[0].split('?')[0] === path; - if (samePath) { - // Already on the target route. Skip the router round-trip and just - // scroll, otherwise Angular short-circuits and nothing happens. - if (hash) this.scrollToWhenReady(hash); - else window.scrollTo({top: 0, behavior: 'smooth'}); - return; - } - this.router.navigateByUrl(path).then(() => { - if (hash) this.scrollToWhenReady(hash); + 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; - const el = document.getElementById(slug); - if (!el) { + if (!document.getElementById(slug)) { setTimeout(() => this.scrollToWhenReady(slug, attempt + 1), 50); return; } - el.scrollIntoView({behavior: 'smooth', block: 'start'}); - history.replaceState(null, '', `${location.pathname}#${slug}`); + 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 index c6d55fc..529cb0b 100644 --- a/apps/docs/src/app/components/content-banners.ts +++ b/apps/docs/src/app/components/content-banners.ts @@ -25,7 +25,7 @@ import {VersionService} from '../services/version/version.service'; template: ` @if (banner(); as b) { <div - class="mb-6 flex items-start gap-3 rounded-lg border px-4 py-3" + class="mt-6 flex items-start gap-3 rounded-lg border px-4 py-3" [class]="b.containerClass" > <svg [lucideIcon]="b.icon" class="mt-0.5 size-5 shrink-0" [class]="b.iconClass"></svg> @@ -40,9 +40,8 @@ import {VersionService} from '../services/version/version.service'; class="inline-flex items-center gap-1 font-medium text-[color:var(--accent-strong)] underline" > {{ b.currentLabel }} - <svg [lucideIcon]="externalIcon" class="size-3"></svg> - </a> - . + <svg [lucideIcon]="externalIcon" class="size-3"></svg></a + >. </p> </div> </div> diff --git a/apps/docs/src/app/components/external-links.ts b/apps/docs/src/app/components/external-links.ts index ed07af3..11609d7 100644 --- a/apps/docs/src/app/components/external-links.ts +++ b/apps/docs/src/app/components/external-links.ts @@ -27,7 +27,7 @@ export class ExternalLinks implements AfterViewInit { (node) => { const a = node as HTMLAnchorElement; a.setAttribute('data-external-enhanced', 'true'); - if (a.href.startsWith(window.location.origin)) return; + 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 index 4f2937e..c0eb4c4 100644 --- a/apps/docs/src/app/components/heading-anchors.ts +++ b/apps/docs/src/app/components/heading-anchors.ts @@ -1,5 +1,6 @@ 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'; @@ -20,6 +21,7 @@ import {enhanceOnNavigation} from '../utils/enhance-on-navigation'; export class HeadingAnchors implements AfterViewInit { private readonly router = inject(Router); private readonly destroyRef = inject(DestroyRef); + private readonly toast = inject(ToastService); ngAfterViewInit(): void { enhanceOnNavigation( @@ -33,29 +35,33 @@ export class HeadingAnchors implements AfterViewInit { 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 ${heading.id}`); + 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-400 hover:text-[color:var(--accent)] opacity-0 transition-opacity focus:opacity-100'; + '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(); - heading.addEventListener('mouseenter', () => (button.style.opacity = '1')); - heading.addEventListener('mouseleave', () => (button.style.opacity = '0')); - // 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<typeof setTimeout> | 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))) return; + if (!(await writeToClipboard(url))) { + this.toast.error('Could not copy link.'); + return; + } + this.toast.success('Link copied to clipboard.'); button.innerHTML = this.checkIcon(); - setTimeout(() => (button.innerHTML = this.linkIcon()), 1500); + clearTimeout(resetTimer); + resetTimer = setTimeout(() => (button.innerHTML = this.linkIcon()), 1500); }); heading.appendChild(button); @@ -63,7 +69,7 @@ export class HeadingAnchors implements AfterViewInit { private linkIcon(): string { return ` - <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> + <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> <path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/> <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/> </svg> @@ -72,7 +78,7 @@ export class HeadingAnchors implements AfterViewInit { private checkIcon(): string { return ` - <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> + <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> <polyline points="20 6 9 17 4 12"/> </svg> `; diff --git a/apps/docs/src/app/components/llm-actions.ts b/apps/docs/src/app/components/llm-actions.ts index f6ddda2..bb0136c 100644 --- a/apps/docs/src/app/components/llm-actions.ts +++ b/apps/docs/src/app/components/llm-actions.ts @@ -1,4 +1,14 @@ -import {Component, DestroyRef, HostListener, computed, inject, signal} from '@angular/core'; +import { + Component, + DestroyRef, + ElementRef, + Injector, + afterNextRender, + computed, + inject, + signal, + viewChild, +} from '@angular/core'; import { LucideDynamicIcon, type LucideIcon, @@ -43,6 +53,10 @@ interface MenuItem { */ @Component({ selector: 'app-llm-actions', + host: { + '(document:click)': 'close()', + '(document:keydown.escape)': 'onEscape()', + }, imports: [LucideDynamicIcon, GithubIcon, ClaudeIcon, OpenaiIcon], template: ` @if (hasMdSource()) { @@ -64,6 +78,7 @@ interface MenuItem { <span>{{ copied() ? 'Copied!' : 'Copy Markdown' }}</span> </button> <button + #trigger type="button" (click)="toggle($event)" class="inline-flex items-center px-1.5 border-l border-zinc-200 dark:border-zinc-800 hover:bg-zinc-100 dark:hover:bg-zinc-900 transition-colors" @@ -80,8 +95,11 @@ interface MenuItem { </div> @if (open()) { <div + #menu role="menu" + aria-label="Markdown actions" (click)="$event.stopPropagation()" + (keydown)="onMenuKeydown($event)" class="absolute right-0 mt-1 z-20 w-56 rounded-lg border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-950 shadow-lg overflow-hidden text-sm" > @for (item of items(); track item.label) { @@ -92,7 +110,7 @@ interface MenuItem { target="_blank" rel="noopener noreferrer" class="flex items-center gap-2.5 px-3 py-2 text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-900" - (click)="close()" + (click)="dismiss()" > @switch (item.icon) { @case ('github') { @@ -143,6 +161,9 @@ interface MenuItem { }) export class LlmActions { private readonly toast = inject(ToastService); + private readonly injector = inject(Injector); + private readonly trigger = viewChild<ElementRef<HTMLButtonElement>>('trigger'); + private readonly menu = viewChild<ElementRef<HTMLElement>>('menu'); private readonly cleanUrl = inject(RouteUrlService).cleanUrl; private copiedTimer: ReturnType<typeof setTimeout> | null = null; @@ -176,14 +197,14 @@ export class LlmActions { * dead `.md` URL. */ protected readonly hasMdSource = computed(() => { const edit = this.editUrl(); - return !!edit && /\/src\/content\/.+\.md$/.test(edit); + return !!edit && this.cleanUrl() !== '/' && /\/src\/content\/.+\.md$/.test(edit); }); /** Permalink to the raw `.md`. Built from the current pathname + `.md`, * served by `raw-md.plugin.ts` in dev and emitted as a static asset in * production. Absolute (with origin) so LLM URLs are shareable. */ protected readonly mdUrl = computed(() => { - const path = this.cleanUrl(); + const path = this.cleanUrl().replace(/\/+$/, ''); if (typeof window === 'undefined') return `${path}.md`; return `${window.location.origin}${path}.md`; }); @@ -210,6 +231,50 @@ export class LlmActions { toggle(event: Event): void { event.stopPropagation(); this.open.update((v) => !v); + if (!this.open()) return; + afterNextRender(() => this.menuItems()[0]?.focus(), {injector: this.injector}); + } + + protected onMenuKeydown(event: KeyboardEvent): void { + const items = this.menuItems(); + const index = items.indexOf(event.target as HTMLElement); + let next: number; + switch (event.key) { + case 'ArrowDown': + next = (index + 1) % items.length; + break; + case 'ArrowUp': + next = (index - 1 + items.length) % items.length; + break; + case 'Home': + next = 0; + break; + case 'End': + next = items.length - 1; + break; + case 'Tab': + this.close(); + return; + default: + return; + } + event.preventDefault(); + items[next]?.focus(); + } + + protected onEscape(): void { + if (this.open()) this.dismiss(); + } + + protected dismiss(): void { + this.close(); + this.trigger()?.nativeElement.focus(); + } + + private menuItems(): HTMLElement[] { + return [ + ...(this.menu()?.nativeElement.querySelectorAll<HTMLElement>('[role="menuitem"]') ?? []), + ]; } /** Main split-button action: copies the markdown directly and flashes a @@ -225,6 +290,7 @@ export class LlmActions { this.toast.error('Could not copy markdown.'); return; } + this.toast.success('Markdown copied to clipboard.'); this.copied.set(true); this.clearCopiedTimer(); this.copiedTimer = setTimeout(() => this.copied.set(false), 1500); @@ -238,18 +304,10 @@ export class LlmActions { try { await fn(); } finally { - this.close(); + this.dismiss(); } } - @HostListener('document:click') onDocClick(): void { - if (this.open()) this.close(); - } - - @HostListener('document:keydown.escape') onEsc(): void { - if (this.open()) this.close(); - } - private async copyMarkdown(): Promise<boolean> { if (typeof window === 'undefined') return false; try { diff --git a/apps/docs/src/app/components/media-enhancer.ts b/apps/docs/src/app/components/media-enhancer.ts index 52b73f8..a5bccd1 100644 --- a/apps/docs/src/app/components/media-enhancer.ts +++ b/apps/docs/src/app/components/media-enhancer.ts @@ -60,7 +60,7 @@ export class MediaEnhancer implements AfterViewInit { // Replace the placeholder with an actual <figure> const figure = document.createElement('figure'); figure.className = 'ngmd-image'; - if (width) figure.style.maxWidth = width; + if (width) figure.style.maxWidth = /^\d+(\.\d+)?$/.test(width) ? `${width}px` : width; const img = document.createElement('img'); img.src = src; diff --git a/apps/docs/src/app/components/page-footer.ts b/apps/docs/src/app/components/page-footer.ts index d6f8c7a..050159d 100644 --- a/apps/docs/src/app/components/page-footer.ts +++ b/apps/docs/src/app/components/page-footer.ts @@ -21,7 +21,7 @@ const RELATED_MAX = 4; <footer class="mt-2 border-t border-zinc-200 dark:border-zinc-800 pt-5 pb-10 text-sm"> @if (related().length && currentSection(); as section) { <p - class="text-xs font-medium tracking-[0.2em] uppercase text-zinc-400 dark:text-zinc-500 mb-3" + class="text-xs font-medium tracking-[0.2em] uppercase text-zinc-500 dark:text-zinc-400 mb-3" > More in {{ section }} </p> @@ -40,7 +40,7 @@ const RELATED_MAX = 4; } @if (prev() || next()) { - <nav class="grid gap-3 sm:grid-cols-2"> + <nav aria-label="Previous and next pages" class="grid gap-3 sm:grid-cols-2"> @if (prev(); as p) { <a [routerLink]="p.href" diff --git a/apps/docs/src/app/components/sidebar.spec.ts b/apps/docs/src/app/components/sidebar.spec.ts new file mode 100644 index 0000000..5fce658 --- /dev/null +++ b/apps/docs/src/app/components/sidebar.spec.ts @@ -0,0 +1,25 @@ +import {TestBed} from '@angular/core/testing'; +import config from '../../ngmd.config'; +import {SidebarState} from './sidebar'; + +const section = config.nav[0]; + +describe('SidebarState', () => { + it('starts with every section open and toggles one at a time', () => { + const state = TestBed.inject(SidebarState); + expect(config.nav.every((s) => state.openSections().has(s.label))).toBe(true); + state.toggle(section.label); + expect(state.openSections().has(section.label)).toBe(false); + state.toggle(section.label); + expect(state.openSections().has(section.label)).toBe(true); + }); + + it('reopens the section that holds the current page', () => { + const state = TestBed.inject(SidebarState); + state.toggle(section.label); + state.reveal('/zz-not-in-nav'); + expect(state.openSections().has(section.label)).toBe(false); + state.reveal(section.items[0].href); + expect(state.openSections().has(section.label)).toBe(true); + }); +}); diff --git a/apps/docs/src/app/components/sidebar.ts b/apps/docs/src/app/components/sidebar.ts index 6e62e10..1c8b180 100644 --- a/apps/docs/src/app/components/sidebar.ts +++ b/apps/docs/src/app/components/sidebar.ts @@ -1,15 +1,46 @@ -import {AfterViewInit, Component, DestroyRef, ElementRef, inject, signal} from '@angular/core'; +import { + AfterViewInit, + Component, + DestroyRef, + ElementRef, + Injectable, + Injector, + afterNextRender, + inject, + signal, +} from '@angular/core'; import {Router, RouterLink, RouterLinkActive} from '@angular/router'; import {LucideDynamicIcon, LucideChevronDown} from '@lucide/angular'; import config from '../../ngmd.config'; import {BADGE_VARIANTS, type BadgeVariant} from '../../types/badge'; import {onNavigation} from '../utils/enhance-on-navigation'; +import {RouteUrlService} from '../services/route-url/route-url.service'; + +@Injectable({providedIn: 'root'}) +export class SidebarState { + readonly openSections = signal<ReadonlySet<string>>(new Set(config.nav.map((s) => s.label))); + + toggle(label: string): void { + this.openSections.update((set) => { + const next = new Set(set); + if (next.has(label)) next.delete(label); + else next.add(label); + return next; + }); + } + + reveal(href: string): void { + const section = config.nav.find((s) => s.items.some((i) => i.href === href)); + if (!section || this.openSections().has(section.label)) return; + this.toggle(section.label); + } +} @Component({ selector: 'app-sidebar', imports: [RouterLink, RouterLinkActive, LucideDynamicIcon], template: ` - <nav class="flex flex-col gap-4 text-sm"> + <nav aria-label="Documentation" class="flex flex-col gap-4 text-sm"> @for (section of sections; track section.label) { <div> <button @@ -58,22 +89,19 @@ export class Sidebar implements AfterViewInit { private readonly host = inject<ElementRef<HTMLElement>>(ElementRef); private readonly router = inject(Router); private readonly destroyRef = inject(DestroyRef); + private readonly injector = inject(Injector); + private readonly state = inject(SidebarState); + private readonly cleanUrl = inject(RouteUrlService).cleanUrl; readonly sections = config.nav; readonly chevron = LucideChevronDown; - private readonly openSections = signal<Set<string>>(new Set(config.nav.map((s) => s.label))); isOpen(label: string): boolean { - return this.openSections().has(label); + return this.state.openSections().has(label); } toggle(label: string): void { - this.openSections.update((set) => { - const next = new Set(set); - if (next.has(label)) next.delete(label); - else next.add(label); - return next; - }); + this.state.toggle(label); } statusClass(status: BadgeVariant): string { @@ -86,15 +114,16 @@ export class Sidebar implements AfterViewInit { // the current page well below the fold) and on long Stack sections // in the desktop sidebar. this.scrollActiveIntoView(); - onNavigation(this.router, this.destroyRef, () => { - // `routerLinkActive` updates synchronously on NavigationEnd, so the - // class is already on the link by the time we read it. - this.scrollActiveIntoView(); - }); + onNavigation(this.router, this.destroyRef, () => this.scrollActiveIntoView()); } private scrollActiveIntoView(): void { if (typeof document === 'undefined') return; + this.state.reveal(this.cleanUrl()); + afterNextRender(() => this.scrollToActive(), {injector: this.injector}); + } + + private scrollToActive(): void { const active = this.host.nativeElement.querySelector<HTMLElement>('a[aria-current="page"]'); active?.scrollIntoView({block: 'nearest', behavior: 'instant'}); } diff --git a/apps/docs/src/app/components/site-footer.ts b/apps/docs/src/app/components/site-footer.ts index dcb9bc9..b32de58 100644 --- a/apps/docs/src/app/components/site-footer.ts +++ b/apps/docs/src/app/components/site-footer.ts @@ -39,10 +39,9 @@ import siteConfig from '../../ngmd.config'; target="_blank" rel="noopener noreferrer" class="inline-flex items-center gap-1.5 hover:text-zinc-700 dark:hover:text-zinc-300" - aria-label="GitHub repository" > <svg ngmdGithubIcon class="size-4"></svg> - {{ repo }} + {{ repoLabel }} </a> </nav> </div> @@ -50,10 +49,10 @@ import siteConfig from '../../ngmd.config'; `, }) export class SiteFooter { - readonly name = siteConfig.site.name; - readonly githubUrl = siteConfig.site.githubUrl; readonly year = new Date().getFullYear(); - readonly repo = new URL(siteConfig.site.githubUrl).pathname.replace(/^\/+/, ''); + readonly name = siteConfig.site.name; readonly sponsorUrl = siteConfig.site.links?.sponsor; readonly discordUrl = siteConfig.site.links?.discord; + readonly githubUrl = siteConfig.site.githubUrl; + readonly repoLabel = this.githubUrl.replace(/^https?:\/\/(www\.)?github\.com\//, ''); } diff --git a/apps/docs/src/app/components/sponsor-list.ts b/apps/docs/src/app/components/sponsor-list.ts index 7feb455..20f7edb 100644 --- a/apps/docs/src/app/components/sponsor-list.ts +++ b/apps/docs/src/app/components/sponsor-list.ts @@ -18,7 +18,7 @@ import siteConfig from '../../ngmd.config'; [src]="'https://github.com/' + sponsor.login + '.png?size=' + size() * 2" [width]="size()" [height]="size()" - [alt]="sponsor.name" + [alt]="showNames() ? '' : sponsor.name" loading="lazy" class="rounded-full border border-zinc-200 dark:border-zinc-800" /> diff --git a/apps/docs/src/app/components/toaster.spec.ts b/apps/docs/src/app/components/toaster.spec.ts new file mode 100644 index 0000000..1c203ae --- /dev/null +++ b/apps/docs/src/app/components/toaster.spec.ts @@ -0,0 +1,19 @@ +import {TestBed} from '@angular/core/testing'; +import {ToastService} from '../services/toast/toast.service'; +import {Toaster} from './toaster'; + +describe('Toaster', () => { + it('keeps its live region mounted so the first toast is announced', async () => { + TestBed.configureTestingModule({imports: [Toaster]}); + const fixture = TestBed.createComponent(Toaster); + await fixture.whenStable(); + const region = fixture.nativeElement.querySelector('[aria-live="polite"]'); + expect(region).not.toBeNull(); + expect(region.textContent.trim()).toBe(''); + + TestBed.inject(ToastService).success('Copied.'); + await fixture.whenStable(); + expect(fixture.nativeElement.querySelector('[aria-live="polite"]')).toBe(region); + expect(region.textContent).toContain('Copied.'); + }); +}); diff --git a/apps/docs/src/app/components/toaster.ts b/apps/docs/src/app/components/toaster.ts index 1d87a3c..d973bcb 100644 --- a/apps/docs/src/app/components/toaster.ts +++ b/apps/docs/src/app/components/toaster.ts @@ -25,34 +25,29 @@ const EXIT_MS = 220; selector: 'app-toaster', imports: [LucideDynamicIcon], template: ` - @if (toasts().length) { - <div - class="fixed top-4 right-4 z-50 flex flex-col gap-2 max-w-sm w-[min(90vw,24rem)] pointer-events-none" - aria-live="polite" - aria-atomic="false" - > - @for (t of toasts(); track t.id) { - <div - role="status" - class="pointer-events-auto flex items-center gap-2.5 rounded-lg border bg-white dark:bg-zinc-950 px-3 py-2.5 shadow-md text-sm" - [class]="variantClass(t.variant) + ' ' + animClass(t.id)" + <div + class="fixed top-4 right-4 z-50 flex flex-col gap-2 max-w-sm w-[min(90vw,24rem)] pointer-events-none" + role="status" + aria-live="polite" + > + @for (t of toasts(); track t.id) { + <div + class="pointer-events-auto flex items-center gap-2.5 rounded-lg border bg-white dark:bg-zinc-950 px-3 py-2.5 shadow-md text-sm" + [class]="variantClass(t.variant) + ' ' + animClass(t.id)" + > + <svg [lucideIcon]="iconFor(t.variant)" class="size-4 shrink-0"></svg> + <span class="flex-1 text-zinc-700 dark:text-zinc-200 leading-snug">{{ t.message }}</span> + <button + type="button" + (click)="requestDismiss(t.id)" + class="shrink-0 rounded p-0.5 text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200 hover:bg-zinc-100 dark:hover:bg-zinc-900 transition-colors" + aria-label="Dismiss" > - <svg [lucideIcon]="iconFor(t.variant)" class="size-4 shrink-0"></svg> - <span class="flex-1 text-zinc-700 dark:text-zinc-200 leading-snug">{{ - t.message - }}</span> - <button - type="button" - (click)="requestDismiss(t.id)" - class="shrink-0 rounded p-0.5 text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200 hover:bg-zinc-100 dark:hover:bg-zinc-900 transition-colors" - aria-label="Dismiss" - > - <svg [lucideIcon]="closeIcon" class="size-3.5"></svg> - </button> - </div> - } - </div> - } + <svg [lucideIcon]="closeIcon" class="size-3.5"></svg> + </button> + </div> + } + </div> `, }) export class Toaster { diff --git a/apps/docs/src/app/components/toc.spec.ts b/apps/docs/src/app/components/toc.spec.ts index 9b5ef20..defa1e1 100644 --- a/apps/docs/src/app/components/toc.spec.ts +++ b/apps/docs/src/app/components/toc.spec.ts @@ -1,25 +1,30 @@ +import {Component} from '@angular/core'; import {TestBed} from '@angular/core/testing'; -import {provideRouter} from '@angular/router'; +import {provideRouter, Router} from '@angular/router'; import {Toc} from './toc'; +@Component({template: ''}) +class Blank {} + describe('Toc', () => { beforeEach(() => { - vi.stubGlobal( - 'IntersectionObserver', - class { - observe(): void {} - disconnect(): void {} - }, - ); - TestBed.configureTestingModule({providers: [provideRouter([])]}); + Element.prototype.scrollIntoView = vi.fn(); + TestBed.configureTestingModule({providers: [provideRouter([{path: '**', component: Blank}])]}); }); afterEach(async () => { await new Promise((resolve) => setTimeout(resolve, 50)); vi.unstubAllGlobals(); + history.replaceState(null, '', '/'); document.body.innerHTML = ''; }); + function placeHeadings(tops: Record<string, number>): void { + for (const [id, top] of Object.entries(tops)) { + document.getElementById(id)!.getBoundingClientRect = () => ({top}) as DOMRect; + } + } + it('gives repeated headings unique ids and lists only h2 and h3', async () => { document.body.innerHTML = ` <main><analog-markdown> @@ -41,4 +46,79 @@ describe('Toc', () => { ]); fixture.destroy(); }); + + it('links keep the page path', async () => { + history.replaceState(null, '', '/guide/page'); + document.body.innerHTML = `<main><analog-markdown><h2>Install</h2></analog-markdown></main>`; + const fixture = TestBed.createComponent(Toc); + await fixture.whenStable(); + + const link = fixture.nativeElement.querySelector('a') as HTMLAnchorElement; + expect(link.getAttribute('href')).toBe('/guide/page#install'); + fixture.destroy(); + }); + + it('starts on the heading in the URL hash', async () => { + history.replaceState(null, '', '/page#run'); + document.body.innerHTML = ` + <main><analog-markdown><h2>Install</h2><h2>Run</h2><h2>Where to next</h2></analog-markdown></main>`; + const fixture = TestBed.createComponent(Toc); + await fixture.whenStable(); + + expect(fixture.componentInstance.active()).toBe('run'); + fixture.destroy(); + }); + + it('marks the last heading above the top 30% of the viewport after a scroll', async () => { + document.body.innerHTML = ` + <main><analog-markdown><h2>Install</h2><h2>Run</h2><h2>Where to next</h2></analog-markdown></main>`; + const fixture = TestBed.createComponent(Toc); + await fixture.whenStable(); + expect(fixture.componentInstance.active()).toBe('install'); + + placeHeadings({install: -900, run: -400, 'where-to-next': 600}); + window.dispatchEvent(new Event('wheel')); + window.dispatchEvent(new Event('scroll')); + expect(fixture.componentInstance.active()).toBe('run'); + + placeHeadings({install: -100, run: 400, 'where-to-next': 1400}); + window.dispatchEvent(new Event('scroll')); + expect(fixture.componentInstance.active()).toBe('install'); + fixture.destroy(); + }); + + it('keeps a clicked heading active until the user scrolls', async () => { + document.body.innerHTML = ` + <main><analog-markdown><h2>Install</h2><h2>Run</h2><h2>Where to next</h2></analog-markdown></main>`; + const fixture = TestBed.createComponent(Toc); + await fixture.whenStable(); + placeHeadings({install: -900, run: -400, 'where-to-next': 600}); + + const links = fixture.nativeElement.querySelectorAll('a') as NodeListOf<HTMLAnchorElement>; + links[2].click(); + window.dispatchEvent(new Event('scroll')); + expect(fixture.componentInstance.active()).toBe('where-to-next'); + expect(location.hash).toBe('#where-to-next'); + + window.dispatchEvent(new Event('wheel')); + window.dispatchEvent(new Event('scroll')); + expect(fixture.componentInstance.active()).toBe('run'); + fixture.destroy(); + }); + + it('drops the previous page on navigation', async () => { + document.body.innerHTML = ` + <main><analog-markdown><h2>Install</h2><h2>Run</h2></analog-markdown></main>`; + const fixture = TestBed.createComponent(Toc); + await fixture.whenStable(); + + document.querySelector('analog-markdown')!.innerHTML = '<p>No sections here.</p>'; + await TestBed.inject(Router).navigateByUrl('/other'); + await fixture.whenStable(); + + expect(fixture.componentInstance.headings()).toEqual([]); + expect(fixture.componentInstance.active()).toBe(''); + expect(fixture.nativeElement.querySelector('nav')).toBeNull(); + fixture.destroy(); + }); }); diff --git a/apps/docs/src/app/components/toc.ts b/apps/docs/src/app/components/toc.ts index 7369f9d..e408fa6 100644 --- a/apps/docs/src/app/components/toc.ts +++ b/apps/docs/src/app/components/toc.ts @@ -27,9 +27,9 @@ interface Heading { @for (h of headings(); track h.id) { <li [style.padding-left.rem]="(h.level - 2) * 0.75"> <a - [href]="'#' + h.id" + [href]="path() + '#' + h.id" (click)="scrollToHeading(h.id, $event)" - class="block rounded px-2 -mx-2 py-0.5 text-zinc-500 hover:bg-[color:var(--accent-soft)] hover:text-[color:var(--accent-strong)] focus:outline-none focus-visible:outline-none" + class="block rounded px-2 -mx-2 py-0.5 text-zinc-500 dark:text-zinc-400 hover:bg-[color:var(--accent-soft)] hover:text-[color:var(--accent-strong)] focus:outline-none focus-visible:outline-none" [class]=" isActive(h.id) ? 'bg-[color:var(--accent-soft)]! text-[color:var(--accent-strong)]! font-medium' @@ -52,21 +52,31 @@ export class Toc implements AfterViewInit { readonly showActive = input<boolean>(true); readonly headings = signal<Heading[]>([]); readonly active = signal<string>(''); + readonly path = signal(''); private readonly host = inject<ElementRef<HTMLElement>>(ElementRef); constructor() { - effect(() => { + effect((onCleanup) => { const id = this.active(); if (!this.showActive() || !id) return; - this.revealActive(id); + const frame = requestAnimationFrame(() => this.revealActive(id)); + onCleanup(() => cancelAnimationFrame(frame)); }); } - private revealActive(id: string): void { - const link = this.host.nativeElement.querySelector<HTMLElement>(`a[href="#${CSS.escape(id)}"]`); + private scrollBox(): HTMLElement | null { let box = this.host.nativeElement.parentElement; - while (box && box.scrollHeight <= box.clientHeight) box = box.parentElement; - if (!link || !box || box === document.documentElement || box === document.body) return; + while (box && box !== document.body && !/auto|scroll/.test(getComputedStyle(box).overflowY)) { + box = box.parentElement; + } + return box === document.body ? null : box; + } + + private revealActive(id: string): void { + const index = this.headings().findIndex((h) => h.id === id); + const link = this.host.nativeElement.querySelectorAll('a')[index]; + const box = this.scrollBox(); + if (!link || !box) return; const linkRect = link.getBoundingClientRect(); const boxRect = box.getBoundingClientRect(); const margin = 48; @@ -76,7 +86,8 @@ export class Toc implements AfterViewInit { box.scrollTop += linkRect.bottom - (boxRect.bottom - margin); } } - private observer?: IntersectionObserver; + private nodes: HTMLElement[] = []; + private pinned: string | null = null; private contentObserver?: MutationObserver; private retryTimer?: ReturnType<typeof setTimeout>; @@ -88,45 +99,37 @@ export class Toc implements AfterViewInit { this.scanWithRetry(); onNavigation(this.router, this.destroyRef, () => { this.headings.set([]); + this.nodes = []; + this.active.set(''); + this.scrollBox()?.scrollTo({top: 0}); this.scanWithRetry(); }); - // Bottom-of-page guard. Registered once here; reads the live - // `headings` signal so each scroll tick picks up the current last - // heading without re-binding. Previously this lived inside - // `setupObserver` which fires on every navigation, leaking a stale - // handler each time. if (typeof window !== 'undefined') { - const onScroll = () => { - const list = this.headings(); - if (list.length === 0) return; - const scrolled = window.innerHeight + window.scrollY; - const fullHeight = document.documentElement.scrollHeight; - if (scrolled >= fullHeight - 100) { - this.active.set(list[list.length - 1].id); - } - }; + const onScroll = () => this.updateActive(); + const unpin = () => (this.pinned = null); + const inputs = ['wheel', 'touchstart', 'keydown', 'mousedown'] as const; window.addEventListener('scroll', onScroll, {passive: true}); - this.destroyRef.onDestroy(() => window.removeEventListener('scroll', onScroll)); + inputs.forEach((type) => window.addEventListener(type, unpin, {passive: true})); + this.destroyRef.onDestroy(() => { + window.removeEventListener('scroll', onScroll); + inputs.forEach((type) => window.removeEventListener(type, unpin)); + }); } this.destroyRef.onDestroy(() => { clearTimeout(this.retryTimer); this.contentObserver?.disconnect(); - this.observer?.disconnect(); }); } scrollToHeading(id: string, event: MouseEvent): void { + if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; event.preventDefault(); const el = document.getElementById(id); if (el) { el.scrollIntoView({behavior: 'smooth', block: 'start'}); - // Force-activate the clicked id. The IntersectionObserver uses a - // `rootMargin: '0px 0px -70% 0px'` so only the top 30% of viewport - // counts as "in view"; the LAST heading can't reach that region if - // there isn't enough content below it, leaving scroll-spy stuck on - // an earlier heading. Setting active directly here bypasses that. + this.pinned = id; this.active.set(id); // index.html has <base href="/">, so a relative `#frag` resolves to // `/#frag` and strips the path. Pass the full path explicitly. @@ -193,26 +196,32 @@ export class Toc implements AfterViewInit { nodes.push(node); result.push({id: node.id, text, level}); } + this.path.set(`${location.pathname}${location.search}`); this.headings.set(result); - this.setupObserver(nodes); + this.nodes = nodes; + const hash = location.hash.slice(1); + this.pinned = !hash ? nodes[0].id : nodes.some((node) => node.id === hash) ? hash : null; + this.updateActive(); } - private setupObserver(nodes: HTMLElement[]): void { - this.observer?.disconnect(); - if (nodes.length === 0) return; - this.observer = new IntersectionObserver( - (entries) => { - for (const entry of entries) { - if (entry.isIntersecting) { - this.active.set(entry.target.id); - break; - } - } - }, - {rootMargin: '0px 0px -70% 0px', threshold: 0}, - ); - nodes.forEach((node) => this.observer!.observe(node)); - // The bottom-of-page scroll guard lives in `ngAfterViewInit` so it - // registers exactly once across the component's lifetime. + private updateActive(): void { + const nodes = this.nodes; + if (!this.showActive() || nodes.length === 0) return; + if (this.pinned) { + this.active.set(this.pinned); + return; + } + const scrolled = window.innerHeight + window.scrollY; + if (window.scrollY > 0 && scrolled >= document.documentElement.scrollHeight - 100) { + this.active.set(nodes[nodes.length - 1].id); + return; + } + const line = window.innerHeight * 0.3; + let id = nodes[0].id; + for (const node of nodes) { + if (node.getBoundingClientRect().top > line) break; + id = node.id; + } + this.active.set(id); } } diff --git a/apps/docs/src/app/components/version-switcher.spec.ts b/apps/docs/src/app/components/version-switcher.spec.ts index b350eee..145d358 100644 --- a/apps/docs/src/app/components/version-switcher.spec.ts +++ b/apps/docs/src/app/components/version-switcher.spec.ts @@ -26,18 +26,36 @@ describe('VersionSwitcher', () => { const fixture = TestBed.createComponent(VersionSwitcher); await fixture.whenStable(); const el: HTMLElement = fixture.nativeElement; - const trigger = el.querySelector<HTMLButtonElement>('button[aria-haspopup="listbox"]')!; + const trigger = el.querySelector<HTMLButtonElement>('button[aria-expanded]')!; expect(trigger.textContent).toContain('v2'); - expect(el.querySelector('[role="listbox"]')).toBeNull(); + expect(el.querySelector('#ngmd-version-list')).toBeNull(); trigger.click(); await fixture.whenStable(); expect(trigger.getAttribute('aria-expanded')).toBe('true'); - expect(el.querySelectorAll('[role="option"]').length).toBe(2); + expect(el.querySelectorAll('#ngmd-version-list li').length).toBe(2); + expect(el.querySelector('[aria-current="true"]')?.textContent).toContain('v2'); + expect(el.querySelector('a[href="https://v1.example.com"]')).not.toBeNull(); + document.body.appendChild(el); + el.querySelector<HTMLElement>('a')!.focus(); document.dispatchEvent(new KeyboardEvent('keydown', {key: 'Escape'})); await fixture.whenStable(); expect(trigger.getAttribute('aria-expanded')).toBe('false'); - expect(el.querySelector('[role="listbox"]')).toBeNull(); + expect(el.querySelector('#ngmd-version-list')).toBeNull(); + expect(document.activeElement).toBe(trigger); + }); + + it('closes when a click lands outside', async () => { + const fixture = TestBed.createComponent(VersionSwitcher); + await fixture.whenStable(); + const el: HTMLElement = fixture.nativeElement; + el.querySelector<HTMLButtonElement>('button')!.click(); + await fixture.whenStable(); + expect(el.querySelector('#ngmd-version-list')).not.toBeNull(); + + document.body.click(); + await fixture.whenStable(); + expect(el.querySelector('#ngmd-version-list')).toBeNull(); }); }); diff --git a/apps/docs/src/app/components/version-switcher.ts b/apps/docs/src/app/components/version-switcher.ts index 623df13..6fa2c0b 100644 --- a/apps/docs/src/app/components/version-switcher.ts +++ b/apps/docs/src/app/components/version-switcher.ts @@ -1,4 +1,4 @@ -import {Component, computed, ElementRef, HostListener, inject, signal} from '@angular/core'; +import {Component, computed, ElementRef, inject, signal, viewChild} from '@angular/core'; import { LucideDynamicIcon, LucideCheck, @@ -23,15 +23,22 @@ import {VersionService} from '../services/version/version.service'; */ @Component({ selector: 'app-version-switcher', + host: { + '(document:click)': 'onDocumentClick($event)', + '(document:keydown.escape)': 'onEscape()', + '(focusout)': 'onFocusOut($event)', + }, imports: [LucideDynamicIcon], template: ` @if (visible()) { <div class="relative"> <button + #trigger type="button" (click)="toggle()" [attr.aria-expanded]="open()" - aria-haspopup="listbox" + aria-controls="ngmd-version-list" + [attr.aria-label]="'Documentation version: ' + triggerLabel()" class="inline-flex items-center gap-1.5 rounded-md border border-zinc-200 dark:border-zinc-800 bg-zinc-50 dark:bg-zinc-900 px-2.5 py-1 text-xs font-medium hover:bg-zinc-100 dark:hover:bg-zinc-800" > {{ triggerLabel() }} @@ -39,15 +46,14 @@ import {VersionService} from '../services/version/version.service'; </button> @if (open()) { <ul - role="listbox" + id="ngmd-version-list" class="absolute right-0 top-[calc(100%+0.375rem)] z-40 min-w-52 rounded-lg border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-950 py-1 shadow-lg" > @for (entry of list(); track entry.label) { - <li role="presentation"> + <li> @if (entry.label === selfLabel()) { <span - role="option" - aria-selected="true" + aria-current="true" class="flex w-full items-center justify-between gap-3 px-3 py-1.5 text-left text-sm bg-zinc-50 dark:bg-zinc-900" > <span class="flex items-center gap-2"> @@ -63,8 +69,6 @@ import {VersionService} from '../services/version/version.service'; </span> } @else { <a - role="option" - aria-selected="false" [href]="entry.url" target="_blank" rel="noopener noreferrer" @@ -94,6 +98,7 @@ import {VersionService} from '../services/version/version.service'; export class VersionSwitcher { private readonly versions = inject(VersionService); private readonly host = inject<ElementRef<HTMLElement>>(ElementRef); + private readonly trigger = viewChild<ElementRef<HTMLButtonElement>>('trigger'); readonly chevronIcon = LucideChevronDown; readonly checkIcon = LucideCheck; @@ -131,7 +136,6 @@ export class VersionSwitcher { return 'bg-zinc-100 dark:bg-zinc-800 text-zinc-500 dark:text-zinc-500'; } - @HostListener('document:click', ['$event']) protected onDocumentClick(event: MouseEvent): void { if (!this.open()) return; if (!this.host.nativeElement.contains(event.target as Node)) { @@ -139,8 +143,14 @@ export class VersionSwitcher { } } - @HostListener('document:keydown.escape') + protected onFocusOut(event: FocusEvent): void { + const next = event.relatedTarget as Node | null; + if (next && !this.host.nativeElement.contains(next)) this.open.set(false); + } + protected onEscape(): void { + if (!this.open()) return; this.open.set(false); + this.trigger()?.nativeElement.focus(); } } diff --git a/apps/docs/src/app/pages/[...slug].page.ts b/apps/docs/src/app/pages/[...slug].page.ts index 8148b43..f4d5b5e 100644 --- a/apps/docs/src/app/pages/[...slug].page.ts +++ b/apps/docs/src/app/pages/[...slug].page.ts @@ -9,13 +9,14 @@ import { resource, } from '@angular/core'; import {toSignal} from '@angular/core/rxjs-interop'; +import {Title} from '@angular/platform-browser'; import {Router, RouterLink} from '@angular/router'; import {injectContent, MarkdownComponent} from '@analogjs/content'; import {LucideDynamicIcon, LucideArrowRight, LucideSearch} from '@lucide/angular'; import {LayoutMode} from '../layout-mode.service'; import {SearchService} from '../services/search/search.service'; import {RouteUrlService} from '../services/route-url/route-url.service'; -import {ContentBanners} from '../components/content-banners'; +import siteConfig from '../../ngmd.config'; /** * Catch-all route for every markdown page. @@ -45,14 +46,14 @@ const NOT_FOUND = '__ngmd-not-found__'; @Component({ selector: 'app-doc', schemas: [CUSTOM_ELEMENTS_SCHEMA], - imports: [AsyncPipe, MarkdownComponent, RouterLink, LucideDynamicIcon, ContentBanners], + imports: [AsyncPipe, MarkdownComponent, RouterLink, LucideDynamicIcon], template: ` @if (content$ | async; as doc) { @if (doc.content === notFound) { <section class="mx-auto max-w-xl w-full px-6 py-16 text-center flex flex-col items-center justify-center min-h-[calc(100vh-12rem)]" > - <p class="text-sm font-medium tracking-[0.2em] text-zinc-400 dark:text-zinc-500">404</p> + <p class="text-sm font-medium tracking-[0.2em] text-zinc-500 dark:text-zinc-400">404</p> <h1 class="mt-3 text-3xl sm:text-4xl font-bold tracking-tight">Page not found</h1> <p class="mt-4 text-base text-zinc-600 dark:text-zinc-400"> The page you're looking for doesn't exist or has moved. @@ -65,7 +66,9 @@ const NOT_FOUND = '__ngmd-not-found__'; class="mt-8 w-full max-w-sm flex items-center gap-3 rounded-lg border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-950 px-4 py-3 text-left hover:bg-[color:var(--accent-soft)] hover:text-[color:var(--accent-strong)] hover:border-transparent transition-colors" > <span class="flex-1 min-w-0"> - <span class="block text-[0.625rem] uppercase tracking-[0.2em] text-zinc-400"> + <span + class="block text-[0.625rem] uppercase tracking-[0.2em] text-zinc-500 dark:text-zinc-400" + > Maybe you meant </span> <span class="mt-1 block text-sm font-medium truncate">{{ @@ -95,7 +98,6 @@ const NOT_FOUND = '__ngmd-not-found__'; </section> } @else { <article class="max-w-3xl mx-auto pt-8 px-8 pb-4"> - <app-content-banners /> <analog-markdown [content]="doc.content" /> </article> } @@ -107,6 +109,7 @@ export default class DocPage implements OnDestroy { private readonly router = inject(Router); private readonly search = inject(SearchService); private readonly route = inject(RouteUrlService); + private readonly title = inject(Title); private readonly cleanUrl = this.route.cleanUrl; protected readonly notFound = NOT_FOUND; @@ -139,7 +142,11 @@ export default class DocPage implements OnDestroy { protected readonly suggestion = computed(() => this.suggestionsResource.value()?.[0] ?? null); constructor() { - effect(() => this.layout.chromeHidden.set(this.missing())); + effect(() => { + const missing = this.doc()?.content === NOT_FOUND; + this.layout.chromeHidden.set(missing); + if (missing) this.title.setTitle(`${siteConfig.site.name} | Page not found`); + }); } protected searchInPalette(): void { diff --git a/apps/docs/src/app/pages/index.page.ts b/apps/docs/src/app/pages/index.page.ts index ce8e642..916d8f0 100644 --- a/apps/docs/src/app/pages/index.page.ts +++ b/apps/docs/src/app/pages/index.page.ts @@ -128,7 +128,7 @@ import {writeToClipboard} from '../utils/clipboard'; <!-- Stack badges --> <div class="mt-14"> - <p class="text-xs font-medium tracking-[0.2em] text-zinc-400 dark:text-zinc-500 mb-5"> + <p class="text-xs font-medium tracking-[0.2em] text-zinc-500 dark:text-zinc-400 mb-5"> WORKS WITH </p> <div class="flex flex-wrap items-center justify-center gap-2 sm:gap-3"> @@ -139,13 +139,13 @@ import {writeToClipboard} from '../utils/clipboard'; rel="noopener noreferrer" class="inline-flex items-center gap-2 rounded-lg border border-zinc-200/60 dark:border-zinc-800/60 bg-white/50 dark:bg-zinc-900/30 px-3 py-1.5 text-sm font-medium text-zinc-700 dark:text-zinc-300 hover:bg-white dark:hover:bg-zinc-900 transition-colors" > - <img [src]="tech.logo" [alt]="tech.name" class="size-5 object-contain" /> + <img [src]="tech.logo" alt="" class="size-5 object-contain" /> {{ tech.name }} </a> } </div> <p - class="mt-8 text-[10px] font-medium tracking-[0.2em] text-zinc-400 dark:text-zinc-500 mb-3" + class="mt-8 text-[10px] font-medium tracking-[0.2em] text-zinc-500 dark:text-zinc-400 mb-3" > COMING SOON </p> @@ -157,7 +157,7 @@ import {writeToClipboard} from '../utils/clipboard'; rel="noopener noreferrer" class="inline-flex items-center gap-2 rounded-lg border border-dashed border-zinc-300 dark:border-zinc-700 px-2.5 py-1 text-xs font-medium text-zinc-500 dark:text-zinc-400 hover:bg-white dark:hover:bg-zinc-900 transition-colors" > - <img [src]="tech.logo" [alt]="tech.name" class="size-4 object-contain opacity-70" /> + <img [src]="tech.logo" alt="" class="size-4 object-contain opacity-70" /> {{ tech.name }} </a> } @@ -182,39 +182,41 @@ import {writeToClipboard} from '../utils/clipboard'; class="rounded-xl border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-950 overflow-hidden" > <div - class="flex items-center justify-between border-b border-zinc-200 dark:border-zinc-800 px-4 py-2 text-xs font-mono text-zinc-500" + class="flex items-center justify-between border-b border-zinc-200 dark:border-zinc-800 px-4 py-2 text-xs font-mono text-zinc-500 dark:text-zinc-400" > <span>src/server.ts</span> - <span class="text-zinc-400">typescript</span> + <span class="text-zinc-500 dark:text-zinc-400">typescript</span> </div> <pre + tabindex="0" + aria-label="src/server.ts and src/main.ts" class="p-4 text-sm overflow-x-auto text-zinc-700 dark:text-zinc-300 leading-relaxed" - ><code><span class="text-[color:var(--accent)] font-semibold">import</span> {{ '{' }} initNgDevtoolsHub {{ '}' }} - <span class="text-[color:var(--accent)] font-semibold">from</span> '@santoshyadavdev/ng-devtools/hub'; + ><code><span class="text-[color:var(--accent-strong)] font-semibold">import</span> {{ '{' }} initNgDevtoolsHub {{ '}' }} + <span class="text-[color:var(--accent-strong)] font-semibold">from</span> '@santoshyadavdev/ng-devtools/hub'; -<span class="text-[color:var(--accent)] font-semibold">const</span> devtools = initNgDevtoolsHub({{ '{' }} ws: false {{ '}' }}); +<span class="text-[color:var(--accent-strong)] font-semibold">const</span> devtools = initNgDevtoolsHub({{ '{' }} ws: false {{ '}' }}); app.use(devtools.nodeMiddleware); -<span class="text-zinc-400">// src/main.ts</span> -<span class="text-[color:var(--accent)] font-semibold">if</span> (typeof ngDevMode === 'undefined' || ngDevMode) <span class="text-[color:var(--accent)] font-semibold">import</span>('@santoshyadavdev/ng-devtools/overlay');</code></pre> +<span class="text-zinc-500 dark:text-zinc-400">// src/main.ts</span> +<span class="text-[color:var(--accent-strong)] font-semibold">if</span> (typeof ngDevMode === 'undefined' || ngDevMode) <span class="text-[color:var(--accent-strong)] font-semibold">import</span>('@santoshyadavdev/ng-devtools/overlay');</code></pre> </div> <div class="rounded-xl border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-950 overflow-hidden" > <div - class="flex items-center justify-between border-b border-zinc-200 dark:border-zinc-800 px-4 py-2 text-xs font-mono text-zinc-500" + class="flex items-center justify-between border-b border-zinc-200 dark:border-zinc-800 px-4 py-2 text-xs font-mono text-zinc-500 dark:text-zinc-400" > <span>Browser → Components</span> - <span class="text-[color:var(--accent)]">live</span> + <span class="text-[color:var(--accent-strong)]">live</span> </div> <div class="p-6 font-mono text-sm text-zinc-700 dark:text-zinc-300 space-y-1.5"> <p>▾ <app-root></p> <p class="pl-4">▾ <app-search></p> - <p class="pl-8 text-zinc-500"> - query <span class="text-[color:var(--accent)]">signal</span> = "Lisbon" + <p class="pl-8 text-zinc-500 dark:text-zinc-400"> + query <span class="text-[color:var(--accent-strong)]">signal</span> = "Lisbon" </p> - <p class="pl-8 text-zinc-500"> - results <span class="text-[color:var(--accent)]">computed</span> = 12 items + <p class="pl-8 text-zinc-500 dark:text-zinc-400"> + results <span class="text-[color:var(--accent-strong)]">computed</span> = 12 items </p> <p class="pl-4">▸ <app-trip-list></p> <p class="pl-4">▸ <router-outlet></p> @@ -286,7 +288,7 @@ app.use(devtools.nodeMiddleware); <span class="text-base font-semibold text-zinc-900 dark:text-zinc-100">{{ m.name }}</span> - <span class="text-sm text-zinc-500">@{{ m.login }}</span> + <span class="text-sm text-zinc-500 dark:text-zinc-400">@{{ m.login }}</span> </a> } </div> @@ -338,27 +340,40 @@ app.use(devtools.nodeMiddleware); <div class="mt-8 inline-block w-[34rem] max-w-full rounded-lg border border-zinc-200 dark:border-zinc-800 bg-zinc-50 dark:bg-zinc-900 overflow-hidden text-left" > - <div role="tablist" class="flex border-b border-zinc-200 dark:border-zinc-800"> + <div + role="tablist" + aria-label="Package manager" + class="flex border-b border-zinc-200 dark:border-zinc-800" + (keydown)="onTabKeydown($event)" + > @for (cmd of installCommands; track cmd.pm) { <button type="button" role="tab" + [id]="'install-tab-' + cmd.pm" + aria-controls="install-panel" [attr.aria-selected]="activePM() === cmd.pm" + [tabIndex]="activePM() === cmd.pm ? 0 : -1" (click)="activePM.set(cmd.pm)" - class="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors aria-selected:border-[color:var(--accent)] aria-selected:text-[color:var(--accent)] [&[aria-selected=false]]:border-transparent [&[aria-selected=false]]:text-zinc-500 [&[aria-selected=false]]:hover:text-zinc-900 dark:[&[aria-selected=false]]:hover:text-zinc-100" + class="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors aria-selected:border-[color:var(--accent)] aria-selected:text-[color:var(--accent-strong)] [&[aria-selected=false]]:border-transparent [&[aria-selected=false]]:text-zinc-500 dark:[&[aria-selected=false]]:text-zinc-400 [&[aria-selected=false]]:hover:text-zinc-900 dark:[&[aria-selected=false]]:hover:text-zinc-100" > <img [src]="cmd.logo" alt="" aria-hidden="true" class="size-4 object-contain" /> {{ cmd.pm }} </button> } </div> - <div class="flex items-center gap-3 pl-4 pr-2 py-2.5 font-mono text-sm"> - <span class="text-zinc-400">$</span> + <div + id="install-panel" + role="tabpanel" + [attr.aria-labelledby]="'install-tab-' + activePM()" + class="flex items-center gap-3 pl-4 pr-2 py-2.5 font-mono text-sm" + > + <span class="text-zinc-500 dark:text-zinc-400">$</span> <span class="overflow-x-auto whitespace-nowrap">{{ activeCmd() }}</span> <button type="button" (click)="copyCmd(activeCmd())" - [attr.aria-label]="copied() === activeCmd() ? 'Copied' : 'Copy'" + [attr.aria-label]="copied() === activeCmd() ? 'Copied' : 'Copy install command'" class="ml-auto shrink-0 inline-flex items-center justify-center size-7 rounded-md text-zinc-500 hover:bg-zinc-200 dark:hover:bg-zinc-800 hover:text-zinc-900 dark:hover:text-zinc-100 transition-colors" > <svg @@ -371,7 +386,7 @@ app.use(devtools.nodeMiddleware); <div class="mt-8"> <a routerLink="/getting-started/installation" - class="inline-flex items-center gap-2 text-base font-medium text-[color:var(--accent)] hover:opacity-80" + class="inline-flex items-center gap-2 text-base font-medium text-[color:var(--accent-strong)] hover:opacity-80" > Read the installation guide <svg [lucideIcon]="arrowIcon" class="size-4"></svg> @@ -432,11 +447,33 @@ export default class Home implements AfterViewInit { () => this.installCommands.find((c) => c.pm === this.activePM())?.cmd ?? '', ); + protected onTabKeydown(event: KeyboardEvent): void { + const pms = this.installCommands.map((c) => c.pm); + const index = pms.indexOf(this.activePM()); + const next = + event.key === 'ArrowRight' + ? (index + 1) % pms.length + : event.key === 'ArrowLeft' + ? (index - 1 + pms.length) % pms.length + : event.key === 'Home' + ? 0 + : event.key === 'End' + ? pms.length - 1 + : -1; + if (next === -1) return; + event.preventDefault(); + this.activePM.set(pms[next]); + (event.currentTarget as HTMLElement) + .querySelector<HTMLElement>(`#install-tab-${pms[next]}`) + ?.focus(); + } + async copyCmd(cmd: string): Promise<void> { if (!(await writeToClipboard(cmd))) { this.toast.error('Could not copy command.'); return; } + this.toast.success('Command copied to clipboard.'); this.copied.set(cmd); clearTimeout(this.copyTimer); this.copyTimer = setTimeout(() => this.copied.set(''), 1500); diff --git a/apps/docs/src/app/services/route-url/route-url.service.spec.ts b/apps/docs/src/app/services/route-url/route-url.service.spec.ts new file mode 100644 index 0000000..57b06ee --- /dev/null +++ b/apps/docs/src/app/services/route-url/route-url.service.spec.ts @@ -0,0 +1,14 @@ +import {stripUrl} from './route-url.service'; + +describe('stripUrl', () => { + it('drops query, fragment and trailing slash', () => { + expect(stripUrl('/concepts/theming?x=1#tokens')).toBe('/concepts/theming'); + expect(stripUrl('/concepts/theming/')).toBe('/concepts/theming'); + expect(stripUrl('/concepts/theming//#a')).toBe('/concepts/theming'); + }); + + it('keeps the root path', () => { + expect(stripUrl('/')).toBe('/'); + expect(stripUrl('/?q=1')).toBe('/'); + }); +}); diff --git a/apps/docs/src/app/services/route-url/route-url.service.ts b/apps/docs/src/app/services/route-url/route-url.service.ts index 20653fb..1bd7ccb 100644 --- a/apps/docs/src/app/services/route-url/route-url.service.ts +++ b/apps/docs/src/app/services/route-url/route-url.service.ts @@ -32,9 +32,12 @@ export class RouteUrlService { readonly cleanUrl = computed(() => stripUrl(this.url())); } -/** Strip query + fragment from a URL string. Exported for non-component +/** Strip query, fragment and trailing slash from a URL string. Exported for non-component * consumers (e.g. `NgmdTitleStrategy`) that work with the snapshot URL * directly and don't need the signal. */ export function stripUrl(url: string): string { - return url.split('?')[0].split('#')[0]; + return url + .split('?')[0] + .split('#')[0] + .replace(/(.)\/+$/, '$1'); } diff --git a/apps/docs/src/app/services/search/algolia-provider.spec.ts b/apps/docs/src/app/services/search/algolia-provider.spec.ts new file mode 100644 index 0000000..34430e0 --- /dev/null +++ b/apps/docs/src/app/services/search/algolia-provider.spec.ts @@ -0,0 +1,46 @@ +import {AlgoliaSearchProvider} from './algolia-provider'; + +const search = vi.fn(); + +vi.mock('algoliasearch/lite', () => ({liteClient: () => ({search})})); + +describe('AlgoliaSearchProvider', () => { + it('escapes record text and maps hits to relative urls with highlights', async () => { + search.mockResolvedValue({ + results: [ + { + hits: [ + { + objectID: '1', + url: 'https://example.com/guide/tags?x=1#use-template', + hierarchy: {lvl0: 'Docs', lvl1: 'Tags <b>', lvl2: 'Use <template>', lvl3: null}, + _snippetResult: { + content: {value: 'Wrap __ngmd_mark__<ng-content>__/ngmd_mark__ & go'}, + hierarchy: {lvl2: {value: 'Use __ngmd_mark__<template>__/ngmd_mark__'}}, + }, + }, + {objectID: '2', url: '/intro', hierarchy: {lvl0: 'Docs', lvl1: 'Intro', lvl2: null}}, + ], + }, + ], + }); + const provider = new AlgoliaSearchProvider({appId: 'a', apiKey: 'k', indexName: 'i'}); + const [section, page] = await provider.search(' template '); + + expect(search.mock.calls[0][0][0].params).toMatchObject({ + query: 'template', + highlightPreTag: '__ngmd_mark__', + highlightPostTag: '__/ngmd_mark__', + }); + expect(section).toEqual({ + id: '1', + kind: 'section', + url: '/guide/tags?x=1#use-template', + labelHtml: 'Use <mark><template></mark>', + subLabelHtml: 'Tags <b>', + contentHtml: 'Wrap <mark><ng-content></mark> & go', + score: undefined, + }); + expect(page).toMatchObject({kind: 'page', url: '/intro', labelHtml: 'Intro', subLabelHtml: ''}); + }); +}); diff --git a/apps/docs/src/app/services/search/algolia-provider.ts b/apps/docs/src/app/services/search/algolia-provider.ts index 0ddce6a..f67bc5c 100644 --- a/apps/docs/src/app/services/search/algolia-provider.ts +++ b/apps/docs/src/app/services/search/algolia-provider.ts @@ -1,4 +1,8 @@ import type {SearchHit, SearchHitKind, SearchProvider} from '../../../types/search'; +import {escapeHtml} from './escape-html'; + +const PRE_TAG = '__ngmd_mark__'; +const POST_TAG = '__/ngmd_mark__'; /** * Optional Algolia DocSearch backend. Activated when the user populates @@ -21,7 +25,13 @@ export interface AlgoliaConfig { interface AlgoliaHit { objectID: string; url: string; - hierarchy: {lvl0?: string; lvl1?: string; lvl2?: string; lvl3?: string; lvl4?: string}; + hierarchy: { + lvl0?: string | null; + lvl1?: string | null; + lvl2?: string | null; + lvl3?: string | null; + lvl4?: string | null; + }; content?: string; _snippetResult?: { content?: {value: string}; @@ -90,8 +100,8 @@ export class AlgoliaSearchProvider implements SearchProvider { attributesToRetrieve: ['hierarchy', 'content', 'url'], attributesToSnippet: ['hierarchy.lvl1:10', 'hierarchy.lvl2:10', 'content:10'], snippetEllipsisText: '…', - highlightPreTag: '<mark>', - highlightPostTag: '</mark>', + highlightPreTag: PRE_TAG, + highlightPostTag: POST_TAG, }, }, ]); @@ -101,22 +111,28 @@ export class AlgoliaSearchProvider implements SearchProvider { } function toSearchHit(hit: AlgoliaHit): SearchHit { - const snippet = hit._snippetResult?.content?.value; - const lvl2 = hit._snippetResult?.hierarchy?.lvl2?.value ?? hit.hierarchy.lvl2; - const lvl1 = hit._snippetResult?.hierarchy?.lvl1?.value ?? hit.hierarchy.lvl1; - const pageTitle = hit.hierarchy.lvl1 ?? hit.hierarchy.lvl0 ?? ''; + const snippet = toHtml(hit._snippetResult?.content?.value); + const lvl2 = toHtml(hit._snippetResult?.hierarchy?.lvl2?.value ?? hit.hierarchy.lvl2); + const lvl1 = toHtml(hit._snippetResult?.hierarchy?.lvl1?.value ?? hit.hierarchy.lvl1); + const lvl0 = toHtml(hit.hierarchy.lvl0); const kind: SearchHitKind = lvl2 ? 'section' : snippet ? 'snippet' : 'page'; return { id: hit.objectID, kind, url: toRelativeUrl(hit.url), - labelHtml: lvl2 ?? lvl1 ?? hit.hierarchy.lvl0 ?? '', + labelHtml: lvl2 ?? lvl1 ?? lvl0 ?? '', subLabelHtml: lvl2 ? (lvl1 ?? '') : '', contentHtml: snippet ?? undefined, score: undefined, }; } +function toHtml(value: string | null | undefined): string | undefined { + return !value + ? undefined + : escapeHtml(value).replaceAll(PRE_TAG, '<mark>').replaceAll(POST_TAG, '</mark>'); +} + /** * Algolia's DocSearch crawler stores absolute URLs (`https://yoursite.com/path#frag`). * The router only accepts in-app paths, so strip the origin and keep diff --git a/apps/docs/src/app/services/search/escape-html.ts b/apps/docs/src/app/services/search/escape-html.ts new file mode 100644 index 0000000..1f605ca --- /dev/null +++ b/apps/docs/src/app/services/search/escape-html.ts @@ -0,0 +1,7 @@ +export function escapeHtml(s: string): string { + return s + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"'); +} diff --git a/apps/docs/src/app/services/search/orama-provider.spec.ts b/apps/docs/src/app/services/search/orama-provider.spec.ts new file mode 100644 index 0000000..781ddf7 --- /dev/null +++ b/apps/docs/src/app/services/search/orama-provider.spec.ts @@ -0,0 +1,64 @@ +import {OramaSearchProvider} from './orama-provider'; + +vi.mock('virtual:ngmd/search-index', () => ({ + searchIndex: [ + { + id: 'page:/tags', + url: '/tags', + anchor: '', + kind: 'page', + pageTitle: 'Tags & markup', + heading: 'Tags & markup', + body: 'Wrap <ngmd-alert> in a paragraph & keep it short.', + }, + { + id: 'section:/tags#amplify', + url: '/tags', + anchor: 'amplify', + kind: 'section', + pageTitle: 'Tags & markup', + heading: 'Amplify', + body: 'Amplify output.', + }, + ], +})); + +vi.mock('virtual:ngmd/api-index', () => ({ + apiIndex: [ + { + kind: 'class', + name: 'NgmdAlert', + filePath: 'src/app/ui/alert.ts', + line: 50, + signature: 'class NgmdAlert', + description: 'Banner with a stripe.', + badges: [], + group: 'src-app-ui', + }, + ], +})); + +describe('OramaSearchProvider', () => { + it('escapes text before highlighting so matches never land inside entities', async () => { + const hits = await new OramaSearchProvider().search('amp'); + const section = hits.find((h) => h.kind === 'section')!; + expect(section.labelHtml).toBe('<mark>Amp</mark>lify'); + expect(section.subLabelHtml).toBe('Tags & markup'); + }); + + it('highlights every query token and keeps markup in the text escaped', async () => { + const hits = await new OramaSearchProvider().search('tags markup'); + const page = hits.find((h) => h.kind === 'page')!; + expect(page.labelHtml).toBe('<mark>Tags</mark> & <mark>markup</mark>'); + }); + + it('indexes API symbols under their encoded reference url', async () => { + const [hit] = await new OramaSearchProvider().search('NgmdAlert'); + expect(hit).toMatchObject({kind: 'symbol', url: '/api/src-app-ui/NgmdAlert'}); + expect(hit.subLabelHtml).toBe('class <mark>NgmdAlert</mark>'); + }); + + it('returns nothing for a blank query', async () => { + expect(await new OramaSearchProvider().search(' ')).toEqual([]); + }); +}); diff --git a/apps/docs/src/app/services/search/orama-provider.ts b/apps/docs/src/app/services/search/orama-provider.ts index d7b40bd..b30a03f 100644 --- a/apps/docs/src/app/services/search/orama-provider.ts +++ b/apps/docs/src/app/services/search/orama-provider.ts @@ -1,23 +1,27 @@ -import {create, insertMultiple, search as oramaSearch, type AnyOrama} from '@orama/orama'; -import {searchIndex} from 'virtual:ngmd/search-index'; -import {apiIndex} from 'virtual:ngmd/api-index'; +import type {AnyOrama, search as oramaSearch} from '@orama/orama'; import type {IndexDoc, SearchHit, SearchHitKind, SearchProvider} from '../../../types/search'; -import type {SymbolRecord} from '../../../types/api'; +import {symbolUrl, type SymbolRecord} from '../../../types/api'; +import {escapeHtml} from './escape-html'; /** - * Default search backend. Builds an in-memory Orama index once on init, - * queries it on every search. Index source is the build-time JSON emitted + * Default search backend. Builds an in-memory Orama index on the first + * query (Orama and the index are lazy chunks), queries it on every search. Index source is the build-time JSON emitted * by `search-index.plugin.ts` under `virtual:ngmd/search-index`. * * Result shape mirrors Algolia's hierarchical (page → section → snippet) * model so the same UI works against either backend. */ export class OramaSearchProvider implements SearchProvider { - private dbPromise: Promise<AnyOrama> | null = null; + private dbPromise: Promise<{db: AnyOrama; search: typeof oramaSearch}> | null = null; - private async getDb(): Promise<AnyOrama> { + private getDb() { if (this.dbPromise) return this.dbPromise; this.dbPromise = (async () => { + const [{create, insertMultiple, search}, {searchIndex}, {apiIndex}] = await Promise.all([ + import('@orama/orama'), + import('virtual:ngmd/search-index'), + import('virtual:ngmd/api-index'), + ]); const db = create({ schema: { id: 'string', @@ -42,16 +46,17 @@ export class OramaSearchProvider implements SearchProvider { if (allDocs.length) { await insertMultiple(db, allDocs as unknown as Array<Record<string, string>>, 50); } - return db; + return {db, search}; })(); + this.dbPromise.catch(() => (this.dbPromise = null)); return this.dbPromise; } async search(query: string): Promise<SearchHit[]> { const trimmed = query.trim(); if (!trimmed) return []; - const db = await this.getDb(); - const result = await oramaSearch(db, { + const {db, search} = await this.getDb(); + const result = await search(db, { term: trimmed, properties: ['pageTitle', 'heading', 'body'], // Heading > title > body so a query that matches a heading ranks above @@ -113,20 +118,13 @@ function pickLabel(doc: IndexDoc): string { * shape so the palette UI can render either backend identically. */ function highlight(text: string, query: string): string { - if (!query) return escapeHtml(text); const tokens = query.split(/\s+/).filter(Boolean).map(escapeRegex); if (!tokens.length) return escapeHtml(text); - const safe = escapeHtml(text); const re = new RegExp(`(${tokens.join('|')})`, 'gi'); - return safe.replace(re, '<mark>$1</mark>'); -} - -function escapeHtml(s: string): string { - return s - .replace(/&/g, '&') - .replace(/</g, '<') - .replace(/>/g, '>') - .replace(/"/g, '"'); + return text + .split(re) + .map((part, i) => (i % 2 ? `<mark>${escapeHtml(part)}</mark>` : escapeHtml(part))) + .join(''); } function escapeRegex(s: string): string { @@ -141,13 +139,10 @@ function escapeRegex(s: string): string { * JSDoc description so prose queries can pull symbols in too. */ function symbolToIndexDoc(sym: SymbolRecord): IndexDoc { - // Encode both segments consistently so reserved characters in a symbol - // name (or raw slashes in a group) never leak into the id or url. - const encGroup = encodeURIComponent(sym.group); - const encName = encodeURIComponent(sym.name); + const url = symbolUrl(sym); return { - id: `symbol:${encGroup}/${encName}`, - url: `/api/${encGroup}/${encName}`, + id: `symbol:${url}`, + url, anchor: '', kind: 'symbol', pageTitle: sym.name, diff --git a/apps/docs/src/app/services/search/search.service.spec.ts b/apps/docs/src/app/services/search/search.service.spec.ts new file mode 100644 index 0000000..5dba576 --- /dev/null +++ b/apps/docs/src/app/services/search/search.service.spec.ts @@ -0,0 +1,83 @@ +import {TestBed} from '@angular/core/testing'; +import {SearchService, type HistoryItem} from './search.service'; + +vi.mock('virtual:ngmd/search-index', () => ({ + searchIndex: [ + { + id: 'page:/theming', + url: '/theming', + anchor: '', + kind: 'page', + pageTitle: 'Theming', + heading: 'Theming', + body: 'Tokens.', + }, + ], +})); +vi.mock('virtual:ngmd/api-index', () => ({apiIndex: []})); + +const KEY = 'ngmd-search-history-v1'; + +function hit(url: string) { + return {id: url, kind: 'page' as const, url, labelHtml: `<mark>${url}</mark>`, subLabelHtml: ''}; +} + +describe('SearchService', () => { + afterEach(() => { + vi.restoreAllMocks(); + localStorage.clear(); + }); + + it('starts empty instead of throwing when storage is blocked', () => { + vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { + throw new DOMException('denied', 'SecurityError'); + }); + vi.spyOn(Storage.prototype, 'removeItem').mockImplementation(() => { + throw new DOMException('denied', 'SecurityError'); + }); + expect(TestBed.inject(SearchService).history()).toEqual([]); + }); + + it('drops malformed and duplicate stored entries', () => { + const good: HistoryItem = {id: 'a', url: '/a', labelHtml: 'A', subLabelHtml: '', createdAt: 1}; + localStorage.setItem(KEY, JSON.stringify([null, {url: 3}, good, {...good, id: 'b'}])); + expect(TestBed.inject(SearchService).history()).toEqual([good]); + }); + + it('records visits newest first, strips highlights, dedupes and caps recents', () => { + const search = TestBed.inject(SearchService); + for (let i = 0; i < 12; i++) search.recordVisit(hit(`/p${i}`)); + search.recordVisit(hit('/p5')); + const urls = search.recents().map((h) => h.url); + expect(urls.length).toBe(10); + expect(urls.slice(0, 2)).toEqual(['/p5', '/p11']); + expect(search.recents()[0].labelHtml).toBe('/p5'); + expect(JSON.parse(localStorage.getItem(KEY)!).length).toBe(10); + }); + + it('puts a newly pinned favourite first and survives clearing recents', () => { + const search = TestBed.inject(SearchService); + ['/a', '/b', '/c'].forEach((url) => search.recordVisit(hit(url))); + search.toggleFavorite('/a'); + search.toggleFavorite('/b'); + expect(search.favorites().map((h) => h.url)).toEqual(['/b', '/a']); + search.clearRecents(); + expect(search.history().map((h) => h.url)).toEqual(['/b', '/a']); + search.toggleFavorite('/a'); + expect(search.recents().map((h) => h.url)).toEqual(['/a']); + search.removeFromHistory('/b'); + expect(search.favorites()).toEqual([]); + }); + + it('reports loading, not "no results", while the query is debouncing', async () => { + const search = TestBed.inject(SearchService); + search.query.set('zzzz'); + TestBed.tick(); + expect(search.loading()).toBe(true); + expect(search.hasNoResults()).toBe(false); + await vi.waitFor(() => expect(search.hasNoResults()).toBe(true), {timeout: 3000}); + search.query.set('theming'); + TestBed.tick(); + await vi.waitFor(() => expect(search.results()[0]?.url).toBe('/theming'), {timeout: 3000}); + }); +}); diff --git a/apps/docs/src/app/services/search/search.service.ts b/apps/docs/src/app/services/search/search.service.ts index 8fe7a69..4e9a203 100644 --- a/apps/docs/src/app/services/search/search.service.ts +++ b/apps/docs/src/app/services/search/search.service.ts @@ -88,8 +88,10 @@ export class SearchService { (next === undefined && this.query().trim() ? prev?.value : next) ?? [], }); - /** True while the resource has a request in flight. */ - readonly loading: Signal<boolean> = this.resultsResource.isLoading; + /** True while the query is debouncing or the resource has a request in flight. */ + readonly loading = computed( + () => this.resultsResource.isLoading() || this.debouncedQuery() !== this.query().trim(), + ); /** Most-recently navigated hits, newest first. */ readonly history: Signal<HistoryItem[]> = this.historyState.asReadonly(); @@ -154,10 +156,15 @@ export class SearchService { toggleFavorite(url: string): void { if (!this.isBrowser) return; this.historyState.update((items) => { - const flipped = items.map((h) => (h.url === url ? {...h, isFavorite: !h.isFavorite} : h)); - const favorites = flipped.filter((h) => h.isFavorite); - const recents = flipped.filter((h) => !h.isFavorite).slice(0, HISTORY_MAX); - return [...favorites, ...recents]; + const target = items.find((h) => h.url === url); + if (!target) return items; + const flipped = {...target, isFavorite: !target.isFavorite}; + const others = items.filter((h) => h !== target); + const favorites = others.filter((h) => h.isFavorite); + const recents = others.filter((h) => !h.isFavorite); + return flipped.isFavorite + ? [flipped, ...favorites, ...recents.slice(0, HISTORY_MAX)] + : [...favorites, flipped, ...recents].slice(0, favorites.length + HISTORY_MAX); }); this.persistHistory(); } @@ -216,18 +223,31 @@ export class SearchService { try { const raw = localStorage.getItem(HISTORY_KEY); if (!raw) return; - const parsed = JSON.parse(raw) as HistoryItem[]; + const parsed: unknown = JSON.parse(raw); if (!Array.isArray(parsed)) return; + const urls = new Set<string>(); + const valid = parsed.filter((h): h is HistoryItem => { + const ok = + typeof h?.url === 'string' && + typeof h.id === 'string' && + typeof h.labelHtml === 'string' && + typeof h.subLabelHtml === 'string' && + !urls.has(h.url); + if (ok) urls.add(h.url); + return ok; + }); // Preserve every favourite (never capped — users pinned them on // purpose), only trim non-favourites to HISTORY_MAX. Otherwise a // user with >10 pinned items would silently lose anything past // the first 10 on the next page load. - const favourites = parsed.filter((h) => h.isFavorite); - const recents = parsed.filter((h) => !h.isFavorite).slice(0, HISTORY_MAX); + const favourites = valid.filter((h) => h.isFavorite); + const recents = valid.filter((h) => !h.isFavorite).slice(0, HISTORY_MAX); this.historyState.set([...favourites, ...recents]); } catch { // Corrupt entry — wipe and move on. - localStorage.removeItem(HISTORY_KEY); + try { + localStorage.removeItem(HISTORY_KEY); + } catch {} } } diff --git a/apps/docs/src/app/theme.ts b/apps/docs/src/app/theme.ts index fdc7398..8056518 100644 --- a/apps/docs/src/app/theme.ts +++ b/apps/docs/src/app/theme.ts @@ -7,7 +7,8 @@ const STORAGE_KEY = 'ngmd-theme'; @Injectable({providedIn: 'root'}) export class ThemeService { private readonly document = inject(DOCUMENT); - private readonly isBrowser = typeof window !== 'undefined'; + private readonly isBrowser = + typeof window !== 'undefined' && typeof window.matchMedia === 'function'; readonly mode = signal<Mode>(this.read()); @@ -36,7 +37,10 @@ export class ThemeService { private read(): Mode { if (!this.isBrowser) return 'auto'; - const stored = localStorage.getItem(STORAGE_KEY) as Mode | null; + let stored: string | null = null; + try { + stored = localStorage.getItem(STORAGE_KEY); + } catch {} if (stored === 'light' || stored === 'dark' || stored === 'auto') return stored; return 'auto'; } @@ -51,6 +55,8 @@ export class ThemeService { : 'light' : mode; root.classList.toggle('dark', resolved === 'dark'); - localStorage.setItem(STORAGE_KEY, mode); + try { + localStorage.setItem(STORAGE_KEY, mode); + } catch {} } } diff --git a/apps/docs/src/app/title-strategy.ts b/apps/docs/src/app/title-strategy.ts index 1bf0057..6c06097 100644 --- a/apps/docs/src/app/title-strategy.ts +++ b/apps/docs/src/app/title-strategy.ts @@ -1,7 +1,7 @@ -import {Injectable} from '@angular/core'; +import {Injectable, inject} from '@angular/core'; import {Title} from '@angular/platform-browser'; import {TitleStrategy, type RouterStateSnapshot} from '@angular/router'; -import siteConfig, {navLabels} from '../ngmd.config'; +import siteConfig, {navItems, navLabels} from '../ngmd.config'; import {stripUrl} from './services/route-url/route-url.service'; /** @@ -12,22 +12,20 @@ import {stripUrl} from './services/route-url/route-url.service'; * by a `.page.ts` component via `routeMeta`). * 2. The matching entry in `ngmd.config.ts > navLabels`. * 3. A prettified last URL segment ("markdown-routes" → "Markdown Routes"). - * 4. `siteConfig.site.tagline` for the homepage (no `|` separator there). + * 4. `siteConfig.site.tagline` for the homepage. * * Replaces Angular's `DefaultTitleStrategy`, which would otherwise overwrite * our format with just the raw frontmatter title. */ @Injectable({providedIn: 'root'}) export class NgmdTitleStrategy extends TitleStrategy { - constructor(private readonly title: Title) { - super(); - } + private readonly title = inject(Title); override updateTitle(snapshot: RouterStateSnapshot): void { const name = siteConfig.site.name; const url = stripUrl(snapshot.url); - // Homepage uses the tagline, no pipe separator. + // Homepage uses the tagline in place of a page title. if (url === '/' || url === '') { const tagline = siteConfig.site.tagline; this.title.setTitle(tagline ? `${name} | ${tagline}` : name); @@ -38,6 +36,7 @@ export class NgmdTitleStrategy extends TitleStrategy { if (!pageTitle) { const last = url.split('/').filter(Boolean).pop() ?? ''; pageTitle = + navItems.find((n) => n.href === url)?.label ?? navLabels[last] ?? last .split('-') diff --git a/apps/docs/src/app/ui/accordion.ts b/apps/docs/src/app/ui/accordion.ts index a9e1fdf..438c158 100644 --- a/apps/docs/src/app/ui/accordion.ts +++ b/apps/docs/src/app/ui/accordion.ts @@ -30,13 +30,13 @@ let idCounter = 0; [attr.aria-expanded]="expanded()" [attr.aria-controls]="regionId" (click)="toggle()" - class="flex w-full items-center justify-between gap-3 cursor-pointer px-5 py-3 text-sm font-medium text-zinc-900 dark:text-zinc-100 hover:bg-zinc-50 dark:hover:bg-zinc-900 transition-colors text-left [outline:none!important] [-webkit-tap-highlight-color:transparent] focus:[box-shadow:none] focus-visible:[box-shadow:none]" + class="flex w-full items-center justify-between gap-3 cursor-pointer px-5 py-3 text-sm font-medium text-zinc-900 dark:text-zinc-100 hover:bg-zinc-50 dark:hover:bg-zinc-900 transition-colors text-left [-webkit-tap-highlight-color:transparent]" > <span class="flex items-center gap-2"> @if (image()) { <img [src]="image()" - [alt]="title()" + alt="" width="16" height="16" style="display:inline-block;object-fit:contain;flex-shrink:0" diff --git a/apps/docs/src/app/ui/alert.ts b/apps/docs/src/app/ui/alert.ts index b1bdd09..b1e43b7 100644 --- a/apps/docs/src/app/ui/alert.ts +++ b/apps/docs/src/app/ui/alert.ts @@ -28,11 +28,11 @@ const STRIPE: Record<AlertSeverity, string> = { }; const ACCENT: Record<AlertSeverity, string> = { - info: 'text-blue-600 dark:text-blue-400', - warning: 'text-amber-600 dark:text-amber-400', - critical: 'text-red-600 dark:text-red-400', - helpful: 'text-teal-600 dark:text-teal-400', - important: 'text-purple-600 dark:text-purple-400', + info: 'text-blue-700 dark:text-blue-400', + warning: 'text-amber-700 dark:text-amber-400', + critical: 'text-red-700 dark:text-red-400', + helpful: 'text-teal-700 dark:text-teal-400', + important: 'text-purple-700 dark:text-purple-400', }; const BOX = diff --git a/apps/docs/src/app/ui/badge.ts b/apps/docs/src/app/ui/badge.ts index 20a7149..e6db930 100644 --- a/apps/docs/src/app/ui/badge.ts +++ b/apps/docs/src/app/ui/badge.ts @@ -26,7 +26,9 @@ export class NgmdBadge { * runtime. Fall back to `new` for any unknown value so the chip always * renders rather than collapsing to an unstyled span. */ - protected readonly variantClass = computed( - () => BADGE_VARIANTS[this.variant()] ?? BADGE_VARIANTS.new, + protected readonly variantClass = computed(() => + Object.hasOwn(BADGE_VARIANTS, this.variant()) + ? BADGE_VARIANTS[this.variant()] + : BADGE_VARIANTS.new, ); } diff --git a/apps/docs/src/app/ui/card-grid.ts b/apps/docs/src/app/ui/card-grid.ts index 510e717..14de23e 100644 --- a/apps/docs/src/app/ui/card-grid.ts +++ b/apps/docs/src/app/ui/card-grid.ts @@ -11,7 +11,7 @@ import {Component, computed, input} from '@angular/core'; @Component({ selector: 'ngmd-card-grid', template: ` - <div class="grid grid-cols-1 gap-4" [class]="colsClass()"> + <div class="grid grid-cols-1 gap-4 [&>ngmd-card]:my-0" [class]="colsClass()"> <ng-content></ng-content> </div> `, diff --git a/apps/docs/src/app/ui/card.ts b/apps/docs/src/app/ui/card.ts index bca088d..6482225 100644 --- a/apps/docs/src/app/ui/card.ts +++ b/apps/docs/src/app/ui/card.ts @@ -1,6 +1,6 @@ -import {Component, computed, input} from '@angular/core'; +import {Component, computed, inject, input} from '@angular/core'; import {NgTemplateOutlet} from '@angular/common'; -import {RouterLink} from '@angular/router'; +import {Router, RouterLink} from '@angular/router'; import { LucideDynamicIcon, type LucideIcon, @@ -73,7 +73,7 @@ const ICON_MAP: Record<string, LucideIcon> = { </a> } @else { <a - [routerLink]="link()" + [routerLink]="route()" class="h-full flex flex-col rounded-xl border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-950 p-5 text-inherit no-underline transition-colors hover:border-zinc-400 dark:hover:border-zinc-600" > <ng-container *ngTemplateOutlet="body"></ng-container> @@ -91,7 +91,7 @@ const ICON_MAP: Record<string, LucideIcon> = { @if (image()) { <img [src]="image()" - [alt]="title() || ''" + alt="" class="mb-4" [class]="avatar() ? 'size-14 rounded-full object-cover' : 'size-8 object-contain'" aria-hidden="true" @@ -118,14 +118,15 @@ const ICON_MAP: Record<string, LucideIcon> = { <ng-content></ng-content> </div> @if (cta()) { - <span class="mt-3 inline-block text-sm font-medium text-[color:var(--accent)]" - >{{ cta() }} →</span + <span class="mt-3 inline-block text-sm font-medium text-[color:var(--accent-strong)]" + >{{ cta() }} <span aria-hidden="true">→</span></span > } </ng-template> `, }) export class NgmdCard { + private readonly router = inject(Router); readonly title = input<string>(''); readonly link = input<string>(''); readonly cta = input<string>(''); @@ -133,6 +134,7 @@ export class NgmdCard { // External = anything with a scheme (http, https, mailto, tel). RouterLink // would interpret these as relative routes and fail to navigate. readonly isExternal = computed(() => /^(https?|mailto|tel):/.test(this.link())); + protected readonly route = computed(() => this.router.parseUrl(this.link())); readonly icon = input<string>(''); /** * Optional image URL (brand logo etc.). Takes priority over `icon` when diff --git a/apps/docs/src/app/ui/code-block.ts b/apps/docs/src/app/ui/code-block.ts index b3f2957..4ac4035 100644 --- a/apps/docs/src/app/ui/code-block.ts +++ b/apps/docs/src/app/ui/code-block.ts @@ -22,7 +22,7 @@ import {writeToClipboard} from '../utils/clipboard'; type="button" (click)="copy()" [attr.aria-label]="copied() ? 'Copied' : 'Copy code'" - class="inline-flex items-center justify-center size-6 rounded-md text-zinc-500 dark:text-zinc-400 hover:bg-zinc-200 dark:hover:bg-zinc-800 hover:text-zinc-900 dark:hover:text-zinc-100 opacity-0 group-hover:opacity-100 focus:opacity-100 transition-opacity" + class="inline-flex items-center justify-center size-6 rounded-md text-zinc-500 dark:text-zinc-400 hover:bg-zinc-200 dark:hover:bg-zinc-800 hover:text-zinc-900 dark:hover:text-zinc-100 opacity-0 group-hover:opacity-100 focus:opacity-100 pointer-coarse:opacity-100 transition-opacity" > @if (copied()) { <svg @@ -61,7 +61,7 @@ import {writeToClipboard} from '../utils/clipboard'; type="button" (click)="copy()" [attr.aria-label]="copied() ? 'Copied' : 'Copy code'" - class="absolute top-2 right-2 inline-flex items-center justify-center size-7 rounded-md bg-zinc-800/80 text-zinc-300 hover:bg-zinc-700 hover:text-white opacity-0 group-hover:opacity-100 focus:opacity-100 transition-opacity z-10" + class="absolute top-2 right-2 inline-flex items-center justify-center size-7 rounded-md bg-zinc-800/80 text-zinc-300 hover:bg-zinc-700 hover:text-white opacity-0 group-hover:opacity-100 focus:opacity-100 pointer-coarse:opacity-100 transition-opacity z-10" > @if (copied()) { <svg @@ -141,7 +141,7 @@ export class NgmdCodeBlock { if (cancelled) return; const html = await codeToHtml(code, { lang: lang || 'text', - themes: {light: 'github-light', dark: 'github-dark'}, + themes: {light: 'github-light-default', dark: 'github-dark-default'}, defaultColor: false, }); if (!cancelled) { diff --git a/apps/docs/src/app/ui/hero.ts b/apps/docs/src/app/ui/hero.ts index dfc0413..823f824 100644 --- a/apps/docs/src/app/ui/hero.ts +++ b/apps/docs/src/app/ui/hero.ts @@ -12,13 +12,13 @@ import {Component, computed, input} from '@angular/core'; @if (logo()) { <img [src]="logo()" alt="" aria-hidden="true" class="size-9 sm:size-10 object-contain" /> } - <h1 + <p class="text-3xl sm:text-4xl font-bold tracking-tight m-0" [class]="titleClass()" [style.background-image]="gradient() ? 'var(--accent-gradient)' : null" > {{ title() }} - </h1> + </p> </div> <div class="text-base sm:text-lg leading-relaxed max-w-prose [&>*:first-child]:mt-0 [&>*:last-child]:mb-0" diff --git a/apps/docs/src/app/ui/image.ts b/apps/docs/src/app/ui/image.ts index 11a808e..5dff7bf 100644 --- a/apps/docs/src/app/ui/image.ts +++ b/apps/docs/src/app/ui/image.ts @@ -1,9 +1,9 @@ -import {Component, input} from '@angular/core'; +import {Component, computed, input} from '@angular/core'; @Component({ selector: 'ngmd-image', template: ` - <figure class="mx-0" [style.max-width]="width()"> + <figure class="mx-0" [style.max-width]="maxWidth()"> <img [src]="src()" [alt]="alt()" @@ -23,4 +23,9 @@ export class NgmdImage { readonly alt = input<string>(''); readonly caption = input<string>(''); readonly width = input<string>(''); + + protected readonly maxWidth = computed(() => { + const width = this.width().trim(); + return /^\d+(\.\d+)?$/.test(width) ? `${width}px` : width || null; + }); } diff --git a/apps/docs/src/app/ui/pill.ts b/apps/docs/src/app/ui/pill.ts index d346948..1e96006 100644 --- a/apps/docs/src/app/ui/pill.ts +++ b/apps/docs/src/app/ui/pill.ts @@ -1,5 +1,5 @@ -import {Component, computed, input} from '@angular/core'; -import {RouterLink} from '@angular/router'; +import {Component, computed, inject, input} from '@angular/core'; +import {Router, RouterLink} from '@angular/router'; import {LucideDynamicIcon, LucideArrowRight, LucideArrowUpRight} from '@lucide/angular'; /** @@ -18,7 +18,7 @@ import {LucideDynamicIcon, LucideArrowRight, LucideArrowUpRight} from '@lucide/a [href]="href()" target="_blank" rel="noopener noreferrer" - class="group inline-flex items-center gap-1.5 px-3 py-1 rounded-full border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-950 text-sm font-medium text-zinc-900 dark:text-zinc-100 no-underline transition-colors hover:border-[color:var(--accent)] hover:text-[color:var(--accent)]" + class="group inline-flex items-center gap-1.5 px-3 py-1 rounded-full border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-950 text-sm font-medium text-zinc-900 dark:text-zinc-100 no-underline transition-colors hover:border-[color:var(--accent)] hover:text-[color:var(--accent-strong)]" > <span>{{ title() }}</span> <svg @@ -29,8 +29,8 @@ import {LucideDynamicIcon, LucideArrowRight, LucideArrowUpRight} from '@lucide/a </a> } @else { <a - [routerLink]="href()" - class="group inline-flex items-center gap-1.5 px-3 py-1 rounded-full border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-950 text-sm font-medium text-zinc-900 dark:text-zinc-100 no-underline transition-colors hover:border-[color:var(--accent)] hover:text-[color:var(--accent)]" + [routerLink]="route()" + class="group inline-flex items-center gap-1.5 px-3 py-1 rounded-full border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-950 text-sm font-medium text-zinc-900 dark:text-zinc-100 no-underline transition-colors hover:border-[color:var(--accent)] hover:text-[color:var(--accent-strong)]" > <span>{{ title() }}</span> <svg @@ -43,10 +43,13 @@ import {LucideDynamicIcon, LucideArrowRight, LucideArrowUpRight} from '@lucide/a `, }) export class NgmdPill { + private readonly router = inject(Router); + readonly href = input.required<string>(); readonly title = input.required<string>(); - readonly isExternal = computed(() => /^https?:\/\//.test(this.href())); + readonly isExternal = computed(() => /^(https?|mailto|tel):/.test(this.href())); + protected readonly route = computed(() => this.router.parseUrl(this.href())); protected readonly internalIcon = LucideArrowRight; protected readonly externalIcon = LucideArrowUpRight; diff --git a/apps/docs/src/app/ui/tabs.ts b/apps/docs/src/app/ui/tabs.ts index 2b8296f..b9d578f 100644 --- a/apps/docs/src/app/ui/tabs.ts +++ b/apps/docs/src/app/ui/tabs.ts @@ -1,6 +1,7 @@ import { AfterViewInit, Component, + contentChildren, DestroyRef, ElementRef, inject, @@ -83,7 +84,13 @@ const ICON_MAP: Record<string, LucideIcon> = { @Component({ selector: 'ngmd-tab', - host: {role: 'tabpanel', '[hidden]': '!active()'}, + host: { + role: 'tabpanel', + tabindex: '0', + class: + 'block focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[color:var(--accent)]', + '[hidden]': '!active()', + }, template: ` <div class="p-5 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0" [hidden]="!active()"> <ng-content></ng-content> @@ -95,13 +102,11 @@ export class NgmdTab { readonly icon = input<string>(''); readonly image = input<string>(''); readonly active = signal(false); + readonly host = inject<ElementRef<HTMLElement>>(ElementRef).nativeElement; constructor() { // Parent (`<ngmd-tabs>`) toggles `data-active` on each child host. - // Server-side falls back to "inactive" — the parent will hydrate state - // when the bundle runs on the client. - const host = inject<ElementRef<HTMLElement>>(ElementRef).nativeElement; - const stop = watchHostAttribute(host, 'data-active', (value) => + const stop = watchHostAttribute(this.host, 'data-active', (value) => this.active.set(value === 'true'), ); inject(DestroyRef).onDestroy(stop); @@ -127,7 +132,7 @@ export class NgmdTab { [tabindex]="active() === tab.key ? 0 : -1" (click)="setActive(tab.key)" (keydown)="onKey($event, i)" - class="inline-flex items-center gap-1.5 px-4 py-2.5 text-sm font-medium border-b-2 -mb-px cursor-pointer transition-colors aria-selected:border-[color:var(--accent)] aria-selected:text-[color:var(--accent)] [&[aria-selected=false]]:border-transparent [&[aria-selected=false]]:text-zinc-500 [&[aria-selected=false]]:hover:text-zinc-900 dark:[&[aria-selected=false]]:hover:text-zinc-100 bg-transparent" + class="inline-flex items-center gap-1.5 px-4 py-2.5 text-sm font-medium border-b-2 -mb-px cursor-pointer transition-colors aria-selected:border-[color:var(--accent)] aria-selected:text-[color:var(--accent-strong)] [&[aria-selected=false]]:border-transparent [&[aria-selected=false]]:text-zinc-500 dark:[&[aria-selected=false]]:text-zinc-400 [&[aria-selected=false]]:hover:text-zinc-900 dark:[&[aria-selected=false]]:hover:text-zinc-100 bg-transparent" > @if (tab.image) { <img @@ -153,6 +158,7 @@ export class NgmdTab { export class NgmdTabs implements AfterViewInit { private readonly host: ElementRef<HTMLElement> = inject(ElementRef); private readonly uid = ++idCounter; + private readonly panels = contentChildren(NgmdTab, {descendants: true}); readonly tabs = signal< { key: string; @@ -165,13 +171,10 @@ export class NgmdTabs implements AfterViewInit { readonly active = signal(''); ngAfterViewInit(): void { - if (typeof document === 'undefined') return; - // `:scope ngmd-tab` because the `<ngmd-tab>` children land in the - // light DOM of `<ngmd-tabs>` — they project through `<ng-content>` but - // remain queryable via querySelectorAll on the host element. - const els = Array.from( - this.host.nativeElement.querySelectorAll<HTMLElement>(':scope ngmd-tab'), - ); + // `<ngmd-tab>` children land in the light DOM of `<ngmd-tabs>` — they + // project through `<ng-content>` but remain queryable via + // querySelectorAll on the host element. + const els = Array.from(this.host.nativeElement.querySelectorAll<HTMLElement>('ngmd-tab')); const list = els.map((el, i) => ({ key: `ngmd-tabs-${this.uid}-${i}`, label: el.getAttribute('title') ?? '', @@ -192,6 +195,9 @@ export class NgmdTabs implements AfterViewInit { for (const tab of this.tabs()) { tab.el.setAttribute('data-active', tab.key === key ? 'true' : 'false'); } + for (const panel of this.panels()) { + panel.active.set(panel.host.getAttribute('data-active') === 'true'); + } } protected onKey(event: KeyboardEvent, index: number): void { diff --git a/apps/docs/src/app/ui/ui.spec.ts b/apps/docs/src/app/ui/ui.spec.ts new file mode 100644 index 0000000..b88f9d1 --- /dev/null +++ b/apps/docs/src/app/ui/ui.spec.ts @@ -0,0 +1,144 @@ +import {Component} from '@angular/core'; +import {TestBed} from '@angular/core/testing'; +import {provideRouter} from '@angular/router'; +import {NgmdBadge} from './badge'; +import {NgmdCard} from './card'; +import {NgmdCardGrid} from './card-grid'; +import {NgmdImage} from './image'; +import {NgmdPill} from './pill'; +import {NgmdTab, NgmdTabs} from './tabs'; + +describe('NgmdBadge', () => { + it('falls back to the new variant for unknown or inherited keys', async () => { + for (const variant of ['nope', 'toString', 'constructor']) { + const fixture = TestBed.createComponent(NgmdBadge); + fixture.componentRef.setInput('variant', variant); + await fixture.whenStable(); + expect(fixture.nativeElement.querySelector('span').className).toContain('bg-sky-100'); + } + }); +}); + +describe('NgmdCard', () => { + beforeEach(() => TestBed.configureTestingModule({providers: [provideRouter([])]})); + + it('keeps the fragment and query of an internal link', async () => { + const fixture = TestBed.createComponent(NgmdCard); + fixture.componentRef.setInput('link', '/concepts/theming?tab=css#tokens'); + await fixture.whenStable(); + const a: HTMLAnchorElement = fixture.nativeElement.querySelector('a'); + expect(a.getAttribute('href')).toBe('/concepts/theming?tab=css#tokens'); + expect(a.hasAttribute('target')).toBe(false); + }); + + it('opens external links in a new tab and hides the CTA arrow', async () => { + const fixture = TestBed.createComponent(NgmdCard); + fixture.componentRef.setInput('link', 'https://angular.dev'); + fixture.componentRef.setInput('cta', 'Visit'); + await fixture.whenStable(); + const a: HTMLAnchorElement = fixture.nativeElement.querySelector('a'); + expect(a.getAttribute('target')).toBe('_blank'); + expect(a.getAttribute('rel')).toBe('noopener noreferrer'); + expect(a.querySelector('[aria-hidden="true"]')?.textContent).toBe('→'); + }); + + it('reads the avatar attribute as a boolean', async () => { + const fixture = TestBed.createComponent(NgmdCard); + fixture.componentRef.setInput('image', '/me.png'); + fixture.componentRef.setInput('avatar', ''); + await fixture.whenStable(); + const img: HTMLImageElement = fixture.nativeElement.querySelector('img'); + expect(img.className).toContain('rounded-full'); + expect(img.getAttribute('alt')).toBe(''); + }); +}); + +describe('NgmdCardGrid', () => { + it('maps the columns attribute and falls back to two', async () => { + const fixture = TestBed.createComponent(NgmdCardGrid); + const grid = (): string => fixture.nativeElement.querySelector('div').className; + fixture.componentRef.setInput('columns', '3'); + await fixture.whenStable(); + expect(grid()).toContain('sm:grid-cols-3'); + fixture.componentRef.setInput('columns', 'abc'); + await fixture.whenStable(); + expect(grid()).toContain('sm:grid-cols-2'); + }); +}); + +describe('NgmdPill', () => { + beforeEach(() => TestBed.configureTestingModule({providers: [provideRouter([])]})); + + it('treats mailto as external and keeps internal fragments', async () => { + const fixture = TestBed.createComponent(NgmdPill); + fixture.componentRef.setInput('title', 'Mail'); + fixture.componentRef.setInput('href', 'mailto:hi@example.com'); + await fixture.whenStable(); + let a: HTMLAnchorElement = fixture.nativeElement.querySelector('a'); + expect(a.getAttribute('href')).toBe('mailto:hi@example.com'); + + fixture.componentRef.setInput('href', '/help#faq'); + await fixture.whenStable(); + a = fixture.nativeElement.querySelector('a'); + expect(a.getAttribute('href')).toBe('/help#faq'); + }); +}); + +describe('NgmdImage', () => { + it('adds px to a unitless width and keeps CSS lengths', async () => { + const fixture = TestBed.createComponent(NgmdImage); + const figure = (): HTMLElement => fixture.nativeElement.querySelector('figure'); + fixture.componentRef.setInput('src', '/logo.svg'); + fixture.componentRef.setInput('width', '640'); + await fixture.whenStable(); + expect(figure().style.maxWidth).toBe('640px'); + fixture.componentRef.setInput('width', '50%'); + await fixture.whenStable(); + expect(figure().style.maxWidth).toBe('50%'); + }); +}); + +describe('NgmdTab', () => { + it('is a focusable tab panel that follows data-active', async () => { + const fixture = TestBed.createComponent(NgmdTab); + const host: HTMLElement = fixture.nativeElement; + await fixture.whenStable(); + expect(host.getAttribute('role')).toBe('tabpanel'); + expect(host.getAttribute('tabindex')).toBe('0'); + expect(host.hidden).toBe(true); + host.setAttribute('data-active', 'true'); + await new Promise((resolve) => setTimeout(resolve)); + await fixture.whenStable(); + expect(host.hidden).toBe(false); + }); +}); + +@Component({ + imports: [NgmdTabs, NgmdTab], + template: ` + <ngmd-tabs> + <ngmd-tab title="One">first</ngmd-tab> + <ngmd-tab title="Two">second</ngmd-tab> + </ngmd-tabs> + `, +}) +class TabsHost {} + +describe('NgmdTabs', () => { + it('activates the first panel and moves with the arrow keys', async () => { + const fixture = TestBed.createComponent(TabsHost); + await fixture.whenStable(); + const root: HTMLElement = fixture.nativeElement; + const panels = Array.from(root.querySelectorAll<HTMLElement>('ngmd-tab')); + const tabs = Array.from(root.querySelectorAll<HTMLButtonElement>('[role=tab]')); + expect(tabs.map((t) => t.textContent?.trim())).toEqual(['One', 'Two']); + expect(panels.map((p) => p.hidden)).toEqual([false, true]); + expect(panels[0].getAttribute('aria-labelledby')).toBe(tabs[0].id); + + tabs[0].dispatchEvent(new KeyboardEvent('keydown', {key: 'ArrowRight', bubbles: true})); + await fixture.whenStable(); + expect(tabs[1].getAttribute('aria-selected')).toBe('true'); + expect(panels.map((p) => p.hidden)).toEqual([true, false]); + expect(document.activeElement === tabs[1] || !tabs[1].isConnected).toBe(true); + }); +}); diff --git a/apps/docs/src/app/utils/heading-slug.spec.ts b/apps/docs/src/app/utils/heading-slug.spec.ts index a9dae3c..e6b0d2a 100644 --- a/apps/docs/src/app/utils/heading-slug.spec.ts +++ b/apps/docs/src/app/utils/heading-slug.spec.ts @@ -44,3 +44,15 @@ describe('headingText', () => { ); }); }); + +describe('slugify edge cases', () => { + it('folds accents and never returns an empty slug', () => { + expect(slugify('Café Über')).toBe('cafe-uber'); + expect(slugify('日本語')).toBe('section'); + expect(slugify('🚀 Launch')).toBe('launch'); + }); + + it('decodes hex entities like the rendered heading', () => { + expect(headingText('Install @scope/pkg')).toBe('Install @scope/pkg'); + }); +}); diff --git a/apps/docs/src/app/utils/heading-slug.ts b/apps/docs/src/app/utils/heading-slug.ts index 5c67302..12794d9 100644 --- a/apps/docs/src/app/utils/heading-slug.ts +++ b/apps/docs/src/app/utils/heading-slug.ts @@ -7,11 +7,15 @@ * `.`, `_`, `*`, spaces, etc.) into a single `-`, then trim outer hyphens. */ export function slugify(s: string): string { - return s - .toLowerCase() - .trim() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-|-$/g, ''); + return ( + s + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, '') || 'section' + ); } export function createSlugger(): (text: string) => string { @@ -49,7 +53,8 @@ export function headingText(markdown: string): string { .replace(/<ngmd-badge\b[^>]*>[\s\S]*?<\/ngmd-badge>/g, '') .replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1') .replace(/<[^>]+>/g, '') - .replace(/&#(\d+);/g, (_, n: string) => String.fromCharCode(Number(n))) + .replace(/&#(\d+);/g, (_, n: string) => String.fromCodePoint(Number(n))) + .replace(/&#x([0-9a-f]+);/gi, (_, n: string) => String.fromCodePoint(parseInt(n, 16))) .replace(/&(amp|lt|gt|quot|apos|nbsp);/g, (_, e: string) => ENTITIES[e]) .replace(/\u0000(\d+)\u0000/g, (_, i: string) => code[Number(i)]) .trim(); diff --git a/apps/docs/src/content/contributing/kitchen-sink.md b/apps/docs/src/content/contributing/kitchen-sink.md index f120ad9..5a6754f 100644 --- a/apps/docs/src/content/contributing/kitchen-sink.md +++ b/apps/docs/src/content/contributing/kitchen-sink.md @@ -67,7 +67,7 @@ export const appConfig: ApplicationConfig = { ### Line highlights -```ts {2,5-7} +```ts {2,6-8} // src/main.ts import {bootstrapApplication} from '@angular/platform-browser'; import {App} from './app/app'; @@ -279,7 +279,9 @@ Text between alerts. ### Image -<ngmd-image src="/og.png" alt="The Angular DevTools social card" caption="An image with a caption." width="640"></ngmd-image> +<ngmd-image src="/images/cats.jpg" alt="Two tabby kittens, Angular and Excel, looking up, one sitting in a flower pot" width="360"></ngmd-image> + +<p style="max-width: 360px; margin-top: -1rem; text-align: center; font-size: 0.875rem; color: var(--muted)">Say hi to <a href="https://github.com/erkamyaman" target="_blank" rel="noopener noreferrer">my</a> cats Angular and Excel 👋</p> ### Video diff --git a/apps/docs/src/content/contributing/writing-docs.md b/apps/docs/src/content/contributing/writing-docs.md index 9715030..e198792 100644 --- a/apps/docs/src/content/contributing/writing-docs.md +++ b/apps/docs/src/content/contributing/writing-docs.md @@ -73,7 +73,7 @@ Every page follows the same skeleton. ```md --- title: Router -description: One sentence for search results and link previews. +description: One sentence that summarizes the page. --- <ngmd-hero title="Router" gradient> @@ -99,7 +99,7 @@ A short intro: what the tab is and when you open it. ### Frontmatter -`title` is the browser tab and sidebar title. `description` is used for search, the page meta tags and the sitemap. Keep it to one sentence. +`title` is the page title in search results. The sidebar and the browser tab use the page's `label` in `nav`. Keep `description` to one sentence that summarizes the page. ### Headings @@ -140,7 +140,7 @@ Card icons come from a fixed set: `book`, `box`, `code`, `compass`, `file`, `lay - End a page with a pill row or a card grid that points to the next pages, not both. - Always write a closing tag, such as `<ngmd-pill ...></ngmd-pill>`. HTML doesn't honour self-closing custom elements, so the next element ends up nested inside. - Write external links in raw HTML with `target="_blank" rel="noopener noreferrer"`, or the build fails. Markdown links get both automatically. -- Components that contain HTML use HTML for inline formatting: `<code>`, `<strong>` and `<a>`. Markdown doesn't render inside them. Write `@` as `@` inside components, because the renderer can read a bare `@` as Angular template syntax. +- Components that contain HTML use HTML for inline formatting: `<code>`, `<strong>` and `<a>`. Markdown doesn't render inside them. Write `@` as `@` inside components. ### Keyword links diff --git a/apps/docs/src/content/getting-started/vite.md b/apps/docs/src/content/getting-started/vite.md index 2879ddf..6760599 100644 --- a/apps/docs/src/content/getting-started/vite.md +++ b/apps/docs/src/content/getting-started/vite.md @@ -84,7 +84,7 @@ It records Analog page renders, `load()` fetches, server functions and API calls ### Answers only your machine -The plugin only answers requests from a loopback address (`127.0.0.1` or `::1`). Other requests to the devtools get `403` with the message "ng-devtools only answers requests from this machine." WebSocket upgrades follow the same rules. +The plugin only answers requests from a loopback address (any `127.x.x.x` address or `::1`). Other requests to the devtools get `403` with the message "ng-devtools only answers requests from this machine." WebSocket upgrades follow the same rules. The Vite plugin turns the one-time code off. The loopback and origin checks take its place. [Access and redaction](/security) covers both checks. diff --git a/apps/docs/src/marked-extensions/fences.ts b/apps/docs/src/marked-extensions/fences.ts new file mode 100644 index 0000000..f2a44f2 --- /dev/null +++ b/apps/docs/src/marked-extensions/fences.ts @@ -0,0 +1,69 @@ +export interface Fence { + start: number; + end: number; + lang: string; + attrs: string; + body: string; +} + +const LINE_RE = /[^\n]*\n?/g; + +export function findFences(markdown: string): Fence[] { + const fences: Fence[] = []; + let open: {marker: string; start: number; info: string; top: boolean; body: string[]} | null = + null; + LINE_RE.lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = LINE_RE.exec(markdown)) !== null && m[0] !== '') { + const line = m[0].replace(/\r?\n$/, ''); + const fence = /^( {0,3})(`{3,}|~{3,})(.*)$/.exec(line); + if (!open) { + if (fence && !(fence[2][0] === '`' && fence[3].includes('`'))) { + open = {marker: fence[2], start: m.index, info: fence[3], top: !fence[1], body: []}; + } + continue; + } + if ( + fence && + fence[2][0] === open.marker[0] && + fence[2].length >= open.marker.length && + !fence[3].trim() + ) { + if (open.top) { + const info = open.info.trim(); + const lang = /^[^\s={}"]+(?=\s|$)/.exec(info)?.[0] ?? ''; + fences.push({ + start: open.start, + end: m.index + line.length, + lang, + attrs: info.slice(lang.length).trim(), + body: open.body.join('\n'), + }); + } + open = null; + continue; + } + open.body.push(line); + } + return fences; +} + +export function getAttr(attrs: string, name: string): string | undefined { + return new RegExp(`(?:^|\\s)${name}="([^"]*)"`).exec(attrs)?.[1]; +} + +export function hasFlag(attrs: string, name: string): boolean { + return new RegExp(`(^|\\s)${name}(\\s|$)`).test(attrs.replace(/"[^"]*"/g, '""')); +} + +export function replaceFences( + markdown: string, + fences: Array<{start: number; end: number}>, + html: string[], +): string { + let result = markdown; + for (let i = fences.length - 1; i >= 0; i--) { + result = result.slice(0, fences[i].start) + `\n\n${html[i]}\n\n` + result.slice(fences[i].end); + } + return result; +} diff --git a/apps/docs/src/marked-extensions/index.ts b/apps/docs/src/marked-extensions/index.ts index 6d6a7a8..4e6be6b 100644 --- a/apps/docs/src/marked-extensions/index.ts +++ b/apps/docs/src/marked-extensions/index.ts @@ -22,16 +22,22 @@ export {ngmdRuntimeExtensions}; // never resolves their `node:fs` / `shiki` imports. The async getter is // called by `vite.config.ts` (Node context) only. export async function getBuildExtensions(): Promise<MarkedExtension[]> { - const [{ngmdCodeImportExtension}, {ngmdCodeGroupExtension}, {ngmdCodeHighlightExtension}] = - await Promise.all([ - import('./ngmd-code-import.ts'), - import('./ngmd-code-group.ts'), - import('./ngmd-code-highlight.ts'), - ]); + const [ + {ngmdCodeImportExtension}, + {ngmdCodeGroupExtension}, + {ngmdCodeHighlightExtension}, + {substituteMdVars}, + ] = await Promise.all([ + import('./ngmd-code-import.ts'), + import('./ngmd-code-group.ts'), + import('./ngmd-code-highlight.ts'), + import('../../vars.plugin.ts'), + ]); return [ ...ngmdRuntimeExtensions, ngmdCodeImportExtension, ngmdCodeGroupExtension, ngmdCodeHighlightExtension, + {hooks: {preprocess: (markdown: string) => substituteMdVars(markdown)}}, ]; } diff --git a/apps/docs/src/marked-extensions/marked-extensions.spec.ts b/apps/docs/src/marked-extensions/marked-extensions.spec.ts index 535be60..494c439 100644 --- a/apps/docs/src/marked-extensions/marked-extensions.spec.ts +++ b/apps/docs/src/marked-extensions/marked-extensions.spec.ts @@ -1,6 +1,11 @@ import {Marked} from 'marked'; +import {findFences, getAttr, hasFlag} from './fences'; import {ngmdImageExtension} from './ngmd-image'; +import {ngmdKeywordsExtension} from './ngmd-keywords'; import {ngmdVideoExtension} from './ngmd-video'; +import {ngmdRuntimeExtensions} from './runtime'; + +vi.mock('../ngmd.config.ts', () => ({default: {keywords: {Kw: '/kw'}}})); function render(markdown: string): string { return new Marked({extensions: [ngmdImageExtension, ngmdVideoExtension]}).parse( @@ -36,3 +41,60 @@ describe('ngmd-video', () => { expect(html).toContain('data-video-title="a <b"'); }); }); + +describe('findFences', () => { + it('returns only closed top-level fences, with CRLF and tildes', () => { + const md = [ + '````md', + '```ts {1}', + 'inner', + '```', + '````', + '~~~ts title="a b" {2}', + 'x', + '~~~', + ' ```ts {1}', + ' indented', + ' ```', + '```ts', + 'unclosed', + ].join('\r\n'); + const fences = findFences(md); + expect(fences.map((f) => [f.lang, f.attrs, f.body])).toEqual([ + ['md', '', '```ts {1}\ninner\n```'], + ['ts', 'title="a b" {2}', 'x'], + ]); + expect(md.slice(fences[1].start, fences[1].end)).toBe('~~~ts title="a b" {2}\r\nx\r\n~~~'); + }); + + it('reads attributes by whole name and flags outside quoted values', () => { + expect(getAttr('filename="a" name="b"', 'name')).toBe('b'); + expect(getAttr('data-group="a"', 'group')).toBeUndefined(); + expect(hasFlag('name="active tab"', 'active')).toBe(false); + expect(hasFlag('name="x" active', 'active')).toBe(true); + }); +}); + +describe('ngmd-keywords', () => { + const md = (src: string) => new Marked(ngmdKeywordsExtension).parse(src) as string; + + it('links keywords but not inside links or code', () => { + expect(md('*Kw. [the *Kw docs](/x) `*Kw`')).toBe( + '<p><a href="/kw">Kw</a>. <a href="/x">the Kw docs</a> <code>*Kw</code></p>\n', + ); + }); + + it('does not warn about emphasis that starts with a capital', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + expect(md('*Note:* hi')).toBe('<p><em>Note:</em> hi</p>\n'); + expect(warn).not.toHaveBeenCalled(); + warn.mockRestore(); + }); +}); + +describe('runtime extensions', () => { + it('makes tables keyboard focusable so they can scroll', () => { + const html = new Marked(...ngmdRuntimeExtensions).parse('| a |\n| - |\n| 1 |') as string; + expect(html).toContain('<table tabindex="0">'); + }); +}); diff --git a/apps/docs/src/marked-extensions/ngmd-code-group.ts b/apps/docs/src/marked-extensions/ngmd-code-group.ts index 2bffd4f..a3647c7 100644 --- a/apps/docs/src/marked-extensions/ngmd-code-group.ts +++ b/apps/docs/src/marked-extensions/ngmd-code-group.ts @@ -1,6 +1,7 @@ import type {MarkedExtension} from 'marked'; -import {getHighlighter, LANGS} from './shiki-shared.ts'; +import {highlightCode} from './shiki-shared.ts'; import {escapeHtml} from './escape-html.ts'; +import {findFences, getAttr, hasFlag, replaceFences, type Fence} from './fences.ts'; /** * Adjacent fenced code blocks tagged with `group="..."` merge into a tabbed @@ -25,57 +26,23 @@ import {escapeHtml} from './escape-html.ts'; let groupCounter = 0; -const FENCE_WITH_GROUP_RE = - /^(`{3,})([\w-]+)?[\t ]+([^\n]*?\bgroup="([^"]+)"[^\n]*)\n([\s\S]*?)\n\1`*$/gm; - -interface Fence { - start: number; - end: number; - lang: string; - attrs: string; +interface GroupFence extends Fence { group: string; - body: string; -} - -function getAttr(attrs: string, name: string): string | undefined { - return new RegExp(`${name}="([^"]*)"`).exec(attrs)?.[1]; -} - -function hasFlag(attrs: string, name: string): boolean { - return new RegExp(`(^|\\s)${name}(\\s|$)`).test(attrs); -} - -async function renderCode(body: string, lang: string): Promise<string> { - const safeLang = LANGS.includes(lang) ? lang : 'text'; - const highlighter = await getHighlighter(); - return highlighter.codeToHtml(body, { - lang: safeLang, - themes: {light: 'github-light', dark: 'github-dark'}, - defaultColor: false, - }); } export const ngmdCodeGroupExtension: MarkedExtension = { hooks: { async preprocess(markdown: string): Promise<string> { - const fences: Fence[] = []; - const re = new RegExp(FENCE_WITH_GROUP_RE.source, FENCE_WITH_GROUP_RE.flags); - let m: RegExpExecArray | null; - while ((m = re.exec(markdown)) !== null) { - fences.push({ - start: m.index, - end: m.index + m[0].length, - lang: m[2] ?? '', - attrs: m[3], - group: m[4], - body: m[5], - }); + const fences: GroupFence[] = []; + for (const f of findFences(markdown)) { + const group = getAttr(f.attrs, 'group'); + if (group) fences.push({...f, group}); } if (fences.length === 0) return markdown; // Cluster consecutive same-group fences (whitespace-only between). - const clusters: Fence[][] = []; - let current: Fence[] = []; + const clusters: GroupFence[][] = []; + let current: GroupFence[] = []; for (const f of fences) { if ( current.length > 0 && @@ -90,12 +57,9 @@ export const ngmdCodeGroupExtension: MarkedExtension = { } if (current.length > 0) clusters.push(current); - // Replace from end to start so indices stay valid. - let result = markdown; - for (let i = clusters.length - 1; i >= 0; i--) { - const c = clusters[i]; - if (c.length < 2) continue; - + const merged = clusters.filter((c) => c.length > 1); + const wrappers: string[] = []; + for (const c of merged) { const groupId = `cg-${++groupCounter}`; let activeIdx = c.findIndex((f) => hasFlag(f.attrs, 'active')); if (activeIdx === -1) activeIdx = 0; @@ -114,18 +78,22 @@ export const ngmdCodeGroupExtension: MarkedExtension = { const panels = ( await Promise.all( c.map(async (f, idx) => { - const html = await renderCode(f.body, f.lang); + const html = await highlightCode(f.body, f.lang); return `<div class="ngmd-code-group__panel" data-id="${groupId}-${idx}" data-active="${idx === activeIdx}">${html}</div>`; }), ) ).join(''); - const wrapper = `\n\n<div class="ngmd-code-group" data-group="${groupId}"><div class="ngmd-code-group__tabs">${tabs}</div>${panels}</div>\n\n`; - - result = result.slice(0, c[0].start) + wrapper + result.slice(c.at(-1)!.end); + wrappers.push( + `<div class="ngmd-code-group" data-group="${groupId}"><div class="ngmd-code-group__tabs">${tabs}</div>${panels}</div>`, + ); } - return result; + return replaceFences( + markdown, + merged.map((c) => ({start: c[0].start, end: c.at(-1)!.end})), + wrappers, + ); }, }, }; diff --git a/apps/docs/src/marked-extensions/ngmd-code-highlight.ts b/apps/docs/src/marked-extensions/ngmd-code-highlight.ts index 442bca1..b656bee 100644 --- a/apps/docs/src/marked-extensions/ngmd-code-highlight.ts +++ b/apps/docs/src/marked-extensions/ngmd-code-highlight.ts @@ -1,5 +1,6 @@ import type {MarkedExtension} from 'marked'; -import {getHighlighter, LANGS} from './shiki-shared.ts'; +import {highlightCode} from './shiki-shared.ts'; +import {findFences, getAttr, replaceFences} from './fences.ts'; /** * Fenced code blocks tagged with `{1,3-5}` get the matching lines visually @@ -24,11 +25,9 @@ import {getHighlighter, LANGS} from './shiki-shared.ts'; * `file="..."` (handled by ngmd-code-import). One fence, one treatment. */ -// Capture: lang, line ranges in {}, body. Skips fences whose info string -// contains `group=` or `file=` so those routes own the fence. -const FENCE_RE = /^(`{3,})([\w-]+)?[\t ]+\{([0-9,\-\s]+)\}[\t ]*\n([\s\S]*?)\n\1`*$/gm; +const RANGES_RE = /(?:^|\s)\{([0-9,\-\s]+)\}(?=\s|$)/; -function parseRanges(spec: string): Set<number> { +function parseRanges(spec: string, lineCount: number): Set<number> { const lines = new Set<number>(); for (const part of spec .split(',') @@ -36,9 +35,10 @@ function parseRanges(spec: string): Set<number> { .filter(Boolean)) { const m = part.match(/^(\d+)(?:-(\d+))?$/); if (!m) continue; - const start = parseInt(m[1], 10); - const end = m[2] ? parseInt(m[2], 10) : start; - for (let i = start; i <= end; i++) lines.add(i); + const a = parseInt(m[1], 10); + const b = m[2] ? parseInt(m[2], 10) : a; + const end = Math.min(Math.max(a, b), lineCount); + for (let i = Math.max(Math.min(a, b), 1); i <= end; i++) lines.add(i); } return lines; } @@ -58,46 +58,28 @@ function applyHighlights(html: string, set: Set<number>): string { export const ngmdCodeHighlightExtension: MarkedExtension = { hooks: { async preprocess(markdown: string): Promise<string> { - // Quick negative check before scanning. - if (!/^`{3,}[\w-]*[\t ]+\{[0-9,\-\s]+\}/m.test(markdown)) return markdown; - - const matches: {start: number; end: number; lang: string; spec: string; body: string}[] = []; - const re = new RegExp(FENCE_RE.source, FENCE_RE.flags); - let m: RegExpExecArray | null; - while ((m = re.exec(markdown)) !== null) { - // Skip if the fence also carries `group=` or `file=` (other ext owns). - const infoLineEnd = markdown.indexOf('\n', m.index); - const infoLine = markdown.slice(m.index, infoLineEnd); - if (/\b(?:group|file)="/.test(infoLine)) continue; - matches.push({ - start: m.index, - end: m.index + m[0].length, - lang: m[2] ?? '', - spec: m[3], - body: m[4], - }); - } + const matches = findFences(markdown).flatMap((f) => { + const spec = RANGES_RE.exec(f.attrs)?.[1]; + if ( + !spec || + getAttr(f.attrs, 'group') !== undefined || + getAttr(f.attrs, 'file') !== undefined + ) { + return []; + } + return [{...f, spec}]; + }); if (matches.length === 0) return markdown; - const highlighter = await getHighlighter(); const renders = await Promise.all( - matches.map(async (mt) => { - const safeLang = LANGS.includes(mt.lang) ? mt.lang : 'text'; - const raw = highlighter.codeToHtml(mt.body, { - lang: safeLang, - themes: {light: 'github-light', dark: 'github-dark'}, - defaultColor: false, - }); - return applyHighlights(raw, parseRanges(mt.spec)); - }), + matches.map(async (mt) => + applyHighlights( + await highlightCode(mt.body, mt.lang), + parseRanges(mt.spec, mt.body.split('\n').length), + ), + ), ); - - let result = markdown; - for (let i = matches.length - 1; i >= 0; i--) { - const mt = matches[i]; - result = result.slice(0, mt.start) + `\n\n${renders[i]}\n\n` + result.slice(mt.end); - } - return result; + return replaceFences(markdown, matches, renders); }, }, }; diff --git a/apps/docs/src/marked-extensions/ngmd-code-import.ts b/apps/docs/src/marked-extensions/ngmd-code-import.ts index 8dedb51..39c7e31 100644 --- a/apps/docs/src/marked-extensions/ngmd-code-import.ts +++ b/apps/docs/src/marked-extensions/ngmd-code-import.ts @@ -1,7 +1,8 @@ import {readFileSync} from 'node:fs'; import type {MarkedExtension} from 'marked'; -import {getHighlighter, LANGS} from './shiki-shared.ts'; +import {highlightCode} from './shiki-shared.ts'; import {escapeHtml} from './escape-html.ts'; +import {findFences, getAttr, replaceFences} from './fences.ts'; import {resolveInside} from '../../plugin-utils.ts'; import config from '../ngmd.config.ts'; @@ -25,23 +26,24 @@ import config from '../ngmd.config.ts'; * self-contained HTML block — marked never sees the inner fence. */ -const FENCE_RE = /^(`{3,})([\w-]+)?[\t ]+file="([^"]+)"[^\n]*\n(?:([\s\S]*?)\n)?\1`*$/gm; const IGNORE_LINE_RE = /^.*\/\/\s*ngmd-ignore-line\s*$/; function loadFile(spec: string): {code: string; rangeFragment: string} { const [path, range] = spec.split('#'); - let content = readFileSync(resolveInside(process.cwd(), path), 'utf8'); + let content = readFileSync(resolveInside(process.cwd(), path), 'utf8').replace(/\r\n?/g, '\n'); let rangeFragment = ''; if (range) { const m = range.match(/^L(\d+)(?:-L?(\d+))?$/); - if (m) { - const start = parseInt(m[1], 10); - const end = m[2] ? parseInt(m[2], 10) : start; - const lines = content.split('\n'); - content = lines.slice(start - 1, end).join('\n'); - rangeFragment = m[2] ? `#L${start}-L${end}` : `#L${start}`; + if (!m) throw new Error(`invalid line range "#${range}", expected #L5 or #L5-L20`); + const start = parseInt(m[1], 10); + const end = m[2] ? parseInt(m[2], 10) : start; + const lines = content.replace(/\n$/, '').split('\n'); + if (start < 1 || end < start || end > lines.length) { + throw new Error(`line range "#${range}" does not fit the file's ${lines.length} lines`); } + content = lines.slice(start - 1, end).join('\n'); + rangeFragment = m[2] ? `#L${start}-L${end}` : `#L${start}`; } const filtered = content @@ -56,14 +58,13 @@ function githubBlobUrl(filePath: string, rangeFragment: string): string { const repo = config.site.githubUrl.replace(/\.git$/, ''); // encodeURI keeps `/` and `.` as-is but escapes brackets, so paths like // `src/app/pages/[...slug].page.ts` resolve on GitHub instead of breaking. - return `${repo}/blob/main/${encodeURI(filePath)}${rangeFragment}`; + const dir = config.site.githubDir ? `${config.site.githubDir.replace(/^\/+|\/+$/g, '')}/` : ''; + return `${repo}/blob/${config.site.githubBranch ?? 'main'}/${dir}${encodeURI(filePath)}${rangeFragment}`; } export const ngmdCodeImportExtension: MarkedExtension = { hooks: { async preprocess(markdown: string): Promise<string> { - if (!/^`{3,}[\w-]*[\t ]+file="/m.test(markdown)) return markdown; - const matches: { start: number; end: number; @@ -73,17 +74,15 @@ export const ngmdCodeImportExtension: MarkedExtension = { code: string; }[] = []; - const re = new RegExp(FENCE_RE.source, FENCE_RE.flags); - let m: RegExpExecArray | null; - while ((m = re.exec(markdown)) !== null) { - const lang = m[2] ?? ''; - const spec = m[3]; + for (const f of findFences(markdown)) { + const spec = getAttr(f.attrs, 'file'); + if (!spec) continue; try { const {code, rangeFragment} = loadFile(spec); matches.push({ - start: m.index, - end: m.index + m[0].length, - lang, + start: f.start, + end: f.end, + lang: f.lang, filePath: spec.split('#')[0], rangeFragment, code, @@ -95,25 +94,15 @@ export const ngmdCodeImportExtension: MarkedExtension = { } if (matches.length === 0) return markdown; - const highlighter = await getHighlighter(); - const renders = matches.map((mt) => { - const safeLang = LANGS.includes(mt.lang) ? mt.lang : 'text'; - const codeHtml = highlighter.codeToHtml(mt.code, { - lang: safeLang, - themes: {light: 'github-light', dark: 'github-dark'}, - defaultColor: false, - }); - const headerLabel = mt.filePath + (mt.rangeFragment || ''); - const headerHtml = `<a class="ngmd-code-import__header" href="${escapeHtml(githubBlobUrl(mt.filePath, mt.rangeFragment))}" target="_blank" rel="noopener noreferrer">${escapeHtml(headerLabel)}</a>`; - return `<div class="ngmd-code-import">${headerHtml}${codeHtml}</div>`; - }); - - let result = markdown; - for (let i = matches.length - 1; i >= 0; i--) { - const mt = matches[i]; - result = result.slice(0, mt.start) + `\n\n${renders[i]}\n\n` + result.slice(mt.end); - } - return result; + const renders = await Promise.all( + matches.map(async (mt) => { + const codeHtml = await highlightCode(mt.code, mt.lang); + const headerLabel = mt.filePath + (mt.rangeFragment || ''); + const headerHtml = `<a class="ngmd-code-import__header" href="${escapeHtml(githubBlobUrl(mt.filePath, mt.rangeFragment))}" target="_blank" rel="noopener noreferrer">${escapeHtml(headerLabel)}</a>`; + return `<div class="ngmd-code-import">${headerHtml}${codeHtml}</div>`; + }), + ); + return replaceFences(markdown, matches, renders); }, }, }; diff --git a/apps/docs/src/marked-extensions/ngmd-keywords.ts b/apps/docs/src/marked-extensions/ngmd-keywords.ts index f3d874b..580fc98 100644 --- a/apps/docs/src/marked-extensions/ngmd-keywords.ts +++ b/apps/docs/src/marked-extensions/ngmd-keywords.ts @@ -1,5 +1,6 @@ -import type {MarkedExtension, Tokens} from 'marked'; +import type {MarkedExtension, TokenizerThis, Tokens} from 'marked'; import config from '../ngmd.config.ts'; +import {escapeHtml} from './escape-html.ts'; /** * Inline keyword auto-linking. Any `*Keyword` token (where `Keyword` is @@ -27,16 +28,13 @@ interface NgmdKeywordToken extends Tokens.Generic { // of `**Keyword**` (which would leave one stray `*` and one stray `**`). const KEYWORD_RE = /^\*(?!\*)([A-Z][a-zA-Z0-9]+)\b(?!\*)/; const HINT_RE = /\*(?!\*)[A-Z]/; +const EMPHASIS_RE = /^\*[^*\n]*[^*\s]\*/; const warned = new Set<string>(); function lookup(keyword: string): string | undefined { return config.keywords?.[keyword]; } -function escapeAttr(s: string): string { - return s.replace(/"/g, '"'); -} - export const ngmdKeywordsExtension: MarkedExtension = { extensions: [ { @@ -45,12 +43,12 @@ export const ngmdKeywordsExtension: MarkedExtension = { start(src: string) { return src.match(HINT_RE)?.index; }, - tokenizer(src: string): NgmdKeywordToken | undefined { + tokenizer(this: TokenizerThis, src: string): Tokens.Generic | undefined { const m = KEYWORD_RE.exec(src); if (!m) return undefined; const url = lookup(m[1]); if (!url) { - if (!warned.has(m[1])) { + if (!warned.has(m[1]) && !EMPHASIS_RE.test(src)) { warned.add(m[1]); console.warn( `[ngmd-keywords] unknown keyword "${m[1]}" — add it to ngmd.config.ts > keywords or escape the asterisk.`, @@ -58,6 +56,7 @@ export const ngmdKeywordsExtension: MarkedExtension = { } return undefined; } + if (this.lexer.state.inLink) return {type: 'text', raw: m[0], text: m[1]}; return { type: 'ngmdKeyword', raw: m[0], @@ -69,7 +68,7 @@ export const ngmdKeywordsExtension: MarkedExtension = { const t = token as NgmdKeywordToken; const isExternal = /^https?:\/\//.test(t.url); const targetAttrs = isExternal ? ' target="_blank" rel="noopener noreferrer"' : ''; - return `<a href="${escapeAttr(t.url)}"${targetAttrs}>${t.keyword}</a>`; + return `<a href="${escapeHtml(t.url)}"${targetAttrs}>${t.keyword}</a>`; }, }, ], diff --git a/apps/docs/src/marked-extensions/runtime.ts b/apps/docs/src/marked-extensions/runtime.ts index 21cc3ff..bf8db3b 100644 --- a/apps/docs/src/marked-extensions/runtime.ts +++ b/apps/docs/src/marked-extensions/runtime.ts @@ -8,4 +8,5 @@ export const ngmdRuntimeExtensions: MarkedExtension[] = [ extensions: [ngmdVideoExtension, ngmdImageExtension], }, ngmdKeywordsExtension, + {hooks: {postprocess: (html: string) => html.replace(/<table>/g, '<table tabindex="0">')}}, ]; diff --git a/apps/docs/src/marked-extensions/shiki-shared.ts b/apps/docs/src/marked-extensions/shiki-shared.ts index 9e29541..2e36e4c 100644 --- a/apps/docs/src/marked-extensions/shiki-shared.ts +++ b/apps/docs/src/marked-extensions/shiki-shared.ts @@ -26,9 +26,21 @@ export const LANGS = [ export function getHighlighter(): Promise<Highlighter> { if (!highlighterPromise) { highlighterPromise = createHighlighter({ - themes: ['github-light', 'github-dark'], + themes: ['github-light-default', 'github-dark-default'], langs: LANGS, + }).catch((e: unknown) => { + highlighterPromise = null; + throw e; }); } return highlighterPromise; } + +export async function highlightCode(code: string, lang: string): Promise<string> { + const highlighter = await getHighlighter(); + return highlighter.codeToHtml(code, { + lang: highlighter.getLoadedLanguages().includes(lang) ? lang : 'text', + themes: {light: 'github-light-default', dark: 'github-dark-default'}, + defaultColor: false, + }); +} diff --git a/apps/docs/src/ngmd.config.ts b/apps/docs/src/ngmd.config.ts index af4584b..bf3f1be 100644 --- a/apps/docs/src/ngmd.config.ts +++ b/apps/docs/src/ngmd.config.ts @@ -72,6 +72,10 @@ export interface SiteConfig { * "view source" link on API symbol pages). Defaults to `main` when * omitted. Set this if the repo's default branch isn't `main`. */ githubBranch?: string; + /** Path from the repository root to this site, for sites inside a + * monorepo (e.g. `apps/docs`). Prefixes the file paths in GitHub edit and + * source links. Omit when the site is the repository root. */ + githubDir?: string; /** Optional community links. `discord` adds an icon to the header and a * link to the footer; `sponsor` adds a "Sponsor" link to the footer. */ links?: { @@ -105,7 +109,7 @@ export interface NgmdConfig { * paths route in-app; `http(s)` URLs open in a new tab. Leave undefined * for no header links. */ headerNav?: NavItem[]; - /** Sponsors listed by `<ngmd-sponsors>`. Leave undefined to render + /** Sponsors listed by `<app-sponsor-list>`. Leave undefined to render * nothing. */ sponsors?: Sponsor[]; /** Sidebar sections, in render order. */ @@ -135,6 +139,7 @@ const config: NgmdConfig = { tagline: 'Devtools for Angular apps and coding agents', url: 'https://santoshyadavdev.github.io/angular-devtools', githubUrl: 'https://github.com/santoshyadavdev/angular-devtools', + githubDir: 'apps/docs', links: { discord: 'https://discord.gg/YRTyJd6Qx', sponsor: 'https://github.com/sponsors/santoshyadavdev', diff --git a/apps/docs/src/styles.css b/apps/docs/src/styles.css index afddea3..8ddb8e9 100644 --- a/apps/docs/src/styles.css +++ b/apps/docs/src/styles.css @@ -471,7 +471,7 @@ analog-markdown a:hover, analog-markdown-route a:hover { - color: var(--accent); + color: var(--accent-strong); } analog-markdown a:focus-visible, @@ -547,6 +547,12 @@ overflow-x: auto; } + analog-markdown table:focus-visible, + analog-markdown-route table:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + } + analog-markdown thead, analog-markdown-route thead { border-block-end: 1px solid var(--border); @@ -674,7 +680,7 @@ font-size: 0.875rem; font-weight: 500; cursor: pointer; - color: var(--muted); + color: color-mix(in srgb, var(--muted) 80%, var(--fg)); font-family: var(--font-mono); transition: color 0.15s ease, @@ -696,7 +702,7 @@ } .ngmd-code-group__tab[data-active='true'] { - color: var(--accent); + color: var(--accent-strong); border-bottom-color: var(--accent); } @@ -714,7 +720,7 @@ border: 0; } - /* Shiki dual-theme: github-light by default, github-dark under `.dark`. + /* Shiki dual-theme: github-light-default by default, github-dark-default under `.dark`. * Shiki emits both --shiki-light and --shiki-dark CSS vars on every span * but never picks a default color, so we have to wire both branches. */ /* Scoped to `.shiki-themes` (only present on dual-theme output, never on @@ -810,13 +816,13 @@ background: var(--bg-muted); font-family: var(--font-mono); font-size: 0.8125rem; - color: var(--muted); + color: color-mix(in srgb, var(--muted) 80%, var(--fg)); text-decoration: none; transition: color 0.15s ease; } .ngmd-code-import__header:hover { - color: var(--accent); + color: var(--accent-strong); } .ngmd-code-import pre { diff --git a/apps/docs/src/types/api.ts b/apps/docs/src/types/api.ts index a52ee34..a82feed 100644 --- a/apps/docs/src/types/api.ts +++ b/apps/docs/src/types/api.ts @@ -50,3 +50,7 @@ export interface SymbolRecord { badges: string[]; group: string; } + +export function symbolUrl(sym: Pick<SymbolRecord, 'group' | 'name'>): string { + return `/api/${encodeURIComponent(sym.group)}/${encodeURIComponent(sym.name)}`; +} diff --git a/apps/docs/src/types/badge.ts b/apps/docs/src/types/badge.ts index edee8f9..231c48a 100644 --- a/apps/docs/src/types/badge.ts +++ b/apps/docs/src/types/badge.ts @@ -15,7 +15,7 @@ export const BADGE_VARIANTS = { alpha: 'bg-red-100 text-red-700 dark:bg-red-500/15 dark:text-red-300', beta: 'bg-amber-100 text-amber-700 dark:bg-amber-500/15 dark:text-amber-300', stable: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300', - deprecated: 'bg-zinc-100 text-zinc-500 dark:bg-zinc-800 dark:text-zinc-400 line-through', + deprecated: 'bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-400 line-through', } as const satisfies Record<string, string>; export type BadgeVariant = keyof typeof BADGE_VARIANTS; diff --git a/apps/docs/vars.plugin.ts b/apps/docs/vars.plugin.ts index e0adadc..491f1bf 100644 --- a/apps/docs/vars.plugin.ts +++ b/apps/docs/vars.plugin.ts @@ -41,7 +41,7 @@ function readVars(root: string): Record<string, string> { * are left in place so an unrecognised marker survives to the rendered * page rather than silently disappearing. */ -export function substituteMdVars(body: string, root: string): string { +export function substituteMdVars(body: string, root = process.cwd()): string { const vars = readVars(root); return body.replace(/\{\{\s*([\w-]+)\s*\}\}/g, (match, key) => { return key in vars ? vars[key] : match; diff --git a/apps/docs/vite.config.ts b/apps/docs/vite.config.ts index e60b7f9..5288e16 100644 --- a/apps/docs/vite.config.ts +++ b/apps/docs/vite.config.ts @@ -12,6 +12,7 @@ import {searchIndexPlugin} from './search-index.plugin.ts'; import {rawMdPlugin} from './raw-md.plugin.ts'; import {varsPlugin} from './vars.plugin.ts'; import {apiGenPlugin} from './api-gen.plugin.ts'; +import {withoutCode} from './plugin-utils.ts'; import config from './src/ngmd.config.ts'; /** @@ -28,14 +29,15 @@ function externalLinkGuard(): Plugin { name: 'ngmd-external-link-guard', enforce: 'pre', transform(_code, id) { - if (!id.endsWith('.md')) return null; - const content = readFileSync(id.split('?')[0], 'utf8'); + const file = id.split('?')[0]; + if (!file.endsWith('.md')) return null; + const content = withoutCode(readFileSync(file, 'utf8')); const anchorRe = /<a\b[^>]*href=["']https?:\/\/[^"']+["'][^>]*>/g; const matches = content.match(anchorRe) ?? []; for (const m of matches) { if (!/target=["']_blank["']/.test(m)) { this.error( - `[ngmd] External anchor in ${id} is missing target="_blank":\n ${m}\n` + + `[ngmd] External anchor in ${file} is missing target="_blank":\n ${m}\n` + `Add target="_blank" rel="noopener noreferrer" so external links open in a new tab.`, ); } @@ -76,7 +78,11 @@ export default defineConfig(async () => ({ varsPlugin(), externalLinkGuard(), internalLinkGuard(), - pageMetaPlugin({repoUrl: config.site.githubUrl, branch: 'main', dir: 'apps/docs'}), + pageMetaPlugin({ + repoUrl: config.site.githubUrl, + branch: config.site.githubBranch ?? 'main', + dir: config.site.githubDir, + }), sitemapPlugin({siteUrl: config.site.url}), rawMdPlugin(), searchIndexPlugin(), @@ -90,7 +96,7 @@ export default defineConfig(async () => ({ }, shikiOptions: { highlight: { - themes: {light: 'github-light', dark: 'github-dark'}, + themes: {light: 'github-light-default', dark: 'github-dark-default'}, defaultColor: false, }, highlighter: { From 98c9b933164530d5e112f47d4cbca88d791a0832 Mon Sep 17 00:00:00 2001 From: Kam <erkamyaman35@gmail.com> Date: Tue, 29 Sep 2026 14:56:02 +0300 Subject: [PATCH 08/13] docs: correct the frontmatter rule in the devtools-docs skill --- .claude/skills/devtools-docs/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.claude/skills/devtools-docs/SKILL.md b/.claude/skills/devtools-docs/SKILL.md index 52e1957..302ddd6 100644 --- a/.claude/skills/devtools-docs/SKILL.md +++ b/.claude/skills/devtools-docs/SKILL.md @@ -51,7 +51,7 @@ Don't mix explainer and tutorial content on one page. ```md --- title: Router -description: One sentence for search and link previews. +description: One sentence that summarizes the page. --- <ngmd-hero title="Router" gradient> From 2cccbb7ba5533d90e833b6936711b0b836af3a91 Mon Sep 17 00:00:00 2001 From: Kam <erkamyaman35@gmail.com> Date: Tue, 29 Sep 2026 15:05:10 +0300 Subject: [PATCH 09/13] style(docs): format three content pages --- .../src/content/contributing/kitchen-sink.md | 9 +++---- .../src/content/contributing/writing-docs.md | 24 +++++++++---------- apps/docs/src/content/inspectors/dashboard.md | 16 ++++++------- 3 files changed, 25 insertions(+), 24 deletions(-) diff --git a/apps/docs/src/content/contributing/kitchen-sink.md b/apps/docs/src/content/contributing/kitchen-sink.md index 5a6754f..df0a345 100644 --- a/apps/docs/src/content/contributing/kitchen-sink.md +++ b/apps/docs/src/content/contributing/kitchen-sink.md @@ -30,10 +30,10 @@ Plain text, **bold**, _italic_, `inline code`, ~~strikethrough~~ and a **UI labe ### Tables -| Column | Type | Notes | -| -------- | --------- | --------------------------------------- | -| `name` | `string` | Short text. | -| `count` | `number` | Right after the name. | +| Column | Type | Notes | +| -------- | --------- | ------------------------------------------------------------------- | +| `name` | `string` | Short text. | +| `count` | `number` | Right after the name. | | `active` | `boolean` | A longer note that wraps on small screens to check the cell layout. | ### Blockquote @@ -102,6 +102,7 @@ bun add @santoshyadavdev/ng-devtools devframe ### File import ```ts file="src/ngmd.config.ts#L1-L12" + ``` ### Other languages diff --git a/apps/docs/src/content/contributing/writing-docs.md b/apps/docs/src/content/contributing/writing-docs.md index e198792..b48609e 100644 --- a/apps/docs/src/content/contributing/writing-docs.md +++ b/apps/docs/src/content/contributing/writing-docs.md @@ -117,18 +117,18 @@ Add an entry to `nav` in `apps/docs/src/ngmd.config.ts`. Pages that aren't liste The site uses NgMd's authoring components. Write them as raw HTML inside the markdown. The full reference is the components page of the [NgMd documentation](https://ngmd.netlify.app/concepts/components). -| Component | Use it for | Attributes | -| -------------------------------------------- | ----------------------------------------------------------------- | --------------------------------------------------------------------------- | -| `<ngmd-hero>` | The page opener. Once per page, before the `#` heading. | `title`, `gradient`, `logo` (only on pages about one external tool, such as NgRx, Analog, Vite, Express, Chrome, MCP or Nx) | -| `<ngmd-callout>` | A short aside with context the reader may need. | `type` (`info`, `tip`, `success`, `warning`, `danger`), `title` | -| `<ngmd-alert>` | One short point the reader must not miss. | `severity` (`info`, `helpful`, `important`, `warning`, `critical`), `label` | -| `<ngmd-card-grid>` + `<ngmd-card>` | Links to related pages, requirements, feature overviews. | grid: `columns`. card: `title`, `link`, `cta`, `icon`, `image`, `avatar` | -| `<ngmd-workflow>` + `<ngmd-step>` | Ordered steps. | step: `title` | -| `<ngmd-accordion>` + `<ngmd-accordion-item>` | FAQ sections. | item: `title`, `open` | -| `<ngmd-pill-row>` + `<ngmd-pill>` | A row of related links at the end of a page. | pill: `href`, `title` | -| `<ngmd-badge>` | A status chip next to a heading. | `variant` (`new`, `updated`, `alpha`, `beta`, `stable`, `deprecated`) | -| `<ngmd-tabs>` + `<ngmd-tab>` | The same content in several forms, when a code group doesn't fit. | tab: `title`, `icon`, `image` | -| `<ngmd-image>`, `<ngmd-video>` | Screenshots and YouTube or Vimeo videos. | image: `src`, `alt`, `caption`, `width`. video: `src`, `title` | +| Component | Use it for | Attributes | +| -------------------------------------------- | ----------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | +| `<ngmd-hero>` | The page opener. Once per page, before the `#` heading. | `title`, `gradient`, `logo` (only on pages about one external tool, such as NgRx, Analog, Vite, Express, Chrome, MCP or Nx) | +| `<ngmd-callout>` | A short aside with context the reader may need. | `type` (`info`, `tip`, `success`, `warning`, `danger`), `title` | +| `<ngmd-alert>` | One short point the reader must not miss. | `severity` (`info`, `helpful`, `important`, `warning`, `critical`), `label` | +| `<ngmd-card-grid>` + `<ngmd-card>` | Links to related pages, requirements, feature overviews. | grid: `columns`. card: `title`, `link`, `cta`, `icon`, `image`, `avatar` | +| `<ngmd-workflow>` + `<ngmd-step>` | Ordered steps. | step: `title` | +| `<ngmd-accordion>` + `<ngmd-accordion-item>` | FAQ sections. | item: `title`, `open` | +| `<ngmd-pill-row>` + `<ngmd-pill>` | A row of related links at the end of a page. | pill: `href`, `title` | +| `<ngmd-badge>` | A status chip next to a heading. | `variant` (`new`, `updated`, `alpha`, `beta`, `stable`, `deprecated`) | +| `<ngmd-tabs>` + `<ngmd-tab>` | The same content in several forms, when a code group doesn't fit. | tab: `title`, `icon`, `image` | +| `<ngmd-image>`, `<ngmd-video>` | Screenshots and YouTube or Vimeo videos. | image: `src`, `alt`, `caption`, `width`. video: `src`, `title` | Card icons come from a fixed set: `book`, `box`, `code`, `compass`, `file`, `layers`, `lightbulb`, `palette`, `rocket`, `search`, `settings`, `shield`, `sparkles`, `terminal`, `wrench`, `zap`. diff --git a/apps/docs/src/content/inspectors/dashboard.md b/apps/docs/src/content/inspectors/dashboard.md index 6e1131d..0e89a1b 100644 --- a/apps/docs/src/content/inspectors/dashboard.md +++ b/apps/docs/src/content/inspectors/dashboard.md @@ -28,14 +28,14 @@ The top block shows the project name and a chip for each of these: Each card counts what one inspector found. Click a card to open its tab. When the hub is mounted, the NgRx card opens the **NgRx** dock. -| Card | Counts | -| --------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | -| [Components](/inspectors/components) | Components in source, plus the number of directives. | -| [Routes](/inspectors/router) | Navigable page paths in source, plus the number of redirects. | -| [Signals](/inspectors/signals) | Nodes in the live signal graph, plus the declarations in source. Without a page, the declarations in source. | -| [Injectors](/inspectors/injectors) | Live injectors on the page, plus their providers. Without a page, the provider declarations in source. | -| [NgRx declarations](/inspectors/ngrx-store) | NgRx declarations in source, broken down by kind. | -| [Pipes](/inspectors/pipes) | Custom pipes in source, plus the built-in pipes in use. | +| Card | Counts | +| ------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | +| [Components](/inspectors/components) | Components in source, plus the number of directives. | +| [Routes](/inspectors/router) | Navigable page paths in source, plus the number of redirects. | +| [Signals](/inspectors/signals) | Nodes in the live signal graph, plus the declarations in source. Without a page, the declarations in source. | +| [Injectors](/inspectors/injectors) | Live injectors on the page, plus their providers. Without a page, the provider declarations in source. | +| [NgRx declarations](/inspectors/ngrx-store) | NgRx declarations in source, broken down by kind. | +| [Pipes](/inspectors/pipes) | Custom pipes in source, plus the built-in pipes in use. | ### Card states From 4ba3bd2c49c4a70b215f28124cd28cdf5acb69a4 Mon Sep 17 00:00:00 2001 From: Kam <erkamyaman35@gmail.com> Date: Wed, 30 Sep 2026 09:13:12 +0300 Subject: [PATCH 10/13] docs: cover the change detection source scan from #45 --- apps/docs/src/content/agents/tools.md | 2 +- apps/docs/src/content/inspectors/components.md | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/docs/src/content/agents/tools.md b/apps/docs/src/content/agents/tools.md index 30de4a2..a9bb625 100644 --- a/apps/docs/src/content/agents/tools.md +++ b/apps/docs/src/content/agents/tools.md @@ -50,7 +50,7 @@ These seven tools take no arguments. They all read your source. | Tool | What it answers | | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `get-routes` | Angular routes from your route files, with full URL path (parents and `loadChildren` prefixes included), kind (page, group, redirect or wildcard), guards, resolvers, and file and line. | -| `get-components` | Components and directives from `@Component` and `@Directive` classes, with class name, selector, kind, inputs, outputs, and file and line. | +| `get-components` | Components and directives from `@Component` and `@Directive` classes, with class name, selector, kind, inputs, outputs, change detection (components only), and file and line. | | `get-signals` | `signal()`, `computed()`, `linkedSignal()`, `effect()`, `toSignal()` and resource declarations (`resource`, `httpResource`, `rxResource`), plus signal inputs, models and queries. | | `get-providers` | DI providers: `@Injectable` services, `inject()` calls and `providers` arrays, with token, file and where each one is provided. | | `get-ngrx-store` | NgRx declarations: `@ngrx/store` actions, reducers, effects, selectors, features and store setup, and `@ngrx/signals` `signalStore` (with its members), `signalState` and `signalMethod`. | diff --git a/apps/docs/src/content/inspectors/components.md b/apps/docs/src/content/inspectors/components.md index 53eae7d..c1a00a6 100644 --- a/apps/docs/src/content/inspectors/components.md +++ b/apps/docs/src/content/inspectors/components.md @@ -32,7 +32,7 @@ When a form exists in the same source file, a **Show … in Forms** button opens ### Facts -- **Change detection**: `OnPush` or `Default`. +- **Change detection**: `OnPush` or `Eager`. - **Encapsulation**: `Emulated`, `None`, `ShadowDom` or `IsolatedShadowDom`. - **Host path**: where the host element sits in the page. - **Routed**: for routed components, the route and the outlet that rendered it. @@ -52,7 +52,9 @@ A fact shows **Unknown** when Angular does not report it. ### Source mode -Without live data, the tab lists the `@Component` and `@Directive` classes in your source. Expand a row to see its class, file, standalone flag, inputs and outputs. Click **Refresh** to scan again. +Without live data, the tab lists the `@Component` and `@Directive` classes in your source. Expand a row to see its class, file, standalone flag, change detection, inputs and outputs. Click **Refresh** to scan again. + +For change detection, the scan reads the `changeDetection` key in the decorator. Without one, it uses the project's Angular version: `OnPush` from Angular 22, `Eager` before. It shows `unknown` when the value is an expression it can't read, or when the version can't be found. A notice at the top says why you see the source list: no page is connected, or the page reported no instances. @@ -132,7 +134,7 @@ Use the arrow keys, Home and End to move through the tree. The right arrow expan | Tool or resource | Kind | What it does | | ---------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------- | -| `ng-devtools:get-components` | tool | Lists components and directives from source, with selector, kind, inputs, outputs, file and line. | +| `ng-devtools:get-components` | tool | Lists components and directives from source, with selector, kind, inputs, outputs, change detection, file and line. | | `ng-devtools:highlight` | tool | Highlights a component in the page. Takes an instance id, class name, host tag or CSS selector. Also retargets the Signals graph. | | `ng-devtools:component-tree` | resource | The live tree per page, with the detail of the selected instance. | From 66ab73ccbcccc7445ba3d4503b4ef2cd2d243cac Mon Sep 17 00:00:00 2001 From: Kam <erkamyaman35@gmail.com> Date: Wed, 30 Sep 2026 10:25:29 +0300 Subject: [PATCH 11/13] docs: the root format check covers content markdown --- .claude/skills/devtools-docs/SKILL.md | 2 +- apps/docs/src/content/contributing/writing-docs.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.claude/skills/devtools-docs/SKILL.md b/.claude/skills/devtools-docs/SKILL.md index 302ddd6..d577af5 100644 --- a/.claude/skills/devtools-docs/SKILL.md +++ b/.claude/skills/devtools-docs/SKILL.md @@ -139,5 +139,5 @@ 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. +3. `pnpm format:check` from the repo root. It checks content markdown too, so run `pnpm exec prettier --write` on the pages you changed. 4. Grep changed files for `—`, "will ", "we ", "new ", "recently", "simply", "just ". diff --git a/apps/docs/src/content/contributing/writing-docs.md b/apps/docs/src/content/contributing/writing-docs.md index b48609e..4888139 100644 --- a/apps/docs/src/content/contributing/writing-docs.md +++ b/apps/docs/src/content/contributing/writing-docs.md @@ -212,7 +212,7 @@ Check names exactly: labels, buttons, tool names, arguments, option names and de Run <code>pnpm docs:build</code>. The build fails on a broken internal link or anchor, and on an external raw HTML link without <code>target="_blank"</code>. </ngmd-step> <ngmd-step title="Format"> - Run <code>pnpm exec prettier --check "apps/docs/**/*.{ts,json,css,html}"</code>. Markdown in <code>src/content</code> is not reformatted, so check tables and line breaks by eye. + Run <code>pnpm format:check</code> from the repo root. It checks content markdown too, so run <code>pnpm exec prettier --write</code> on the pages you changed. </ngmd-step> <ngmd-step title="Reread against the rules"> Check the style rules above, and that every claim you added matches the code. Search your changes for em dashes, "will", "we", "new", "recently", "simply" and "just". From 95e995a5fa0c735ecd8fd8f8cc436a9bf8684efe Mon Sep 17 00:00:00 2001 From: Kam <erkamyaman35@gmail.com> Date: Wed, 30 Sep 2026 10:22:31 +0300 Subject: [PATCH 12/13] docs(extension): describe host access, connection status and Elements sync --- .../content/contributing/chrome-extension.md | 55 +++++++++++++------ .../getting-started/chrome-extension.md | 46 +++++++++++++--- .../docs/src/content/inspectors/components.md | 4 ++ apps/docs/src/content/security.md | 8 ++- 4 files changed, 85 insertions(+), 28 deletions(-) diff --git a/apps/docs/src/content/contributing/chrome-extension.md b/apps/docs/src/content/contributing/chrome-extension.md index 4a3e116..82eda1f 100644 --- a/apps/docs/src/content/contributing/chrome-extension.md +++ b/apps/docs/src/content/contributing/chrome-extension.md @@ -15,31 +15,27 @@ The Chrome extension lives in `extension/`. It detects Angular pages, creates th ```text extension/ - manifest.json # Manifest V3, host permissions for localhost and 127.0.0.1 + manifest.json # Manifest V3, loopback host permissions, optional access to other hosts background.js # Tracks which tabs run Angular content-script.js # Relays the detection result to the background worker detect-angular.js # Runs in the page, looks for ng-version or window.ng devtools.html devtools.js # Creates the panel on Angular pages - panel.html - panel-bridge.js # Finds the dev server and connects the UI to it + panel.html # The panel page and its status view + panel-bridge.js # Asks for host access, finds the dev server, connects the UI to it icons/ ui/ # The built devtools UI (committed) ``` ### What the manifest asks for -<ngmd-card-grid columns="3"> - <ngmd-card icon="shield" title="No permissions"> - <code>permissions</code> is empty. - </ngmd-card> - <ngmd-card icon="compass" title="Loopback host permissions"> - Host permissions for <code>localhost</code> and <code>127.0.0.1</code>, over HTTP and HTTPS. The content scripts still run on every page. - </ngmd-card> - <ngmd-card icon="settings" title="Chrome 111 or later"> - Set by <code>minimum_chrome_version</code>. - </ngmd-card> -</ngmd-card-grid> +| Key | Value | +| --------------------------- | ------------------------------------------------------------------------------------------------------------ | +| `permissions` | Empty. | +| `host_permissions` | `*.localhost`, `127.0.0.1` and `[::1]`, over HTTP and HTTPS. `*.localhost` also matches `localhost`. | +| `optional_host_permissions` | `http://*/*` and `https://*/*`. The panel requests one host at a time, only when you click **Allow access**. | +| `content_scripts` | `content-script.js` and `detect-angular.js`, on every page. | +| `minimum_chrome_version` | `111`. | ## Build @@ -93,20 +89,43 @@ This runs `extension:build`, then writes `dist/ng-devtools-extension.zip`. The z ## How the panel connects +### Host access + +`panel-bridge.js` reads the origin of the inspected page. If the page is not served over `http` or `https`, it stops and says so. + +It then calls `chrome.permissions.contains()` for `<scheme>://<hostname>/*` of the page. Loopback hosts pass, since the manifest grants them. For any other host it shows the **Allow access** button. The button calls `chrome.permissions.request()` for that one pattern and, if Chrome grants it, starts over. + ### Finding the server -`panel-bridge.js` reads the origin of the inspected page. On `localhost` and `127.0.0.1`, it looks for the devtools server at these paths, in order: +With access granted, it looks for the devtools server under these paths, in order: 1. `/__ng-devtools/` 2. `/__devframes/ng-devtools/` 3. `/__devframe/` 4. `/` -It passes the first path that serves a devframe connection file to the UI. It runs the search again after each navigation. +Under each path it fetches `__devframe/__connection.json`, then `__connection.json`, with no credentials, no cache, no redirects and a 1.5 second timeout. The first response that is OK and parses as JSON wins. + +If none answers, the status view lists every URL it tried and links to the setup section of the README. + +### Loading the UI + +The panel loads `ui/index.html` with two query parameters: + +| Parameter | Value | +| --------- | -------------------------------------------------------------------------------------- | +| `baseURL` | The path that served the connection file, on the origin of the page. | +| `pageId` | The `ng-devtools-page-id` value the overlay keeps in `sessionStorage`, when it is set. | + +Outside the extension, the UI accepts a `baseURL` only on its own origin. Inside the extension, it accepts any `http` or `https` URL. The panel only passes hosts the extension can reach. + +On each navigation of the inspected page, the panel shows its status view again and repeats the whole search. + +### Elements panel selection -### Other hosts +The overlay defines `window.__ngDevtoolsComponentOf` on the page. It takes an element and returns the id of the nearest component host, through shadow roots, or `null`. -The UI accepts a loopback address only when it runs inside the extension. On other hosts, the panel shows the UI without a connection. +When the Elements panel selection changes, `panel-bridge.js` evaluates it with `$0`. If it gets an id, it posts an `ng-devtools:inspect-component` message to the UI frame. The UI accepts the message only from its parent window and its own origin, and only while the **Components** tab is open. The tab then expands the parent rows, clears the filter if needed, selects the row and scrolls it into view. ## Where to next diff --git a/apps/docs/src/content/getting-started/chrome-extension.md b/apps/docs/src/content/getting-started/chrome-extension.md index 3723ba2..a37459d 100644 --- a/apps/docs/src/content/getting-started/chrome-extension.md +++ b/apps/docs/src/content/getting-started/chrome-extension.md @@ -70,7 +70,7 @@ A content script checks each page for Angular: an `ng-version` attribute or a `w ### Finding the server -On pages served from `localhost` or `127.0.0.1`, the panel looks for the devtools server on the same origin. It tries these paths in order: +The panel looks for the devtools server on the origin of the inspected page. It tries these paths in order: | Path | Mounted by | | --------------------------- | ------------------------------------- | @@ -79,25 +79,49 @@ On pages served from `localhost` or `127.0.0.1`, the panel looks for the devtool | `/__devframe/` | A bare devframe mount | | `/` | A devframe served at the root | -When it finds a connection file on one of them, it connects the UI to it. +Under each path it asks for `__devframe/__connection.json`, then `__connection.json`. It connects the UI to the first path that answers with a connection file. Each request times out after 1.5 seconds. + +If no path answers, the panel says "No devtools server answered", lists every URL it tried and links to the setup instructions. + +The panel only connects to pages served over `http` or `https`. On other pages it says so and stops. ### Other hosts -On other hosts, the panel shows the UI without a connection. It does not probe them. +The extension can reach loopback hosts from the start. For any other host, such as a LAN IP or a tunnel, the panel shows an **Allow access** button instead of looking for the server. Click it and confirm the Chrome prompt. The panel then looks for the server again. See [Host access](#host-access) for what the button grants. + +### The inspected tab + +The overlay gives each page an id. The panel passes the id of the page it inspects to the UI. If several tabs run the same app, the panel shows the tab you inspect, not the one that reported last. ### Navigation -When the inspected page navigates, the panel looks for the server again. +When the inspected page navigates, the panel shows "Detecting Angular app…", looks for the server again and reconnects. + +### Elements panel + +While the **Components** tab is open, select an element in the Chrome **Elements** panel. The Components tab selects the component that hosts that element (the element itself, or the nearest ancestor that is a component host). It expands the parent rows, clears the filter if it hides the row, and scrolls the row into view. On other tabs, the Elements selection does nothing. + +This needs the overlay on the page, since the overlay answers which component hosts the element. ## Permissions ### Host access -The manifest asks for no `permissions`. Its host permissions cover only `localhost` and `127.0.0.1`, over HTTP and HTTPS. +The manifest asks for no `permissions`. Its host permissions cover loopback hosts only, over HTTP and HTTPS: + +| Host | Covers | +| ------------- | -------------------------------------------------------- | +| `*.localhost` | `localhost` and every subdomain, such as `app.localhost` | +| `127.0.0.1` | The IPv4 loopback address | +| `[::1]` | The IPv6 loopback address | + +Other hosts are optional host permissions. **Allow access** asks Chrome for the host of the inspected page only, on the scheme of that page (`http` or `https`) and on any port. The extension never asks for all hosts at once. + +Granting the extension a host doesn't change what the devtools server accepts. The server still applies its own checks. The Vite plugin, for example, only answers requests from a loopback address. See [Access and redaction](/security). ### Content scripts -The content scripts are wider. Two of them run on every page. They check for an `ng-version` attribute or `window.ng`, and pass the Angular version to the extension. The panel only connects to local dev servers. The Vite plugin accepts requests from Chrome extension origins. See [Access and redaction](/security). +The content scripts are wider. Two of them run on every page. They check for an `ng-version` attribute or `window.ng`, and pass the Angular version to the extension. They don't read or change anything else. ## FAQ @@ -105,8 +129,14 @@ The content scripts are wider. Two of them run on every page. They check for an <ngmd-accordion-item title="The panel does not appear" open> The page did not look like an Angular app. Check that it renders an <code>ng-version</code> attribute or exposes <code>window.ng</code>, which development builds do. Then close and reopen DevTools. </ngmd-accordion-item> - <ngmd-accordion-item title="The panel shows no data"> - Check that the page is served from <code>localhost</code> or <code>127.0.0.1</code>, that its server mounts the devtools, and that the overlay is loaded. + <ngmd-accordion-item title="The panel asks me to allow access"> + The page is not on a loopback host. Click <strong>Allow access</strong> to let the extension reach that host. Chrome asks you to confirm. + </ngmd-accordion-item> + <ngmd-accordion-item title="The panel lists the URLs it tried"> + None of them served a connection file. Check that the server of the page mounts the devtools and that the server accepts the request. See <a href="/security">Access and redaction</a>. + </ngmd-accordion-item> + <ngmd-accordion-item title="Selecting an element does not select a component"> + Open the <strong>Components</strong> tab first, and check that the overlay is loaded. Elements outside any component select nothing. </ngmd-accordion-item> <ngmd-accordion-item title="Does the floating button go away?"> No. The overlay still adds the button to the page. Use the button or the panel, whichever you prefer. diff --git a/apps/docs/src/content/inspectors/components.md b/apps/docs/src/content/inspectors/components.md index c1a00a6..e689b12 100644 --- a/apps/docs/src/content/inspectors/components.md +++ b/apps/docs/src/content/inspectors/components.md @@ -101,6 +101,10 @@ The page reads the tree every 3 seconds, and at once when you select an instance </ngmd-step> </ngmd-workflow> +### Start from the Elements panel + +If you use the [Chrome extension](/getting-started/chrome-extension), open the **Components** tab in its panel. Then select an element in the Chrome **Elements** panel. The tab selects the component that hosts that element and scrolls its row into view. + ### Check why an output does nothing <ngmd-workflow> diff --git a/apps/docs/src/content/security.md b/apps/docs/src/content/security.md index fe29ad2..3136d7f 100644 --- a/apps/docs/src/content/security.md +++ b/apps/docs/src/content/security.md @@ -28,7 +28,7 @@ The devtools read your running app and send what they find to a server on your m Binds to <code>localhost</code> and asks for a one-time code by default. </ngmd-card> <ngmd-card icon="compass" title="Chrome extension"> - Connects only to pages served from <code>localhost</code> or <code>127.0.0.1</code>. + Reaches loopback hosts out of the box. Any other host needs a click on <strong>Allow access</strong>, for that host only. </ngmd-card> </ngmd-card-grid> @@ -101,7 +101,11 @@ The HTTP MCP endpoint answers only requests from a loopback address that carry a ### Chrome extension -The extension connects only to pages served from `localhost` or `127.0.0.1`. On other hosts the panel shows the UI without data. +The extension has host permissions for loopback hosts only: `localhost` and its subdomains, `127.0.0.1` and `[::1]`, over HTTP and HTTPS. On those hosts, the panel looks for the devtools server as soon as it opens. + +On any other host, the panel doesn't send a request until you click **Allow access**. Chrome then asks you to grant the extension that one host, on the scheme of the page and any port. The extension never asks for all hosts at once. + +Granting the extension a host doesn't change what the devtools server accepts. The server still applies the checks on this page. See [Chrome extension](/getting-started/chrome-extension#host-access). ## What is redacted From f84f498e67e4b940d5d1b1701cc11e2ac2fc54d1 Mon Sep 17 00:00:00 2001 From: Kam <erkamyaman35@gmail.com> Date: Wed, 30 Sep 2026 11:03:33 +0300 Subject: [PATCH 13/13] fix(extension): point the setup link at the README's Get started section --- extension/panel.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extension/panel.html b/extension/panel.html index 752b07d..8048b8c 100644 --- a/extension/panel.html +++ b/extension/panel.html @@ -70,7 +70,7 @@ <button id="status-allow" type="button" hidden>Allow access</button> <a id="status-docs" - href="https://github.com/santoshyadavdev/angular-devtools#how-to-use" + href="https://github.com/santoshyadavdev/angular-devtools#get-started" target="_blank" rel="noreferrer" hidden