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

{{ b.title }}

+

+ {{ b.prefix }} + + {{ b.currentLabel }} + . +

+
+
+ } + `, +}) +export class ContentBanners { + private readonly versions = inject(VersionService); + + readonly externalIcon = LucideExternalLink; + + readonly banner = computed(() => { + const self = this.versions.self(); + const current = this.versions.current(); + if (!self || !current) return null; + if (self.status === 'current') return null; + + const currentUrl = current.url; + const currentLabel = current.label; + + if (self.status === 'next') { + return { + title: "You're reading the next-release docs.", + prefix: 'The current stable is', + currentUrl, + currentLabel, + icon: LucideRocket, + containerClass: 'border-amber-200 dark:border-amber-900 bg-amber-50 dark:bg-amber-950/40', + iconClass: 'text-amber-600', + bodyClass: 'text-amber-700 dark:text-amber-300', + }; + } + if (self.status === 'rc') { + return { + title: "You're reading a release candidate.", + prefix: 'The current stable is', + currentUrl, + currentLabel, + icon: LucideTriangleAlert, + containerClass: 'border-amber-200 dark:border-amber-900 bg-amber-50 dark:bg-amber-950/40', + iconClass: 'text-amber-600', + bodyClass: 'text-amber-700 dark:text-amber-300', + }; + } + return { + title: `This is ${self.label}, no longer the current release.`, + prefix: 'The current stable is', + currentUrl, + currentLabel, + icon: LucideArchive, + containerClass: 'border-zinc-200 dark:border-zinc-800 bg-zinc-50 dark:bg-zinc-900', + iconClass: 'text-zinc-500', + bodyClass: 'text-zinc-600 dark:text-zinc-400', + }; + }); +} diff --git a/apps/docs/src/app/components/external-links.ts b/apps/docs/src/app/components/external-links.ts new file mode 100644 index 0000000..11609d7 --- /dev/null +++ b/apps/docs/src/app/components/external-links.ts @@ -0,0 +1,36 @@ +import {AfterViewInit, Component, DestroyRef, inject} from '@angular/core'; +import {Router} from '@angular/router'; +import {enhanceOnNavigation} from '../utils/enhance-on-navigation'; + +/** + * Adds `target="_blank" rel="noopener noreferrer"` to external anchors in + * rendered markdown after each route change. + */ +@Component({ + selector: 'app-external-links', + template: '', + styles: ` + :host { + display: none; + } + `, +}) +export class ExternalLinks implements AfterViewInit { + private readonly router = inject(Router); + private readonly destroyRef = inject(DestroyRef); + + ngAfterViewInit(): void { + enhanceOnNavigation( + this.router, + this.destroyRef, + 'main analog-markdown a[href^="http"]:not([data-external-enhanced]), main analog-markdown-route a[href^="http"]:not([data-external-enhanced])', + (node) => { + const a = node as HTMLAnchorElement; + a.setAttribute('data-external-enhanced', 'true'); + if (a.origin === window.location.origin) return; + a.setAttribute('target', '_blank'); + a.setAttribute('rel', 'noopener noreferrer'); + }, + ); + } +} diff --git a/apps/docs/src/app/components/heading-anchors.ts b/apps/docs/src/app/components/heading-anchors.ts new file mode 100644 index 0000000..c0eb4c4 --- /dev/null +++ b/apps/docs/src/app/components/heading-anchors.ts @@ -0,0 +1,86 @@ +import {AfterViewInit, Component, DestroyRef, inject} from '@angular/core'; +import {Router} from '@angular/router'; +import {ToastService} from '../services/toast/toast.service'; +import {writeToClipboard} from '../utils/clipboard'; +import {enhanceOnNavigation} from '../utils/enhance-on-navigation'; + +/** + * Scans rendered docs pages for h2/h3 with an id and appends a copy-link + * button that writes the absolute URL with `#fragment` to the clipboard. + * Runs on mount and after every route change, same pattern as CodeCopy. + */ +@Component({ + selector: 'app-heading-anchors', + template: '', + styles: ` + :host { + display: none; + } + `, +}) +export class HeadingAnchors implements AfterViewInit { + private readonly router = inject(Router); + private readonly destroyRef = inject(DestroyRef); + private readonly toast = inject(ToastService); + + ngAfterViewInit(): void { + enhanceOnNavigation( + this.router, + this.destroyRef, + 'main h1[id]:not([data-anchor-enhanced]), main h2[id]:not([data-anchor-enhanced]), main h3[id]:not([data-anchor-enhanced])', + (h) => this.enhance(h), + ); + } + + private enhance(heading: HTMLElement): void { + heading.setAttribute('data-anchor-enhanced', 'true'); + heading.style.scrollMarginTop = heading.style.scrollMarginTop || '6rem'; + heading.classList.add('group/heading'); + + const button = document.createElement('button'); + button.type = 'button'; + button.setAttribute('aria-label', 'Copy link to this section'); + button.className = + 'ml-2 inline-flex items-center justify-center size-5 align-middle relative -top-[2px] rounded text-zinc-500 dark:text-zinc-400 hover:text-[color:var(--accent)] opacity-0 transition-opacity group-hover/heading:opacity-100 focus-visible:opacity-100 [@media(hover:none)]:opacity-100'; + button.innerHTML = this.linkIcon(); + + // h1 is the page itself; copying #h1-slug duplicates the path in the URL. + // For h1, copy + show the bare page URL with no fragment. + const isH1 = heading.tagName === 'H1'; + + let resetTimer: ReturnType | undefined; + button.addEventListener('click', async (e) => { + e.preventDefault(); + e.stopPropagation(); + const base = `${location.origin}${location.pathname}`; + const url = isH1 ? base : `${base}#${heading.id}`; + if (!(await writeToClipboard(url))) { + this.toast.error('Could not copy link.'); + return; + } + this.toast.success('Link copied to clipboard.'); + button.innerHTML = this.checkIcon(); + clearTimeout(resetTimer); + resetTimer = setTimeout(() => (button.innerHTML = this.linkIcon()), 1500); + }); + + heading.appendChild(button); + } + + private linkIcon(): string { + return ` + + `; + } + + private checkIcon(): string { + return ` + + `; + } +} diff --git a/apps/docs/src/app/components/llm-actions.ts b/apps/docs/src/app/components/llm-actions.ts new file mode 100644 index 0000000..bb0136c --- /dev/null +++ b/apps/docs/src/app/components/llm-actions.ts @@ -0,0 +1,331 @@ +import { + Component, + DestroyRef, + ElementRef, + Injector, + afterNextRender, + computed, + inject, + signal, + viewChild, +} from '@angular/core'; +import { + LucideDynamicIcon, + type LucideIcon, + LucideCheck, + LucideChevronDown, + LucideCopy, + LucideLink, +} from '@lucide/angular'; +import {GithubIcon} from '../ui/github-icon'; +import {pageMeta} from 'virtual:ngmd/page-meta'; +import {ToastService} from '../services/toast/toast.service'; +import {RouteUrlService} from '../services/route-url/route-url.service'; +import {writeToClipboard} from '../utils/clipboard'; +import siteConfig from '../../ngmd.config'; +import {ClaudeIcon, OpenaiIcon} from '../ui/brand-icons'; + +interface MenuItem { + label: string; + icon: LucideIcon | 'github' | 'claude' | 'openai'; + /** Either a click handler or a target URL — drives the ` + +
+ @if (open()) { + + } + + } + `, +}) +export class LlmActions { + private readonly toast = inject(ToastService); + private readonly injector = inject(Injector); + private readonly trigger = viewChild>('trigger'); + private readonly menu = viewChild>('menu'); + 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 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 && 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().replace(/\/+$/, ''); + 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: 'openai', + href: `https://chatgpt.com/?q=${encodeURIComponent(this.prompt())}`, + }, + { + label: 'Open in Claude', + icon: 'claude', + href: `https://claude.ai/new?q=${encodeURIComponent(this.prompt())}`, + }, + ]); + + 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('[role="menuitem"]') ?? []), + ]; + } + + /** 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.toast.success('Markdown copied to clipboard.'); + 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.dismiss(); + } + } + + 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..a5bccd1 --- /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 'about:blank'; + }); +} diff --git a/apps/docs/src/app/ui/workflow.ts b/apps/docs/src/app/ui/workflow.ts new file mode 100644 index 0000000..b87541d --- /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..4740ff0 --- /dev/null +++ b/apps/docs/src/app/utils/enhance-on-navigation.ts @@ -0,0 +1,60 @@ +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; + document.querySelectorAll(selector).forEach(enhanceEach); + setTimeout(() => run(attempt + 1), delayMs); + }; + + 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/heading-slug.spec.ts b/apps/docs/src/app/utils/heading-slug.spec.ts new file mode 100644 index 0000000..e6b0d2a --- /dev/null +++ b/apps/docs/src/app/utils/heading-slug.spec.ts @@ -0,0 +1,58 @@ +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 MIT')).toBe( + 'Prerequisites', + ); + expect(headingText('Read [the guide](/guide) and ![logo](/logo.svg)')).toBe( + 'Read the guide and logo', + ); + expect(headingText('Use `` here')).toBe('Use here'); + expect(slugify(headingText('Use ``'))).toBe('use-router-outlet'); + expect(headingText('Install @scope/pkg & run')).toBe( + 'Install @scope/pkg & run', + ); + }); +}); + +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 new file mode 100644 index 0000000..12794d9 --- /dev/null +++ b/apps/docs/src/app/utils/heading-slug.ts @@ -0,0 +1,61 @@ +/** + * 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 + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, '') || 'section' + ); +} + +export function createSlugger(): (text: string) => string { + const seen = new Set(); + 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 = { + 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 { + const code: string[] = []; + return markdown + .replace(/(`+)([\s\S]*?)\1/g, (_, _ticks: string, inner: string) => { + code.push(inner); + return `\u0000${code.length - 1}\u0000`; + }) + .replace(/]*>[\s\S]*?<\/ngmd-badge>/g, '') + .replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1') + .replace(/<[^>]+>/g, '') + .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/app/utils/watch-host-attribute.ts b/apps/docs/src/app/utils/watch-host-attribute.ts new file mode 100644 index 0000000..59b3edb --- /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..84f2a53 --- /dev/null +++ b/apps/docs/src/content/agents/mcp-server.md @@ -0,0 +1,191 @@ +--- +title: MCP server +description: Connect a coding agent to the devtools over stdio or HTTP, in Claude Code, Cursor or VS Code. +--- + + + 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. + + +# 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 + + + + Your client starts ng-devtools mcp in the project folder. The server scans your source. No page ever connects to it. + + + Your client calls /__devframes/__mcp on the server that runs your app. Pages open in a browser report to it, so the live tools work. + + + +| 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. | + +## Connect over stdio + +The package ships an `ng-devtools` binary. Its `mcp` command starts an MCP server on stdin and stdout. + +### 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 group="stdio" name="Cursor" +// .cursor/mcp.json +{ + "mcpServers": { + "ng-devtools": { + "command": "npx", + "args": ["@santoshyadavdev/ng-devtools", "mcp"] + } + } +} +``` + +```json group="stdio" name="VS Code" +// .vscode/mcp.json +{ + "servers": { + "ng-devtools": { + "type": "stdio", + "command": "npx", + "args": ["@santoshyadavdev/ng-devtools", "mcp"] + } + } +} +``` + +### 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`. + + + Inside this repository, pnpm devtools:mcp runs the same server against the demo app. + + +### What stdio can answer + +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 | +| --------------------------------------- | ----------------------------------------- | +| [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` | + +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 + + + 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. + + +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 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"} + } + } +} +``` + +### Open the app in a browser + +The live tools read what the page reports. Without an open page, they have nothing to answer with. + + + + Run the server that mounts the devtools: your Express SSR server, the Vite dev server, or ng-devtools dev. + + + Load the app with the overlay. The page connects to the devtools and starts reporting. + + + Ask your agent something the page knows, like "why is the checkout form invalid?". It calls explain-form-invalid on the connected page. + + + +## How tools behave + +### Tool names + +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 + +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: + +| 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. The server drops pages that stop reporting after a short time. + +## Where to next + + + + Each tool grouped by inspector, with what it answers and its arguments. + + + The live state an agent can read as JSON. + + + 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..d2a6c05 --- /dev/null +++ b/apps/docs/src/content/agents/resources.md @@ -0,0 +1,111 @@ +--- +title: Resources +description: Live state an agent can read as MCP resources, and the shared-state keys behind them. +--- + + + Six JSON resources hold what the connected pages reported. Shared-state keys cover the rest. + + +# Resources + +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. + + + Over stdio, no page ever connects. Every resource stays empty. + + +### 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 + +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. + +### injector-tree + +The injector hierarchy the page last reported, with the providers at each level. + +### 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. + + + When the data is too large, the resource returns a summary per form (status, field count, error count) and points to inspect-forms. + + +### 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 + +The devtools keep their live data in shared-state keys. Every key is also listed as a resource, at `devframe://state/` with the key encoded. + +### Keys + +This table covers the data that has no resource of its own. + +| 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` | Declared but not filled. Use `get-routes` or `list-routes` instead. | + +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 + +Some clients only use tools. The `devframe_state_read` tool reads the same keys: + + + + Call devframe_state_read without arguments. It returns every key. + + + Call it again with key, for example ng-devtools:http. It returns the value as JSON. + + + +## Where to next + + + + + + diff --git a/apps/docs/src/content/agents/tools.md b/apps/docs/src/content/agents/tools.md new file mode 100644 index 0000000..30de4a2 --- /dev/null +++ b/apps/docs/src/content/agents/tools.md @@ -0,0 +1,266 @@ +--- +title: Tools +description: Every agent tool the devtools expose, grouped by inspector, with what it answers and its arguments. +--- + + + Forty-four tools, grouped by inspector. Each one answers a question you would otherwise answer by clicking through the panel. + + +# Tools + +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. + + + + Reads your files. Works over stdio and HTTP, with or without a browser. + + + Reads what a connected page reported. Needs the HTTP endpoint and the app open in a browser. + + + Reads what the Vite dev server recorded. Needs the Vite plugin. + + + +### The page argument + +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 + + + highlight, navigate, form-action, fill-form and analog-call-api act on the app. Every other tool is marked read-only for your client. + + +## Source scan + +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-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 project name, the Angular and TypeScript versions, SSR status, the Analog version in Analog apps, and a `builtAt` timestamp. | + +## Components, signals and DI + +### highlight Action + +Highlights a component in the page and makes it the target of `inspect-signals`. Reads: page. + +| 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`. | + + + The page reports one graph: the component picked on the Signals page or with highlight, otherwise the deepest component in the primary router outlet. Call highlight first to switch the graph to another component. Only signals a template or an effect has read appear. + + +### 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 + +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: + +| 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 Action + +`navigate` acts on the running app's router, in development only. Reads: page. + +| 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 + +All forms tools read the page. They cover Signal Forms, reactive forms and template-driven forms. + +Two arguments come up in almost every tool: + +| 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 + +| 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 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. The tools redact passwords and other secret-looking values. + +### 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. 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 | +| ------------- | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | +| `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`. + + + reset, submit and restore need confirm: true, and so does fill-form with submit. Disabled reactive fields need force. + + +## Pipes + +### 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 + +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 Action + +`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 | +| --------- | -------- | ------------------------------------------------------------- | +| `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. + + + analog-server-calls and analog-call-api need the Vite plugin. The plugin records the calls and knows the dev server address. + + +## Shared state + +`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 + + + + + + diff --git a/apps/docs/src/content/community.md b/apps/docs/src/content/community.md new file mode 100644 index 0000000..151e992 --- /dev/null +++ b/apps/docs/src/content/community.md @@ -0,0 +1,90 @@ +--- +title: Get involved +description: Where to ask questions, report bugs and support the project. +--- + + + Ask questions on Discord, report bugs on GitHub, send a pull request, or sponsor the project. + + +# Get involved + +Angular DevTools is open source under the MIT license. Here is where to reach the people behind it, and how to help. + +## Maintainers + + + + Maintainer. + + + Maintainer. + + + +## Get in touch + + + + Join the conversation, ask questions, and share feedback. + + + File an issue when something doesn't work as documented. + + + Propose a new inspector, agent tool or setup. + + + Fixes, docs and features. + + + +### 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. + +## Contribute code + +### Where to start + + + + Clone the repository, install dependencies and run the devtools locally. + + + The apps in this repository that give every inspector something to show. + + + Build and package the Chrome extension. + + + How the npm package is built and published. + + + +### 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 + +### 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. 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 new file mode 100644 index 0000000..4a3e116 --- /dev/null +++ b/apps/docs/src/content/contributing/chrome-extension.md @@ -0,0 +1,117 @@ +--- +title: Build the extension +description: Build, load and package the Chrome DevTools extension. +--- + + + A thin Manifest V3 shell around the devtools UI. Build it, load it unpacked, and zip it for the Chrome Web Store. + + +# Build the extension + +The Chrome extension lives in `extension/`. It detects Angular pages, creates the panel, and loads the devtools UI from `extension/ui`. + +## Files + +```text +extension/ + 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 + 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) +``` + +### What the manifest asks for + + + + permissions is empty. + + + Host permissions for localhost and 127.0.0.1, over HTTP and HTTPS. The content scripts still run on every page. + + + Set by minimum_chrome_version. + + + +## Build + +```bash +pnpm extension:build +``` + +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 builds the extension and fails when extension/ui is stale. + + +## Load in Chrome + + + + Go to chrome://extensions. + + + Turn on the Developer mode toggle in the top right corner. + + + Click Load unpacked and select the extension/ directory. + + + Start a demo app and open DevTools. The Angular DevTools panel appears once the extension detects Angular on the page. + + + +After a rebuild, click the reload icon on the extension card, then reopen DevTools. + +## Package for the Chrome Web Store + +```bash +pnpm extension:zip +``` + +This runs `extension:build`, then writes `dist/ng-devtools-extension.zip`. The zip leaves out `.DS_Store` files. + +### Upload + +1. Bump `version` in `extension/manifest.json`. +2. Go to the Chrome Developer Dashboard. +3. Click **New item** (or open the existing item) and upload the zip. +4. Fill in the listing details and submit for review. + + + The privacy policy for the listing is in docs/privacy-policy.html. + + +## How the panel connects + +### 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 + + + + + + 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..86f6c2c --- /dev/null +++ b/apps/docs/src/content/contributing/demo-apps.md @@ -0,0 +1,120 @@ +--- +title: Demo apps +description: The Angular Travel demo and the Analog demo in the repository, and how to run each in development and production. +--- + + + Two apps that give every inspector something to show. One Angular CLI app with SSR, one Analog app on Vite. + + +# Demo apps + +The repository has two demo apps. Use them to try a change against a real app before you open a PR. + + + + An Angular CLI app with SSR and Express. It uses the hub, the overlay and the HTTP providers. + + + An Analog app wired with the Vite plugin. It covers file routes, server loads, API routes and content. + + + +## Angular Travel + +**Angular Travel** (`src/`) looks and behaves like a real booking site. + +### What's inside + +| 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`. + +### 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: + +```bash +pnpm build --configuration development +node dist/angular-devtools/server/server.mjs +``` + +It listens on port 4000, or on `PORT` when set. + + + pnpm build defaults to the production configuration. src/main.ts only loads the overlay when ngDevMode is on, and the HTTP interceptor passes requests through in production. Build with --configuration development to use the devtools. + + +### Render modes + +`src/app/app.routes.server.ts` sets a render mode per route, so the SSR tools have something to compare: + +| 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 + +`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. That dev server also serves the devtools and the MCP endpoint. + +### Build and preview + +```bash +pnpm --filter analog-demo build +pnpm --filter analog-demo preview +``` + + + The Vite plugin runs on the dev server only, and the overlay loads only when import.meta.env.DEV is true. A production build has no devtools. Use it to check the analog-prerender-plan tool against real build output. + + +## Where to next + + + + + + diff --git a/apps/docs/src/content/contributing/development.md b/apps/docs/src/content/contributing/development.md new file mode 100644 index 0000000..f3d1fd4 --- /dev/null +++ b/apps/docs/src/content/contributing/development.md @@ -0,0 +1,199 @@ +--- +title: Development setup +description: Set up the repository, run the devtools UI and the demo apps, and run the same checks as CI. +--- + + + Clone, install, and run the devtools against a real Angular app. The same checks CI runs, on your machine. + + +# 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 + + + + .nvmrc pins 24, and the root package.json requires >=24. + + + The root package.json sets packageManager to pnpm@10.33.4. + + + +## Set up the repository + +```bash +git clone https://github.com/santoshyadavdev/angular-devtools.git +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 the Analog Angular plugin +packages/ + ng-devtools/ # Publishable npm package + 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 Travel, the host demo app +``` + +### 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 + +### Root scripts + +Most work goes through the root `package.json` scripts: + +```bash +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 +``` + +### 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 +``` + +```bash group="nx" name="Test" +pnpm exec nx test # Angular Travel +pnpm exec nx test angular-devtools-docs # This site +``` + +```bash group="nx" name="Serve" +pnpm exec nx serve # Angular Travel on port 4200 +pnpm exec nx dev analog-demo +``` + +```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. + + + The package's build target lists app/** as an input. A change to the devtools UI invalidates the package 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 and its own RPC. Source-scan data only; live tabs need an app page connected, so use the SSR server for those. | + + + 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/. + + +## Run the checks + +Run the same checks as CI before you open a PR: + +```bash +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 +``` + +### What CI runs + +`.github/workflows/ci.yml` runs on pushes and pull requests to `main`, in this order: + + + + pnpm install --frozen-lockfile on the Node version from .nvmrc. + + + pnpm format:check, then pnpm typecheck. + + + nx affected -t test build, compared against the last green commit on main. + + + pnpm test:devtools. + + + pnpm extension:build. The job fails when extension/ui differs from the committed copy. + + + node bin.mjs --help. + + + +## 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/`. + +### 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`. + +### Add an agent tool + +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. + + + Run pnpm extension:build and commit extension/ui. CI fails when it is stale. See Build the extension. + + +## 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 +``` + +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 and on raw external anchors without target="_blank". Run pnpm docs:build before you open a PR. + + +## Where to next + + + + + + 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..df0a345 --- /dev/null +++ b/apps/docs/src/content/contributing/kitchen-sink.md @@ -0,0 +1,297 @@ +--- +title: Kitchen sink +description: Every NgMd component and markdown feature this site uses, on one page, for checking styles and behaviour. +noIndex: true +--- + + + 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. + + +# 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 Beta + +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,6-8} +// 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 + +``` + +```css +:root { + --accent: #b45309; +} +``` + +## Callouts + + + Context the reader may need, with code and a link. + + +Callouts are never adjacent on real pages. The text between them here keeps that rule. + + + A shortcut or a better way to do something. + + +Text between callouts. + + + Confirms a result. + + +Text between callouts. + + + Something that can go wrong. + + +Text between callouts. + + + Data loss or a security risk. + + +## Alerts + + + An info alert. + + +Text between alerts. + + + A helpful alert. + + +Text between alerts. + + + An important alert. + + +Text between alerts. + + + A warning alert. + + +Text between alerts. + + + A critical alert with a custom label. + + +## Badges + +New Updated Alpha Beta Stable Deprecated + +## Cards + +### Three columns with icons + + + + A card that links inside the site. + + + A card that opens another site in a new tab. + + + A card with inline code and no link. + + + +### Two columns with images + + + + A logo instead of an icon. + + + A round profile photo. + + + +### Every icon + + + + + + + + + + + + + + + + + + + + +## Workflow + + + + Run pnpm add @santoshyadavdev/ng-devtools devframe. + + + Add the hub to your server. + + + Import the overlay in development builds. + + + +## Tabs + + + + Content of the first tab. + + + Content of the second tab, with an image instead of an icon. + + + A tab with no icon. + + + +## Accordion + + + + This item starts open. + + + This item starts closed. + + + +## Media + +### Image + + + +

