Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 9 additions & 7 deletions .claude/skills/devtools-docs/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ Short intro.
- 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`.
- Link to other pages by the relative path of the `.md` file, in markdown links and `<a>` tags: `[Configuration](./configuration.md)`, `<a href="../inspectors/router.md#agent-tools">`. The site resolves them to routes and they also work on GitHub. Never write `/getting-started/...` routes in markdown links or `<a>`.
- `<ngmd-pill href>`, `<ngmd-card link>` and pages without a `.md` file (`/sponsors`) use the site route.
- `<ngmd-hero logo="...">` only on pages about one external tool (NgRx, Analog, Vite, Express, Chrome, MCP, Nx).

## 5. Components
Expand Down Expand Up @@ -123,13 +125,13 @@ Rules:

## 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/` |
| 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`, `config.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.

Expand Down
2 changes: 2 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ The labels follow the Angular repository.
| `needs triage`, `needs reproduction`, `needs: clarification`, `needs: discussion` | What an issue is waiting for. |
| `state: confirmed`, `state: has PR`, `state: blocked`, `state: WIP` | Where an issue stands. |
| `action: review`, `action: cleanup`, `action: merge`, `action: discuss` | What a pull request needs next. |
| `target: patch`, `target: minor`, `target: major` | Which release a pull request goes into. |
| `merge: preserve commits`, `merge: caretaker note`, `merge: fix commit message` | Pull requests are squash merged. These mark the exceptions: keep each commit, read the note in the description first, or fix the commit message when merging. |
| `good first issue`, `help wanted` | Issues open to new contributors. |
| `no-docs`, `release: skip` | No docs change needed; leave out of the release notes. |

Expand Down
1 change: 1 addition & 0 deletions apps/docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ With Nx: `pnpm nx serve angular-devtools-docs`, `pnpm nx build angular-devtools-
## Edit

- Pages are markdown files in `src/content`. The path becomes the URL: `src/content/inspectors/signals.md` is served at `/inspectors/signals`.
- Link between pages with relative `.md` paths, such as `[Router](../inspectors/router.md)`, so the links also work on GitHub.
- 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`.
Expand Down
15 changes: 15 additions & 0 deletions apps/docs/build-plugins.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,21 @@ describe('internalLinkGuard', () => {
expect(warning).toContain('"/missing.png" is not a known route or file in public/');
expect(warning).toContain('"/guide#nope"');
});

it('resolves relative .md links from the linking file', () => {
expect(
check(
'[a](./guide/index.md#setup-1) [b](guide/index.md) [c](./some%20page.md) <a href="./hidden.md">d</a> ![e](./logo.png)',
),
).toEqual([]);
const [warning] = check(
'[a](./missing.md) [b](../README.md) [c](./guide/index.md#nope) [d](./guide/index.md?x=1)',
);
expect(warning).toContain('"/missing" is not a known route');
expect(warning).toContain('"../README.md" is not a page in src/content');
expect(warning).toContain('"/guide#nope"');
expect(warning).toContain('"./guide/index.md?x=1" is not a page in src/content');
});
});

