diff --git a/.agents/skills/doc-writer/SKILL.md b/.agents/skills/doc-writer/SKILL.md
index 2c3392ab8..1dc395c0c 100644
--- a/.agents/skills/doc-writer/SKILL.md
+++ b/.agents/skills/doc-writer/SKILL.md
@@ -1003,6 +1003,59 @@ Use standard Markdown links with absolute paths from the docs root:
For more information, see [Service Defaults](/fundamentals/service-defaults/).
```
+### Inline API references
+
+Use `ApiReference` selectively to connect an explanation to API reference documentation, not to turn every API mention into a link.
+
+- In ordinary prose, use `` only on the **first named mention of a given API in an article**, when naming that API helps explain the behavior or accompanying example. Do not introduce an API name just to add a reference link.
+- After that first mention, do **not** repeat the component or API reference link in ordinary prose, including prose in callouts and later sections. Prefer descriptive prose such as "this method" or "the dependency configuration." If repeating the name is necessary for clarity, use unlinked inline code appropriate to the AppHost language being discussed.
+- Apply the ordinary-prose limit **per API, per article**, not per section or language tab. A different API can have its own first reference.
+- In **bulleted or numbered lists**, including language-specific API explanation lists, repeated `` components are allowed. Treat equivalent API entries consistently, regardless of earlier mentions, rather than mixing linked components and unlinked API names.
+- An optional API reference link in **See also** is also allowed.
+- Keep API names in code samples as code; do not add reference markup inside code fences.
+- Match the reference to the API actually used in the example. A PostgreSQL example calling `withPostgresMcp()` / `WithPostgresMcp()` must be introduced with `Aspire.Hosting.PostgresBuilderExtensions.WithPostgresMcp`, not the generic `WithMcpServer` API.
+
+For example, introduce an API once:
+
+```mdx
+import ApiReference from '@components/ApiReference.astro';
+
+For PostgreSQL, use to expose MCP tools for a database.
+```
+
+Later in the same article's ordinary prose, refer to "the PostgreSQL MCP helper" rather than repeating the linked API name. In an API explanation list, repeat the component for consistent entries:
+
+```mdx
+- exposes MCP tools for a database.
+- connects resources.
+```
+
+#### Selecting an overload
+
+Author `name`, `package`, and `parameterTypes` as explicit static props. The authoring validator does not evaluate spread objects: it reports `unsupported-spread` when a spread could supply or override any of these props, including optional ones. Remove the spread, or explicitly set all three props after the last spread so their values are known.
+
+By default, `ApiReference` shows the method name **without `()`** and links to the method group. To discuss a specific overload, supply `parameterTypes` as a static array of its **complete declared C# parameter types**, in declaration order. Copy the types from the generated C# catalog, preserving namespaces, generic arguments, and nullability. Include the `this` receiver's type for an extension method, but not the `this` keyword. Use `[]` only for a declaration with no parameters.
+
+```mdx
+',
+ 'string',
+ 'string?',
+ ]}
+/>
+```
+
+This links to the exact C# overload and displays `WithEnvironment(string name, string? value)`. The extension receiver is used for selection but omitted from the visible call signature. In TypeScript mode, the component uses the generated TypeScript export's own name and parameters; several C# overloads may share one TypeScript dispatcher. Do not copy C# parameter types into a TypeScript signature or select overloads by ordinal position. A missing or ambiguous match is an authoring error, not permission to link to the first overload. Use `package` when the same API is declared in multiple packages.
+
+Linked references reuse the code-block headers' C# and TypeScript icons from `material-icon-theme`, centered inside the code background. Do not add a separate icon or empty parentheses manually. `()` is shown only when an explicitly selected API genuinely has no call parameters.
+
+The component derives its top-positioned tooltip from the resolved API's generated summary or description, including the selected overload when specified. Do not duplicate that description in an authored `title` prop. When no description exists, the tooltip identifies the API and language instead. Tooltip titles are capped at 160 characters, including an ASCII `...` suffix when shortened, preferably at a word boundary. This presentation limit also applies to diagnostic titles; full source descriptions, diagnostics, and visible API labels remain unchanged.
+
+Title-based tooltips use the shared `src/frontend/src/scripts/tooltips.ts` lifecycle on initial load and after ClientRouter navigation. Keep API descriptions text-only with `data-tippy-allowhtml="false"`; do not add a competing per-component initializer.
+The lifecycle is installed once per document; HMR disposal removes its listeners, restores titles, and destroys tooltip instances before the replacement module initializes.
+
### Reference NuGet Packages
Use the 📦 emoji with links:
diff --git a/src/frontend/astro.config.mjs b/src/frontend/astro.config.mjs
index 4d9523fa8..e7b697807 100644
--- a/src/frontend/astro.config.mjs
+++ b/src/frontend/astro.config.mjs
@@ -54,6 +54,7 @@ const buildConcurrency = Number(process.env.ASPIRE_BUILD_CONCURRENCY) || 4;
// https://astro.build/config
export default defineConfig({
+ cacheDir: './node_modules/.astro',
...(outDir ? { outDir } : {}),
vite: {
define: {
diff --git a/src/frontend/package.json b/src/frontend/package.json
index f22d80ea0..5c8b10856 100644
--- a/src/frontend/package.json
+++ b/src/frontend/package.json
@@ -33,13 +33,14 @@
"astro": "pnpm git-env && astro",
"test": "pnpm test:unit && pnpm test:e2e",
"test:all": "pnpm lint && pnpm test",
- "test:unit": "vitest run --config vitest.config.ts",
+ "test:unit": "pnpm exec astro sync && vitest run --config vitest.config.ts",
+ "test:unit:api-reference": "vitest run --config vitest.config.ts tests/unit/api-reference.vitest.test.ts",
"test:unit:api-markdown": "vitest run --config vitest.config.ts tests/unit/api-markdown.vitest.test.ts",
"test:unit:ts-api": "vitest run --config vitest.config.ts tests/unit/api-reference-routes.vitest.test.ts tests/unit/browser-storage.vitest.test.ts tests/unit/locale-routes.vitest.test.ts tests/unit/ts-api-routes.vitest.test.ts tests/unit/ts-api-search.vitest.test.ts",
"test:unit:twoslash-types": "vitest run --config vitest.config.ts tests/unit/twoslash-types-generator.vitest.test.ts",
"test:unit:twoslash-blocks": "vitest run --config vitest.config.ts tests/unit/twoslash-blocks.vitest.test.ts",
"test:unit:contracts": "vitest run --config vitest.config.ts tests/unit/analytics-script-contracts.vitest.test.ts tests/unit/redirects.vitest.test.ts",
- "test:unit:components": "vitest run --config vitest.config.ts tests/unit/custom-components.vitest.test.ts tests/unit/site-tour.vitest.test.ts",
+ "test:unit:components": "vitest run --config vitest.config.ts tests/unit/api-reference.vitest.test.ts tests/unit/custom-components.vitest.test.ts tests/unit/site-tour.vitest.test.ts",
"test:unit:docs": "vitest run --config vitest.config.ts tests/unit/filetree-format.vitest.test.ts",
"test:unit:structured-data": "vitest run --config vitest.structured-data.config.ts",
"test:unit:cli-config-schema": "vitest run --config vitest.config.ts tests/unit/cli-config-schema.vitest.test.ts",
@@ -82,7 +83,6 @@
"astro": "^7.2.8",
"astro-contributors": "^0.9.0",
"astro-expressive-code": "^0.44.1",
- "astro-tooltips": "^0.6.2",
"hast-util-to-html": "^9.0.5",
"mdast-util-to-hast": "^13.2.1",
"mermaid": "^11.16.1",
@@ -97,7 +97,8 @@
"starlight-llms-txt": "^0.11.0",
"starlight-page-actions": "^0.7.0",
"starlight-plugin-icons": "^1.1.6",
- "starlight-sidebar-topics": "^0.8.0"
+ "starlight-sidebar-topics": "^0.8.0",
+ "tippy.js": "6.3.7"
},
"devDependencies": {
"@axe-core/playwright": "^4.12.1",
diff --git a/src/frontend/pnpm-lock.yaml b/src/frontend/pnpm-lock.yaml
index d88ad5e24..e02075f6d 100644
--- a/src/frontend/pnpm-lock.yaml
+++ b/src/frontend/pnpm-lock.yaml
@@ -107,9 +107,6 @@ importers:
astro-expressive-code:
specifier: ^0.44.1
version: 0.44.1(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))
- astro-tooltips:
- specifier: ^0.6.2
- version: 0.6.2
hast-util-to-html:
specifier: ^9.0.5
version: 9.0.5
@@ -155,6 +152,9 @@ importers:
starlight-sidebar-topics:
specifier: ^0.8.0
version: 0.8.0(@astrojs/starlight@0.41.3(patch_hash=ad8925179f0e3050ae6c804196faa6d38a2be2b718acf138001215a18648d3b5)(@astrojs/markdown-remark@7.2.1)(astro@7.3.2(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@6.0.3))
+ tippy.js:
+ specifier: 6.3.7
+ version: 6.3.7
devDependencies:
'@axe-core/playwright':
specifier: ^4.12.1
@@ -1989,9 +1989,6 @@ packages:
peerDependencies:
astro: ^4.0.0-beta || ^5.0.0-beta || ^3.3.0 || ^6.0.0-beta || ^7.0.0
- astro-tooltips@0.6.2:
- resolution: {integrity: sha512-I9uXbchctnRqbc0mnxKcBRfweMuql/U+619+MzNvq3kANc7xthOXj6cMNgAkTaXoHJLdFMKL3Fx6vB5cyiiRXg==}
-
astro-vtbot@3.0.0:
resolution: {integrity: sha512-jVpYqaPi9Nz5UP9KgDFatSzXIe6n0w2YJjjzdsxNicd2ETQlfOkzbP8Qu7W6HC0DCE4dNTWLs8o7QRiFDqJkfw==}
@@ -6080,10 +6077,6 @@ snapshots:
rehype-expressive-code: 0.44.1
url-extras: 0.1.0
- astro-tooltips@0.6.2:
- dependencies:
- tippy.js: 6.3.7
-
astro-vtbot@3.0.0:
dependencies:
'@vtbag/cam-shaft': 1.0.6
diff --git a/src/frontend/src/components/ApiReference.astro b/src/frontend/src/components/ApiReference.astro
new file mode 100644
index 000000000..411df3d5e
--- /dev/null
+++ b/src/frontend/src/components/ApiReference.astro
@@ -0,0 +1,162 @@
+---
+import { resolveApiReference } from '@utils/api-reference';
+
+interface Props {
+ /**
+ * Canonical fully qualified C# API member name, including the declaring type.
+ * Example: `Aspire.Hosting.PostgresBuilderExtensions.AddPostgres`.
+ */
+ name: string;
+ /**
+ * Package identity used only when multiple packages declare the same FQN.
+ * Example: `Aspire.Hosting.JavaScript`.
+ */
+ package?: string;
+ /**
+ * Complete declared C# parameter types, in order, including an extension
+ * method's receiver. Omit for a method-group link; [] selects zero parameters.
+ */
+ parameterTypes?: string[];
+}
+
+function formatTitle(title: string | undefined): string | undefined {
+ if (!title || title.length <= 160) return title;
+
+ const prefix = title.slice(0, 157);
+ const text = /\s/.test(title[157]) ? prefix : prefix.replace(/\s+\S*$/, '');
+ return `${text.trimEnd()}...`;
+}
+
+const { name, package: packageName, parameterTypes } = Astro.props;
+const resolution = await resolveApiReference(name, packageName, Astro.locals, parameterTypes);
+const base = import.meta.env.BASE_URL.replace(/\/$/, '');
+const csharpHref = resolution.csharp.path ? `${base}${resolution.csharp.path}` : undefined;
+const typescriptHref = resolution.typescript.path
+ ? `${base}${resolution.typescript.path}`
+ : undefined;
+const diagnosticText = resolution.diagnostics.map((diagnostic) => diagnostic.message).join(' ');
+const csharpDiagnostic = resolution.status === 'resolved' ? undefined : diagnosticText;
+const typescriptDiagnostic = resolution.diagnostics.length > 0 ? diagnosticText : undefined;
+---
+
+
+ {
+ csharpHref ? (
+
+
+
+ {resolution.csharp.label}
+
+
+ ) : (
+
+ {resolution.csharp.label}
+
+ )
+ }
+ {
+ typescriptHref ? (
+
+
+
+ {resolution.typescript.label}
+
+
+ ) : (
+
+ {resolution.typescript.label}
+
+ )
+ }
+
+
+
diff --git a/src/frontend/src/components/starlight/Head.astro b/src/frontend/src/components/starlight/Head.astro
index f49450567..9fce57471 100644
--- a/src/frontend/src/components/starlight/Head.astro
+++ b/src/frontend/src/components/starlight/Head.astro
@@ -1,5 +1,5 @@
---
-import { Tooltips } from 'astro-tooltips';
+import 'tippy.js/dist/tippy.css';
import DefaultHead from '@astrojs/starlight/components/Head.astro';
import { ClientRouter } from 'astro:transitions';
import AccessibleCodeButtons from '../AccessibleCodeButtons.astro';
@@ -574,34 +574,8 @@ function computeSourceUrl() {
import '@scripts/webmcp';
-
-
-
-
diff --git a/src/frontend/src/content/docs/get-started/add-aspire-existing-app.mdx b/src/frontend/src/content/docs/get-started/add-aspire-existing-app.mdx
index b19173d68..b7820b0fd 100644
--- a/src/frontend/src/content/docs/get-started/add-aspire-existing-app.mdx
+++ b/src/frontend/src/content/docs/get-started/add-aspire-existing-app.mdx
@@ -7,6 +7,7 @@ next: false
import { Steps, TabItem, Tabs } from '@astrojs/starlight/components';
import FileTree from 'starlight-plugin-icons/components/FileTree.astro';
import { Kbd } from 'starlight-kbd/components';
+import ApiReference from '@components/ApiReference.astro';
import LearnMore from '@components/LearnMore.astro';
Add Aspire to the app you already have instead of rebuilding your solution around a new template. The fastest path is `aspire init` paired with an AI coding agent that automatically discovers your services and wires them into an AppHost. If you prefer full control, manual steps are provided below.
@@ -64,7 +65,7 @@ The fastest way to add Aspire to an existing app is to let `aspire init` scaffol
- Scan your repo and discover existing projects, services, containers, and infrastructure
- Ask you to confirm the resources it found, which ones you want included, and other clarifying questions before starting
- - Wire resources into the AppHost with `WithReference`, `WaitFor`, endpoints, and volumes
+ - Wire resources into the AppHost with , , endpoints, and volumes
- Add ServiceDefaults and configure OpenTelemetry for each service
- Validate the setup by running `aspire start`
@@ -98,7 +99,7 @@ Aspire offers two C# AppHost styles:
**File-based AppHost** — a single `apphost.cs` file that uses `#:sdk` and `#:package` directives. No `.csproj`, no solution integration required. Best for repos with [polyglot](/get-started/glossary/#polyglot) code or quick setups.
-**Project-based AppHost** — a traditional `AppHost.csproj` that lives inside a `.sln` alongside your other C# projects. Uses `ProjectReference` items and the generated `Projects` namespace for strongly-typed `AddProject()` calls. Best when your repo is already a .NET solution and you want IDE-integrated orchestration.
+**Project-based AppHost** — a traditional `AppHost.csproj` that lives inside a `.sln` alongside your other C# projects. Uses `ProjectReference` items and the generated `Projects` namespace for strongly-typed calls. Best when your repo is already a .NET solution and you want IDE-integrated orchestration.
Both styles use the same `Aspire.AppHost.Sdk` and the same hosting APIs.
@@ -394,6 +395,8 @@ Use this approach when Aspire already has a first-class resource type for the wo
Common examples include Node.js apps, Vite frontends, Python workers, and Uvicorn-based APIs.
+The following example registers a Python API with , a worker with , and a frontend with .
+
@@ -453,7 +456,7 @@ await builder.build().run();
-If a workload does not have a dedicated hosting API yet, model it as an executable resource with `AddExecutable` or `addExecutable` so it can still participate in the same application model.
+If a workload does not have a dedicated hosting API yet, model it as an executable resource with so it can still participate in the same application model.
For first-class workload guidance, see [JavaScript integration](/integrations/frameworks/javascript/), [Python integration](/integrations/frameworks/python/), and [Multi-language architecture](/architecture/multi-language-architecture/).
@@ -474,6 +477,8 @@ aspire add postgres
aspire add redis
```
+Use for the published application images and to configure their HTTP endpoints:
+
diff --git a/src/frontend/src/content/docs/get-started/app-host.mdx b/src/frontend/src/content/docs/get-started/app-host.mdx
index 39a9b88ab..88a38f876 100644
--- a/src/frontend/src/content/docs/get-started/app-host.mdx
+++ b/src/frontend/src/content/docs/get-started/app-host.mdx
@@ -13,6 +13,7 @@ import {
Tabs,
} from '@astrojs/starlight/components';
import FileTree from 'starlight-plugin-icons/components/FileTree.astro';
+import ApiReference from '@components/ApiReference.astro';
import LearnMore from '@components/LearnMore.astro';
import SimpleAppHostCode from '@components/SimpleAppHostCode.astro';
import PivotSelector from '@components/PivotSelector.astro';
@@ -156,22 +157,12 @@ You can represent that architecture in an AppHost like this:
-
-
Uses AddUvicornApp() with WithUv() for ASGI apps like FastAPI.
-
-
-
Uses addUvicornApp() with withUv() for ASGI apps like FastAPI.
-
+
Uses with for ASGI apps like FastAPI.
-
-
Uses AddNodeApp() with WithNpm() for Node.js applications.
-
-
-
Uses addNodeApp() with withNpm() for Node.js applications.
-
+
Uses with for Node.js applications.
@@ -368,7 +359,7 @@ With the builder ready, define resources and services. The snippet below shows h
@@ -377,7 +368,7 @@ With the builder ready, define resources and services. The snippet below shows h
#### Adding an API resource and declaring a dependency
-Next, register the API service and wire it to the PostgreSQL resource:
+Next, register the API service and wire it to the PostgreSQL resource with :
@@ -758,6 +749,6 @@ You can hook into lifecycle events to run custom logic during startup and resour
## Best practices
- Keep the AppHost minimal to start; add complexity only as required.
-- Define explicit dependencies with `.WithReference(...)` to make wiring obvious.
+- Define explicit resource references to make dependency wiring obvious.
- Use separate configurations for development, testing, and production.
- Pick clear, descriptive names for resources to make debugging and logging easier.
diff --git a/src/frontend/src/content/docs/get-started/aspire-mcp-server.mdx b/src/frontend/src/content/docs/get-started/aspire-mcp-server.mdx
index 626b0540b..5da2f6e9b 100644
--- a/src/frontend/src/content/docs/get-started/aspire-mcp-server.mdx
+++ b/src/frontend/src/content/docs/get-started/aspire-mcp-server.mdx
@@ -4,6 +4,7 @@ description: Give coding agents MCP tools for application logs, distributed trac
---
import { Aside, Steps, Tabs, TabItem } from '@astrojs/starlight/components';
+import ApiReference from '@components/ApiReference.astro';
import LearnMore from '@components/LearnMore.astro';
:::note
@@ -124,7 +125,7 @@ The Aspire MCP server provides the following tools to AI agents:
### Exclude resources from MCP
-By default, all resources, console logs, and telemetry are accessible through the MCP server. You can exclude specific resources and their associated telemetry by annotating them with `ExcludeFromMcp()`:
+By default, all resources, console logs, and telemetry are accessible through the MCP server. You can exclude specific resources and their associated telemetry by annotating them with :
@@ -207,7 +208,7 @@ For teams evaluating the Aspire MCP server:
- **Development-time only** — not included in published or deployed applications
- **No external network access** — no network listeners when using STDIO transport
- **Data stays local** — telemetry remains on the developer's machine. Data shared with AI assistants is governed by the assistant's own data policies
-- **Granular access control** — use `ExcludeFromMcp()` to restrict sensitive resources
+- **Granular access control** — exclude sensitive resources and their telemetry from MCP access
- **No persistent storage** — all data is in memory for the session duration
- **Open specification** — implements the [Model Context Protocol specification](https://modelcontextprotocol.io/)
diff --git a/src/frontend/src/content/docs/get-started/deploy-first-app.mdx b/src/frontend/src/content/docs/get-started/deploy-first-app.mdx
index d9034a297..ff6d60fdc 100644
--- a/src/frontend/src/content/docs/get-started/deploy-first-app.mdx
+++ b/src/frontend/src/content/docs/get-started/deploy-first-app.mdx
@@ -8,6 +8,7 @@ import { Aside, Steps, TabItem, Tabs } from '@astrojs/starlight/components';
import FileTree from 'starlight-plugin-icons/components/FileTree.astro';
import { Image } from 'astro:assets';
import { Kbd } from 'starlight-kbd/components';
+import ApiReference from '@components/ApiReference.astro';
import Expand from '@components/Expand.astro';
import LearnMore from '@components/LearnMore.astro';
import PivotSelector from '@components/PivotSelector.astro';
@@ -91,7 +92,7 @@ architecture-beta
The React (Vite) and Express starter template consists of two resources that are deployed as a single container. The Express server hosts both the API and the static frontend files generated by React.
- This tutorial uses the **backend serves frontend** deployment model. For the other production patterns available to `AddViteApp` and `AddJavaScriptApp`, including reverse proxy and gateway/BFF approaches, see [Deploy JavaScript apps](/deployment/javascript-apps/).
+ This tutorial uses the **backend serves frontend** deployment model. For the other production patterns available to and , including reverse proxy and gateway/BFF approaches, see [Deploy JavaScript apps](/deployment/javascript-apps/).
@@ -242,9 +243,9 @@ In the root directory of your Aspire app that you created in the previous quicks
## Update your AppHost
-
+In the AppHost, use for Docker Compose or for Azure Container Apps to configure the deployment environment. Use to expose a resource's HTTP endpoints when deployed.
-In the AppHost, chain a call to the appropriate environment API method to configure the deployment environment for your target.
+
@@ -267,8 +268,8 @@ In the AppHost, chain a call to the appropriate environment API method to config
builder.Build().Run();
```
- - `AddDockerComposeEnvironment` - Configures the Docker Compose environment for deployment. This call implicitly adds support for containerizing resources in the AppHost as part of deployment.
- - `WithExternalHttpEndpoints` - Exposes HTTP endpoints for the resource when deployed.
+ - - Configures the Docker Compose environment for deployment. This call implicitly adds support for containerizing resources in the AppHost as part of deployment.
+ - - Exposes HTTP endpoints for the resource when deployed.
@@ -291,8 +292,8 @@ In the AppHost, chain a call to the appropriate environment API method to config
builder.Build().Run();
```
- - `AddAzureContainerAppEnvironment` - Configures the Azure App Container environment for deployment. This call implicitly adds support for containerizing resources in the AppHost as part of deployment.
- - `WithExternalHttpEndpoints` - Exposes HTTP endpoints for the resource when deployed.
+ - - Configures the Azure App Container environment for deployment. This call implicitly adds support for containerizing resources in the AppHost as part of deployment.
+ - - Exposes HTTP endpoints for the resource when deployed.
@@ -304,8 +305,6 @@ After installing a new deployment package, you can run `aspire deploy --list-ste
-In the AppHost, chain a call to the appropriate environment API method to configure the deployment environment for your target.
-
@@ -332,8 +331,8 @@ In the AppHost, chain a call to the appropriate environment API method to config
await builder.build().run();
```
- - `addDockerComposeEnvironment` - Configures the Docker Compose environment for deployment. This call implicitly adds support for containerizing resources in the AppHost as part of deployment.
- - `withExternalHttpEndpoints` - Exposes HTTP endpoints for the resource when deployed.
+ - - Configures the Docker Compose environment for deployment. This call implicitly adds support for containerizing resources in the AppHost as part of deployment.
+ - - Exposes HTTP endpoints for the resource when deployed.
@@ -361,8 +360,8 @@ In the AppHost, chain a call to the appropriate environment API method to config
await builder.build().run();
```
- - `addAzureContainerAppEnvironment` - Configures the Azure App Container environment for deployment. This call implicitly adds support for containerizing resources in the AppHost as part of deployment.
- - `withExternalHttpEndpoints` - Exposes HTTP endpoints for the resource when deployed.
+ - - Configures the Azure App Container environment for deployment. This call implicitly adds support for containerizing resources in the AppHost as part of deployment.
+ - - Exposes HTTP endpoints for the resource when deployed.
diff --git a/src/frontend/src/content/docs/get-started/faq.mdx b/src/frontend/src/content/docs/get-started/faq.mdx
index 7b72b775c..bc0444a26 100644
--- a/src/frontend/src/content/docs/get-started/faq.mdx
+++ b/src/frontend/src/content/docs/get-started/faq.mdx
@@ -4,6 +4,8 @@ seoTitle: 'Aspire FAQ: common questions and answers for developers'
description: Answers to common questions about Aspire — the AppHost, dashboard, integrations, TypeScript support, deployment targets, telemetry, and licensing for production apps.
---
+import ApiReference from '@components/ApiReference.astro';
+
This page answers common questions about what Aspire is, how it fits into your workflow, what it can do for you, and how it compares to other technologies.
## What is Aspire?
@@ -97,8 +99,8 @@ Learn more: [MCP tools](/reference/cli/commands/aspire-mcp/), [`aspire agent`](/
Aspire is a multi-language platform. The AppHost can be written in **C# or TypeScript**. Services can be written in any language:
- **C# / .NET** — First-class project references, service defaults, and integrations
-- **Python** — `AddPythonApp`, `AddUvicornApp`, `AddPythonModule` with uv/pip/venv package management and automatic Dockerfile generation
-- **JavaScript / TypeScript** — `AddJavaScriptApp`, `AddViteApp`, `AddNodeApp` with npm/yarn/pnpm auto-detection
+- **Python** — , , with uv/pip/venv package management and automatic Dockerfile generation
+- **JavaScript / TypeScript** — Node.js services, Vite frontends, and JavaScript apps with npm/yarn/pnpm auto-detection
- **Go, Java, Rust** — Via community toolkit integrations and code generation packages
- **Any containerized app** — Anything that runs in a container can be added to the AppHost
diff --git a/src/frontend/src/content/docs/get-started/first-app.mdx b/src/frontend/src/content/docs/get-started/first-app.mdx
index 6c04433df..13d62e74b 100644
--- a/src/frontend/src/content/docs/get-started/first-app.mdx
+++ b/src/frontend/src/content/docs/get-started/first-app.mdx
@@ -6,6 +6,7 @@ prev: false
---
import { Steps } from '@astrojs/starlight/components';
+import ApiReference from '@components/ApiReference.astro';
import FileTree from 'starlight-plugin-icons/components/FileTree.astro';
import { Kbd } from 'starlight-kbd/components';
import LearnMore from '@components/LearnMore.astro';
@@ -153,6 +154,8 @@ To create your first Aspire application, use the [Aspire CLI](/get-started/insta
## Review the template code
+Both templates start with to create the AppHost builder. They use to connect resources and to wait for dependencies before starting dependent resources.
+
@@ -208,11 +211,11 @@ To create your first Aspire application, use the [Aspire CLI](/get-started/insta
_What's happening here?_
- - `CreateBuilder` creates the distributed application builder
- - `AddProject` registers your API service and web frontend
- - `WithReference` connects services. It injects the API's URL as an environment variable and sets up service discovery so you can use service names instead of hardcoded URLs
- - `WaitFor` ensures the API is healthy before starting the frontend, preventing connection errors from race conditions
- - `WithHttpHealthCheck` monitors service health
+ - creates the distributed application builder
+ - registers your API service and web frontend
+ - connects services. It injects the API's URL as an environment variable and sets up service discovery so you can use service names instead of hardcoded URLs
+ - ensures the API is healthy before starting the frontend, preventing connection errors from race conditions
+ - monitors service health
:::note[Code-first orchestration]
Your application topology is defined in code, making it easy to understand, modify, and version control. Learn more about the [AppHost](/get-started/app-host/).
@@ -290,12 +293,12 @@ To create your first Aspire application, use the [Aspire CLI](/get-started/insta
_What's happening here?_
- - `createBuilder` creates the distributed application builder
- - `addNodeApp` adds a Node.js application (the Express API)
- - `addViteApp` registers your React frontend
- - `withReference` connects the frontend to the API. It injects the API's URL and sets up service discovery
- - `waitFor` ensures the API is running before starting the frontend, preventing connection errors
- - `publishWithContainerFiles` bundles the frontend for production deployment
+ - creates the distributed application builder
+ - adds a Node.js application (the Express API)
+ - registers your React frontend
+ - connects the frontend to the API. It injects the API's URL and sets up service discovery
+ - ensures the API is running before starting the frontend, preventing connection errors
+ - bundles the frontend for production deployment
This template uses a TypeScript AppHost. To learn more about how multi-language AppHosts work, see [Multi-language architecture](/architecture/multi-language-architecture/).
diff --git a/src/frontend/src/content/docs/get-started/resource-mcp-servers.mdx b/src/frontend/src/content/docs/get-started/resource-mcp-servers.mdx
index e3cc2b6de..0e4913375 100644
--- a/src/frontend/src/content/docs/get-started/resource-mcp-servers.mdx
+++ b/src/frontend/src/content/docs/get-started/resource-mcp-servers.mdx
@@ -5,6 +5,7 @@ description: Expose MCP tools from Aspire resources and interact with them using
---
import { Aside, Code, Steps, Tabs, TabItem } from '@astrojs/starlight/components';
+import ApiReference from '@components/ApiReference.astro';
import ContainerImages from '@components/ContainerImages.astro';
import LearnMore from '@components/LearnMore.astro';
@@ -12,14 +13,14 @@ Aspire resources can expose their own in the AppHost, Aspire discovers the MCP endpoint and makes its tools available in two ways:
- **Through the Aspire MCP server** — resource tools are automatically proxied alongside the built-in [Aspire MCP server](/get-started/aspire-mcp-server/) tools. AI agents see them in their tool list without any extra configuration.
- **Through the CLI** — use `aspire mcp tools` and `aspire mcp call` to discover and invoke resource tools directly from the terminal.
## Add an MCP server to a resource
-Use the `WithMcpServer()` extension method to declare that a resource hosts an MCP server:
+For PostgreSQL, use to expose MCP tools for a database:
@@ -56,7 +57,7 @@ await builder.build().run();
@@ -91,7 +92,7 @@ aspire mcp call appdata query --input '{"sql": "SELECT * FROM users LIMIT 5"}'
## Custom MCP server resources
-You can add any container that implements the MCP protocol as an MCP-enabled resource. Use `WithMcpServer()` to tell Aspire where the MCP endpoint lives:
+You can add any container that implements the MCP protocol as an MCP-enabled resource. Annotate the container with the location of its MCP endpoint:
@@ -125,11 +126,10 @@ await builder.build().run();
-The `WithMcpServer()` method accepts an optional path and endpoint name:
+The MCP annotation method accepts an optional path and endpoint name:
-- `WithMcpServer()` — uses the default HTTP endpoint at the root path
-- `WithMcpServer("/mcp")` — uses the default HTTP endpoint at `/mcp`
-- `WithMcpServer("/sse", endpointName: "https")` — uses a named endpoint at `/sse`
+- With no arguments, it uses the default HTTP endpoint at `/mcp`.
+- To use a named endpoint at a different path, pass the path and endpoint name, such as `/sse` and `https`.
## See also
@@ -137,4 +137,3 @@ The `WithMcpServer()` method accepts an optional path and endpoint name:
- [Aspire MCP server](/get-started/aspire-mcp-server/) — the built-in MCP server tools
- [ASPIREMCP001 diagnostic](/diagnostics/aspiremcp001/) — experimental MCP server API warning
- [ASPIREPOSTGRES001 diagnostic](/diagnostics/aspirepostgres001/) — experimental PostgreSQL MCP warning
-
diff --git a/src/frontend/src/content/docs/get-started/troubleshooting.mdx b/src/frontend/src/content/docs/get-started/troubleshooting.mdx
index cdbe201dc..05dd93728 100644
--- a/src/frontend/src/content/docs/get-started/troubleshooting.mdx
+++ b/src/frontend/src/content/docs/get-started/troubleshooting.mdx
@@ -5,6 +5,7 @@ description: Solutions to common problems when getting started with Aspire — D
import { Aside, Code, Steps } from '@astrojs/starlight/components';
import { Kbd } from 'starlight-kbd/components';
+import ApiReference from '@components/ApiReference.astro';
import LearnMore from '@components/LearnMore.astro';
import OsAwareTabs from '@components/OsAwareTabs.astro';
@@ -72,7 +73,7 @@ Having issues getting started with Aspire? This page covers solutions to the mos
**Symptoms**: Your frontend can't connect to the API, showing connection refused errors.
-**Solution**: Make sure you're using `WaitFor()` in your AppHost:
+**Solution**: Make sure you're using in your AppHost:
```csharp title="AppHost.cs"
builder.AddProject("frontend")
@@ -84,7 +85,7 @@ builder.AddProject("frontend")
**Symptoms**: Your service can't find connection strings or configuration that should be injected.
-**Solution**: Verify you're using `WithReference()` to connect resources.
+**Solution**: Verify you're using to connect resources.
### Example: Connecting a database to your service
diff --git a/src/frontend/src/scripts/tooltips.ts b/src/frontend/src/scripts/tooltips.ts
new file mode 100644
index 000000000..832479124
--- /dev/null
+++ b/src/frontend/src/scripts/tooltips.ts
@@ -0,0 +1,69 @@
+import tippy, { type Instance, type Placement, type ReferenceElement } from 'tippy.js';
+
+const tooltips = new Map();
+
+function initializeTooltips() {
+ for (const element of document.querySelectorAll('[title]')) {
+ const title = element.getAttribute('title');
+ if (!title || element._tippy || tooltips.has(element)) continue;
+
+ const placement = element.getAttribute('data-tooltip-placement') as Placement | null;
+ const interactive = element.getAttribute('data-tooltip-interactive');
+ const instance = tippy(element, {
+ content: title,
+ allowHTML: element.getAttribute('data-tippy-allowhtml') !== 'false',
+ theme: 'default',
+ maxWidth: 'none',
+ placement: placement ?? 'auto',
+ interactive: interactive === 'true',
+ delay: [0, 0],
+ duration: [0, 0],
+ hideOnClick: true,
+ animation: 'scale',
+ onClickOutside: (instance) => instance.hide(),
+ });
+ tooltips.set(element, { instance, title });
+ element.setAttribute('title', '');
+ }
+}
+
+function destroyTooltips() {
+ for (const [element, { instance, title }] of tooltips) {
+ if (!instance.state.isDestroyed) instance.destroy();
+ // Persisted nodes need their source title when the next page initializes.
+ if (element.getAttribute('title') === '') element.setAttribute('title', title);
+ }
+ tooltips.clear();
+}
+
+function dismissTooltip(event: KeyboardEvent) {
+ if (event.key === 'Escape') {
+ const activeElement: ReferenceElement | null = document.activeElement;
+ activeElement?._tippy?.hide();
+ }
+}
+
+const lifecycleDocument = document as Document & { __aspireTooltipsCleanup?: () => void };
+
+if (!lifecycleDocument.__aspireTooltipsCleanup) {
+ const cleanup = () => {
+ document.removeEventListener('astro:before-swap', destroyTooltips);
+ document.removeEventListener('astro:page-load', initializeTooltips);
+ document.removeEventListener('keydown', dismissTooltip);
+ document.removeEventListener('DOMContentLoaded', initializeTooltips);
+ destroyTooltips();
+ delete lifecycleDocument.__aspireTooltipsCleanup;
+ };
+ lifecycleDocument.__aspireTooltipsCleanup = cleanup;
+ document.addEventListener('astro:before-swap', destroyTooltips);
+ document.addEventListener('astro:page-load', initializeTooltips);
+ document.addEventListener('keydown', dismissTooltip);
+
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', initializeTooltips, { once: true });
+ } else {
+ initializeTooltips();
+ }
+
+ import.meta.hot?.dispose(cleanup);
+}
diff --git a/src/frontend/src/utils/api-reference-core.ts b/src/frontend/src/utils/api-reference-core.ts
new file mode 100644
index 000000000..fef0e56e6
--- /dev/null
+++ b/src/frontend/src/utils/api-reference-core.ts
@@ -0,0 +1,965 @@
+import { memberNameSlug, resolveMemberAnchorMap } from './api-member-anchors';
+import { sampleDescriptionText } from './samples';
+import {
+ getTsItemSlug,
+ getTsMemberAnchor,
+ getTsMethodSlug,
+ getTsTopLevelRouteItems,
+ type TsRouteParameterLike,
+} from './ts-api-routes';
+
+export interface ApiReferenceAttribute {
+ name: string;
+ constructorArguments?: unknown[];
+ arguments?: Record;
+ namedArguments?: Record;
+}
+
+export interface ApiReferenceParameter {
+ name?: string;
+ type: string;
+ modifier?: string;
+}
+
+export interface ApiReferenceMember {
+ name: string;
+ kind?: string;
+ signature?: string;
+ genericParameters?: { name: string; constraints?: string[] }[];
+ parameters?: ApiReferenceParameter[];
+ attributes?: ApiReferenceAttribute[];
+ isStatic?: boolean;
+ isExtension?: boolean;
+ docs?: { summary?: string | ApiReferenceDocNode[] };
+}
+
+interface ApiReferenceDocNode {
+ kind: string;
+ text?: string;
+ value?: string;
+ children?: ApiReferenceDocNode[];
+}
+
+export interface ApiReferenceType {
+ name: string;
+ fullName?: string;
+ namespace?: string;
+ genericParameters?: { name: string; constraints?: string[] }[];
+ attributes?: ApiReferenceAttribute[];
+ members?: ApiReferenceMember[];
+}
+
+export interface ApiReferencePackageDocument {
+ package: {
+ name: string;
+ };
+ types?: ApiReferenceType[];
+}
+
+export interface ApiReferenceTsCallable {
+ name: string;
+ description?: string;
+ kind?: string;
+ capabilityId?: string;
+ qualifiedName?: string;
+ signature?: string;
+ targetTypeId?: string;
+ expandedTargetTypes?: string[];
+ parameters?: (TsRouteParameterLike & { isOptional?: boolean })[];
+}
+
+export interface ApiReferenceTsHandle {
+ name: string;
+ fullName?: string;
+ capabilities?: ApiReferenceTsCallable[];
+}
+
+export interface ApiReferenceTsDocument {
+ package: {
+ name: string;
+ };
+ functions?: ApiReferenceTsCallable[];
+ handleTypes?: ApiReferenceTsHandle[];
+ dtoTypes?: { name: string; fullName?: string }[];
+ enumTypes?: { name: string; fullName?: string }[];
+}
+
+export interface ApiReferenceTarget {
+ label: string;
+ path?: string;
+ description?: string;
+}
+
+export type ApiReferenceDiagnosticCode =
+ | 'unsupported-spread'
+ | 'invalid-fqn'
+ | 'missing-csharp'
+ | 'ambiguous-csharp'
+ | 'invalid-overload'
+ | 'missing-overload'
+ | 'ambiguous-overload'
+ | 'missing-typescript'
+ | 'unresolved-typescript-export'
+ | 'ambiguous-typescript';
+
+export interface ApiReferenceDiagnostic {
+ code: ApiReferenceDiagnosticCode;
+ severity: 'error' | 'warning';
+ message: string;
+ candidates: string[];
+}
+
+export interface ApiReferenceResolution {
+ name: string;
+ status: 'resolved' | 'missing' | 'ambiguous';
+ csharp: ApiReferenceTarget;
+ typescript: ApiReferenceTarget;
+ diagnostics: ApiReferenceDiagnostic[];
+}
+
+export interface ApiReferenceIndex {
+ readonly size: number;
+ resolve(
+ name: string,
+ packageName?: string,
+ parameterTypes?: readonly string[]
+ ): ApiReferenceResolution;
+}
+
+interface CSharpCandidate {
+ fqn: string;
+ packageName: string;
+ type: ApiReferenceType;
+ members: ApiReferenceMember[];
+}
+
+interface TsRouteCandidate {
+ moduleName: string;
+ name: string;
+ kind?: string;
+ capabilityId?: string;
+ qualifiedName?: string;
+ targetTypeId?: string;
+ expandedTargetTypes: string[];
+ parentTypeFullName?: string;
+ path: string;
+ priority: number;
+ parameters?: ApiReferenceTsCallable['parameters'];
+ description?: string;
+}
+
+interface TsRouteIndex {
+ byCapabilityId: Map;
+ byModuleAndName: Map;
+ byName: Map;
+}
+
+interface ExportMapping {
+ capabilityId?: string;
+ methodName: string;
+ member: ApiReferenceMember;
+ required: boolean;
+ allowUntargeted: boolean;
+}
+
+export const API_REFERENCE_FQN_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)+$/;
+
+const ASPIRE_EXPORT_ATTRIBUTE = /(?:^|\.)AspireExportAttribute$/;
+const ASPIRE_EXPORT_IGNORE_ATTRIBUTE = /(?:^|\.)AspireExportIgnoreAttribute$/;
+const CALLABLE_KINDS = new Set(['method', 'constructor', 'Method', 'InstanceMethod']);
+const MEMBER_KIND_SLUGS: Record = {
+ constructor: 'constructors',
+ property: 'properties',
+ method: 'methods',
+ field: 'fields',
+ event: 'events',
+ indexer: 'indexers',
+};
+
+function addToIndex(index: Map, key: string | undefined, value: T): void {
+ if (!key) return;
+ const values = index.get(key) ?? [];
+ values.push(value);
+ index.set(key, values);
+}
+
+function genericArity(type: ApiReferenceType): number {
+ return type.genericParameters?.length ?? 0;
+}
+
+function csharpTypePath(packageName: string, typeName: string, arity: number): string {
+ const typeSlug = `${typeName.toLowerCase()}${arity > 0 ? `-${arity}` : ''}`;
+ return `/reference/api/csharp/${packageName.toLowerCase()}/${typeSlug}/`;
+}
+
+function tsModuleSlug(name: string): string {
+ return name.toLowerCase();
+}
+
+function normalizeTypeName(value: string): string {
+ const withoutAssembly = value.includes('/') ? value.slice(value.indexOf('/') + 1) : value;
+ return withoutAssembly
+ .trim()
+ .replace(/\?$/, '')
+ .replace(/^global::/, '')
+ .replace(/`\d+/g, '')
+ .replace(/<.*>$/, '')
+ .replace(/\[\[.*\]\]$/, '');
+}
+
+function genericArguments(value: string): string[] {
+ const start = value.indexOf('<');
+ if (start < 0) return [];
+
+ const argumentsList: string[] = [];
+ let depth = 0;
+ let current = '';
+
+ for (let index = start + 1; index < value.length; index++) {
+ const character = value[index];
+ if (character === '<') {
+ depth++;
+ current += character;
+ } else if (character === '>') {
+ if (depth === 0) {
+ if (current.trim()) argumentsList.push(current.trim());
+ break;
+ }
+ depth--;
+ current += character;
+ } else if (character === ',' && depth === 0) {
+ argumentsList.push(current.trim());
+ current = '';
+ } else {
+ current += character;
+ }
+ }
+
+ return argumentsList;
+}
+
+function simpleTypeName(value: string): string {
+ const normalized = normalizeTypeName(value);
+ return normalized.slice(normalized.lastIndexOf('.') + 1);
+}
+
+function lowerCamelCase(value: string): string {
+ return value ? value.charAt(0).toLowerCase() + value.slice(1) : value;
+}
+
+function isCallable(kind: string | undefined): boolean {
+ return kind ? CALLABLE_KINDS.has(kind) : false;
+}
+
+function readNamedString(attribute: ApiReferenceAttribute, name: string): string | undefined {
+ const value = attribute.arguments?.[name] ?? attribute.namedArguments?.[name];
+ return typeof value === 'string' && value ? value : undefined;
+}
+
+function readNamedBoolean(attribute: ApiReferenceAttribute, name: string): boolean {
+ const value = attribute.arguments?.[name] ?? attribute.namedArguments?.[name];
+ return value === true || (typeof value === 'string' && value.toLowerCase() === 'true');
+}
+
+function extensionMappingIdentity(member: ApiReferenceMember): string {
+ if (!member.isExtension) return '';
+ const receiver =
+ member.parameters?.find((parameter) => parameter.modifier === 'this')?.type ?? '';
+ const constraints = (member.genericParameters ?? [])
+ .flatMap((parameter) => parameter.constraints ?? [])
+ .join(',');
+ return `${receiver}\0${constraints}`;
+}
+
+function getExportMappings(candidate: CSharpCandidate): ExportMapping[] {
+ const mappings = new Map();
+ const typeExport = (candidate.type.attributes ?? []).find((attribute) =>
+ ASPIRE_EXPORT_ATTRIBUTE.test(attribute.name)
+ );
+ const exposeMethods = typeExport ? readNamedBoolean(typeExport, 'ExposeMethods') : false;
+ const exposeProperties = typeExport ? readNamedBoolean(typeExport, 'ExposeProperties') : false;
+
+ for (const member of candidate.members) {
+ const attributes = (member.attributes ?? []).filter((attribute) =>
+ ASPIRE_EXPORT_ATTRIBUTE.test(attribute.name)
+ );
+
+ if (attributes.length > 0) {
+ for (const attribute of attributes) {
+ const explicitId = attribute.constructorArguments?.[0];
+ const capabilityId =
+ typeof explicitId === 'string' && explicitId
+ ? `${candidate.packageName}/${explicitId}`
+ : member.isStatic || member.isExtension
+ ? `${candidate.packageName}/${lowerCamelCase(member.name)}`
+ : undefined;
+ const methodName = readNamedString(attribute, 'MethodName') ?? lowerCamelCase(member.name);
+ const key = `${capabilityId ?? ''}\0${methodName}\0${extensionMappingIdentity(member)}`;
+ mappings.set(key, {
+ capabilityId,
+ methodName,
+ member,
+ required: true,
+ allowUntargeted: false,
+ });
+ }
+ continue;
+ }
+
+ const ignoredAttributes = (member.attributes ?? []).filter((attribute) =>
+ ASPIRE_EXPORT_IGNORE_ATTRIBUTE.test(attribute.name)
+ );
+ if (ignoredAttributes.length > 0) {
+ const methodName = lowerCamelCase(member.name);
+ const allowUntargeted = ignoredAttributes.some((attribute) =>
+ /\bdispatcher\b|\b(?:canonical|generic)\b[^.]*\bexport\b/i.test(
+ readNamedString(attribute, 'Reason') ?? ''
+ )
+ );
+ const key = `optional\0${methodName}\0${allowUntargeted}\0${extensionMappingIdentity(member)}`;
+ mappings.set(key, {
+ methodName,
+ member,
+ required: false,
+ allowUntargeted,
+ });
+ continue;
+ }
+
+ const exposedByType =
+ (member.kind === 'method' && exposeMethods) ||
+ (member.kind === 'property' && exposeProperties);
+ if (exposedByType) {
+ const methodName = lowerCamelCase(member.name);
+ const key = `type\0${methodName}\0${extensionMappingIdentity(member)}`;
+ mappings.set(key, {
+ methodName,
+ member,
+ required: false,
+ allowUntargeted: false,
+ });
+ }
+ }
+
+ return [...mappings.values()];
+}
+
+function buildTsRouteIndex(modules: readonly ApiReferenceTsDocument[]): TsRouteIndex {
+ const byCapabilityId = new Map();
+ const byModuleAndName = new Map();
+ const byName = new Map();
+
+ const register = (candidate: TsRouteCandidate) => {
+ addToIndex(byCapabilityId, candidate.capabilityId, candidate);
+ addToIndex(byName, candidate.name.toLowerCase(), candidate);
+ addToIndex(
+ byModuleAndName,
+ `${candidate.moduleName}\0${candidate.name.toLowerCase()}`,
+ candidate
+ );
+ };
+
+ for (const module of modules) {
+ const moduleName = module.package.name;
+ const modulePath = `/reference/api/typescript/${tsModuleSlug(moduleName)}`;
+ const topLevelItems = getTsTopLevelRouteItems(module);
+
+ const standaloneFunctions = (module.functions ?? []).filter(
+ (fn) => !fn.qualifiedName || !fn.qualifiedName.includes('.')
+ );
+ for (const fn of standaloneFunctions) {
+ register({
+ moduleName,
+ name: fn.name,
+ kind: fn.kind,
+ capabilityId: fn.capabilityId,
+ qualifiedName: fn.qualifiedName,
+ targetTypeId: fn.targetTypeId,
+ expandedTargetTypes: fn.expandedTargetTypes ?? [],
+ path: `${modulePath}/${getTsItemSlug(fn, topLevelItems)}/`,
+ priority: 0,
+ parameters: fn.parameters,
+ description: fn.description,
+ });
+ }
+
+ for (const handle of module.handleTypes ?? []) {
+ const itemSlug = getTsItemSlug(handle, topLevelItems);
+ const methods = (handle.capabilities ?? []).filter(
+ (capability) => capability.kind === 'Method' || capability.kind === 'InstanceMethod'
+ );
+
+ for (const capability of handle.capabilities ?? []) {
+ let path: string | undefined;
+ let priority = 1;
+
+ if (capability.kind === 'Method' || capability.kind === 'InstanceMethod') {
+ path = `${modulePath}/${itemSlug}/${getTsMethodSlug(capability, methods, handle.name)}/`;
+ } else if (capability.kind === 'PropertyGetter' || capability.kind === 'PropertySetter') {
+ path = `${modulePath}/${itemSlug}/#${getTsMemberAnchor(capability.name)}`;
+ priority = capability.kind === 'PropertyGetter' ? 1 : 2;
+ }
+
+ if (!path) continue;
+
+ register({
+ moduleName,
+ name: capability.name,
+ kind: capability.kind,
+ capabilityId: capability.capabilityId,
+ qualifiedName: capability.qualifiedName,
+ targetTypeId: capability.targetTypeId,
+ expandedTargetTypes: capability.expandedTargetTypes ?? [],
+ parentTypeFullName: handle.fullName,
+ path,
+ priority,
+ parameters: capability.parameters,
+ description: capability.description,
+ });
+ }
+ }
+ }
+
+ return { byCapabilityId, byModuleAndName, byName };
+}
+
+function extensionReceiverTypes(candidate: CSharpCandidate, member: ApiReferenceMember): string[] {
+ if (!member.isExtension) return [];
+ const receiver = member.parameters?.find((parameter) => parameter.modifier === 'this');
+ if (!receiver) return [];
+
+ const genericParameters = [
+ ...(candidate.type.genericParameters ?? []),
+ ...(member.genericParameters ?? []),
+ ];
+ const receiverTypes = [receiver.type, ...genericArguments(receiver.type)];
+ const resolved = new Set();
+
+ for (const receiverType of receiverTypes) {
+ const normalized = normalizeTypeName(receiverType);
+ const genericParameter = genericParameters.find((parameter) => parameter.name === normalized);
+ if (genericParameter?.constraints?.length) {
+ for (const constraint of genericParameter.constraints) {
+ resolved.add(normalizeTypeName(constraint));
+ }
+ } else {
+ resolved.add(normalized);
+ }
+ }
+
+ return [...resolved];
+}
+
+function candidateMatchesMember(
+ route: TsRouteCandidate,
+ candidate: CSharpCandidate,
+ member: ApiReferenceMember
+): boolean {
+ const declaringFullName = normalizeTypeName(
+ candidate.type.fullName ??
+ `${candidate.type.namespace ? `${candidate.type.namespace}.` : ''}${candidate.type.name}`
+ );
+ const expectedTypes = member.isExtension
+ ? extensionReceiverTypes(candidate, member)
+ : [declaringFullName];
+ if (expectedTypes.length === 0) expectedTypes.push(declaringFullName);
+ const normalizedExpectedTypes = expectedTypes.map(normalizeTypeName);
+ const expectedSimpleNames = normalizedExpectedTypes.map(simpleTypeName);
+ const targetTypes = [
+ route.parentTypeFullName,
+ route.targetTypeId,
+ ...route.expandedTargetTypes,
+ ].filter((value): value is string => Boolean(value));
+
+ if (
+ targetTypes.some((targetType) =>
+ normalizedExpectedTypes.includes(normalizeTypeName(targetType))
+ )
+ ) {
+ return true;
+ }
+
+ const qualifiedName = route.qualifiedName;
+ const lastDot = qualifiedName?.lastIndexOf('.') ?? -1;
+ return (
+ lastDot > 0 && expectedSimpleNames.includes(simpleTypeName(qualifiedName!.slice(0, lastDot)))
+ );
+}
+
+function deduplicateTsCandidates(candidates: readonly TsRouteCandidate[]): TsRouteCandidate[] {
+ const unique = new Map();
+ for (const candidate of candidates) {
+ const key = `${candidate.path}\0${candidate.name}\0${candidate.kind ?? ''}`;
+ const current = unique.get(key);
+ if (!current || candidate.priority < current.priority) {
+ unique.set(key, candidate);
+ }
+ }
+ return [...unique.values()];
+}
+
+function preferredTsCandidates(candidates: readonly TsRouteCandidate[]): TsRouteCandidate[] {
+ const unique = deduplicateTsCandidates(candidates);
+ if (unique.length <= 1) return unique;
+ const priority = Math.min(...unique.map((candidate) => candidate.priority));
+ return unique.filter((candidate) => candidate.priority === priority);
+}
+
+function describeTsCandidate(candidate: TsRouteCandidate): string {
+ return `${candidate.moduleName}:${candidate.qualifiedName ?? candidate.name} (${candidate.path})`;
+}
+
+function findTsCandidates(
+ mapping: ExportMapping,
+ candidate: CSharpCandidate,
+ tsIndex: TsRouteIndex
+): { matches: TsRouteCandidate[]; suggestions: TsRouteCandidate[] } {
+ if (mapping.capabilityId) {
+ const exact = tsIndex.byCapabilityId.get(mapping.capabilityId) ?? [];
+ if (exact.length > 0) {
+ return { matches: preferredTsCandidates(exact), suggestions: exact };
+ }
+ }
+
+ const named =
+ tsIndex.byModuleAndName.get(`${candidate.packageName}\0${mapping.methodName.toLowerCase()}`) ??
+ [];
+ const targeted = named.filter((route) =>
+ candidateMatchesMember(route, candidate, mapping.member)
+ );
+
+ if (targeted.length > 0) {
+ return { matches: preferredTsCandidates(targeted), suggestions: named };
+ }
+
+ if (mapping.allowUntargeted) {
+ const globalNamed = tsIndex.byName.get(mapping.methodName.toLowerCase()) ?? [];
+ const globalTargeted = globalNamed.filter((route) =>
+ candidateMatchesMember(route, candidate, mapping.member)
+ );
+ if (globalTargeted.length > 0) {
+ return {
+ matches: preferredTsCandidates(globalTargeted),
+ suggestions: globalNamed,
+ };
+ }
+ return { matches: preferredTsCandidates(named), suggestions: named };
+ }
+
+ return { matches: [], suggestions: named };
+}
+
+function summaryText(summary: string | ApiReferenceDocNode[] | undefined): string | undefined {
+ if (typeof summary === 'string') {
+ return sampleDescriptionText(summary)?.replace(/\s+/g, ' ') || undefined;
+ }
+ let text = '';
+ for (const node of summary ?? []) {
+ const value = node.children
+ ? (summaryText(node.children) ?? '')
+ : (node.text ?? node.value ?? '');
+ const label =
+ node.kind === 'cref'
+ ? value
+ .replace(/^[A-Z]:/, '')
+ .replace(/\(.*$/, '')
+ .replace(/``?\d+/g, '')
+ : value;
+ if (text && label && !/\s$/.test(text) && !/^[\s,.:;!?)}\]]/.test(label)) text += ' ';
+ text += label;
+ }
+ return text.replace(/\s+/g, ' ').trim() || undefined;
+}
+
+function createCSharpTarget(candidate: CSharpCandidate, exactOverload = false): ApiReferenceTarget {
+ const member = candidate.members[0];
+ const kind = member.kind ?? 'method';
+ const anchor = exactOverload
+ ? resolveMemberAnchorMap(candidate.type.members ?? []).get(member)!.exact
+ : memberNameSlug(member);
+ const parameters = (member.parameters ?? [])
+ .filter((parameter) => !member.isExtension || parameter.modifier !== 'this')
+ .map(
+ (parameter) =>
+ `${parameter.modifier ? `${parameter.modifier} ` : ''}${parameter.type.replace(/\b(?:[A-Za-z_]\w*\.)+/g, '')}${parameter.name ? ` ${parameter.name}` : ''}`
+ );
+ return {
+ label: exactOverload ? `${member.name}(${parameters.join(', ')})` : member.name,
+ description: summaryText(member.docs?.summary),
+ path: `${csharpTypePath(
+ candidate.packageName,
+ candidate.type.name,
+ genericArity(candidate.type)
+ )}${MEMBER_KIND_SLUGS[kind] ?? `${kind}s`}/#${anchor}`,
+ };
+}
+
+function resolveTypescriptTarget(
+ candidate: CSharpCandidate,
+ csharp: ApiReferenceTarget,
+ tsIndex: TsRouteIndex,
+ exactOverload = false
+): { target: ApiReferenceTarget; diagnostics: ApiReferenceDiagnostic[] } {
+ const mappings = getExportMappings(candidate);
+ if (mappings.length === 0) {
+ return {
+ target: { label: csharp.label },
+ diagnostics: [
+ {
+ code: 'missing-typescript',
+ severity: 'warning',
+ message: `ApiReference: "${candidate.fqn}" has no TypeScript export; the C# API name is shown without a TypeScript link.`,
+ candidates: [],
+ },
+ ],
+ };
+ }
+
+ const matches: TsRouteCandidate[] = [];
+ const suggestions: TsRouteCandidate[] = [];
+ let unresolved = false;
+
+ for (const mapping of mappings) {
+ const result = findTsCandidates(mapping, candidate, tsIndex);
+ suggestions.push(...result.suggestions);
+ if (result.matches.length === 0) {
+ unresolved ||= mapping.required;
+ continue;
+ }
+ matches.push(...result.matches);
+ }
+
+ const canonicalName = lowerCamelCase(candidate.members[0].name).toLowerCase();
+ const canonicalMatches = matches.filter((match) => match.name.toLowerCase() === canonicalName);
+ const preferred = preferredTsCandidates(canonicalMatches.length > 0 ? canonicalMatches : matches);
+ const candidateDescriptions = deduplicateTsCandidates(
+ suggestions.length > 0 ? suggestions : matches
+ )
+ .map(describeTsCandidate)
+ .sort();
+
+ if (unresolved) {
+ return {
+ target: { label: csharp.label },
+ diagnostics: [
+ {
+ code: 'unresolved-typescript-export',
+ severity: 'error',
+ message: `ApiReference: "${candidate.fqn}" declares a TypeScript export, but no generated TypeScript API matched it.`,
+ candidates: candidateDescriptions,
+ },
+ ],
+ };
+ }
+
+ if (preferred.length === 0) {
+ return {
+ target: { label: csharp.label },
+ diagnostics: [
+ {
+ code: 'missing-typescript',
+ severity: 'warning',
+ message: `ApiReference: "${candidate.fqn}" has no TypeScript export; the C# API name is shown without a TypeScript link.`,
+ candidates: candidateDescriptions,
+ },
+ ],
+ };
+ }
+
+ if (preferred.length > 1) {
+ return {
+ target: { label: csharp.label },
+ diagnostics: [
+ {
+ code: 'ambiguous-typescript',
+ severity: 'error',
+ message: `ApiReference: "${candidate.fqn}" maps to multiple generated TypeScript APIs.`,
+ candidates: preferred.map(describeTsCandidate).sort(),
+ },
+ ],
+ };
+ }
+
+ const match = preferred[0];
+ return {
+ target: {
+ description:
+ sampleDescriptionText(match.description ?? null)?.replace(/\s+/g, ' ') || undefined,
+ label:
+ exactOverload && isCallable(match.kind)
+ ? `${match.name}(${(match.parameters ?? [])
+ .map((parameter) => {
+ const type = parameter.callbackSignature ?? parameter.type;
+ return parameter.name
+ ? `${parameter.name}${parameter.isOptional ? '?' : ''}${type ? `: ${type}` : ''}`
+ : type;
+ })
+ .join(', ')})`
+ : match.name,
+ path: match.path,
+ },
+ diagnostics: [],
+ };
+}
+
+function describeCSharpCandidate(candidate: CSharpCandidate): string {
+ const signatures = candidate.members
+ .map((member) => member.signature)
+ .filter((signature): signature is string => Boolean(signature));
+ return signatures.length > 0
+ ? `${candidate.packageName}: ${signatures.join(' | ')}`
+ : `${candidate.packageName}: ${candidate.fqn}`;
+}
+
+function editDistance(left: string, right: string): number {
+ const previous = Array.from({ length: right.length + 1 }, (_, index) => index);
+ const current = new Array(right.length + 1);
+
+ for (let leftIndex = 1; leftIndex <= left.length; leftIndex++) {
+ current[0] = leftIndex;
+ for (let rightIndex = 1; rightIndex <= right.length; rightIndex++) {
+ current[rightIndex] = Math.min(
+ current[rightIndex - 1] + 1,
+ previous[rightIndex] + 1,
+ previous[rightIndex - 1] + (left[leftIndex - 1] === right[rightIndex - 1] ? 0 : 1)
+ );
+ }
+ for (let index = 0; index < current.length; index++) {
+ previous[index] = current[index];
+ }
+ }
+
+ return previous[right.length];
+}
+
+function buildSuggestions(
+ name: string,
+ fqnsByMemberName: ReadonlyMap
+): string[] {
+ const memberName = name.slice(name.lastIndexOf('.') + 1).toLowerCase();
+ const exact = fqnsByMemberName.get(memberName);
+ if (exact?.length) return [...exact].slice(0, 8);
+
+ return [...fqnsByMemberName.entries()]
+ .map(([candidateName, fqns]) => ({
+ distance: editDistance(memberName, candidateName),
+ fqns,
+ }))
+ .sort((left, right) => left.distance - right.distance)
+ .filter(({ distance }) => distance <= Math.max(2, Math.floor(memberName.length / 3)))
+ .flatMap(({ fqns }) => fqns)
+ .slice(0, 8);
+}
+
+function fallbackTarget(name: string): ApiReferenceTarget {
+ const label = name.slice(name.lastIndexOf('.') + 1) || name || 'Unknown API';
+ return { label };
+}
+
+export function buildApiReferenceIndex(
+ packages: readonly ApiReferencePackageDocument[],
+ modules: readonly ApiReferenceTsDocument[]
+): ApiReferenceIndex {
+ const candidates = new Map>();
+ const fqnsByMemberName = new Map();
+ const tsIndex = buildTsRouteIndex(modules);
+
+ for (const pkg of packages) {
+ for (const type of pkg.types ?? []) {
+ const typeFullName = normalizeTypeName(
+ type.fullName ?? `${type.namespace ? `${type.namespace}.` : ''}${type.name}`
+ );
+
+ for (const member of type.members ?? []) {
+ const fqn = `${typeFullName}.${member.name}`;
+ const groupKey = `${pkg.package.name}\0${type.fullName ?? typeFullName}`;
+ const groups = candidates.get(fqn) ?? new Map();
+ const group = groups.get(groupKey) ?? {
+ fqn,
+ packageName: pkg.package.name,
+ type,
+ members: [],
+ };
+ group.members.push(member);
+ groups.set(groupKey, group);
+ candidates.set(fqn, groups);
+
+ const memberName = member.name.toLowerCase();
+ const memberFqns = fqnsByMemberName.get(memberName) ?? [];
+ if (!memberFqns.includes(fqn)) memberFqns.push(fqn);
+ fqnsByMemberName.set(memberName, memberFqns);
+ }
+ }
+ }
+
+ const resolutions = new Map();
+ const packageResolutions = new Map();
+
+ for (const [fqn, groups] of candidates) {
+ const matches = [...groups.values()];
+ const matchesByPackage = new Map();
+ for (const match of matches) {
+ const packageMatches = matchesByPackage.get(match.packageName) ?? [];
+ packageMatches.push(match);
+ matchesByPackage.set(match.packageName, packageMatches);
+ }
+
+ for (const [packageName, packageMatches] of matchesByPackage) {
+ if (packageMatches.length > 1) {
+ const fallback = fallbackTarget(fqn);
+ packageResolutions.set(`${packageName}\0${fqn}`, {
+ name: fqn,
+ status: 'ambiguous',
+ csharp: fallback,
+ typescript: fallback,
+ diagnostics: [
+ {
+ code: 'ambiguous-csharp',
+ severity: 'error',
+ message: `ApiReference: "${fqn}" resolves to multiple generated C# API members in package "${packageName}".`,
+ candidates: packageMatches.map(describeCSharpCandidate).sort(),
+ },
+ ],
+ });
+ continue;
+ }
+
+ const match = packageMatches[0];
+ const csharp = createCSharpTarget(match);
+ const typescript = resolveTypescriptTarget(match, csharp, tsIndex);
+ packageResolutions.set(`${packageName}\0${fqn}`, {
+ name: fqn,
+ status: 'resolved',
+ csharp,
+ typescript: typescript.target,
+ diagnostics: typescript.diagnostics,
+ });
+ }
+
+ if (matches.length > 1) {
+ const fallback = fallbackTarget(fqn);
+ resolutions.set(fqn, {
+ name: fqn,
+ status: 'ambiguous',
+ csharp: fallback,
+ typescript: fallback,
+ diagnostics: [
+ {
+ code: 'ambiguous-csharp',
+ severity: 'error',
+ message: `ApiReference: "${fqn}" resolves to multiple generated C# API members.`,
+ candidates: matches.map(describeCSharpCandidate).sort(),
+ },
+ ],
+ });
+ continue;
+ }
+
+ resolutions.set(fqn, packageResolutions.get(`${matches[0].packageName}\0${fqn}`)!);
+ }
+
+ const missingResolutions = new Map();
+ const overloadResolutions = new Map();
+
+ return {
+ size: packageResolutions.size,
+ resolve(
+ name: string,
+ packageName?: string,
+ parameterTypes?: readonly string[]
+ ): ApiReferenceResolution {
+ if (parameterTypes !== undefined) {
+ const cacheKey = JSON.stringify([name, packageName, parameterTypes]);
+ const cached = overloadResolutions.get(cacheKey);
+ if (cached) return cached;
+
+ const groups = [...(candidates.get(name)?.values() ?? [])].filter(
+ (candidate) => !packageName || candidate.packageName === packageName
+ );
+ const matches = groups.flatMap((candidate) =>
+ candidate.members
+ .filter(
+ (member) =>
+ isCallable(member.kind) &&
+ (member.parameters ?? []).length === parameterTypes.length &&
+ (member.parameters ?? []).every(
+ (parameter, index) => parameter.type === parameterTypes[index]
+ )
+ )
+ .map((member) => ({ ...candidate, members: [member] }))
+ );
+ let resolution: ApiReferenceResolution;
+ if (matches.length === 1) {
+ const candidate = matches[0];
+ const csharp = createCSharpTarget(candidate, true);
+ const typescript = resolveTypescriptTarget(candidate, csharp, tsIndex, true);
+ resolution = {
+ name,
+ status: 'resolved',
+ csharp,
+ typescript: typescript.target,
+ diagnostics: typescript.diagnostics,
+ };
+ } else {
+ const fallback = fallbackTarget(name);
+ resolution = {
+ name,
+ status: matches.length > 1 ? 'ambiguous' : 'missing',
+ csharp: fallback,
+ typescript: fallback,
+ diagnostics: [
+ {
+ code: matches.length > 1 ? 'ambiguous-overload' : 'missing-overload',
+ severity: 'error',
+ message: `ApiReference: "${name}" with parameter types ${JSON.stringify(parameterTypes)} ${matches.length > 1 ? 'matches multiple overloads' : 'does not match a generated overload'}. Use the complete declared C# parameter types, including the extension receiver, and qualify the package if needed.`,
+ candidates: (matches.length > 1 ? matches : groups)
+ .map(describeCSharpCandidate)
+ .sort(),
+ },
+ ],
+ };
+ }
+ overloadResolutions.set(cacheKey, resolution);
+ return resolution;
+ }
+ const resolved = packageName
+ ? packageResolutions.get(`${packageName}\0${name}`)
+ : resolutions.get(name);
+ if (resolved) return resolved;
+
+ const cacheKey = `${packageName ?? ''}\0${name}`;
+ const cached = missingResolutions.get(cacheKey);
+ if (cached) return cached;
+
+ const validFqn = API_REFERENCE_FQN_PATTERN.test(name);
+ const fallback = fallbackTarget(name);
+ const packageCandidates = candidates.get(name);
+ const candidatesForDiagnostic = packageCandidates
+ ? [...packageCandidates.values()].map(describeCSharpCandidate).sort()
+ : validFqn
+ ? buildSuggestions(name, fqnsByMemberName)
+ : [];
+ const missing: ApiReferenceResolution = {
+ name,
+ status: 'missing',
+ csharp: fallback,
+ typescript: fallback,
+ diagnostics: [
+ {
+ code: validFqn ? 'missing-csharp' : 'invalid-fqn',
+ severity: 'error',
+ message:
+ validFqn && packageName
+ ? `ApiReference: could not resolve "${name}" in package "${packageName}".`
+ : validFqn
+ ? `ApiReference: could not resolve "${name}" to a generated C# API member.`
+ : `ApiReference: "${name}" is not a canonical fully qualified API member name.`,
+ candidates: candidatesForDiagnostic,
+ },
+ ],
+ };
+ missingResolutions.set(cacheKey, missing);
+ return missing;
+ },
+ };
+}
diff --git a/src/frontend/src/utils/api-reference-validator.ts b/src/frontend/src/utils/api-reference-validator.ts
new file mode 100644
index 000000000..240d68f87
--- /dev/null
+++ b/src/frontend/src/utils/api-reference-validator.ts
@@ -0,0 +1,351 @@
+import { createProcessor } from '@mdx-js/mdx';
+import type { ApiReferenceDiagnostic, ApiReferenceIndex } from './api-reference-core';
+
+export interface ApiReferenceSourceFile {
+ path: string;
+ content: string;
+}
+
+export interface ApiReferenceUsageDiagnostic extends ApiReferenceDiagnostic {
+ filePath: string;
+ line: number;
+ name?: string;
+ packageName?: string;
+}
+
+interface SyntaxNode {
+ type: string;
+ name?: string | null;
+ attributes?: unknown[];
+ children?: unknown[];
+ value?: unknown;
+ data?: {
+ estree?: unknown;
+ };
+ position?: {
+ start?: {
+ line?: number;
+ };
+ };
+}
+
+interface ApiReferenceSyntax {
+ line: number;
+ attributes: SourceAttribute[];
+}
+
+interface SourceAttribute {
+ name?: string;
+ value?: string | string[];
+ spread: boolean;
+}
+
+interface ResolvedAttribute {
+ source: 'absent' | 'explicit' | 'spread';
+ value?: string | string[];
+}
+
+const mdxProcessor = createProcessor({ format: 'mdx' });
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null;
+}
+
+function isUnknownArray(value: unknown): value is unknown[] {
+ return Array.isArray(value);
+}
+
+function isSyntaxNode(value: unknown): value is SyntaxNode {
+ return isRecord(value) && typeof value.type === 'string';
+}
+
+function readStaticExpression(value: unknown): string | string[] | undefined {
+ if (!isRecord(value)) return undefined;
+ if (value.type === 'Literal' && typeof value.value === 'string') {
+ return value.value;
+ }
+ if (value.type === 'ParenthesizedExpression') {
+ return readStaticExpression(value.expression);
+ }
+ if (value.type === 'ArrayExpression' && isUnknownArray(value.elements)) {
+ const elements = value.elements.map(readStaticExpression);
+ return elements.every((element): element is string => typeof element === 'string')
+ ? elements
+ : undefined;
+ }
+ if (
+ value.type === 'TemplateLiteral' &&
+ isUnknownArray(value.expressions) &&
+ value.expressions.length === 0 &&
+ isUnknownArray(value.quasis) &&
+ value.quasis.length === 1
+ ) {
+ const quasi = value.quasis[0];
+ if (!isRecord(quasi) || !isRecord(quasi.value)) return undefined;
+ const cooked = quasi.value.cooked;
+ return typeof cooked === 'string' ? cooked : undefined;
+ }
+ return undefined;
+}
+
+function readStaticProgram(value: unknown): string | string[] | undefined {
+ if (!isRecord(value) || value.type !== 'Program' || !isUnknownArray(value.body)) {
+ return undefined;
+ }
+ if (value.body.length !== 1 || !isRecord(value.body[0])) return undefined;
+ const statement = value.body[0];
+ return statement.type === 'ExpressionStatement'
+ ? readStaticExpression(statement.expression)
+ : undefined;
+}
+
+function readMdxAttribute(value: unknown): SourceAttribute | undefined {
+ if (!isRecord(value)) return undefined;
+ if (value.type === 'mdxJsxExpressionAttribute') {
+ return { spread: true };
+ }
+ if (value.type !== 'mdxJsxAttribute' || typeof value.name !== 'string') {
+ return undefined;
+ }
+
+ if (typeof value.value === 'string') {
+ return { name: value.name, value: value.value, spread: false };
+ }
+
+ const expressionValue = isRecord(value.value) ? value.value : undefined;
+ const data = expressionValue && isRecord(expressionValue.data) ? expressionValue.data : undefined;
+ return {
+ name: value.name,
+ value: readStaticProgram(data?.estree),
+ spread: false,
+ };
+}
+
+function readJsxAttribute(value: unknown): SourceAttribute | undefined {
+ if (!isRecord(value)) return undefined;
+ if (value.type === 'JSXSpreadAttribute') {
+ return { spread: true };
+ }
+ if (value.type !== 'JSXAttribute' || !isRecord(value.name)) {
+ return undefined;
+ }
+
+ const name = value.name.type === 'JSXIdentifier' ? value.name.name : undefined;
+ if (typeof name !== 'string') return undefined;
+
+ if (isRecord(value.value) && value.value.type === 'Literal') {
+ return {
+ name,
+ value: typeof value.value.value === 'string' ? value.value.value : undefined,
+ spread: false,
+ };
+ }
+
+ const expression =
+ isRecord(value.value) && value.value.type === 'JSXExpressionContainer'
+ ? value.value.expression
+ : undefined;
+ return {
+ name,
+ value: readStaticExpression(expression),
+ spread: false,
+ };
+}
+
+function resolveAttribute(attributes: readonly SourceAttribute[], name: string): ResolvedAttribute {
+ for (let index = attributes.length - 1; index >= 0; index--) {
+ const attribute = attributes[index];
+ if (attribute.spread) return { source: 'spread' };
+ if (attribute.name === name) return { source: 'explicit', value: attribute.value };
+ }
+ return { source: 'absent' };
+}
+
+function findApiReferenceNodes(content: string): ApiReferenceSyntax[] {
+ const root = mdxProcessor.parse(content);
+ const matches: ApiReferenceSyntax[] = [];
+ const seenEstreeNodes = new WeakSet