Say hi to my cats Angular and Excel 👋

+ +### Video + + + +## Where to next + + + + + + diff --git a/apps/docs/src/content/contributing/publishing.md b/apps/docs/src/content/contributing/publishing.md new file mode 100644 index 0000000..711a013 --- /dev/null +++ b/apps/docs/src/content/contributing/publishing.md @@ -0,0 +1,92 @@ +--- +title: Publishing +description: Bump the version, build, and publish the npm package. Ship the Chrome extension with a fresh UI. +--- + + + One npm package and one Chrome extension, each with its own version. Bump, build, publish. + + +# Publishing + +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: + + + + tsdown builds the entry points into dist/. + + + vite build with app/vite.config.ts writes the devtools UI to dist/public. + + + +`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 + +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 + +```bash +pnpm devtools:publish +``` + +This runs `pnpm --filter @santoshyadavdev/ng-devtools publish --access public`. The `prepack` build bundles the library and the UI. + + + pnpm publish checks git before it publishes. Run it from a clean working tree on main. + + +## 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`. + +See [Build the extension](/contributing/chrome-extension) for the upload steps. + +## Where to next + + + + + + 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..b48609e --- /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. +--- + + + 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. + + +# 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. + + + The repository ships a devtools-docs skill in .claude/skills with the same rules, so agents follow this page when they edit docs. + + +## 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 that summarizes the page. +--- + + + One or two sentences on what the page covers. + + +# Router + +A short intro: what the tab is and when you open it. + +## What it shows + +### Navigations + +... + +## Where to next + + + + +``` + +### Frontmatter + +`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 + +- 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 | +| -------------------------------------------- | ----------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | +| `` | 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) | +| `` | A short aside with context the reader may need. | `type` (`info`, `tip`, `success`, `warning`, `danger`), `title` | +| `` | One short point the reader must not miss. | `severity` (`info`, `helpful`, `important`, `warning`, `critical`), `label` | +| `` + `` | Links to related pages, requirements, feature overviews. | grid: `columns`. card: `title`, `link`, `cta`, `icon`, `image`, `avatar` | +| `` + `` | Ordered steps. | step: `title` | +| `` + `` | FAQ sections. | item: `title`, `open` | +| `` + `` | A row of related links at the end of a page. | pill: `href`, `title` | +| `` | A status chip next to a heading. | `variant` (`new`, `updated`, `alpha`, `beta`, `stable`, `deprecated`) | +| `` + `` | The same content in several forms, when a code group doesn't fit. | tab: `title`, `icon`, `image` | +| ``, `` | 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 ``. 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: ``, `` and ``. Markdown doesn't render inside them. Write `@` as `@` inside components. + +### 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 + + + + Run pnpm docs:dev and open every page you changed. Check the "On this page" list, the links and dark mode. + + + Run pnpm docs:build. The build fails on a broken internal link or anchor, and on an external raw HTML link without target="_blank". + + + Run pnpm exec prettier --check "apps/docs/**/*.{ts,json,css,html}". Markdown in src/content is not reformatted, so check tables and line breaks by eye. + + + 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". + + + +## 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 new file mode 100644 index 0000000..3723ba2 --- /dev/null +++ b/apps/docs/src/content/getting-started/chrome-extension.md @@ -0,0 +1,125 @@ +--- +title: Chrome extension +description: Open the devtools as a panel inside Chrome DevTools. +--- + + + An Angular DevTools panel inside Chrome DevTools. It loads the devtools UI and connects it to the dev server of the page you inspect. + + +# Chrome extension + +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. + + + The page still needs the devtools mounted on its server and the overlay loaded. The extension is one more way to open the devtools. It does not replace the setup. Start with Angular CLI and Express or Vite and Analog. + + +## Before you start + + + + The manifest sets minimum_chrome_version to 111. + + + The extension lives in the extension/ folder. You build it from source. + + + The repository itself needs Node.js 24 or later and pnpm 10 or later. + + + +## Install + +### Build and load it + + + + Run pnpm install in the root of the repository. + + + Run pnpm extension:build. It builds the devtools UI and copies it into extension/ui. + + + Go to chrome://extensions and turn on Developer mode. + + + Click Load unpacked and select the extension/ directory. + + + The Angular DevTools panel appears next to the built-in panels. + + + +### Commands + +```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 + +### 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 extension creates the panel 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. + +### 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 + +### Host access + +The manifest asks for no `permissions`. Its host permissions cover only `localhost` and `127.0.0.1`, over HTTP and HTTPS. + +### 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). + +## FAQ + + + + The page did not look like an Angular app. Check that it renders an ng-version attribute or exposes window.ng, which development builds do. Then close and reopen DevTools. + + + Check that the page is served from localhost or 127.0.0.1, that its server mounts the devtools, and that the overlay is loaded. + + + No. The overlay still adds the button to the page. Use the button or the panel, whichever you prefer. + + + +## Where to next + + + + The build, the zip and the store package. + + + Which origins the devtools trust. + + 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..bcda20e --- /dev/null +++ b/apps/docs/src/content/getting-started/cli.md @@ -0,0 +1,149 @@ +--- +title: Standalone CLI +description: Run the devtools from the command line, build a static report, or start an MCP server. +--- + + + Three commands from one binary. A local devtools server, an offline report, and an MCP server for coding agents. + + +# Standalone CLI + +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. + +## 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 +``` + +```bash group="run" name="pnpm" image="https://cdn.simpleicons.org/pnpm/F69220" +pnpm dlx @santoshyadavdev/ng-devtools dev +``` + +```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 to listen on. The default is 9999. If it is taken, a random 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 binds to localhost by default and asks for a one-time code. Changing --host or passing --no-auth widens who can reach it. See Access and redaction. + + +### What it shows + +No page is connected to the CLI server. The tabs show what your source declares: + +- [Components](/inspectors/components) +- [Routes](/inspectors/router) +- [Signals](/inspectors/signals) +- [Providers](/inspectors/injectors) +- [NgRx declarations](/inspectors/ngrx-store) +- [Pipes](/inspectors/pipes) + + + For live data, mount the devtools in your app's own server. See Angular CLI and Express or Vite and Analog. + + +## Static report + +### 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 ` | 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 + +### 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 + + + + No. All three commands read your source files. Only live data needs a running app with the overlay loaded. + + + The root of your Angular workspace. The scan starts from the current directory. + + + Without --port, the server picks a random free port. Pass --port to choose one yourself. + + + +## Where to next + + + + Client setup for stdio, and the HTTP endpoint for live data. + + + Mount the hub in your app for live inspectors. + + 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..532322c --- /dev/null +++ b/apps/docs/src/content/getting-started/express.md @@ -0,0 +1,271 @@ +--- +title: Angular CLI and Express +description: Mount the devtools hub in the Express server of an Angular SSR app. +--- + + + Mount the devtools hub in your server.ts, load the overlay in main.ts, and open the panel from a button on your page. + + +# 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. + + + This setup mounts the devtools in the Express server.ts that Angular SSR generates. For an Analog app, follow Vite and Analog instead. + + +## Setup at a glance + + + + Add @santoshyadavdev/ng-devtools and devframe. See Installation. + + + Add initNgDevtoolsHub() to server.ts, before your other routes. + + + Import the overlay in main.ts, in development only. + + + Start the app in development mode and click the amber button in the corner of the page. + + + +## Mount the hub + +### Add the middleware + +```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); +``` + +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. + +### Middleware order + +Mount the middleware before `express.static` and the Angular SSR handler, so the devtools routes answer first. + +```ts +// src/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. + +### 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 +// src/server.ts +const devtools = initNgDevtoolsHub({ws: false}); +``` + +```ts group="transport" name="WebSocket side-car" +// src/server.ts +const devtools = initNgDevtoolsHub({ws: {sidecar: true}}); +``` + +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 + +`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. | + +### Access control + +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. + +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 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. + + + initNgDevtoolsHub() has no production switch of its own. If your server.ts also runs in production, decide there whether to mount it. + + +## 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 {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) { + return import('@santoshyadavdev/ng-devtools/overlay'); + } + return undefined; + }) + .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 | +| ------------ | ------------------------------------------------------------------------------- | +| 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 | 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. + +## 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//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 `` with your project name. + + + ng build uses the production configuration by default. ngDevMode is false there, so the overlay is never imported and the button never appears. The live tabs need --configuration development. In this repository, the same applies to pnpm build. + + +## 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 {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 = { + providers: [ + provideClientHydration(), + provideHttpClient(withFetch(), withNgDevtools()), + provideNgDevtoolsHttp(), + ], +}; +``` + +`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 + +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 + +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()` from `devframe/initiate`: + +```ts +// src/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 + + + + The app is probably a production build. Run ng serve, or build with --configuration development. Then check that main.ts imports the overlay. + + + Check that the server is running and that the hub middleware is mounted before express.static and the SSR handler. Then reload the page. + + + With auth on, a browser reads data only after it exchanges the one-time code the server printed. On a machine only you use, pass auth: false. + + + Add withNgDevtools() and provideNgDevtoolsHttp(), and run SSR in the same Express process as the hub. Prerendered routes make no requests at runtime. + + + +## Where to next + + + + What the overlay sends, and how to point it at a custom mount path. + + + Dock modes, the hub rail, deep links and connection status. + + + Interceptor order, fault rules and hydration warnings. + + + Who can reach the hub, and what is redacted. + + 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..0e3b9ec --- /dev/null +++ b/apps/docs/src/content/getting-started/installation.md @@ -0,0 +1,179 @@ +--- +title: Installation +description: Install the devtools package and choose where it runs. +--- + + + One package, two parts. A server part that hosts the devtools, and a browser part that sends live data from your page. + + +# 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. + +## Prerequisites + + + + The package declares node >=22 in its engines field. + + + @angular/core and @angular/common 20 and newer are supported. + + + pnpm, npm, yarn or bun. Any of the four. + + + + + Live data comes from Angular's debug API (window.ng). Production builds remove it, so the live tabs stay empty there. Run your app in development mode while you inspect it. + + +## 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="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 +``` + +MCP agent support (`@devframes/agentic`) is included. You don't install it separately. + +### 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 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: + +- **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 +// 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); +``` + +```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 +// 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; + }) + .catch((err) => console.error(err)); +``` + +```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'); +}); +``` + +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 + + + + Run ng serve for an Angular CLI app, or the Vite dev server for an Analog app. + + + An amber button appears in the bottom-right corner of the page. The overlay adds it. + + + Click the button. The header shows Live once the panel is connected. + + + Go to /__devframes/ on the same server to see the devtools on their own page. + + + +## FAQ + + + + The devtools are built on Devframe. Some setups import from devframe directly, for example initDevframe from devframe/initiate to mount only the panel. Package managers like pnpm only resolve imports of direct dependencies. + + + Wherever your server part runs. An Express app imports the hub in server.ts, so the package must be installed where that server starts. The overlay import in main.ts only runs in development builds. + + + Check that the app runs as a development build and that main.ts imports the overlay. A production build skips the import, so there is no button. + + + +## Where to next + + + + + + + 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..c52074a --- /dev/null +++ b/apps/docs/src/content/getting-started/introduction.md @@ -0,0 +1,161 @@ +--- +title: Introduction +description: What the devtools inspect, and the ways you can run them. +--- + + + Inspect components, signals, injectors, routes, forms, pipes, NgRx stores and HTTP calls. In the page, from the command line, or through a coding agent. + + +# Introduction + +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. + + + Everything ships in @santoshyadavdev/ng-devtools: the server side, the browser overlay, the in-page popup, the CLI and the built UI. See Installation. + + +## What it inspects + +### Live inspectors + +These tabs read the running page through Angular's debug API. They need a development build. + + + + 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. + + + The live signal graph of one component (signal, computed, linkedSignal and effect nodes with their edges), plus a value history per signal. + + + The element and environment injector hierarchy, the lookup path for a token, and the providers at each level. + + + The live route, every navigation as a full story, the live route config with URL testing, the router setup and a route lint. + + + Every Signal Form, reactive form and template-driven form, with values, status, readable errors, a change timeline and a lint. + + + Custom and built-in pipes, where they are used, live instances, call recording, async subscriptions and a pipe lint. + + + Live @ngrx/signals stores with a change log, diffs and state restore, plus the @ngrx/store state and action log. + + + An HTTP timeline for SSR and client calls, fault injection, hydration stats and the TransferState payload. + + + +### Project overview + +| 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 + +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 Devframe 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 + + + + The same definition serves the embedded panel, the standalone CLI, the static report, the MCP server and the Chrome extension. + + + The hub comes from @devframes/hub, so other Devframe tools can join the same dock next to the devtools. + + + The UI talks to the server over Devframe RPC, and live data sits in shared state that the UI and agents both read. + + + An RPC function marked for agents becomes an MCP tool, and shared state is exposed as MCP resources. + + + +## Requirements + + + + The package declares Angular 20 and later as its peer range. + + + The package runs on Node.js 22 and newer. + + + Live data comes from window.ng, which production builds remove. + + + + + 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. + + +## FAQ + + + + No. The overlay adds a floating button to your page and opens the devtools in a panel. The Chrome extension is optional. It adds the same UI as a panel in Chrome DevTools. + + + The devtools need a server part. An Angular CLI app mounts it in its Express server.ts. An Analog app gets it from the Vite plugin. Without either, the standalone CLI serves the source scan. + + + Not if you follow the setup guides. They load the overlay with a dynamic import that only runs in development builds. + + + By default, no. The Vite plugin only answers requests from your machine, and the Express hub asks for a one-time code. See Access and redaction. + + + +## Where to next + + + + 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..70aa704 --- /dev/null +++ b/apps/docs/src/content/getting-started/overlay.md @@ -0,0 +1,169 @@ +--- +title: Browser overlay +description: The script that runs in your page and sends live data to the devtools. +--- + + + The script that runs inside your page. It reads Angular's debug API and sends live data to the devtools server. + + +# 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 one dynamic import in `main.ts` is all you need. + +## 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 +// 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; + }) + .catch((err) => console.error(err)); +``` + +```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'); +}); +``` + +### Why development only + +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 + + + + Components, inputs, outputs and injected services. + + + Signal, computed, linkedSignal and effect nodes. + + + Element and environment injectors with their providers. + + + Signal stores and the global store. + + + Every form on the page, and pipe instances. + + + Navigations, HTTP calls and Analog page data. + + + +## How it connects + +### 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/` + +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 + +### Call `initOverlay` + +If you mount the devtools somewhere else, call `initOverlay` with that path: + +```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(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 + +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 {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([ + 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 + + + + No. The overlay adds the floating button itself. See Popup and hub. + + + It polls every 3 seconds and only sends data that changed. With the dynamic import above, it never loads in production builds. + + + Live values are sent to the devtools server. Secret-looking values are redacted first. See Access and redaction. + + + +## Where to next + + + + + + + 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..4154eb4 --- /dev/null +++ b/apps/docs/src/content/getting-started/popup-and-hub.md @@ -0,0 +1,160 @@ +--- +title: Popup and hub +description: The floating button, the panel and its dock modes, the hub rail and deep links. +--- + + + 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. + + +# 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. You don't need a browser extension. + +## The floating button + +### Where it comes from + +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. + +### Create it yourself + +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. Importing the popup module in the browser also adds the button on its own. + + + The popup alone sends no live data. Load the overlay for that. + + +### Match your app colors + +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 */ + --ng-devtools-title: #f5a524; /* panel title and active dock mode */ +} +``` + +The values above are the defaults. + +## The panel + +### Dock modes + + + + A free panel on top of your app. Drag it by the toolbar and resize it from the corner. + + + Full width, 40% of the viewport height. Resize it vertically. + + + 40% of the viewport width, full height. Resize it horizontally. + + + +You can drag only the floating panel. Switch modes from the buttons in the panel toolbar. + +### Keyboard and mouse + +| Action | How | +| ------------------------- | ------------------------------------------- | +| Close the panel | Escape | +| Move the button | Drag it, or focus it and use the arrow keys | +| Move the button further | Hold Shift with the arrow keys | +| Reset the button position | Double-click it | + +### Saved layout + +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 + +### 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 | +| ------------ | ------------------------------------------------------------------------------- | +| 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 | A **Coming Soon** placeholder | +| Capacitor | A **Coming Soon** placeholder | + +### 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 + +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 | +| ---------- | ----------------- | +| 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` | + +### Limits + +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 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 + + + + Drag it somewhere else, or focus it and use the arrow keys. Double-click it to reset its position. + + + Remove the ng-devtools-popup key from localStorage and reload. + + + The overlay did not find the hub at /__devframes/. Check that your server mounts initNgDevtoolsHub() or the Vite plugin on the default base. + + + +## Where to next + + + + + + 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..6760599 --- /dev/null +++ b/apps/docs/src/content/getting-started/vite.md @@ -0,0 +1,182 @@ +--- +title: Vite and Analog +description: Add the devtools Vite plugin to an Analog app. +--- + + + One plugin next to analog(), one import in main.ts. The hub mounts on the Vite dev server. + + +# Vite and Analog + +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. + +## Setup at a glance + + + + Add @santoshyadavdev/ng-devtools and devframe. See Installation. + + + Register ngDevtools() after analog() in vite.config.ts. + + + Import the overlay in src/main.ts when import.meta.env.DEV is true. + + + Start the dev server and click the amber button, or open /__devframes/. + + + +## 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'; + +export default defineConfig({ + plugins: [analog(), ngDevtools()], +}); +``` + +### Load the overlay + +The plugin does not inject the overlay. Your app imports it in `main.ts`: + +```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'); +}); +``` + +`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 + +### 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 + +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. + +## Options + +```ts +// vite.config.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. | + +### `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 + +### Local hostnames + +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 {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()], +}); +``` + +### Tunnels and other origins + +Add other origins with `allowedOrigins`: + +```ts +// vite.config.ts +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. + + +## FAQ + + + + No. It applies to the dev server only, and the overlay import is guarded by import.meta.env.DEV. + + + The request did not come from your machine, or its origin is not trusted. Open the app on localhost, list your hostname in server.allowedHosts, or add the origin to allowedOrigins. + + + The plugin records server calls made through the Vite dev server. Check that the plugin is registered and that apiPrefix matches your server routes. The Analog guide walks through a full setup. + + + +## Where to next + + + + A full Analog setup, including the demo in this repository. + + + File routes, server calls, render modes, content and lint. + + + What the overlay sends, and how it finds the server. + + + The loopback check and the origin rules in detail. + + diff --git a/apps/docs/src/content/guides/analog.md b/apps/docs/src/content/guides/analog.md new file mode 100644 index 0000000..0df7fe4 --- /dev/null +++ b/apps/docs/src/content/guides/analog.md @@ -0,0 +1,171 @@ +--- +title: Set up Analog +description: Add the devtools to an Analog app, step by step. +--- + + + 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. + + +# Set up Analog + +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 get + + + + Components, injectors, signals, forms, router and pipes, reading the live page. + + + File routes, server calls, render modes, content and lint. + + + Signal stores and the change log, when your app uses NgRx. + + + /__devframes/__mcp on the Vite dev server, with the Analog tools. + + + +## The flow + + + + Add @santoshyadavdev/ng-devtools and devframe. + + + Register it next to analog() in vite.config.ts. + + + Import the overlay in src/main.ts, in development only. + + + Start the dev server and click the floating button. + + + +## 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="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 +``` + +## Step 2: Add the Vite plugin + +Add the plugin after `analog()`: + +```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 (`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. | + +### Custom hostnames + +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'); +}); +``` + +The import is dynamic and guarded by `import.meta.env.DEV`, so production bundles do not include it. + +## 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. + + + Point your MCP client at http://localhost:5173/__devframes/__mcp with an Origin header. See MCP server. The analog-server-calls and analog-call-api tools only work through the Vite plugin. + + +## 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: + +```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 = { + 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`. 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 +``` + +The script builds the devtools package first, then starts the Vite dev server. + + + The demo aliases @santoshyadavdev/ng-devtools/overlay to the built package in its vite.config.ts. Your app does not need that alias. + + +## Where to next + + + + + + 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..a59cd9b --- /dev/null +++ b/apps/docs/src/content/guides/ngrx-signals-restore.md @@ -0,0 +1,136 @@ +--- +title: Restore NgRx signal state +description: Register patchState so that restoring a signal store also notifies watchState listeners. +--- + + + Put a signal store back to any state in its change log. Register patchState once, and your watchState listeners run too. + + +# 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. Load both modules with dynamic imports in development only, so production bundles do not include the devtools. + +```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) => { + 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)); +``` + +```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 + + + + With the hub, it is the NgRx dock. + + + Select a store, then open an entry in its change log. + + + Click Restore this state, then Restore. + + + +### 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. The log gets a **Restore** entry. + + + Restore still works, but the log entry says that watchState listeners were not notified. + + +## Limits + +### Signal stores + +- 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 + +For `@ngrx/store`, restore uses Store DevTools instead. Add `provideStoreDevtools()` to enable it. + +```ts {6} +// src/app/app.config.ts +import {ApplicationConfig} from '@angular/core'; +import {provideStoreDevtools} from '@ngrx/store-devtools'; + +export const appConfig: ApplicationConfig = { + providers: [provideStoreDevtools()], +}; +``` + + + Without provideStoreDevtools(), the action log is read-only. + + +## Where to next + + + + + + 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..b84c6a2 --- /dev/null +++ b/apps/docs/src/content/guides/ssr-http.md @@ -0,0 +1,191 @@ +--- +title: Set up SSR & HTTP +description: Add the interceptor and hydration hooks, in the right order, to fill the SSR & HTTP tab. +--- + + + Record every HttpClient call on the server and in the browser, then break them on purpose with fault rules. + + +# Set up SSR & 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 set up + + + + withNgDevtools() records each request and applies fault rules. + + + provideNgDevtoolsHttp() captures the NG05xx hydration warnings Angular logs. + + + SSR and the devtools hub run in the same Express process. + + + +## The flow + + + + Register the interceptor and the hydration hook in app.config.ts. + + + Place withNgDevtools() before your own interceptors. + + + SSR and the devtools middleware share one Express process. + + + Use RenderMode.Server for them in app.routes.server.ts. + + + Add a rule in the tab and reload the page. + + + +## 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: [ + provideClientHydration(), + provideHttpClient(withFetch(), withNgDevtools()), + provideNgDevtoolsHttp(), + ], +}; +``` + +### What each provider does + +- `withNgDevtools()` adds the interceptor that records calls and applies fault rules. +- `provideNgDevtoolsHttp()` captures the hydration warnings (NG05xx) before the overlay loads. + + + The interceptor checks ngDevMode. In production builds it passes every request through untouched. + + +## Step 2: Put withNgDevtools first + +Register `withNgDevtools()` before your own interceptors. Then it records requests as the app makes them, and fault rules apply before anything else. + +```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 + +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. + +## Step 3: Mount the hub in server.ts + +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-12} +// src/server.ts +import {AngularNodeAppEngine, createNodeRequestHandler} from '@angular/ssr/node'; +import express from 'express'; +import {initNgDevtoolsHub} from '@santoshyadavdev/ng-devtools/hub'; + +const app = express(); +const angularApp = new AngularNodeAppEngine(); + +const devtools = initNgDevtoolsHub({ + ws: {sidecar: true}, +}); +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`. 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 + +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}, +]; +``` + + + The explain-render-mode agent tool tells you which ServerRoute and render mode a URL gets. See Tools. + + +## Step 5: Inject a fault + + + + Open the SSR & HTTP tab and go to Fault injection. + + + Enter a URL pattern, for example /api/*. + + + SSR + client, SSR only or Client only. + + + Set a status (for example 500), a delay, or a mock JSON body. Click Add rule. + + + SSR rules apply from the next page load. Client rules apply right away. + + + +### 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. + + + SSR mocks are not written to TransferState, so the browser requests the URL again. Apply the rule on SSR + client to mock both. + + +## 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. + +## Where to next + + + + + + diff --git a/apps/docs/src/content/inspectors/analog.md b/apps/docs/src/content/inspectors/analog.md new file mode 100644 index 0000000..f59afb2 --- /dev/null +++ b/apps/docs/src/content/inspectors/analog.md @@ -0,0 +1,203 @@ +--- +title: Analog +description: File routes, server calls, render modes, content and lint for Analog apps. +--- + + + How an Analog app is put together and what its dev server does. File routes, server calls, render modes, content files and a lint. + + +# Analog + +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. In other apps it shows a **This app doesn’t use Analog** page. Without the hub, the Analog tab appears only in Analog apps. + +## 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} +// 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`. + +### 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 + +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 + +Every page, layout and markdown file with its URL, route groups, `[param]` and catch-all segments, `.server.ts` files and `routeMeta`. + +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. + +### Server + +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. + +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. + +### Render + +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. + +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. The tab marks files with frontmatter errors. + +### Lint + +Checks grouped by rule, each with a fix: + +- Two files for one URL, and sibling `[param]` files. +- Missing default exports, and layouts without ``. +- `.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 added pages that need a restart. + +## Where the data comes from + + + + The server scans your pages, layouts, .server.ts files, server routes, middleware, content files, vite.config and the build output. + + + The Vite plugin records page renders, load() fetches, server functions and API calls. + + + The overlay reports the open page, the load() data it received, and its hydration state. + + + +### 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 + + + + Type the URL into Test a URL. + + + Click Explain. The result lists the layouts, the page and the params. + + + +### Fix a `load()` that runs twice + + + + A warning at the top names the route. + + + Open the SSR & HTTP tab and look for the Analog entry in the payload. + + + After the fix, the browser should not fetch the route's load() again. + + + +### Call an API route + + + + Click Try in the API routes table. + + + For methods other than GET, add a JSON body and check This request can change data on the dev server. + + + The status, the time and the body appear below. The call also shows in the list. + + + +## 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 + +### `analog-call-api` changes real data + +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. + +### Redaction + +Response previews and `load()` data redact secret-looking keys, tokens, `Bearer` values and secret query parameters. See [what the devtools redact](/security). + +### Call history size + +The server keeps the last 200 calls. It cuts previews to 1000 characters, and page renders have no preview. + +## FAQ + + + + The running router does not know page files added after the dev server started. The lint flags them. Restart the dev server. + + + Without the hub, the Analog tab appears only in Analog apps. The app counts as Analog when its package.json depends on @analogjs/platform or @analogjs/router. + + + +## Where to next + + + + Install, add the plugin and load the overlay. + + + The Vite plugin and its options. + + + The TransferState payload, with Analog entries decoded. + + + The live router of the Analog app. + + diff --git a/apps/docs/src/content/inspectors/components.md b/apps/docs/src/content/inspectors/components.md new file mode 100644 index 0000000..53eae7d --- /dev/null +++ b/apps/docs/src/content/inspectors/components.md @@ -0,0 +1,188 @@ +--- +title: Components +description: Every component instance on the page, with live inputs, outputs and injected services. +--- + + + 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. + + +# Components + +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 + +### 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. + +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 exists in the same source file, a **Show … in Forms** button opens it in the [Forms tab](/inspectors/forms). + +### Facts + +- **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. + +A fact shows **Unknown** when Angular does not report it. + +### 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. This block appears only when there are any. +- One block per directive on the host, with its inputs and outputs. + +### Injected services + +**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 + +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. + +A notice at the top says why you see the source list: no page is connected, or the page reported no instances. + +## Where the data comes from + + + + The overlay walks the page with Angular's debug API every 3 seconds. It resends an unchanged tree only every fourth time. + + + The server scans your files for @Component and @Directive classes. It also supplies the file and line in the detail header. + + + +### 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.isSignal` | Unwrapping signal inputs. | +| `ng.getListeners` | Output listeners and DOM listeners. | +| `ng.getInjector`, `ɵgetDependenciesFromInjectable`, `ɵgetInjectorMetadata` | The **Injected** block. | + +### Refresh rate + +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 + +### Find a component in the page + + + + Type part of the class, tag or directive name. + + + The page highlights each host element as you move over it. + + + Click the row to open its details. + + + +### Check why an output does nothing + + + + Pick the component that declares the output. + + + An output marked no listener has no parent binding. Check the parent template. + + + +### Track down a missing provider + + + + Open the component that throws. + + + A token marked not provided is the one to fix. + + + Open the Injectors tab to see where Angular searched. + + + +### 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). + +## Limits and gotchas + +### Development builds only + +Live data reads `window.ng`. Production builds remove it, so the tab falls back to the source list. + +### Values are shortened + +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 + + + + No page is connected, or the connected page is a production build. Open the app in a development build with the overlay loaded. + + + The detail header matches the class name against the source scan. Classes outside the scanned folders, or from libraries, have no match. + + + Two directives sit on its host element. Select it to see one block per directive. + + + +## Where to next + + + + The injector tree and the lookup path of each token. + + + The live signal graph of one component. + + + Every form on the page, with field state and errors. + + + The script that reports the live page. + + diff --git a/apps/docs/src/content/inspectors/dashboard.md b/apps/docs/src/content/inspectors/dashboard.md new file mode 100644 index 0000000..0e89a1b --- /dev/null +++ b/apps/docs/src/content/inspectors/dashboard.md @@ -0,0 +1,111 @@ +--- +title: Dashboard +description: Project metadata and a count for each inspector. +--- + + + The first tab. It shows what the project is built with and how much each inspector found. + + +# Dashboard + +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. + +## What it shows + +### Project block + +The top block shows the project name and a chip for each of these: + +| 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. 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 states + +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 loads 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. 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 + + + + Confirm the Angular and TypeScript chips match what you expect. A mismatch usually means a stale install. + + + The Signals and Injectors cards switch to live counts once a page connects. + + + Click the card for the area you want to look at. It opens that tab. + + + +## Agent tools + +| Tool | What it returns | +| ------------------------ | ------------------------------------------------------------------------------------------------------ | +| `ng-devtools:build-meta` | Angular and TypeScript versions, the project name, SSR status and, in Analog apps, the Analog version. | + +[Static reports](/getting-started/cli) include the same data. See [Tools](/agents/tools) for every tool. + +## Limits and gotchas + +If the project block says **Project details unavailable**, check that the dev server is running, then reload the panel. + +## FAQ + + + + No. The source-based cards fill in from the workspace scan. Only the Signals and Injectors cards change when a page connects. + + + +## Where to next + + + + Every component instance on the page, with live inputs and outputs. + + + The live route, every navigation, and a route lint. + + + Serve the devtools or build a static report. + + + Every tool a coding agent can call. + + diff --git a/apps/docs/src/content/inspectors/forms.md b/apps/docs/src/content/inspectors/forms.md new file mode 100644 index 0000000..fa7f3ee --- /dev/null +++ b/apps/docs/src/content/inspectors/forms.md @@ -0,0 +1,223 @@ +--- +title: Forms +description: Every form on the page with each field's state and errors, a change timeline, submit explanations and a lint. +--- + + + 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. + + +# 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 go back to the page and run there. + +## What it shows + +### 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. + +Select a form to see its status, whether it is dirty or touched, whether it was submitted or is submitting, and an **Error summary**. + +### Fields view + +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`), `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. + +### Error sources + +Each error says where it comes from: + +| 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()`. | + +### Field details + +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. 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 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 + +Form bugs and model-aware accessibility checks, each with a fix. For generic accessibility checks, run axe on the page. + +### Actions bar + +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 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 + + + + The overlay finds the forms through Angular's debug API and pushes their state. + + + The server adds the file and line of each form and its rules. + + + +### When the page reports + +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. 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. + +The devtools never run async validators. The probe emits no form events, so it does not show up in the timeline. + + + The devtools call your sync validators. A validator that logs, counts or changes state sees extra calls while the Forms tab is open. + + +## How to use it + +### Find why a form is invalid + + + + The error count in the sidebar shows which forms fail. + + + Click the Invalid chip. + + + Each error says which validator, attribute or rule set it. + + + Click Error not shown to find errors that have no visible message. + + + +### Find why submit does nothing + + + + It explains what submit does. + + + Compare the value with what your API expects. + + + Click Copy test fixture to reproduce it in a test. + + + +### Test a form by hand + + + + Save the current values. + + + Type in the app, or set values from the field details. + + + Click Restore s1, then click again to confirm. + + + +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 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. | +| `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 + + + 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 [redacted]. To mask or unmask a field, see Security. + + +### Reset, submit and restore ask first + +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`. + +### 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 + + + + The current tab has no form yet. Click Show forms from all pages to see forms from other tabs. + + + The timeline records callers only with Record details checked. + + + No. It reads state and runs sync validators without emitting events. Only the actions write. + + + +## Where to next + + + + Open a form from the component that owns it. + + + What is redacted, and how to mask a field. + + + Every tool a coding agent can call. + + + The script that reports the live page. + + diff --git a/apps/docs/src/content/inspectors/injectors.md b/apps/docs/src/content/inspectors/injectors.md new file mode 100644 index 0000000..4ec3a2d --- /dev/null +++ b/apps/docs/src/content/inspectors/injectors.md @@ -0,0 +1,154 @@ +--- +title: Injectors +description: The injector hierarchy, token lookup paths and the providers at each level. +--- + + + The injector tree of the running page. Find a token, see who provides it, and follow the path Angular takes to resolve it. + + +# Injectors + +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. + +## What it shows + +### 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, the component or directive classes, and icons with the number of injected and provided tokens. + +### Search and filters + +- 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 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. 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 + +**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` 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 + + + + The overlay reads the tree with Angular's debug API and pushes it with the component tree, every 3 seconds. + + + The server reads your .ts files, skipping specs and type declarations. + + + +### 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 + + + + Type the token name in the search box. If no Provided by chip appears, nothing on the page provides it. + + + Read Injected here. The token is marked not provided anywhere. + + + Each step is an injector Angular asked. Add the provider to one of them, usually the app config or the component. + + + +### Find which instance a component gets + + + + Open its element injector. + + + Each token shows the injector that answered. A component-level provider shadows the root one. + + + +### Keyboard + +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 + +| 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. | + +See [Tools](/agents/tools) and [Resources](/agents/resources). + +## Limits and gotchas + +### Up to 2000 element injectors + +The **Elements** view stops at 2000 injectors, without a notice. Environment injectors have no cap. + +### Source mode only knows some provide functions + +The **Root Providers** group matches a fixed list of Angular `provide*()` functions. It doesn't list your own provider functions. + +## FAQ + + + + The source scan doesn't find constructor parameters without @Inject(). The live tree has them. + + + No live tree has reached the tab. The live tree needs the overlay, a development build and Angular 17 or later. + + + +## Where to next + + + + Each instance, with the services it injects. + + + Signal stores, found through the injectors. + + + Every tool a coding agent can call. + + + The script that reports the live page. + + 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..a303fd9 --- /dev/null +++ b/apps/docs/src/content/inspectors/ngrx-store.md @@ -0,0 +1,176 @@ +--- +title: NgRx Store +description: Live NgRx signal stores and @ngrx/store state, with change logs, diffs and restore. +--- + + + Your NgRx state as it changes. Signal stores and the classic Store, with a log of every change, a diff per entry, and restore. + + +# NgRx Store + +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. + +The tab has two sections. **Live stores** reads the running page. **Source declarations** lists what your files declare. + +## What it shows + +### Store list + +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: + +- `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. + +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` 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. 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, 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 + +The server scans your files for: + +- `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. + +Filter by kind with the chips. + +## Where the data comes from + + + + The overlay finds stores in the page's injectors and component fields, and records every change. + + + The server reads your .ts files for NgRx declarations, skipping specs. + + + +### 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. 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 + +The overlay finds stores through Angular's debug API, so the live section needs a development build. + +## How to use it + +### Find the change that broke the state + + + + Pick it in the list. Filter by name if there are many. + + + Open entries from newest to oldest. Each State diff shows the keys that changed. + + + The entry that set the wrong value shows the method and the arguments it got. + + + +### Restore an earlier state + + + + Pick the change you want to go back to. + + + Click Restore this state, then Restore to confirm. + + + Components that read the store update at once. The log gets a Restore #N entry. + + + +### Restore modes + + + + Restore sets every state key that differs back to its value right after that change. Every state signal must be writable. + + + Restore uses Store DevTools to jump to the state right after that action. Later actions continue from there. It needs provideStoreDevtools(). Without it, the log is read-only. + + + +## 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 + +### `watchState` needs `registerNgrxSignals` + +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). + +### Stores appear when they are created + +Angular creates a `signalStore` the first time something injects it. Open a page that uses it, and it appears. + +### 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 + + + + Store DevTools is not set up, so the log is read-only. The store detail shows read-only. Add provideStoreDevtools() to the app config. + + + The entry fell out of the 200-entry log in the page. Pick a newer entry. + + + The tab matches the file by state keys. A store whose keys match no signalStore or signalState in the scanned source has none. + + + +## Where to next + + + + Register patchState so restore notifies watchState. + + + The signal graph of the components that read the store. + + + Where each store is provided. + + + Every tool a coding agent can call. + + diff --git a/apps/docs/src/content/inspectors/pipes.md b/apps/docs/src/content/inspectors/pipes.md new file mode 100644 index 0000000..9cad3ad --- /dev/null +++ b/apps/docs/src/content/inspectors/pipes.md @@ -0,0 +1,166 @@ +--- +title: Pipes +description: Custom and built-in pipes, where they are used, live instances, call recording and a pipe lint. +--- + + + 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. + + +# Pipes + +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. + +## What it shows + +### 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**. + +Each row shows the pipe name, its class, and chips: + +- **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. + +Hover or focus a row to highlight the first component that uses it. + +### Declaration + +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. + +### Used in templates + +For built-in pipes, every template that uses it, with file and line. + +### Live on the page + +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. + +### 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 the tab marks those rows **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. | + +## Where the data comes from + + + + @Pipe classes with name, class, file, standalone and pure flags. Built-in pipes with every usage site. + + + The overlay finds pipe instances in the rendered views. It is read-only until you record. + + + The server checks your source for the three rules above. + + + +### 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 + + + + Pick Impure in Show pipes. + + + Click Record calls, then use the page for a moment. + + + A pipe with a high call count reruns on every check. Make it pure, or move the work into a computed(). + + + Click Stop recording. + + + +### Find a stale value + + + + Recording turns on the stale check. + + + It marks a pure pipe that got an argument whose contents changed while its reference stayed the same. + + + Replace the object or array instead of mutating it, so the pipe reruns. + + + +### 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 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 + + + The devtools send pipe inputs, outputs and async values as they are, cut to 200 characters. Keep the dev server on localhost. See Security. + + +### The stale warning is experimental + +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 + + + + Recording is off. Agents can't turn it on. Click Record calls in the panel first. + + + The built-in list covers the @angular/common pipes only. + + + +## Where to next + + + + The components that use each pipe. + + + Signals a pure pipe should not read. + + + Every tool a coding agent can call. + + + What the devtools redact, and what they don't. + + diff --git a/apps/docs/src/content/inspectors/router.md b/apps/docs/src/content/inspectors/router.md new file mode 100644 index 0000000..a7e52b4 --- /dev/null +++ b/apps/docs/src/content/inspectors/router.md @@ -0,0 +1,214 @@ +--- +title: Router +description: The live route, every navigation as a story, the live route config, router setup and a route lint. +--- + + + 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. + + +# Router + +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: + +- 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. 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. 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. +- 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. 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, 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). + +## Where the data comes from + + + + The overlay finds the Router through Angular's debug API and reports the route, navigations, config and setup. + + + The server reads your route files for the source table, and *.routes.server.ts for render modes. + + + +### 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 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. 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. + +## How to use it + +### Find out why a navigation failed + + + + Check Only problems to hide the navigations that succeeded. + + + The phase bar shows where it stopped. The guard rows show which guard returned false or a UrlTree. + + + Fix the code, then click Replay to run the same navigation again. + + + Click Copy repro to paste a markdown repro into an issue. + + + +### Check which route a URL hits + + + + Type the URL into Test a URL. + + + Click Predict. A miss lists the nearest routes. + + + Click Probe in app to confirm with the real matcher. It runs canMatch and may load lazy chunks. + + + +### Clean up the config + + + + Read the findings, most severe first. + + + Each finding comes with a fix. Start with the ones where Angular stays silent. + + + Click Check again after the app reloads. + + + +## Agent tools + +| 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. | + +`navigate` only accepts same-origin relative URLs that start with `/`. `resolve-lazy` needs a `routeId`. See [Tools](/agents/tools). + +## Limits and gotchas + +### Guard recording is on by default + +**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`. + +### Abort and probe need Angular 20.2 + +Aborting and probing use the `currentNavigation` signal and `Navigation.abort()`, which older versions lack. On those versions the action returns an error. + +### 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 + + + + Guard recording is off for that tab. Check Record each guard and resolver and run the navigation again. + + + No. It runs the real matcher with skipLocationChange and stops after recognition. canActivate, canDeactivate and resolvers do not run. + + + The live config is available, so it is the better source. Click Show table to open the source list. + + + +## Where to next + + + + Routed components show their route and outlet. + + + File routes, server calls and render modes for Analog apps. + + + What is redacted, and how access is limited. + + + Every tool a coding agent can call. + + diff --git a/apps/docs/src/content/inspectors/signals.md b/apps/docs/src/content/inspectors/signals.md new file mode 100644 index 0000000..b4e8268 --- /dev/null +++ b/apps/docs/src/content/inspectors/signals.md @@ -0,0 +1,168 @@ +--- +title: Signals +description: The live signal graph of one component, with a value history per signal. +--- + + + The live signal graph of one component: its signals, computeds, linked signals and effects, the edges between them, and a value history per signal. + + +# Signals + +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. + +## What it shows + +### Component picker + +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 ``. +- 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**. + +### Node cards + +Filter by name, or by kind with the chips. Each card shows: + +- 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. + +### 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. + +### Value history + +| 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. 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 and line, and the component when the scan finds one. + +## Where the data comes from + + + + The overlay reads the graph of the chosen component and pushes it every 3 seconds. + + + The server scans your files for signal declarations. + + + +### 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. 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. + + + Pass a debugName to signal() to get exact history entries and a readable label on the card. + + +## How to use it + +### See why a computed changed + + + + Leave the picker on the routed component, or pick the one you care about. + + + Expand its card and read Dependencies (producers). + + + Open each producer. The one with a change at the same time is the cause. + + + +### Find what reruns an effect + + + + Click the effect chip. + + + Its producers are every signal it read on the last run. + + + Wrap reads that should not rerun it in untracked(), then check the graph again. + + + +### 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 + +### 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. + +### 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 + +When the picked component is gone or has no graph, a notice appears and the tab shows another one. + +## FAQ + + + + The default follows the deepest component in the primary router outlet. It skips named outlets. Pick the component yourself to pin it. + + + Exact entries need a debugName on the signal. Without one, the overlay samples values on each poll. + + + A computed that nothing has read yet has no value. It fills in after its first read. + + + +## Where to next + + + + Each instance, with its live inputs. + + + Signal store state, computeds and methods. + + + Every tool a coding agent can call. + + + The script that reports the live page. + + 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..5c55489 --- /dev/null +++ b/apps/docs/src/content/inspectors/ssr-http.md @@ -0,0 +1,207 @@ +--- +title: SSR & HTTP +description: An HTTP timeline for SSR and client calls, fault injection, hydration stats and the TransferState payload. +--- + + + Every HttpClient call made while rendering on the server and in the browser. Fault injection, hydration stats and the TransferState payload, in one tab. + + +# SSR & HTTP + +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 {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([authInterceptor])), + provideNgDevtoolsHttp(), + ], +}; +``` + + + Put withNgDevtools() 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 SSR & HTTP guide. + + +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. 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 + + + + The interceptor on the server hands SSR calls to the devtools through the shared Node process. + + + The overlay reports client calls, hydration stats and the payload. + + + Fault rules live on the devtools server, which sends them to every page. + + + +### 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 + + + + Enter the URL pattern, pick Client only, and set the status to 500. + + + Trigger the request. The row is marked faulted. + + + Your error handling runs against a real HttpErrorResponse. + + + Click Remove when you are done. + + + +### Test a slow API + + + + Leave the body empty and set a delay, for example 3000 ms. + + + The request still reaches the API, only later. + + + +### Check that TransferState works + + + + Use a route with RenderMode.Server. + + + Each GET should show an SSR row, and a Client row marked transfer cache. + + + The matching entry should appear in TransferState payload. + + + +## 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: + +| 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. | + +## Limits and gotchas + + + The devtools send response previews and TransferState values to the devtools server as they are. Don't expose the dev server beyond localhost. See Security. + + +### Prerendered routes make no requests + +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`. + +### 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 + +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 + + + + SSR runs in a different process from the devtools, or the route is prerendered. Mount the hub in the same server, and use RenderMode.Server. + + + provideNgDevtoolsHttp() is missing from the app providers. + + + Client calls live in the page, so a reload clears them. SSR calls stay until Clear timeline or a server restart. + + + +## Where to next + + + + The providers, their order, and the server setup. + + + Mount the hub in server.ts. + + + HTTP requests per navigation, and render modes. + + + What is redacted, and what is not. + + diff --git a/apps/docs/src/content/security.md b/apps/docs/src/content/security.md new file mode 100644 index 0000000..fe29ad2 --- /dev/null +++ b/apps/docs/src/content/security.md @@ -0,0 +1,194 @@ +--- +title: Access and redaction +description: Who can reach the devtools, and which values are redacted before they leave the page. +--- + + + 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. + + +# 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. + + + Don't expose the dev server beyond localhost. Some values, such as HTTP response previews, are sent as they are. + + +## At a glance + + + + Loopback requests only. A request that sends an Origin must come from a loopback host, a Chrome extension, allowedOrigins or Vite's server.allowedHosts. + + + A one-time code and a loopback origin check. Both on by default. + + + Binds to localhost and asks for a one-time code by default. + + + Connects only to pages served from localhost or 127.0.0.1. + + + +## Local-only access + +### Vite plugin + +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: + +- 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. Add other origins with `allowedOrigins`: + +```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']})], +}); +``` + +The Vite plugin turns the one-time code off. The loopback and origin checks take its place. + + + 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. + + +### Express hub + +`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, 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(); + +const devtools = initNgDevtoolsHub({ + allowedOrigins: ['https://tunnel.example'], +}); +app.use(devtools.nodeMiddleware); +``` + + + Pass auth: false 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. allowedOrigins: false turns the origin check off. The demo app in this repository sets it. Keep the check on for your own apps. + + +### 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). + +### 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 + +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. 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 + +Mark a field in the template, or list keys on `window`: + +```html + + +``` + +```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. + +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. + + + + 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 + +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. + + +### 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 reach the devtools server unchanged, so don't expose the dev server beyond localhost. + +## Checklist + + + + Open the app on localhost. Add other hostnames or origins one by one, only when you need them. + + + Keep auth and the origin check on in the Express hub unless the machine is yours alone. + + + Keep real credentials out of forms and API responses you inspect. + + + Use data-ng-devtools="mask" or window.__NG_DEVTOOLS_FORMS__ for fields the secret words miss. + + + +## Related pages + + + + + + + diff --git a/apps/docs/src/main.server.ts b/apps/docs/src/main.server.ts new file mode 100644 index 0000000..e6ce09f --- /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..2523ba0 --- /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/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, '"'); +} 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 new file mode 100644 index 0000000..4e6be6b --- /dev/null +++ b/apps/docs/src/marked-extensions/index.ts @@ -0,0 +1,43 @@ +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}, + {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 new file mode 100644 index 0000000..494c439 --- /dev/null +++ b/apps/docs/src/marked-extensions/marked-extensions.spec.ts @@ -0,0 +1,100 @@ +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( + markdown, + ) as string; +} + +describe('ngmd-image', () => { + it('escapes every attribute it writes', () => { + const html = render( + '', + ); + 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('')).toContain( + 'data-video-src="https://www.youtube.com/embed/abc123"', + ); + expect(render('')).toContain( + 'data-video-src="https://player.vimeo.com/video/42"', + ); + }); + + it('refuses other URLs and escapes the title', () => { + const html = render(''); + expect(html).toContain('data-video-src="about:blank"'); + 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( + '