describe('searchIndexPlugin', () => {
Expand Down
29 changes: 23 additions & 6 deletions apps/docs/link-guard.plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,23 @@ import {
walkPageFiles,
withoutCode,
} from './plugin-utils.ts';
import {resolveMdHref} from './md-links.plugin.ts';

/**
* Build-time guard that errors on broken internal links inside markdown files.
*
* Validates three cases:
* Validates four 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
* - `[text](../dir/page.md#fragment)` — resolved from this file, then checked
* like `/dir/page#fragment`
*
* 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.
* External (`http(s)://`), mail (`mailto:`), and relative links that don't
* end in `.md` (`./foo.png`) 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.
Expand Down Expand Up @@ -119,10 +123,23 @@ export function internalLinkGuard(): Plugin {
const content = readFileSync(file, 'utf8');
const ownSlugs = extractHeadings(content);
const issues: string[] = [];
const pageFile =
'/src/content/' + relative(join(root, 'src/content'), file).replace(/\\/g, '/');

const validate = (href: string, label: string) => {
if (!href) return;
// external / mail / relative — skip
const validate = (link: string, label: string) => {
if (!link) return;
let href = link;
if (!/^([a-z][a-z0-9+.-]*:|\/|#)/i.test(href)) {
const route = resolveMdHref(href, pageFile);
if (route === null) {
if (/\.md([#?]|$)/.test(href)) {
issues.push(` ${label} → "${href}" is not a page in src/content`);
}
return;
}
href = route;
}
// external / mail — skip
if (!href.startsWith('#') && (!href.startsWith('/') || href.startsWith('//'))) return;

const hashAt = href.indexOf('#');
Expand Down
81 changes: 81 additions & 0 deletions apps/docs/md-links.plugin.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import {mdLinksPlugin, resolveDocLinks, resolveMdHref} from './md-links.plugin';

describe('resolveMdHref', () => {
const page = '/src/content/getting-started/installation';

it('resolves sibling, parent and nested .md links to routes and keeps the fragment', () => {
expect(resolveMdHref('./configuration.md', page)).toBe('/getting-started/configuration');
expect(resolveMdHref('vite.md#analog', page)).toBe('/getting-started/vite#analog');
expect(resolveMdHref('../inspectors/router.md#tools', page)).toBe('/inspectors/router#tools');
expect(resolveMdHref('../security.md', page)).toBe('/security');
expect(resolveMdHref('./guide/index.md', '/src/content/intro.md')).toBe('/guide');
expect(resolveMdHref('../index.md', page)).toBe('/');
});

it('resolves against the directory of an index page', () => {
expect(resolveMdHref('./setup.md', '/src/content/guide/index')).toBe('/guide/setup');
});

it('ignores routes, fragments, external links, other files and paths outside content', () => {
for (const href of [
'/getting-started/vite',
'#local',
'https://angular.dev/guide.md',
'mailto:a@b.md',
'./diagram.png',
'./configuration',
'../../../../README.md',
]) {
expect(resolveMdHref(href, page)).toBeNull();
}
});
});

describe('resolveDocLinks', () => {
it('rewrites relative .md hrefs in HTML and leaves the rest alone', () => {
const html =
'<a href="./configuration.md#auth">a</a> <a class="x" href=\'../security.md\'>b</a> ' +
'<a href="/agents/tools">c</a> <a href="https://github.com/x/README.md">d</a>';
expect(resolveDocLinks(html, '/src/content/getting-started/installation')).toBe(
'<a href="/getting-started/configuration#auth">a</a> <a class="x" href=\'/security\'>b</a> ' +
'<a href="/agents/tools">c</a> <a href="https://github.com/x/README.md">d</a>',
);
});
});

describe('mdLinksPlugin', () => {
type Hook = (this: unknown, ...args: unknown[]) => unknown;
const plugin = mdLinksPlugin();
(plugin.configResolved as Hook).call(undefined, {root: '/site'});
const transform = (code: string, id: string) =>
(plugin.transform as Hook).call(
{
error: (m: string) => {
throw new Error(m);
},
},
code,
id,
);

it('rewrites the rendered content module of a page', () => {
const html = '---\ntitle: Vite\n---\n\n<a href="./cli.md#flags">CLI</a>';
expect(
transform(
`export default ${JSON.stringify(html)}`,
'/site/src/content/getting-started/vite.md?analog-content-file=true',
),
).toEqual({
code: `export default ${JSON.stringify(html.replace('./cli.md', '/getting-started/cli'))}`,
map: null,
});
});

it('skips other modules and fails on an unexpected module shape', () => {
expect(transform('x', '/site/src/content/vite.md?raw')).toBeNull();
expect(transform('x', '/site/README.md?analog-content-file=true')).toBeNull();
expect(() =>
transform('export const x = 1', '/site/src/content/vite.md?analog-content-file=true'),
).toThrow('Unexpected content module shape');
});
});
52 changes: 52 additions & 0 deletions apps/docs/md-links.plugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import {join, relative} from 'node:path';
import type {Plugin} from 'vite';

export function mdLinksPlugin(): Plugin {
let contentDir = join(process.cwd(), 'src/content');
return {
name: 'ngmd-md-links',
configResolved(cfg) {
contentDir = join(cfg.root, 'src/content');
},
transform(code, id) {
const [file, query = ''] = id.split('?');
if (!query.includes('analog-content-file=true')) return null;
const rel = relative(contentDir, file).replace(/\\/g, '/');
if (rel.startsWith('..')) return null;
const match = /^export default (".*");?\s*$/s.exec(code);
if (!match) {
return this.error(
`[ngmd] Unexpected content module shape for ${rel}, can't resolve .md links.`,
);
}
const html = resolveDocLinks(JSON.parse(match[1]) as string, `/src/content/${rel}`);
return {code: `export default ${JSON.stringify(html)}`, map: null};
},
};
}

const CONTENT_ROOT = '/src/content/';

export function resolveMdHref(href: string, pageFile: string): string | null {
if (/^([a-z][a-z0-9+.-]*:|\/|#|\?)/i.test(href)) return null;
const hashAt = href.indexOf('#');
const path = hashAt === -1 ? href : href.slice(0, hashAt);
if (!path.endsWith('.md')) return null;
const {pathname} = new URL(path, `http://docs${pageFile}`);
if (!pathname.startsWith(CONTENT_ROOT)) return null;
const route = pathname
.slice(CONTENT_ROOT.length - 1, -'.md'.length)
.replace(/(^|\/)index$/, '$1')
.replace(/(.)\/$/, '$1');
return route + (hashAt === -1 ? '' : href.slice(hashAt));
}

export function resolveDocLinks(html: string, pageFile: string): string {
return html.replace(
/(\shref=)(["'])([^"']*)\2/g,
(match, attr: string, quote: string, href: string) => {
const route = resolveMdHref(href, pageFile);
return route === null ? match : `${attr}${quote}${route}${quote}`;
},
);
}
38 changes: 19 additions & 19 deletions apps/docs/src/content/agents/mcp-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,11 +86,11 @@ When the devtools are embedded in your app's server, the same tools are served o

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` |
| Setup | Endpoint |
| -------------------------------------------- | ----------------------------------------- |
| [Express hub](../getting-started/express.md) | `http://localhost:4000/__devframes/__mcp` |
| [Vite plugin](../getting-started/vite.md) | `http://localhost:5173/__devframes/__mcp` |
| [Standalone CLI](../getting-started/cli.md) | `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.

Expand All @@ -99,7 +99,7 @@ If you mount the devtools panel without the hub, at `/__ng-devtools/`, the endpo
### Send an Origin header

<ngmd-callout type="warning" title="Requests without an Origin header get 403">
The HTTP endpoint only answers requests from this machine that carry a local <code>Origin</code> header, such as <code>http://localhost:4000</code>. Requests without one get <code>403 Forbidden</code>. If your MCP client does not send an <code>Origin</code> header, add it in the client config.
The HTTP endpoint only answers requests that carry a local <code>Origin</code> header, such as <code>http://localhost:4000</code>. Requests without one get <code>403 Forbidden</code>. With the Vite plugin, the request must also come from a loopback address. If your MCP client does not send an <code>Origin</code> header, add it in the client config.
</ngmd-callout>

The header value is the origin of your dev server. Every example below sets it.
Expand All @@ -108,10 +108,10 @@ The header value is the origin of your dev server. Every example below sets it.

If the hub asks for the one-time code, the HTTP endpoint also asks for a bearer token. Requests without the right token get `401`.

| Setup | Token required |
| --------------------------------------- | ------------------------------------------------------------------------------------------------ |
| [Express hub](/getting-started/express) | Yes, unless you pass `auth: false` or your own `mcp` option. |
| [Vite plugin](/getting-started/vite) | Only when the one-time code is on. See the plugin's [`auth` option](/getting-started/vite#auth). |
| Setup | Token required |
| -------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
| [Express hub](../getting-started/express.md) | Yes, unless you pass `auth: false` or your own `mcp` option. |
| [Vite plugin](../getting-started/vite.md) | Only when the one-time code is on. See the plugin's [`auth` option](../getting-started/vite.md#auth). |

The hub prints a generated token in the terminal when it starts. The token changes on every restart. To keep the same token across restarts, set `NG_DEVTOOLS_MCP_TOKEN` in the environment of the server. The hub then uses that value and prints nothing.

Expand Down Expand Up @@ -187,7 +187,7 @@ The live tools read what the page reports. Without an open page, they have nothi
Run the server that mounts the devtools: your Express SSR server, the Vite dev server, or <code>ng-devtools dev</code>.
</ngmd-step>
<ngmd-step title="Open it in a browser">
Load the app with the <a href="/getting-started/overlay">overlay</a>. The page connects to the devtools and starts reporting.
Load the app with the <a href="../getting-started/overlay.md">overlay</a>. The page connects to the devtools and starts reporting.
</ngmd-step>
<ngmd-step title="Call a tool">
Ask your agent something the page knows, like "why is the checkout form invalid?". It calls <code>explain-form-invalid</code> on the connected page.
Expand All @@ -204,15 +204,15 @@ The server registers tools with a colon, as `ng-devtools:get-routes`. MCP client

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) |
| Tool | Reference |
| ----------------- | ------------------------------------------------------------------ |
| `highlight` | [Components, signals and DI](./tools.md#components-signals-and-di) |
| `navigate` | [Act on the router](./tools.md#act-on-the-router) |
| `form-action` | [Act on a form](./tools.md#act-on-a-form) |
| `fill-form` | [Act on a form](./tools.md#act-on-a-form) |
| `analog-call-api` | [Call a server route](./tools.md#call-a-server-route) |

Your client can ask you before it runs them.
Your client can ask you before it runs them. To drop them from the server, set `agent.readOnly`. See [Inspectors and agent tools](../getting-started/configuration.md#inspectors-and-agent-tools).

### Pages and tabs

Expand Down
Loading
Loading