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} + + ) + } + { + typescriptHref ? ( + + + + + ) : ( + + {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(); + + const visitEstree = (value: unknown): void => { + if (isUnknownArray(value)) { + for (const item of value) visitEstree(item); + return; + } + if (!isRecord(value)) return; + if (seenEstreeNodes.has(value)) return; + seenEstreeNodes.add(value); + + if (value.type === 'JSXElement' && isRecord(value.openingElement)) { + const openingElement = value.openingElement; + const elementName = isRecord(openingElement.name) ? openingElement.name : undefined; + if (elementName?.type === 'JSXIdentifier' && elementName.name === 'ApiReference') { + const loc = isRecord(value.loc) && isRecord(value.loc.start) ? value.loc.start : undefined; + matches.push({ + line: typeof loc?.line === 'number' ? loc.line : 1, + attributes: isUnknownArray(openingElement.attributes) + ? openingElement.attributes.flatMap((attribute) => { + const parsed = readJsxAttribute(attribute); + return parsed ? [parsed] : []; + }) + : [], + }); + } + } + + for (const child of Object.values(value)) { + visitEstree(child); + } + }; + + const visit = (value: unknown): void => { + if (!isSyntaxNode(value)) return; + if ( + (value.type === 'mdxJsxFlowElement' || value.type === 'mdxJsxTextElement') && + value.name === 'ApiReference' + ) { + matches.push({ + line: value.position?.start?.line ?? 1, + attributes: (value.attributes ?? []).flatMap((attribute) => { + const parsed = readMdxAttribute(attribute); + return parsed ? [parsed] : []; + }), + }); + } + visitEstree(value.data?.estree); + for (const attribute of value.attributes ?? []) { + visit(attribute); + } + visit(value.value); + for (const child of value.children ?? []) { + visit(child); + } + }; + + visit(root); + return matches; +} + +export function validateApiReferenceSource( + file: ApiReferenceSourceFile, + index: ApiReferenceIndex +): ApiReferenceUsageDiagnostic[] { + const diagnostics: ApiReferenceUsageDiagnostic[] = []; + + for (const node of findApiReferenceNodes(file.content)) { + const nameAttribute = resolveAttribute(node.attributes, 'name'); + const name = nameAttribute.value; + const packageAttribute = resolveAttribute(node.attributes, 'package'); + const packageName = packageAttribute.value; + const parameterTypesAttribute = resolveAttribute(node.attributes, 'parameterTypes'); + const spreadProps = [ + { name: 'name', attribute: nameAttribute }, + { name: 'package', attribute: packageAttribute }, + { name: 'parameterTypes', attribute: parameterTypesAttribute }, + ].filter(({ attribute }) => attribute.source === 'spread'); + + if (spreadProps.length > 0) { + diagnostics.push({ + filePath: file.path, + line: node.line, + name: typeof name === 'string' ? name : undefined, + code: 'unsupported-spread', + severity: 'error', + message: `ApiReference: spread props cannot be statically validated for ${spreadProps.map(({ name }) => name).join(', ')}. Remove the spread or specify these props explicitly after it.`, + candidates: [], + }); + continue; + } + + if (typeof name !== 'string' || !name) { + diagnostics.push({ + filePath: file.path, + line: node.line, + code: 'invalid-fqn', + severity: 'error', + message: + 'ApiReference: the name prop must be a static, canonical fully qualified API member name.', + candidates: [], + }); + continue; + } + + if (packageAttribute.source === 'explicit' && (typeof packageName !== 'string' || !packageName)) { + diagnostics.push({ + filePath: file.path, + line: node.line, + name, + code: 'invalid-fqn', + severity: 'error', + message: 'ApiReference: the package prop must be a static package name.', + candidates: [], + }); + continue; + } + + const parameterTypes = parameterTypesAttribute.value; + if ( + parameterTypesAttribute.source === 'explicit' && + (!Array.isArray(parameterTypes) || parameterTypes.some((type) => !type.trim())) + ) { + diagnostics.push({ + filePath: file.path, + line: node.line, + name, + code: 'invalid-overload', + severity: 'error', + message: + 'ApiReference: parameterTypes must be a static array of nonempty C# parameter type strings.', + candidates: [], + }); + continue; + } + + const resolvedPackage = typeof packageName === 'string' ? packageName : undefined; + const resolution = index.resolve( + name, + resolvedPackage, + Array.isArray(parameterTypes) ? parameterTypes : undefined + ); + diagnostics.push( + ...resolution.diagnostics.map((diagnostic) => ({ + ...diagnostic, + filePath: file.path, + line: node.line, + name, + packageName: resolvedPackage, + })) + ); + } + + return diagnostics; +} + +export function validateApiReferenceFiles( + files: readonly ApiReferenceSourceFile[], + index: ApiReferenceIndex +): ApiReferenceUsageDiagnostic[] { + return files + .flatMap((file) => validateApiReferenceSource(file, index)) + .sort( + (left, right) => + left.filePath.localeCompare(right.filePath) || + left.line - right.line || + left.severity.localeCompare(right.severity) + ); +} + +export function formatApiReferenceDiagnostics( + diagnostics: readonly ApiReferenceUsageDiagnostic[] +): string { + return diagnostics + .map((diagnostic) => { + const candidates = + diagnostic.candidates.length > 0 + ? `\n Candidates:\n${diagnostic.candidates + .map((candidate) => ` - ${candidate}`) + .join('\n')}` + : ''; + return `${diagnostic.filePath}:${diagnostic.line} [${diagnostic.severity}] ${diagnostic.message}${candidates}`; + }) + .join('\n'); +} diff --git a/src/frontend/src/utils/api-reference.ts b/src/frontend/src/utils/api-reference.ts new file mode 100644 index 000000000..39dc97319 --- /dev/null +++ b/src/frontend/src/utils/api-reference.ts @@ -0,0 +1,55 @@ +import { + buildApiReferenceIndex, + type ApiReferenceIndex, + type ApiReferenceResolution, +} from './api-reference-core'; +import { getPackages } from './packages'; +import { getTsModules } from './ts-modules'; + +let productionIndexPromise: Promise | undefined; +const requestIndexPromises = new WeakMap>(); + +async function createApiReferenceIndex(): Promise { + const [packages, modules] = await Promise.all([getPackages(), getTsModules()]); + return buildApiReferenceIndex( + packages.map((entry) => entry.data), + modules.map((entry) => entry.data) + ); +} + +/** + * Reuse one index for an entire production build. In development, cache per + * page request so repeated references are O(1) while content edits appear on + * the next request without restarting the dev server. + */ +export function getApiReferenceIndex(requestScope?: object): Promise { + if (import.meta.env.PROD || !requestScope) { + productionIndexPromise ??= createApiReferenceIndex(); + return productionIndexPromise; + } + + let indexPromise = requestIndexPromises.get(requestScope); + if (!indexPromise) { + indexPromise = createApiReferenceIndex(); + requestIndexPromises.set(requestScope, indexPromise); + } + return indexPromise; +} + +export async function resolveApiReference( + name: string, + packageName?: string, + requestScope?: object, + parameterTypes?: readonly string[] +): Promise { + const index = await getApiReferenceIndex(requestScope); + return index.resolve(name, packageName, parameterTypes); +} + +export type { + ApiReferenceDiagnostic, + ApiReferenceDiagnosticCode, + ApiReferenceIndex, + ApiReferenceResolution, + ApiReferenceTarget, +} from './api-reference-core'; diff --git a/src/frontend/tests/e2e/custom-components.spec.ts b/src/frontend/tests/e2e/custom-components.spec.ts index 195d405af..3ba2ab302 100644 --- a/src/frontend/tests/e2e/custom-components.spec.ts +++ b/src/frontend/tests/e2e/custom-components.spec.ts @@ -469,3 +469,74 @@ test('samples grid hydrates filters from the URL and syncs them back on change', await expect(clearAll).toBeHidden(); await expect.poll(() => page.url()).not.toMatch(/[?&](q|tags)=/); }); + +test('ApiReference renders as valid phrasing content and is never reparented out of its containing paragraph or list item', async ({ + page, +}) => { + // Regression test: ApiReference used to render Starlight's `` + // component, which emits block-level Expressive Code markup + // (`
...`). This page embeds ApiReference both inside a
+  // literal `

` (the Python/Node.js pivot panels) and inside a Markdown + // list item ("Best practices"), which is exactly the phrasing-content + // position that triggered the bug: the browser's HTML parser can't nest + // block content inside a `

`, so it implicitly closes the paragraph and + // promotes the block markup out as a sibling — leaving the `.api-reference` + // element empty in place and a detached code block (with its own copy + // button) floating elsewhere on the page. + await page.goto('/get-started/app-host/'); + await dismissCookieConsentIfVisible(page); + + const references = page.locator('.api-reference'); + const count = await references.count(); + expect(count).toBeGreaterThan(0); + + for (const reference of await references.all()) { + // The symptom of the reparenting bug: an ApiReference instance left + // empty at its authored position because its only content got hoisted + // out by the parser. + await expect(reference).not.toBeEmpty(); + + // Still a real descendant of the paragraph/list item it was authored + // inside, not promoted elsewhere in the tree by implied tag closing. + const containingParent = await reference.evaluate( + (element) => element.closest('p, li')?.tagName.toLowerCase() ?? null + ); + expect(containingParent).toMatch(/^(p|li)$/); + + // A correctly rendered ApiReference is just ``, so a copy + // affordance anywhere inside one means a code frame crept back in. + await expect(reference.locator('.copy, .copy button, button.copy')).toHaveCount(0); + } + + // No ApiReference instance should ever emit block-level code markup — + // that's the root cause a browser has to reparent out of a `

`/`

  • ` + // in the first place. + await expect( + page.locator('.api-reference pre, .api-reference figure, .api-reference .expressive-code') + ).toHaveCount(0); + + // A hoisted frame lands as a *sibling* of the paragraph, out of reach of the + // `.api-reference`-scoped selectors above, so also check the whole page for a + // copy button belonging to one of these references. Expressive Code + // SSR-attaches the copied text to the button as `data-code` (see + // integrations-gallery.spec.ts), and matching it exactly keeps the page's real + // code samples - whose `data-code` is a full statement that may well mention + // the same API - from tripping this. Labels are read with `evaluateAll` rather + // than `allInnerTexts` so the chips hidden by the language pivot still count. + const referenceLabels = await page + .locator('.api-reference .ar-lang code') + .evaluateAll((elements) => + elements.flatMap((element) => { + const label = element.textContent?.trim() ?? ''; + return label ? [label, label.replace(/\(\)$/, '')] : []; + }) + ); + expect(referenceLabels.length).toBeGreaterThan(0); + + for (const label of new Set(referenceLabels)) { + const selector = JSON.stringify(label); + await expect( + page.locator(`.copy button[data-code=${selector}], button.copy[data-code=${selector}]`) + ).toHaveCount(0); + } +}); diff --git a/src/frontend/tests/e2e/pivot-selector.spec.ts b/src/frontend/tests/e2e/pivot-selector.spec.ts index e67dada5e..c6fe470af 100644 --- a/src/frontend/tests/e2e/pivot-selector.spec.ts +++ b/src/frontend/tests/e2e/pivot-selector.spec.ts @@ -157,6 +157,99 @@ test('app host page restores pivot state from the lang query string', async ({ p await expect(nodeJsContent).toBeHidden(); }); +test('ApiReference chips follow the language selection through every entry point', async ({ + page, +}) => { + // ApiReference renders both languages and lets CSS pick one from + // `` (see ApiReference.astro), so every writer of + // that state has to keep the chips in step: the tab strip by pointer and by + // keyboard, the PivotSelector, the `?aspire-lang=` query string, and the + // persisted preference. The references on both pages below sit in prose, + // outside any tab panel or pivot block, so they stay in the DOM no matter + // which panel is showing. + const reference = page.locator('.api-reference').first(); + const csharpChip = reference.locator('[data-lang="csharp"]'); + const typeScriptChip = reference.locator('[data-lang="typescript"]'); + + // Query string initialization. + await page.goto('/get-started/resource-mcp-servers/?aspire-lang=csharp'); + await dismissCookieConsentIfVisible(page); + + await expect(csharpChip).toBeVisible(); + await expect(csharpChip).toHaveText('WithMcpServer'); + await expect(typeScriptChip).toBeHidden(); + await expect(typeScriptChip).toHaveText('withMcpServer'); + await expect(csharpChip.locator('code > .ar-icon')).toBeVisible(); + await expect(csharpChip).toHaveAccessibleName('WithMcpServer — C# API reference'); + await expect(csharpChip).toHaveAttribute('data-tooltip-placement', 'top'); + const normalBackground = await csharpChip.locator('code').evaluate( + (element) => getComputedStyle(element).backgroundColor + ); + await csharpChip.hover(); + await expect(page.getByRole('tooltip')).toBeVisible(); + await expect(page.getByRole('tooltip')).not.toBeEmpty(); + await expect(csharpChip.locator('code')).not.toHaveCSS('background-color', normalBackground); + await expect(csharpChip.locator('code')).toHaveCSS('border-style', 'none'); + await page.mouse.move(0, 0); + + // Pointer: clicking the tab strip. + const appHostTabs = page.locator('starlight-tabs[data-sync-key="aspire-lang"]').first(); + await appHostTabs.getByRole('tab', { name: 'TypeScript' }).click(); + + await expect(typeScriptChip).toBeVisible(); + await expect(csharpChip).toBeHidden(); + await expect(typeScriptChip.locator('code > .ar-icon')).toBeVisible(); + await expect(typeScriptChip).toHaveAccessibleName('withMcpServer — TypeScript API reference'); + const iconCenterOffset = await typeScriptChip.evaluate((element) => { + const code = element.querySelector('code')!.getBoundingClientRect(); + const icon = element.querySelector('.ar-icon')!.getBoundingClientRect(); + return Math.abs(icon.y + icon.height / 2 - (code.y + code.height / 2)); + }); + expect(iconCenterOffset).toBeLessThan(1); + for (const language of ['csharp', 'typescript']) { + const referenceIcon = reference.locator(`[data-lang="${language}"] .ar-icon`); + const headerIcon = page.locator(`.code-block-icon[data-language="${language}"]`).first(); + const headerBackground = await headerIcon.evaluate( + (element) => getComputedStyle(element).backgroundImage + ); + expect(headerBackground).not.toBe('none'); + await expect(referenceIcon).toHaveCSS('background-image', headerBackground); + } + + // Keyboard: arrowing along the same tab strip. + await appHostTabs.locator('[role="tab"][aria-selected="true"]').focus(); + await page.keyboard.press('ArrowRight'); + + await expect(appHostTabs.getByRole('tab', { name: 'C#' })).toHaveAttribute( + 'aria-selected', + 'true' + ); + await expect(csharpChip).toBeVisible(); + await expect(typeScriptChip).toBeHidden(); + + // Persisted initialization: the C# choice above was stored, so a fresh load + // with no query string has to restore it before paint. + await page.goto('/get-started/resource-mcp-servers/'); + + await expect(csharpChip).toBeVisible(); + await expect(typeScriptChip).toBeHidden(); + + // PivotSelector. Regression: the pivot wrote the storage keys and the query + // string but never ``, so the chips stayed on the + // old language until the page was reloaded. + await page.goto('/get-started/first-app/?aspire-lang=typescript'); + + await expect(typeScriptChip).toBeVisible(); + await expect(typeScriptChip).toHaveText('createBuilder'); + + await page.locator('#pivot-selector-aspire-lang').getByRole('button', { name: 'C#' }).click(); + + await expect(page).toHaveURL(/\?aspire-lang=csharp$/); + await expect(csharpChip).toBeVisible(); + await expect(csharpChip).toHaveText('CreateBuilder'); + await expect(typeScriptChip).toBeHidden(); +}); + test('first-app pivots default to TypeScript and preserve history and shared preferences', async ({ page, }) => { @@ -253,6 +346,78 @@ test('floating pivot controls clear the TOC across its responsive breakpoint', a }); for (const transition of ['native', 'fallback'] as const) { + test(`ApiReference links and tooltips survive navigation and history with ${transition} swaps`, async ({ + page, isMobile, + }) => { + test.skip(isMobile && transition === 'native', + 'Chromium touch emulation aborts native transitions; touch projects cover the swap fallback.'); + test.setTimeout(120_000); + const errors: string[] = []; + page.on('pageerror', (error) => errors.push(error.message)); + if (transition === 'fallback') { + await page.addInitScript(() => { + Object.defineProperty(document, 'startViewTransition', { value: undefined }); + }); + } + await page.goto('/docs/'); + await dismissCookieConsentIfVisible(page); + const marker = await page.evaluate(() => { + const value = crypto.randomUUID(); + Reflect.set(window, '__apiReferenceSession', value); + return value; + }); + const navigate = async (action: () => Promise) => { + await page.evaluate(() => { + Reflect.set(window, '__apiReferenceLoaded', false); + document.addEventListener('astro:page-load', () => { + Reflect.set(window, '__apiReferenceLoaded', true); + }, { once: true }); + }); + await action(); + await expect.poll(() => page.evaluate(() => Reflect.get(window, '__apiReferenceLoaded'))).toBe(true); + await expect(page.locator('html[data-astro-transition]')).toHaveCount(0); + expect(await page.evaluate(() => Reflect.get(window, '__apiReferenceSession'))).toBe(marker); + }; + await navigate(() => page.locator('a[href="/get-started/first-app/"]:visible').first().click()); + const reference = page.locator('.api-reference').first(); + + for (const language of ['csharp', 'typescript'] as const) { + await page.evaluate(() => window.scrollTo({ top: 0, behavior: 'instant' })); + await page.locator('#pivot-selector-aspire-lang').getByRole('button', { + name: language === 'csharp' ? 'C#' : 'TypeScript', exact: true, + }).click(); + const chip = reference.locator(`a[data-lang="${language}"]`); + await expect(chip).toBeVisible(); + await expect(chip).toHaveAttribute('href', new RegExp(`/reference/api/${language}/`)); + const destination = new URL((await chip.getAttribute('href'))!, page.url()).href; + + await chip.focus(); + await expect(page.getByRole('tooltip')).toBeVisible(); + await expect(page.getByRole('tooltip')).not.toBeEmpty(); + await navigate(() => chip.press('Enter')); + await expect(page).toHaveURL(destination); + await expect(page.getByRole('tooltip')).toHaveCount(0); + + await navigate(() => page.goBack()); + await expect(page.locator('html')).toHaveAttribute('data-apphost-lang', language); + await expect(chip).toBeVisible(); + await chip.focus(); + await expect(page.getByRole('tooltip')).toBeVisible(); + await expect(page.getByRole('tooltip')).not.toBeEmpty(); + await chip.press('Escape'); + await expect(page.getByRole('tooltip')).toHaveCount(0); + await chip.blur(); + + await navigate(() => page.goForward()); + await expect(page).toHaveURL(destination); + await expect(page.getByRole('tooltip')).toHaveCount(0); + await navigate(() => page.goBack()); + await expect(page.locator('html')).toHaveAttribute('data-apphost-lang', language); + await expect(chip).toBeVisible(); + } + expect(errors).toEqual([]); + }); + test(`pivot lifecycle survives repeated visits and history with ${transition} swaps`, async ({ page, isMobile, }) => { diff --git a/src/frontend/tests/typecheck/component-props.contracts.ts b/src/frontend/tests/typecheck/component-props.contracts.ts index e884e4026..ffeba3de0 100644 --- a/src/frontend/tests/typecheck/component-props.contracts.ts +++ b/src/frontend/tests/typecheck/component-props.contracts.ts @@ -1,6 +1,7 @@ import type { ComponentProps } from 'astro/types'; import heroImage from '@assets/aspire-hero.png'; +import ApiReference from '@components/ApiReference.astro'; import AsciinemaPlayer from '@components/AsciinemaPlayer.astro'; import Breadcrumb from '@components/Breadcrumb.astro'; import CTABanner from '@components/CTABanner.astro'; @@ -196,6 +197,28 @@ const integrationsFixture = [ const availableDocs = [{ match: 'Higher Priority Package', href: '/integrations/higher/docs/' }]; +const validApiReferenceProps = { + name: 'Aspire.Hosting.JavaScriptHostingExtensions.WithNpm', + package: 'Aspire.Hosting.JavaScript', +} satisfies PropsOf; +const validUnqualifiedApiReferenceProps = { + name: 'Aspire.Hosting.PostgresBuilderExtensions.AddPostgres', +} satisfies PropsOf; +const validOverloadApiReferenceProps = { + name: 'Aspire.Hosting.ResourceBuilderExtensions.WithEnvironment', + parameterTypes: ['Aspire.Hosting.ApplicationModel.IResourceBuilder', 'string', 'string?'], +} satisfies PropsOf; +// @ts-expect-error ApiReference parameterTypes must be an array of strings. +const invalidOverloadApiReferenceProps: PropsOf = { + name: 'Aspire.Hosting.ResourceBuilderExtensions.WithEnvironment', + parameterTypes: [42], +}; +// @ts-expect-error ApiReference package must be a string. +const invalidApiReferenceProps: PropsOf = { + name: 'Aspire.Hosting.JavaScriptHostingExtensions.WithNpm', + package: 42, +}; + const validAsciinemaPlayerProps = { src: '/casts/aspire-help.cast', rows: 18, @@ -763,6 +786,11 @@ const invalidYouTubeGridProps: PropsOf = { }; void [ + validApiReferenceProps, + validUnqualifiedApiReferenceProps, + validOverloadApiReferenceProps, + invalidOverloadApiReferenceProps, + invalidApiReferenceProps, validAsciinemaPlayerProps, invalidAsciinemaPlayerProps, validBreadcrumbProps, diff --git a/src/frontend/tests/unit/api-reference.vitest.test.ts b/src/frontend/tests/unit/api-reference.vitest.test.ts new file mode 100644 index 000000000..2138de733 --- /dev/null +++ b/src/frontend/tests/unit/api-reference.vitest.test.ts @@ -0,0 +1,1315 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const apiReferenceMocks = vi.hoisted(() => ({ + resolve: vi.fn(), +})); + +vi.mock('@utils/api-reference', () => ({ + resolveApiReference: apiReferenceMocks.resolve, +})); + +import ApiReference from '@components/ApiReference.astro'; +import { + buildApiReferenceIndex, + type ApiReferencePackageDocument, + type ApiReferenceResolution, + type ApiReferenceTsDocument, +} from '@utils/api-reference-core'; +import { + formatApiReferenceDiagnostics, + validateApiReferenceFiles, + validateApiReferenceSource, +} from '@utils/api-reference-validator'; +import { normalizeHtml, renderComponent } from './astro-test-utils'; +import { resolveMemberAnchors } from '@utils/api-member-anchors'; + +const EXPORT_ATTRIBUTE = 'Aspire.Hosting.AspireExportAttribute'; +const EXPORT_IGNORE_ATTRIBUTE = 'Aspire.Hosting.AspireExportIgnoreAttribute'; + +function packageDocument( + packageName: string, + types: ApiReferencePackageDocument['types'] +): ApiReferencePackageDocument { + return { + package: { name: packageName }, + types, + }; +} + +function tsDocument( + packageName: string, + functions: ApiReferenceTsDocument['functions'] = [] +): ApiReferenceTsDocument { + return { + package: { name: packageName }, + functions, + handleTypes: [], + dtoTypes: [], + enumTypes: [], + }; +} + +const widgetPackage = packageDocument('Aspire.Hosting.Widget', [ + { + name: 'WidgetBuilderExtensions', + fullName: 'Aspire.Hosting.WidgetBuilderExtensions', + members: [ + { + name: 'AddWidget', + kind: 'method', + isStatic: true, + isExtension: true, + attributes: [{ name: EXPORT_ATTRIBUTE }], + }, + ], + }, +]); + +const widgetModule = tsDocument('Aspire.Hosting.Widget', [ + { + name: 'addWidget', + kind: 'Method', + capabilityId: 'Aspire.Hosting.Widget/addWidget', + qualifiedName: 'addWidget', + }, +]); + +describe('API reference index', () => { + it('resolves a canonical FQN through precomputed C# and TypeScript indexes', () => { + const index = buildApiReferenceIndex([widgetPackage], [widgetModule]); + const first = index.resolve('Aspire.Hosting.WidgetBuilderExtensions.AddWidget'); + const second = index.resolve('Aspire.Hosting.WidgetBuilderExtensions.AddWidget'); + + expect(first).toBe(second); + expect(first).toMatchObject({ + status: 'resolved', + csharp: { + label: 'AddWidget', + path: '/reference/api/csharp/aspire.hosting.widget/widgetbuilderextensions/methods/#addwidget', + }, + typescript: { + label: 'addWidget', + path: '/reference/api/typescript/aspire.hosting.widget/addwidget/', + }, + diagnostics: [], + }); + }); + + it('honors explicit AspireExport capability IDs and TypeScript method names', () => { + const index = buildApiReferenceIndex( + [ + packageDocument('Aspire.Hosting', [ + { + name: 'ResourceBuilderExtensions', + fullName: 'Aspire.Hosting.ResourceBuilderExtensions', + members: [ + { + name: 'WaitForCompletion', + kind: 'method', + isStatic: true, + isExtension: true, + attributes: [ + { + name: EXPORT_ATTRIBUTE, + constructorArguments: ['waitForResourceCompletion'], + arguments: { MethodName: 'waitForCompletion' }, + }, + ], + }, + ], + }, + ]), + ], + [ + tsDocument('Aspire.Hosting', [ + { + name: 'waitForCompletion', + kind: 'Method', + capabilityId: 'Aspire.Hosting/waitForResourceCompletion', + qualifiedName: 'waitForCompletion', + }, + ]), + ] + ); + + expect( + index.resolve('Aspire.Hosting.ResourceBuilderExtensions.WaitForCompletion').typescript + ).toEqual({ + label: 'waitForCompletion', + path: '/reference/api/typescript/aspire.hosting/waitforcompletion/', + }); + }); + + it('does not invent a TypeScript API when the C# member is not exported', () => { + const index = buildApiReferenceIndex( + [ + packageDocument('Aspire.Hosting', [ + { + name: 'IResourceBuilder', + fullName: 'Aspire.Hosting.ApplicationModel.IResourceBuilder', + genericParameters: [{ name: 'T' }], + members: [{ name: 'WithAnnotation', kind: 'method' }], + }, + ]), + ], + [] + ); + + const resolution = index.resolve( + 'Aspire.Hosting.ApplicationModel.IResourceBuilder.WithAnnotation' + ); + + expect(resolution.csharp).toEqual({ + label: 'WithAnnotation', + path: '/reference/api/csharp/aspire.hosting/iresourcebuilder-1/methods/#withannotation', + }); + expect(resolution.typescript).toEqual({ label: 'WithAnnotation' }); + expect(resolution.diagnostics).toMatchObject([ + { code: 'missing-typescript', severity: 'warning' }, + ]); + }); + + it('does not match an ignored member to another type by name alone', () => { + const index = buildApiReferenceIndex( + [ + packageDocument('Aspire.Hosting', [ + { + name: 'ExecuteCommandContext', + fullName: 'Aspire.Hosting.ApplicationModel.ExecuteCommandContext', + members: [ + { + name: 'ServiceProvider', + kind: 'property', + attributes: [ + { + name: EXPORT_IGNORE_ATTRIBUTE, + arguments: { + Reason: 'Obsolete alias for Services.', + }, + }, + ], + }, + ], + }, + ]), + ], + [ + { + package: { name: 'Aspire.Hosting' }, + handleTypes: [ + { + name: 'DistributedApplicationExecutionContext', + fullName: 'Aspire.Hosting.DistributedApplicationExecutionContext', + capabilities: [ + { + name: 'serviceProvider', + kind: 'PropertyGetter', + targetTypeId: + 'Aspire.Hosting/Aspire.Hosting.DistributedApplicationExecutionContext', + }, + ], + }, + ], + }, + ] + ); + + const resolution = index.resolve( + 'Aspire.Hosting.ApplicationModel.ExecuteCommandContext.ServiceProvider' + ); + + expect(resolution.typescript).toEqual({ label: 'ServiceProvider' }); + expect(resolution.diagnostics).toMatchObject([ + { code: 'missing-typescript', severity: 'warning' }, + ]); + }); + + it('matches extension exports to their receiver type', () => { + const index = buildApiReferenceIndex( + [ + packageDocument('Aspire.Hosting.Yarp', [ + { + name: 'YarpResourceExtensions', + fullName: 'Aspire.Hosting.YarpResourceExtensions', + members: [ + { + name: 'WithStaticFiles', + kind: 'method', + isStatic: true, + isExtension: true, + parameters: [ + { + name: 'builder', + type: 'Aspire.Hosting.ApplicationModel.IResourceBuilder', + modifier: 'this', + }, + ], + attributes: [ + { + name: EXPORT_IGNORE_ATTRIBUTE, + arguments: { + Reason: 'An internal export provides the polyglot API.', + }, + }, + ], + }, + ], + }, + ]), + ], + [ + tsDocument('Aspire.Hosting.Yarp', [ + { + name: 'withStaticFiles', + kind: 'Method', + capabilityId: 'Aspire.Hosting.Yarp/withStaticFiles', + qualifiedName: 'withStaticFiles', + targetTypeId: 'Aspire.Hosting.Yarp/Aspire.Hosting.Yarp.YarpResource', + }, + ]), + ] + ); + + expect( + index.resolve('Aspire.Hosting.YarpResourceExtensions.WithStaticFiles').typescript + ).toEqual({ + label: 'withStaticFiles', + path: '/reference/api/typescript/aspire.hosting.yarp/withstaticfiles/', + }); + }); + + it('reports ambiguity for extension families with different receiver routes', () => { + const ignoredExport = { + name: EXPORT_IGNORE_ATTRIBUTE, + arguments: { + Reason: 'An internal export provides the polyglot API.', + }, + }; + const index = buildApiReferenceIndex( + [ + packageDocument('Aspire.Hosting.Widget', [ + { + name: 'WidgetExtensions', + fullName: 'Aspire.Hosting.WidgetExtensions', + members: [ + { + name: 'WithFeature', + kind: 'method', + isStatic: true, + isExtension: true, + parameters: [ + { + name: 'builder', + type: 'Aspire.Hosting.ApplicationModel.IResourceBuilder', + modifier: 'this', + }, + ], + attributes: [ignoredExport], + }, + { + name: 'WithFeature', + kind: 'method', + isStatic: true, + isExtension: true, + parameters: [ + { + name: 'builder', + type: 'Aspire.Hosting.ApplicationModel.IResourceBuilder', + modifier: 'this', + }, + ], + attributes: [ignoredExport], + }, + ], + }, + ]), + ], + [ + tsDocument('Aspire.Hosting.Widget', [ + { + name: 'withFeature', + kind: 'Method', + targetTypeId: 'Aspire.Hosting.Widget/Aspire.Hosting.FirstResource', + }, + { + name: 'withFeature', + kind: 'Method', + targetTypeId: 'Aspire.Hosting.Widget/Aspire.Hosting.SecondResource', + }, + ]), + ] + ); + + expect(index.resolve('Aspire.Hosting.WidgetExtensions.WithFeature').diagnostics).toMatchObject([ + { + code: 'ambiguous-typescript', + severity: 'error', + }, + ]); + }); + + it('resolves canonical dispatcher exports across TypeScript modules', () => { + const index = buildApiReferenceIndex( + [ + packageDocument('Aspire.Hosting.Azure', [ + { + name: 'AzureBicepResourceExtensions', + fullName: 'Aspire.Hosting.AzureBicepResourceExtensions', + members: [ + { + name: 'WithEnvironment', + kind: 'method', + isStatic: true, + isExtension: true, + genericParameters: [ + { + name: 'T', + constraints: ['Aspire.Hosting.ApplicationModel.IResourceWithEnvironment'], + }, + ], + parameters: [ + { + name: 'builder', + type: 'Aspire.Hosting.ApplicationModel.IResourceBuilder', + modifier: 'this', + }, + ], + attributes: [ + { + name: EXPORT_IGNORE_ATTRIBUTE, + arguments: { + Reason: + 'Polyglot AppHosts use the internal withEnvironment dispatcher export.', + }, + }, + ], + }, + ], + }, + ]), + ], + [ + tsDocument('Aspire.Hosting', [ + { + name: 'withEnvironment', + kind: 'Method', + capabilityId: 'Aspire.Hosting/withEnvironment', + qualifiedName: 'withEnvironment', + targetTypeId: 'Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithEnvironment', + }, + ]), + ] + ); + + expect( + index.resolve('Aspire.Hosting.AzureBicepResourceExtensions.WithEnvironment').typescript + ).toEqual({ + label: 'withEnvironment', + path: '/reference/api/typescript/aspire.hosting/withenvironment/', + }); + }); + + it('resolves type-level property exports and ignored dispatcher overloads', () => { + const index = buildApiReferenceIndex( + [ + packageDocument('Aspire.Hosting', [ + { + name: 'DistributedApplicationExecutionContext', + fullName: 'Aspire.Hosting.DistributedApplicationExecutionContext', + attributes: [ + { + name: EXPORT_ATTRIBUTE, + arguments: { ExposeProperties: 'True' }, + }, + ], + members: [{ name: 'IsPublishMode', kind: 'property' }], + }, + { + name: 'ResourceBuilderExtensions', + fullName: 'Aspire.Hosting.ResourceBuilderExtensions', + members: [ + { + name: 'WithReference', + kind: 'method', + isStatic: true, + isExtension: true, + attributes: [ + { + name: EXPORT_IGNORE_ATTRIBUTE, + arguments: { + Reason: 'Polyglot AppHosts use the internal withReference dispatcher export.', + }, + }, + ], + }, + { + name: 'WithReference', + kind: 'method', + isStatic: true, + isExtension: true, + attributes: [ + { + name: EXPORT_ATTRIBUTE, + constructorArguments: ['withReferenceCallback'], + }, + ], + }, + ], + }, + ]), + ], + [ + { + package: { name: 'Aspire.Hosting' }, + functions: [ + { + name: 'withReference', + kind: 'Method', + capabilityId: 'Aspire.Hosting/withReference', + qualifiedName: 'withReference', + targetTypeId: + 'Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithEnvironment', + }, + { + name: 'withReferenceCallback', + kind: 'Method', + capabilityId: 'Aspire.Hosting/withReferenceCallback', + qualifiedName: 'withReferenceCallback', + targetTypeId: + 'Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithEnvironment', + }, + ], + handleTypes: [ + { + name: 'DistributedApplicationExecutionContext', + fullName: 'Aspire.Hosting.DistributedApplicationExecutionContext', + capabilities: [ + { + name: 'isPublishMode', + kind: 'PropertyGetter', + capabilityId: + 'Aspire.Hosting/DistributedApplicationExecutionContext.isPublishMode', + qualifiedName: 'DistributedApplicationExecutionContext.isPublishMode', + targetTypeId: + 'Aspire.Hosting/Aspire.Hosting.DistributedApplicationExecutionContext', + }, + ], + }, + ], + }, + ] + ); + + expect( + index.resolve('Aspire.Hosting.DistributedApplicationExecutionContext.IsPublishMode') + .typescript + ).toEqual({ + label: 'isPublishMode', + path: '/reference/api/typescript/aspire.hosting/distributedapplicationexecutioncontext/#ispublishmode', + }); + expect( + index.resolve('Aspire.Hosting.ResourceBuilderExtensions.WithReference').typescript + ).toEqual({ + label: 'withReference', + path: '/reference/api/typescript/aspire.hosting/withreference/', + }); + }); + + it('requires a package qualifier for ambiguous C# identities', () => { + const duplicateType = { + name: 'SharedExtensions', + fullName: 'Aspire.Hosting.SharedExtensions', + members: [{ name: 'WithShared', kind: 'method' }], + }; + const index = buildApiReferenceIndex( + [ + packageDocument('Aspire.Hosting.One', [duplicateType]), + packageDocument('Aspire.Hosting.Two', [duplicateType]), + ], + [] + ); + + const resolution = index.resolve('Aspire.Hosting.SharedExtensions.WithShared'); + + expect(resolution.status).toBe('ambiguous'); + expect(resolution.diagnostics[0]).toMatchObject({ + code: 'ambiguous-csharp', + severity: 'error', + }); + expect(resolution.diagnostics[0].candidates).toHaveLength(2); + expect( + index.resolve('Aspire.Hosting.SharedExtensions.WithShared', 'Aspire.Hosting.One') + ).toMatchObject({ + status: 'resolved', + csharp: { + path: '/reference/api/csharp/aspire.hosting.one/sharedextensions/methods/#withshared', + }, + }); + }); + + it('preserves ambiguity between generic arities within one package', () => { + const index = buildApiReferenceIndex( + [ + packageDocument('Aspire.Hosting', [ + { + name: 'IResourceWithParent', + fullName: 'Aspire.Hosting.ApplicationModel.IResourceWithParent', + members: [{ name: 'Parent', kind: 'property' }], + }, + { + name: 'IResourceWithParent', + fullName: 'Aspire.Hosting.ApplicationModel.IResourceWithParent', + genericParameters: [{ name: 'T' }], + members: [{ name: 'Parent', kind: 'property' }], + }, + ]), + ], + [] + ); + + const resolution = index.resolve( + 'Aspire.Hosting.ApplicationModel.IResourceWithParent.Parent', + 'Aspire.Hosting' + ); + + expect(resolution.status).toBe('ambiguous'); + expect(resolution.diagnostics).toMatchObject([ + { + code: 'ambiguous-csharp', + severity: 'error', + }, + ]); + expect(resolution.diagnostics[0].candidates).toHaveLength(2); + }); + + it('reports ambiguous TypeScript routes instead of selecting the first overload', () => { + const index = buildApiReferenceIndex( + [widgetPackage], + [ + tsDocument('Aspire.Hosting.Widget', [ + { + name: 'addWidget', + kind: 'Method', + capabilityId: 'Aspire.Hosting.Widget/addWidget', + qualifiedName: 'addWidget', + targetTypeId: 'Aspire.Hosting.Widget/FirstBuilder', + }, + { + name: 'addWidget', + kind: 'Method', + capabilityId: 'Aspire.Hosting.Widget/addWidget', + qualifiedName: 'addWidget', + targetTypeId: 'Aspire.Hosting.Widget/SecondBuilder', + }, + ]), + ] + ); + + expect( + index.resolve('Aspire.Hosting.WidgetBuilderExtensions.AddWidget').diagnostics + ).toMatchObject([{ code: 'ambiguous-typescript', severity: 'error' }]); + }); + + it('suggests generated candidates for an unresolved FQN', () => { + const index = buildApiReferenceIndex([widgetPackage], [widgetModule]); + const resolution = index.resolve('Aspire.Hosting.OtherExtensions.AddWidget'); + + expect(resolution.status).toBe('missing'); + expect(resolution.diagnostics[0]).toMatchObject({ + code: 'missing-csharp', + candidates: ['Aspire.Hosting.WidgetBuilderExtensions.AddWidget'], + }); + }); +}); + +describe('API reference overloads', () => { + const name = 'Aspire.Hosting.WidgetBuilderExtensions.AddWidget'; + const receiver = { + name: 'builder', + type: 'Aspire.Hosting.IDistributedApplicationBuilder', + modifier: 'this', + }; + const members = ['One.Options', 'Two.Options'].map((type, index) => ({ + name: 'AddWidget', + kind: 'method', + isStatic: true, + isExtension: true, + parameters: [receiver, { name: 'options', type }], + docs: { + summary: [ + { + kind: 'para', + children: [ + { kind: 'text', text: ' Uses ' }, + { kind: 'code', text: `Options${index}` }, + { kind: 'text', text: '.' }, + ], + }, + ], + }, + attributes: [ + { + name: EXPORT_ATTRIBUTE, + constructorArguments: [`addWidget${index}`], + arguments: { MethodName: `addWidget${index}` }, + }, + ], + })); + const pkg = packageDocument('Aspire.Hosting.Widget', [ + { + name: 'WidgetBuilderExtensions', + fullName: 'Aspire.Hosting.WidgetBuilderExtensions', + members, + }, + ]); + const module = tsDocument( + 'Aspire.Hosting.Widget', + members.map((_, index) => ({ + name: `addWidget${index}`, + kind: 'Method', + capabilityId: `Aspire.Hosting.Widget/addWidget${index}`, + description: `Uses \`Options${index}\` in TypeScript.`, + parameters: [{ name: 'options', type: `Options${index}`, isOptional: true }], + })) + ); + + it('selects exact anchors despite short-type collisions and uses the selected TS export', () => { + const index = buildApiReferenceIndex([pkg], [module]); + const anchors = resolveMemberAnchors(members); + for (const [ordinal, member] of members.entries()) { + const types = member.parameters.map((parameter) => parameter.type); + const resolution = index.resolve(name, undefined, types); + expect(resolution).toBe(index.resolve(name, undefined, [...types])); + expect(resolution).toMatchObject({ + status: 'resolved', + csharp: { + label: 'AddWidget(Options options)', + description: `Uses Options${ordinal}.`, + path: `/reference/api/csharp/aspire.hosting.widget/widgetbuilderextensions/methods/#${anchors[ordinal].exact}`, + }, + typescript: { + label: `addWidget${ordinal}(options?: Options${ordinal})`, + description: `Uses Options${ordinal} in TypeScript.`, + path: `/reference/api/typescript/aspire.hosting.widget/addwidget${ordinal}/`, + }, + diagnostics: [], + }); + } + expect(anchors[0].exact).not.toBe(anchors[1].exact); + expect(index.resolve(name).csharp.label).toBe('AddWidget'); + }); + + it('reports incorrect types and missing receivers instead of linking the first overload', () => { + const index = buildApiReferenceIndex([pkg], [module]); + for (const types of [ + [], + ['One.Options'], + [receiver.type, 'Options'], + ['One.Options', receiver.type], + ]) { + const resolution = index.resolve(name, undefined, types); + expect(resolution.status).toBe('missing'); + expect(resolution.csharp.path).toBeUndefined(); + expect(resolution.diagnostics).toMatchObject([ + { code: 'missing-overload', severity: 'error' }, + ]); + } + }); + + it('retains package ambiguity and accepts package qualification', () => { + const duplicate = packageDocument('Aspire.Hosting.Other', pkg.types); + const index = buildApiReferenceIndex([pkg, duplicate], [module]); + const types = members[0].parameters.map((parameter) => parameter.type); + expect(index.resolve(name, undefined, types).diagnostics[0].code).toBe('ambiguous-overload'); + expect(index.resolve(name, 'Aspire.Hosting.Widget', types).status).toBe('resolved'); + }); + + it('reports same-type overload ambiguity rather than choosing by order', () => { + const duplicate = packageDocument('Aspire.Hosting.Widget', [ + { + ...pkg.types![0], + members: [members[0], { ...members[0], genericParameters: [{ name: 'T' }] }], + }, + ]); + const index = buildApiReferenceIndex([duplicate], [module]); + const resolution = index.resolve( + name, + undefined, + members[0].parameters.map((p) => p.type) + ); + expect(resolution.diagnostics[0].code).toBe('ambiguous-overload'); + expect(resolution.csharp.path).toBeUndefined(); + }); + + it('distinguishes a selected zero-parameter overload from a method-group reference', () => { + const index = buildApiReferenceIndex([widgetPackage], [widgetModule]); + expect(index.resolve(name).csharp.label).toBe('AddWidget'); + expect(index.resolve(name, undefined, []).csharp.label).toBe('AddWidget()'); + expect(index.resolve(name, undefined, []).typescript.label).toBe('addWidget()'); + }); + + it('preserves nullable generic and array types in a selected signature', () => { + const type = + 'System.Collections.Generic.List?>?[]'; + const index = buildApiReferenceIndex( + [ + packageDocument('Aspire.Hosting.Widget', [ + { + ...pkg.types![0], + members: [{ ...members[0], parameters: [receiver, { name: 'values', type }] }], + }, + ]), + ], + [module] + ); + expect(index.resolve(name, undefined, [receiver.type, type]).csharp.label).toBe( + 'AddWidget(List?>?[] values)' + ); + }); + + it('validates static arrays in MDX and nested JSX, including spread precedence', () => { + const index = buildApiReferenceIndex([pkg], [module]); + const props = `name="${name}" package="Aspire.Hosting.Widget" parameterTypes={${JSON.stringify(members[0].parameters.map((p) => p.type))}}`; + const diagnostics = validateApiReferenceSource( + { + path: 'test.mdx', + content: [ + ``, + `{true && }`, + `} />`, + ``, + ``, + ].join('\n'), + }, + index + ); + expect(diagnostics).toMatchObject([ + { line: 4, code: 'unsupported-spread', message: expect.stringContaining('parameterTypes') }, + { line: 5, code: 'missing-overload' }, + ]); + }); + + it.each(['types', '[type]', '[...types]', '[""]', '"string"', '[42]', '[["string"]]'])( + 'rejects non-static or malformed parameterTypes: %s', + (expression) => { + const index = buildApiReferenceIndex([pkg], [module]); + expect( + validateApiReferenceSource( + { + path: 'test.mdx', + content: ``, + }, + index + ) + ).toMatchObject([{ code: 'invalid-overload', severity: 'error' }]); + } + ); +}); + +describe('API reference authoring validator', () => { + it('reports the source file, line, FQN, and candidates', () => { + const index = buildApiReferenceIndex([widgetPackage], [widgetModule]); + const diagnostics = validateApiReferenceSource( + { + path: 'src/content/docs/test.mdx', + content: + '---\ntitle: Test\n---\n\n\n', + }, + index + ); + + expect(diagnostics).toMatchObject([ + { + filePath: 'src/content/docs/test.mdx', + line: 5, + name: 'Aspire.Hosting.OtherExtensions.AddWidget', + code: 'missing-csharp', + candidates: ['Aspire.Hosting.WidgetBuilderExtensions.AddWidget'], + }, + ]); + }); + + it('rejects dynamic or missing name props', () => { + const index = buildApiReferenceIndex([widgetPackage], [widgetModule]); + const diagnostics = validateApiReferenceSource( + { + path: 'src/content/docs/test.mdx', + content: '', + }, + index + ); + + expect(diagnostics).toMatchObject([{ code: 'invalid-fqn', severity: 'error', line: 1 }]); + }); + + it('rejects dynamic package qualifiers', () => { + const index = buildApiReferenceIndex([widgetPackage], [widgetModule]); + const diagnostics = validateApiReferenceSource( + { + path: 'src/content/docs/test.mdx', + content: + '', + }, + index + ); + + expect(diagnostics).toMatchObject([{ code: 'invalid-fqn', severity: 'error', line: 1 }]); + }); + + it('validates paired components and ignores misleading attribute contents', () => { + const index = buildApiReferenceIndex([widgetPackage], [widgetModule]); + const diagnostics = validateApiReferenceSource( + { + path: 'src/content/docs/test.mdx', + content: [ + '', + '', + '```mdx', + '', + '```', + ].join('\n'), + }, + index + ); + + expect(diagnostics).toMatchObject([ + { + line: 1, + name: 'Aspire.Hosting.OtherExtensions.AddWidget', + code: 'missing-csharp', + }, + { + line: 2, + code: 'invalid-fqn', + }, + ]); + }); + + it('validates static expression props and components inside MDX expressions', () => { + const index = buildApiReferenceIndex([widgetPackage], [widgetModule]); + const diagnostics = validateApiReferenceSource( + { + path: 'src/content/docs/test.mdx', + content: [ + '', + '{true && }', + '', + ].join('\n'), + }, + index + ); + + expect(diagnostics).toMatchObject([ + { + line: 2, + name: 'Aspire.Hosting.OtherExtensions.AddWidget', + code: 'missing-csharp', + }, + { + line: 3, + code: 'unsupported-spread', + }, + ]); + }); + + it.each([ + '', + '', + '{true && }', + '} />', + '', + ])('reports uncertain spread props explicitly: %s', (content) => { + const index = buildApiReferenceIndex([widgetPackage], [widgetModule]); + const diagnostics = validateApiReferenceSource( + { path: 'spread.mdx', content: `\n${content}` }, + index + ); + expect(diagnostics).toEqual([ + expect.objectContaining({ + filePath: 'spread.mdx', + line: 2, + code: 'unsupported-spread', + severity: 'error', + message: expect.stringContaining('spread props cannot be statically validated'), + }), + ]); + expect(diagnostics[0].message).toContain('package, parameterTypes'); + }); + + it('does not require optional props when no spread is present', () => { + const index = buildApiReferenceIndex([widgetPackage], [widgetModule]); + expect(validateApiReferenceSource({ + path: 'test.mdx', + content: '', + }, index)).toEqual([]); + }); + + it('validates components inside MDX attributes and exports', () => { + const index = buildApiReferenceIndex([widgetPackage], [widgetModule]); + const diagnostics = validateApiReferenceSource( + { + path: 'src/content/docs/test.mdx', + content: [ + '} />', + 'export const reference = ', + ].join('\n'), + }, + index + ); + + expect(diagnostics).toMatchObject([ + { + line: 1, + name: 'Aspire.Hosting.OtherExtensions.AddWidget', + code: 'missing-csharp', + }, + { + line: 2, + name: 'Aspire.Hosting.OtherExtensions.AddWidget', + code: 'missing-csharp', + }, + ]); + }); + + it('resolves every authored API reference against the generated catalogs', () => { + const frontendRoot = fileURLToPath(new URL('../..', import.meta.url)); + const readJsonDocuments = (directory: string): T[] => + fs + .readdirSync(directory) + .filter((file) => file.endsWith('.json')) + .map((file) => { + const parsed: unknown = JSON.parse(fs.readFileSync(path.join(directory, file), 'utf8')); + return parsed as T; + }); + const readMdxFiles = (directory: string): string[] => { + const files: string[] = []; + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) { + files.push(...readMdxFiles(entryPath)); + } else if (entry.isFile() && entry.name.endsWith('.mdx')) { + files.push(entryPath); + } + } + return files; + }; + + const packageDocuments = readJsonDocuments( + path.join(frontendRoot, 'src', 'data', 'pkgs') + ); + const index = buildApiReferenceIndex( + packageDocuments, + readJsonDocuments( + path.join(frontendRoot, 'src', 'data', 'ts-modules') + ) + ); + const files = readMdxFiles(path.join(frontendRoot, 'src', 'content', 'docs')).map( + (filePath) => ({ + path: path.relative(frontendRoot, filePath).replaceAll('\\', '/'), + content: fs.readFileSync(filePath, 'utf8'), + }) + ); + + const overload = index.resolve( + 'Aspire.Hosting.ResourceBuilderExtensions.WithEnvironment', + undefined, + ['Aspire.Hosting.ApplicationModel.IResourceBuilder', 'string', 'string?'] + ); + expect(overload).toMatchObject({ + csharp: { + label: 'WithEnvironment(string name, string? value)', + path: '/reference/api/csharp/aspire.hosting/resourcebuilderextensions/methods/#withenvironment-iresourcebuilder-t-string-string', + }, + typescript: { + label: 'withEnvironment(name: string, value: IExpressionValue)', + path: '/reference/api/typescript/aspire.hosting/withenvironment/', + }, + diagnostics: [], + }); + + expect( + index.resolve('Aspire.Hosting.YarpResourceExtensions.WithStaticFiles').typescript.path + ).toBe('/reference/api/typescript/aspire.hosting.yarp/withstaticfiles/'); + expect( + index.resolve('Aspire.Hosting.AzureBicepResourceExtensions.WithEnvironment').typescript.path + ).toBe('/reference/api/typescript/aspire.hosting/withenvironment/'); + expect( + index.resolve('Aspire.Hosting.QdrantBuilderExtensions.WithReference', 'Aspire.Hosting.Qdrant') + .typescript.path + ).toBe('/reference/api/typescript/aspire.hosting/withreference/'); + expect( + index.resolve('Aspire.Hosting.ExternalServiceBuilderExtensions.WithHttpHealthCheck') + .typescript.path + ).toContain('externalserviceresource'); + expect( + index.resolve('Aspire.Hosting.ApplicationModel.ExecuteCommandContext.ServiceProvider') + .diagnostics + ).toMatchObject([{ code: 'missing-typescript', severity: 'warning' }]); + expect( + index.resolve('Aspire.Hosting.DistributedApplicationExecutionContext.IsPublishMode') + .typescript.path + ).toBe( + '/reference/api/typescript/aspire.hosting/distributedapplicationexecutioncontext/#ispublishmode' + ); + + const declaredExportFailures: string[] = []; + const checkedExports = new Set(); + for (const pkg of packageDocuments) { + for (const type of pkg.types ?? []) { + const typeName = ( + type.fullName ?? `${type.namespace ? `${type.namespace}.` : ''}${type.name}` + ) + .replace(/`\d+/g, '') + .replace(/<.*>$/, ''); + + for (const member of type.members ?? []) { + const hasMemberExport = (member.attributes ?? []).some((attribute) => + /(?:^|\.)AspireExportAttribute$/.test(attribute.name) + ); + if (!hasMemberExport) continue; + + const fqn = `${typeName}.${member.name}`; + const key = `${pkg.package.name}\0${fqn}`; + if (checkedExports.has(key)) continue; + checkedExports.add(key); + + const resolution = index.resolve(fqn, pkg.package.name); + const errors = resolution.diagnostics.filter( + (diagnostic) => diagnostic.code === 'unresolved-typescript-export' + ); + if (errors.length > 0) { + declaredExportFailures.push( + `${pkg.package.name}: ${fqn}\n${errors + .map((diagnostic) => ` ${diagnostic.message}`) + .join('\n')}` + ); + } + } + } + } + + expect(declaredExportFailures).toEqual([]); + + const diagnostics = validateApiReferenceFiles(files, index); + + expect(diagnostics, formatApiReferenceDiagnostics(diagnostics)).toEqual([]); + }); +}); + +describe('ApiReference component', () => { + beforeEach(() => { + apiReferenceMocks.resolve.mockReset(); + }); + + it('renders indexed links as valid inline phrasing content', async () => { + apiReferenceMocks.resolve.mockResolvedValue({ + name: 'Aspire.Hosting.WidgetBuilderExtensions.AddWidget', + status: 'resolved', + csharp: { + label: 'AddWidget', + path: '/reference/api/csharp/aspire.hosting.widget/widgetbuilderextensions/methods/#addwidget', + }, + typescript: { + label: 'addWidget', + path: '/reference/api/typescript/aspire.hosting.widget/addwidget/', + }, + diagnostics: [], + } satisfies ApiReferenceResolution); + + const html = normalizeHtml( + await renderComponent(ApiReference, { + props: { + name: 'Aspire.Hosting.WidgetBuilderExtensions.AddWidget', + }, + }) + ); + + expect(html).toContain('data-lang="csharp"'); + expect(html).toContain('AddWidget'); + expect(html).toContain('data-lang="typescript"'); + expect(html).toContain('addWidget'); + expect(html).toContain('aria-label="AddWidget — C# API reference"'); + expect(html).toContain('aria-label="addWidget — TypeScript API reference"'); + expect(html).toContain('title="AddWidget — C# API reference"'); + expect(html.match(/data-tooltip-placement="top"/g)).toHaveLength(2); + expect(html.match(/data-tippy-allowhtml="false"/g)).toHaveLength(2); + expect(html).toContain('ar-icon i-material-icon-theme:csharp'); + expect(html).toContain('ar-icon i-material-icon-theme:typescript'); + expect(html.match(/aria-hidden="true"/g)).toHaveLength(2); + expect(html.match(/]*>\s* match[1])).toEqual([ + title, + title, + ]); + expect(title.length).toBeLessThanOrEqual(160); + expect(html).toContain('>AddWidget(string name)'); + expect(html).toContain('>addWidget(name: string)'); + expect(html).toContain('aria-label="AddWidget(string name) — C# API reference"'); + expect(html).toContain('aria-label="addWidget(name: string) — TypeScript API reference"'); + expect(html).toContain(`href="${resolution.csharp.path}"`); + expect(html).toContain(`href="${resolution.typescript.path}"`); + expect(html.match(/data-tooltip-placement="top"/g)).toHaveLength(2); + expect(html.match(/data-tippy-allowhtml="false"/g)).toHaveLength(2); + expect(resolution.csharp.description).toBe(description); + expect(resolution.typescript.description).toBe(description); + } + ); + + it('caps language-qualified fallback titles without shortening API labels', async () => { + const label = 'x'.repeat(170); + apiReferenceMocks.resolve.mockResolvedValue({ + name: 'Aspire.Hosting.WidgetBuilderExtensions.AddWidget', + status: 'resolved', + csharp: { label, path: '/reference/api/csharp/widget/#addwidget' }, + typescript: { label, path: '/reference/api/typescript/widget/addwidget/' }, + diagnostics: [], + } satisfies ApiReferenceResolution); + + const html = normalizeHtml( + await renderComponent(ApiReference, { + props: { name: 'Aspire.Hosting.WidgetBuilderExtensions.AddWidget' }, + }) + ); + const title = `${'x'.repeat(157)}...`; + expect([...html.matchAll(/\btitle="([^"]*)"/g)].map((match) => match[1])).toEqual([ + title, + title, + ]); + expect(html).toContain(`>${label}`); + expect(html).toContain(`aria-label="${label} — C# API reference"`); + expect(html).toContain(`aria-label="${label} — TypeScript API reference"`); + }); + + it('caps unresolved titles in both languages without changing source diagnostics', async () => { + const message = 'Diagnostic '.repeat(20).trimEnd(); + const resolution = { + name: 'Aspire.Hosting.WidgetBuilderExtensions.AddWidget', + status: 'missing', + csharp: { label: 'AddWidget' }, + typescript: { label: 'AddWidget' }, + diagnostics: [{ code: 'missing-csharp', severity: 'error', message, candidates: [] }], + } satisfies ApiReferenceResolution; + apiReferenceMocks.resolve.mockResolvedValue(resolution); + + const html = normalizeHtml( + await renderComponent(ApiReference, { props: { name: resolution.name } }) + ); + const title = `${'Diagnostic '.repeat(14).trimEnd()}...`; + expect([...html.matchAll(/\btitle="([^"]*)"/g)].map((match) => match[1])).toEqual([ + title, + title, + ]); + expect(title.length).toBeLessThanOrEqual(160); + expect(resolution.diagnostics[0].message).toBe(message); + expect(html).not.toContain('href='); + }); + + it('renders one-language APIs as warned, unlinked TypeScript code', async () => { + const message = + 'ApiReference: "Aspire.Hosting.ApplicationModel.IResourceBuilder.WithAnnotation" has no TypeScript export; the C# API name is shown without a TypeScript link.'; + apiReferenceMocks.resolve.mockResolvedValue({ + name: 'Aspire.Hosting.ApplicationModel.IResourceBuilder.WithAnnotation', + status: 'resolved', + csharp: { + label: 'WithAnnotation', + path: '/reference/api/csharp/aspire.hosting/iresourcebuilder-1/methods/#withannotation', + }, + typescript: { label: 'WithAnnotation' }, + diagnostics: [ + { + code: 'missing-typescript', + severity: 'warning', + message, + candidates: [], + }, + ], + } satisfies ApiReferenceResolution); + + const html = normalizeHtml( + await renderComponent(ApiReference, { + props: { + name: 'Aspire.Hosting.ApplicationModel.IResourceBuilder.WithAnnotation', + }, + }) + ); + + expect(html).toContain(`title="${message.replaceAll('"', '"')}"`); + expect(html.match(/href=/g)).toHaveLength(1); + expect(html.match(/class="ar-icon /g)).toHaveLength(1); + expect(html).not.toContain('/reference/api/typescript/'); + }); + + it('forwards overload parameters and renders the selected signature', async () => { + apiReferenceMocks.resolve.mockResolvedValue({ + name: 'Aspire.Hosting.WidgetBuilderExtensions.AddWidget', + status: 'resolved', + csharp: { + label: 'AddWidget(string name)', + description: 'Adds safely.', + path: '/reference/api/csharp/widget/#addwidget-string', + }, + typescript: { + label: 'addWidget(name: string)', + description: 'Adds a named widget.', + path: '/reference/api/typescript/widget/addwidget/', + }, + diagnostics: [], + } satisfies ApiReferenceResolution); + const html = normalizeHtml( + await renderComponent(ApiReference, { + props: { + name: 'Aspire.Hosting.WidgetBuilderExtensions.AddWidget', + parameterTypes: ['string'], + }, + }) + ); + expect(apiReferenceMocks.resolve.mock.calls[0][3]).toEqual(['string']); + expect(html).toContain('AddWidget(string name)'); + expect(html).toContain('addWidget(name: string)'); + expect(html).toContain('#addwidget-string'); + expect(html).toContain('title="Adds safely."'); + expect(html).toContain('title="Adds a named widget."'); + }); +}); diff --git a/src/frontend/tests/unit/custom-components.vitest.test.ts b/src/frontend/tests/unit/custom-components.vitest.test.ts index 2cf8910c3..c4e9ac299 100644 --- a/src/frontend/tests/unit/custom-components.vitest.test.ts +++ b/src/frontend/tests/unit/custom-components.vitest.test.ts @@ -83,6 +83,7 @@ type BasicRenderCase = { props?: Record; slots?: Record; includes: string[]; + excludes?: string[]; requestUrl?: string; }; @@ -970,6 +971,9 @@ describe('custom Astro component render coverage', () => { for (const fragment of testCase.includes) { expect(html).toContain(fragment); } + for (const fragment of testCase.excludes ?? []) { + expect(html).not.toContain(fragment); + } }); } diff --git a/src/frontend/tests/unit/tooltips-lifecycle.vitest.test.ts b/src/frontend/tests/unit/tooltips-lifecycle.vitest.test.ts new file mode 100644 index 000000000..994f25f0a --- /dev/null +++ b/src/frontend/tests/unit/tooltips-lifecycle.vitest.test.ts @@ -0,0 +1,211 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const { createTooltip } = vi.hoisted(() => ({ + createTooltip: + vi.fn<(element: TooltipElement, options: TooltipOptions) => TooltipElement['_tippy']>(), +})); +vi.mock('tippy.js', () => ({ default: createTooltip })); + +interface TooltipOptions { + content: string; + onClickOutside: (instance: { hide(): void }) => void; +} + +class TooltipElement { + attributes = new Map(); + _tippy?: { + state: { isDestroyed: boolean }; + hide: ReturnType; + destroy: ReturnType; + }; + + constructor(title: string) { + this.setAttribute('title', title); + } + + getAttribute(name: string) { + return this.attributes.get(name) ?? null; + } + + setAttribute(name: string, value: string) { + this.attributes.set(name, value); + } +} + +describe('title tooltip navigation lifecycle', () => { + let elements: TooltipElement[]; + let document: EventTarget & { + readyState: string; + activeElement: TooltipElement | null; + querySelectorAll: () => TooltipElement[]; + __aspireTooltipsCleanup?: () => void; + }; + + beforeEach(() => { + vi.resetModules(); + elements = [new TooltipElement('Initial title')]; + document = Object.assign(new EventTarget(), { + readyState: 'complete', + activeElement: null as TooltipElement | null, + querySelectorAll: () => elements, + }); + vi.stubGlobal('document', document); + createTooltip.mockReset().mockImplementation((element: TooltipElement) => { + const instance = { + state: { isDestroyed: false }, + hide: vi.fn(), + destroy: vi.fn(() => { + instance.state.isDestroyed = true; + delete element._tippy; + }), + }; + element._tippy = instance; + return instance; + }); + }); + + afterEach(() => vi.unstubAllGlobals()); + + it('initializes once with existing defaults and removes the native title', async () => { + await import('@scripts/tooltips'); + expect(createTooltip).toHaveBeenCalledWith( + elements[0], + expect.objectContaining({ + content: 'Initial title', + allowHTML: true, + theme: 'default', + maxWidth: 'none', + placement: 'auto', + interactive: false, + delay: [0, 0], + duration: [0, 0], + hideOnClick: true, + animation: 'scale', + }) + ); + const instance = elements[0]._tippy; + expect(elements[0].getAttribute('title')).toBe(''); + document.dispatchEvent(new Event('astro:page-load')); + document.dispatchEvent(new Event('astro:page-load')); + expect(createTooltip).toHaveBeenCalledOnce(); + expect(elements[0]._tippy).toBe(instance); + }); + + it('waits for DOM readiness and honors per-element placement and interactivity', async () => { + document.readyState = 'loading'; + elements[0].setAttribute('data-tooltip-placement', 'top'); + elements[0].setAttribute('data-tooltip-interactive', 'true'); + await import('@scripts/tooltips'); + expect(createTooltip).not.toHaveBeenCalled(); + document.dispatchEvent(new Event('DOMContentLoaded')); + document.dispatchEvent(new Event('astro:page-load')); + expect(createTooltip).toHaveBeenCalledOnce(); + expect(createTooltip).toHaveBeenCalledWith( + elements[0], + expect.objectContaining({ + placement: 'top', + interactive: true, + }) + ); + }); + + it('destroys outgoing instances, restores titles and reinitializes persisted and new nodes', async () => { + const outgoing = elements[0]; + const persisted = new TooltipElement('Persisted title'); + elements.push(persisted); + await import('@scripts/tooltips'); + const oldInstances = elements.map((element) => element._tippy!); + + document.dispatchEvent(new Event('astro:before-swap')); + for (const instance of oldInstances) expect(instance.destroy).toHaveBeenCalledOnce(); + expect(outgoing.getAttribute('title')).toBe('Initial title'); + expect(persisted.getAttribute('title')).toBe('Persisted title'); + + elements = [persisted, new TooltipElement('New page title')]; + document.dispatchEvent(new Event('astro:page-load')); + expect(createTooltip).toHaveBeenCalledTimes(4); + expect(persisted._tippy).not.toBe(oldInstances[1]); + expect(createTooltip.mock.calls.slice(2).map(([, options]) => options.content)).toEqual([ + 'Persisted title', + 'New page title', + ]); + document.dispatchEvent(new Event('astro:page-load')); + expect(createTooltip).toHaveBeenCalledTimes(4); + document.dispatchEvent(new Event('astro:before-swap')); + expect(oldInstances[0].destroy).toHaveBeenCalledOnce(); + }); + + it('preserves title edits during teardown and skips empty or externally owned tooltips', async () => { + const external = new TooltipElement('Other tooltip'); + external._tippy = { state: { isDestroyed: false }, hide: vi.fn(), destroy: vi.fn() }; + elements.push(new TooltipElement(''), external); + await import('@scripts/tooltips'); + expect(createTooltip).toHaveBeenCalledOnce(); + elements[0].setAttribute('title', 'Updated title'); + document.dispatchEvent(new Event('astro:before-swap')); + expect(elements[0].getAttribute('title')).toBe('Updated title'); + expect(external._tippy.destroy).not.toHaveBeenCalled(); + }); + + it('retains Escape and outside-click dismissal', async () => { + await import('@scripts/tooltips'); + document.activeElement = elements[0]; + const instance = elements[0]._tippy!; + document.dispatchEvent(Object.assign(new Event('keydown'), { key: 'Escape' })); + createTooltip.mock.calls[0][1].onClickOutside(instance); + expect(instance.hide).toHaveBeenCalledTimes(2); + }); + + it('registers only one lifecycle when the module is evaluated again', async () => { + const addListener = vi.spyOn(document, 'addEventListener'); + await import('@scripts/tooltips'); + vi.resetModules(); + await import('@scripts/tooltips'); + expect(addListener.mock.calls.map(([event]) => event)).toEqual([ + 'astro:before-swap', 'astro:page-load', 'keydown', + ]); + + document.activeElement = elements[0]; + const instance = elements[0]._tippy!; + document.dispatchEvent(Object.assign(new Event('keydown'), { key: 'Escape' })); + expect(instance.hide).toHaveBeenCalledOnce(); + document.dispatchEvent(new Event('astro:before-swap')); + expect(instance.destroy).toHaveBeenCalledOnce(); + document.dispatchEvent(new Event('astro:page-load')); + expect(createTooltip).toHaveBeenCalledTimes(2); + }); + + it.each(['loading', 'complete'])('disposes listeners and instances before HMR with readyState %s', async (readyState) => { + document.readyState = readyState; + await import('@scripts/tooltips'); + const instance = elements[0]._tippy; + expect(document.__aspireTooltipsCleanup).toBeTypeOf('function'); + document.__aspireTooltipsCleanup!(); + if (instance) expect(instance.destroy).toHaveBeenCalledOnce(); + expect(elements[0].getAttribute('title')).toBe('Initial title'); + expect(Reflect.has(document, '__aspireTooltipsCleanup')).toBe(false); + createTooltip.mockClear(); + + document.dispatchEvent(new Event('DOMContentLoaded')); + document.dispatchEvent(new Event('astro:page-load')); + expect(createTooltip).not.toHaveBeenCalled(); + vi.resetModules(); + document.readyState = 'complete'; + await import('@scripts/tooltips'); + expect(createTooltip).toHaveBeenCalledOnce(); + document.activeElement = elements[0]; + document.dispatchEvent(Object.assign(new Event('keydown'), { key: 'Escape' })); + expect(elements[0]._tippy!.hide).toHaveBeenCalledOnce(); + }); + + it('honors the text-only API reference flag after navigation', async () => { + elements[0].setAttribute('data-tippy-allowhtml', 'false'); + await import('@scripts/tooltips'); + document.dispatchEvent(new Event('astro:before-swap')); + document.dispatchEvent(new Event('astro:page-load')); + expect(createTooltip).toHaveBeenCalledTimes(2); + for (const [, options] of createTooltip.mock.calls) { + expect(options).toMatchObject({ allowHTML: false }); + } + }); +});