Kw. the Kw docs *Kw

\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('

Note: hi

\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(''); + }); +}); 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..a3647c7 --- /dev/null +++ b/apps/docs/src/marked-extensions/ngmd-code-group.ts @@ -0,0 +1,99 @@ +import type {MarkedExtension} from 'marked'; +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 + * 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;
+
+interface GroupFence extends Fence {
+  group: string;
+}
+
+export const ngmdCodeGroupExtension: MarkedExtension = {
+  hooks: {
+    async preprocess(markdown: string): Promise {
+      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: GroupFence[][] = [];
+      let current: GroupFence[] = [];
+      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);
+
+      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;
+
+        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 highlightCode(f.body, f.lang);
+              return `
${html}
`; + }), + ) + ).join(''); + + wrappers.push( + `
${tabs}
${panels}
`, + ); + } + + 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 new file mode 100644 index 0000000..b656bee --- /dev/null +++ b/apps/docs/src/marked-extensions/ngmd-code-highlight.ts @@ -0,0 +1,85 @@ +import type {MarkedExtension} from 'marked'; +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 + * 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. + */ + +const RANGES_RE = /(?:^|\s)\{([0-9,\-\s]+)\}(?=\s|$)/; + +function parseRanges(spec: string, lineCount: number): 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 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; +} + +/** + * 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) ? ' { + 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 renders = await Promise.all( + matches.map(async (mt) => + applyHighlights( + await highlightCode(mt.body, mt.lang), + parseRanges(mt.spec, mt.body.split('\n').length), + ), + ), + ); + 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 new file mode 100644 index 0000000..39c7e31 --- /dev/null +++ b/apps/docs/src/marked-extensions/ngmd-code-import.ts @@ -0,0 +1,108 @@ +import {readFileSync} from 'node:fs'; +import type {MarkedExtension} from 'marked'; +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'; + +/** + * 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 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').replace(/\r\n?/g, '\n'); + + let rangeFragment = ''; + if (range) { + const m = range.match(/^L(\d+)(?:-L?(\d+))?$/); + 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 + .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. + 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 { + const matches: { + start: number; + end: number; + lang: string; + filePath: string; + rangeFragment: string; + code: string; + }[] = []; + + for (const f of findFences(markdown)) { + const spec = getAttr(f.attrs, 'file'); + if (!spec) continue; + try { + const {code, rangeFragment} = loadFile(spec); + matches.push({ + start: f.start, + end: f.end, + lang: f.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 renders = await Promise.all( + matches.map(async (mt) => { + const codeHtml = await highlightCode(mt.code, mt.lang); + const headerLabel = mt.filePath + (mt.rangeFragment || ''); + const headerHtml = `${escapeHtml(headerLabel)}`; + return `
${headerHtml}${codeHtml}
`; + }), + ); + return replaceFences(markdown, matches, renders); + }, + }, +}; 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..6d253dd --- /dev/null +++ b/apps/docs/src/marked-extensions/ngmd-image.ts @@ -0,0 +1,46 @@ +import type {Tokens} from 'marked'; +import {escapeHtml} from './escape-html.ts'; + +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..580fc98 --- /dev/null +++ b/apps/docs/src/marked-extensions/ngmd-keywords.ts @@ -0,0 +1,75 @@ +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 + * 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 EMPHASIS_RE = /^\*[^*\n]*[^*\s]\*/; +const warned = new Set(); + +function lookup(keyword: string): string | undefined { + return config.keywords?.[keyword]; +} + +export const ngmdKeywordsExtension: MarkedExtension = { + extensions: [ + { + name: 'ngmdKeyword', + level: 'inline', + start(src: string) { + return src.match(HINT_RE)?.index; + }, + 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]) && !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.`, + ); + } + return undefined; + } + if (this.lexer.state.inLink) return {type: 'text', raw: m[0], text: m[1]}; + 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..a8f76fe --- /dev/null +++ b/apps/docs/src/marked-extensions/ngmd-video.ts @@ -0,0 +1,53 @@ +import type {Tokens} from 'marked'; +import {escapeHtml} from './escape-html.ts'; + +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 'about:blank'; +} + +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..bf8db3b --- /dev/null +++ b/apps/docs/src/marked-extensions/runtime.ts @@ -0,0 +1,12 @@ +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, + {hooks: {postprocess: (html: string) => html.replace(/
/g, '
')}}, +]; 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..2e36e4c --- /dev/null +++ b/apps/docs/src/marked-extensions/shiki-shared.ts @@ -0,0 +1,46 @@ +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-default', 'github-dark-default'], + langs: LANGS, + }).catch((e: unknown) => { + highlighterPromise = null; + throw e; + }); + } + return highlighterPromise; +} + +export async function highlightCode(code: string, lang: string): Promise { + 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 new file mode 100644 index 0000000..bf3f1be --- /dev/null +++ b/apps/docs/src/ngmd.config.ts @@ -0,0 +1,256 @@ +/** + * 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; + /** 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?: { + 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 { + /** 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 `<app-sponsor-list>`. Leave undefined to render + * nothing. */ + 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', + githubDir: 'apps/docs', + links: { + discord: 'https://discord.gg/YRTyJd6Qx', + sponsor: 'https://github.com/sponsors/santoshyadavdev', + }, + }, + + 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'}, + {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', 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', 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: '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'}, + {label: 'Write documentation', href: '/contributing/writing-docs'}, + {label: 'Kitchen sink', href: '/contributing/kitchen-sink'}, + ], + }, + ], +}; + +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..8ddb8e9 --- /dev/null +++ b/apps/docs/src/styles.css @@ -0,0 +1,833 @@ +@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-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) { + 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-strong); + } + + 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; + } + + :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; + 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 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); + } + + 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: color-mix(in srgb, var(--muted) 80%, var(--fg)); + 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-strong); + 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-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 + * 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: 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-strong); + } + + .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..73d65f2 --- /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..a82feed --- /dev/null +++ b/apps/docs/src/types/api.ts @@ -0,0 +1,56 @@ +/** + * 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; +} + +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 new file mode 100644 index 0000000..231c48a --- /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-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/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..0298696 --- /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..5e79f72 --- /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..491f1bf --- /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 = process.cwd()): 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..5288e16 --- /dev/null +++ b/apps/docs/vite.config.ts @@ -0,0 +1,117 @@ +/// <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 {withoutCode} from './plugin-utils.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) { + 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 ${file} 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: config.site.githubBranch ?? 'main', + dir: config.site.githubDir, + }), + sitemapPlugin({siteUrl: config.site.url}), + rawMdPlugin(), + searchIndexPlugin(), + apiGenPlugin(), + analog({ + apiPrefix: '_server', + content: { + highlighter: 'shiki', + markedOptions: { + extensions: await getBuildExtensions(), + }, + shikiOptions: { + highlight: { + themes: {light: 'github-light-default', dark: 'github-dark-default'}, + 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':