From 55b9b6e69dbdac62c56539f1fc45f8f5dadfdbfa Mon Sep 17 00:00:00 2001 From: AJ Matthews Date: Thu, 27 Aug 2026 15:29:41 +0100 Subject: [PATCH 01/16] Added an API Reference component that renders the right API name based on the user's preferred language. --- .../src/components/ApiReference.astro | 162 ++++++++++++++++++ .../unit/custom-components.vitest.test.ts | 19 ++ 2 files changed, 181 insertions(+) create mode 100644 src/frontend/src/components/ApiReference.astro diff --git a/src/frontend/src/components/ApiReference.astro b/src/frontend/src/components/ApiReference.astro new file mode 100644 index 000000000..a9f406553 --- /dev/null +++ b/src/frontend/src/components/ApiReference.astro @@ -0,0 +1,162 @@ +--- +/** + * ApiReference — renders a single Aspire API name as inline, syntax-highlighted + * code that pivots between its C# and TypeScript forms with the page's C#/TS + * language selection (Starlight `syncKey="aspire-lang"`). + * + * Accepts the name in either language's casing (C# PascalCase or TypeScript + * camelCase); only the last dot-separated segment's casing pivots between + * languages; any declaring-type prefix (e.g. `StripeResource.`) and a + * trailing `()` are preserved as-is. Mirrors the API-label derivation in + * ContainerImages.astro. + */ +import { Code } from '@astrojs/starlight/components'; + +interface Props { + /** + * Fully qualified Aspire API name, e.g. `AddPostgres`, `addPostgres`, + * `StripeResource.SetWebhookSigningSecret`, or `WithReference()`. + */ + name: string; +} + +const { name } = Astro.props; + +function apiNames(raw: string) { + const hasParens = raw.endsWith('()'); + const base = hasParens ? raw.slice(0, -2) : raw; + const lastDot = base.lastIndexOf('.'); + const prefix = lastDot >= 0 ? base.slice(0, lastDot + 1) : ''; + const leaf = lastDot >= 0 ? base.slice(lastDot + 1) : base; + const suffix = hasParens ? '()' : ''; + + if (!leaf) return { csharp: raw, typescript: raw }; + + const pascalLeaf = leaf.charAt(0).toUpperCase() + leaf.slice(1); + const camelLeaf = leaf.charAt(0).toLowerCase() + leaf.slice(1); + + return { + csharp: `${prefix}${pascalLeaf}${suffix}`, + typescript: `${prefix}${camelLeaf}${suffix}`, + }; +} + +const { csharp, typescript } = apiNames(name); +--- + + + + + + + + + + +{/* Reflect the page-wide C#/TS pivot (Starlight `syncKey="aspire-lang"`) onto + so the name above can swap language with CSS. + Inlined + guarded so it runs once per page and before paint. Shares its + guard flag and storage key with ContainerImages.astro's identical script, + so pages that use both stay in sync through one listener registration. */} + + + diff --git a/src/frontend/tests/unit/custom-components.vitest.test.ts b/src/frontend/tests/unit/custom-components.vitest.test.ts index 111567de4..6330292d5 100644 --- a/src/frontend/tests/unit/custom-components.vitest.test.ts +++ b/src/frontend/tests/unit/custom-components.vitest.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import heroImage from '@assets/aspire-hero.png'; import AccessibleCodeButtons from '@components/AccessibleCodeButtons.astro'; +import ApiReference from '@components/ApiReference.astro'; import AppHostBuilder from '@components/AppHostBuilder.astro'; import AspireMap from '@components/AspireMap.astro'; import AsciinemaPlayer from '@components/AsciinemaPlayer.astro'; @@ -193,6 +194,24 @@ const journeySteps = [ ]; const basicRenderCases: BasicRenderCase[] = [ + { + name: 'ApiReference renders both C# and TypeScript casings and the language pivot script', + Component: ApiReference, + props: { name: 'AddPostgres()' }, + includes: [ + 'data-lang="csharp"', + 'AddPostgres()', + 'data-lang="typescript"', + 'addPostgres()', + 'starlight-synced-tabs__aspire-lang', + ], + }, + { + name: 'ApiReference preserves a declaring-type prefix and pivots only the leaf name', + Component: ApiReference, + props: { name: 'StripeResource.setWebhookSigningSecret' }, + includes: ['StripeResource.SetWebhookSigningSecret', 'StripeResource.setWebhookSigningSecret'], + }, { name: 'AsciinemaPlayer renders player options as data attributes', Component: AsciinemaPlayer, From f9f60d4096559ac01604d7bb0adbaaa880e33f74 Mon Sep 17 00:00:00 2001 From: AJ Matthews Date: Thu, 27 Aug 2026 16:40:42 +0100 Subject: [PATCH 02/16] Modified pages in get-started to use new component. --- .../get-started/add-aspire-existing-app.mdx | 5 ++-- .../src/content/docs/get-started/app-host.mdx | 19 ++++----------- .../docs/get-started/aspire-mcp-server.mdx | 5 ++-- .../get-started/aspire-vscode-extension.mdx | 3 ++- .../docs/get-started/deploy-first-app.mdx | 3 ++- .../src/content/docs/get-started/faq.mdx | 6 +++-- .../src/content/docs/get-started/glossary.mdx | 23 ++++++++++--------- .../docs/get-started/resource-mcp-servers.mdx | 15 ++++++------ .../docs/get-started/troubleshooting.mdx | 7 +++--- 9 files changed, 43 insertions(+), 43 deletions(-) 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..84a8984f7 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` @@ -453,7 +454,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/). 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..d690c60cf 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 @@ -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 dependencies with to make 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 865eab60b..fda2831eb 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: Learn how the Aspire MCP server exposes resource graphs, logs, and --- import { Aside, Steps, Tabs, TabItem } from '@astrojs/starlight/components'; +import ApiReference from '@components/ApiReference.astro'; import LearnMore from '@components/LearnMore.astro'; :::note @@ -123,7 +124,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 : @@ -206,7 +207,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** — use to restrict sensitive resources - **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/aspire-vscode-extension.mdx b/src/frontend/src/content/docs/get-started/aspire-vscode-extension.mdx index 4dc735010..34fd3da78 100644 --- a/src/frontend/src/content/docs/get-started/aspire-vscode-extension.mdx +++ b/src/frontend/src/content/docs/get-started/aspire-vscode-extension.mdx @@ -4,6 +4,7 @@ description: Use the Aspire Visual Studio Code extension to create, configure, r --- import { Steps } from '@astrojs/starlight/components'; +import ApiReference from '@components/ApiReference.astro'; import ThemeImage from '@components/ThemeImage.astro'; import LearnMore from '@components/LearnMore.astro'; import createProject from '@assets/get-started/code-extension-create-aspire-project.png'; @@ -87,7 +88,7 @@ When an AppHost is running, the extension paints live state directly into your e Aspire registers an AppHost view to VS Code. **Workspace** mode shows the AppHost in your current workspace; toggle to **Global** in the view header to manage every running AppHost on your machine. -Each resource shows its type, state, health summary, and exit code, plus a health-aware icon and a markdown tooltip listing endpoint URLs. Resources with health checks expose an expandable **Health Checks** group; use **Expand all** on the AppHost item to open everything at once. You can right-click a resource to start, stop, or restart it, view its logs, run resource-specific commands, open the dashboard, or open an interactive terminal session (for resources configured with [`WithTerminal()`](/app-host/with-terminal/)). +Each resource shows its type, state, health summary, and exit code, plus a health-aware icon and a markdown tooltip listing endpoint URLs. Resources with health checks expose an expandable **Health Checks** group; use **Expand all** on the AppHost item to open everything at once. You can right-click a resource to start, stop, or restart it, view its logs, run resource-specific commands, open the dashboard, or open an interactive terminal session (for resources configured with [](/app-host/with-terminal/)). :::note[Renamed polling setting] `aspire.globalAppHostsPollingInterval` is deprecated. Use `aspire.appHostsPollingInterval` to configure how often the Aspire view polls for running AppHosts. 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 85cc55037..ae7063080 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 @@ -7,6 +7,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'; @@ -90,7 +91,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/). diff --git a/src/frontend/src/content/docs/get-started/faq.mdx b/src/frontend/src/content/docs/get-started/faq.mdx index 7b72b775c..6ff6acca7 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** — , , 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/glossary.mdx b/src/frontend/src/content/docs/get-started/glossary.mdx index e150ba72a..e70a6aff5 100644 --- a/src/frontend/src/content/docs/get-started/glossary.mdx +++ b/src/frontend/src/content/docs/get-started/glossary.mdx @@ -4,6 +4,7 @@ description: Key terms and concepts used throughout Aspire documentation — App --- import { Aside } from '@astrojs/starlight/components'; +import ApiReference from '@components/ApiReference.astro'; import PivotSelector from '@components/PivotSelector.astro'; import Pivot from '@components/Pivot.astro'; @@ -112,7 +113,7 @@ These are the key APIs and patterns you'll use when building Aspire applications ### WithReference -`WithReference()`/`withReference()` is how you connect resources together. When you call `.WithReference(otherResource)`/`.withReference(otherResource)`, Aspire: + is how you connect resources together. When you call , Aspire: 1. Injects the connection information as environment variables 2. Sets up service discovery so your code can find the other resource @@ -139,7 +140,7 @@ The API project will receive environment variables like: ### WaitFor -`WaitFor()`/`waitFor()` tells Aspire to delay starting a resource until its dependency is ready: + tells Aspire to delay starting a resource until its dependency is ready: ```csharp title="AppHost.cs" @@ -159,12 +160,12 @@ await builder.addProject("api", "../Api/Api.csproj") ### WaitForCompletion -`WaitForCompletion()`/`waitForCompletion()` waits for a resource to finish and exit (not just start). Useful for setup scripts: + waits for a resource to finish and exit (not just start). Useful for setup scripts: ```csharp title="AppHost.cs" @@ -183,7 +184,7 @@ await builder.addProject("api", "../Api/Api.csproj") ### WaitForStart -`WaitForStart()`/`waitForStart()` waits only for a resource to reach the running state, without waiting for health checks to pass: + waits only for a resource to reach the running state, without waiting for health checks to pass: ```csharp title="AppHost.cs" @@ -201,7 +202,7 @@ await builder.addProject("api", "../Api/Api.csproj") --- @@ -226,7 +227,7 @@ Example: `Host=localhost;Port=5432;Database=mydb;Username=postgres;Password=secr - Resources are addressable by their resource name (e.g., `http://apiservice`) - The AppHost configures DNS/environment variables so services can resolve each other -- Works automatically when you use `WithReference()`/`withReference()` +- Works automatically when you use For details, see [Service Discovery](/fundamentals/service-discovery/). @@ -234,7 +235,7 @@ For details, see [Service Discovery](/fundamentals/service-discovery/). A **health check** is a mechanism to determine if a resource is ready and functioning. Aspire uses health checks in two ways: -1. **AppHost level**: Determines when dependencies are ready (controls `WaitFor()`/`waitFor()` behavior) +1. **AppHost level**: Determines when dependencies are ready (controls behavior) 2. **Application level**: Exposes `/health` and `/alive` endpoints for load balancers For details, see [Health Checks](/fundamentals/health-checks/). @@ -245,7 +246,7 @@ Aspire uses **environment variables** to pass configuration from the AppHost to - Connection strings: `ConnectionStrings__resourcename` - Service endpoints: `services__servicename__https__0` -- Custom values via `WithEnvironment()`/`withEnvironment()` +- Custom values via Your application reads these using standard .NET configuration (`IConfiguration`). @@ -263,7 +264,7 @@ A **hosting integration** is an Aspire package that helps you add and configure - Configure networking, health checks, and volumes - Handle connection string generation -Example: `Aspire.Hosting.PostgreSQL` adds the `AddPostgres()`/`addPostgres()` method. +Example: `Aspire.Hosting.PostgreSQL` adds the method. ### Client integration @@ -399,7 +400,7 @@ These terms appear frequently in API documentation and advanced usage: | Term | Description | |------|-------------| | `IResourceAnnotation` | Typed metadata object attached to resources. | -| `WithAnnotation()`/`withAnnotation()` | Fluent method to attach typed annotations. | +| | Fluent method to attach typed annotations. | | `ReferenceExpression` | Structured formatter preserving value references. | | `ResourceNotificationService` | Publishes observable state updates. | 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..a16048713 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: +Use the extension method to declare that a resource hosts an MCP server: @@ -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. Use to tell Aspire where the MCP endpoint lives: @@ -125,10 +126,10 @@ await builder.build().run(); -The `WithMcpServer()` method accepts an optional path and endpoint name: +The 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` +- — uses the default HTTP endpoint at the root path +- — uses the default HTTP endpoint at `/mcp` - `WithMcpServer("/sse", endpointName: "https")` — uses a named endpoint at `/sse` ## See also diff --git a/src/frontend/src/content/docs/get-started/troubleshooting.mdx b/src/frontend/src/content/docs/get-started/troubleshooting.mdx index cdbe201dc..8073c542f 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 @@ -160,7 +161,7 @@ var client = new HttpClient { BaseAddress = new Uri("http://apiservice") }; Also verify: - Both services have `AddServiceDefaults()` called -- The consuming service has `.WithReference(api)` in the AppHost +- The consuming service has in the AppHost For a deeper understanding of how service discovery works in Aspire, see From e24dac84f3c54cadeb88c0224b381da578d32e2f Mon Sep 17 00:00:00 2001 From: AJ Matthews Date: Tue, 1 Sep 2026 10:35:11 +0100 Subject: [PATCH 03/16] Fixed the WCAG AA audit error. --- .../src/content/docs/get-started/aspire-vscode-extension.mdx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/frontend/src/content/docs/get-started/aspire-vscode-extension.mdx b/src/frontend/src/content/docs/get-started/aspire-vscode-extension.mdx index 34fd3da78..4dc735010 100644 --- a/src/frontend/src/content/docs/get-started/aspire-vscode-extension.mdx +++ b/src/frontend/src/content/docs/get-started/aspire-vscode-extension.mdx @@ -4,7 +4,6 @@ description: Use the Aspire Visual Studio Code extension to create, configure, r --- import { Steps } from '@astrojs/starlight/components'; -import ApiReference from '@components/ApiReference.astro'; import ThemeImage from '@components/ThemeImage.astro'; import LearnMore from '@components/LearnMore.astro'; import createProject from '@assets/get-started/code-extension-create-aspire-project.png'; @@ -88,7 +87,7 @@ When an AppHost is running, the extension paints live state directly into your e Aspire registers an AppHost view to VS Code. **Workspace** mode shows the AppHost in your current workspace; toggle to **Global** in the view header to manage every running AppHost on your machine. -Each resource shows its type, state, health summary, and exit code, plus a health-aware icon and a markdown tooltip listing endpoint URLs. Resources with health checks expose an expandable **Health Checks** group; use **Expand all** on the AppHost item to open everything at once. You can right-click a resource to start, stop, or restart it, view its logs, run resource-specific commands, open the dashboard, or open an interactive terminal session (for resources configured with [](/app-host/with-terminal/)). +Each resource shows its type, state, health summary, and exit code, plus a health-aware icon and a markdown tooltip listing endpoint URLs. Resources with health checks expose an expandable **Health Checks** group; use **Expand all** on the AppHost item to open everything at once. You can right-click a resource to start, stop, or restart it, view its logs, run resource-specific commands, open the dashboard, or open an interactive terminal session (for resources configured with [`WithTerminal()`](/app-host/with-terminal/)). :::note[Renamed polling setting] `aspire.globalAppHostsPollingInterval` is deprecated. Use `aspire.appHostsPollingInterval` to configure how often the Aspire view polls for running AppHosts. From 8b3cf15d4ae10108bbf109eac83c9ce592ab0e4d Mon Sep 17 00:00:00 2001 From: AJ Matthews Date: Wed, 2 Sep 2026 15:35:49 +0100 Subject: [PATCH 04/16] Required FQNs and removed spans from rendered output. --- .../src/components/ApiReference.astro | 217 +++++++++++++----- .../get-started/add-aspire-existing-app.mdx | 4 +- .../src/content/docs/get-started/app-host.mdx | 8 +- .../docs/get-started/aspire-mcp-server.mdx | 4 +- .../docs/get-started/deploy-first-app.mdx | 2 +- .../src/content/docs/get-started/faq.mdx | 4 +- .../src/content/docs/get-started/glossary.mdx | 22 +- .../docs/get-started/resource-mcp-servers.mdx | 14 +- .../docs/get-started/troubleshooting.mdx | 6 +- .../tests/e2e/custom-components.spec.ts | 42 ++++ .../unit/custom-components.vitest.test.ts | 22 +- 11 files changed, 252 insertions(+), 93 deletions(-) diff --git a/src/frontend/src/components/ApiReference.astro b/src/frontend/src/components/ApiReference.astro index a9f406553..f96c396b6 100644 --- a/src/frontend/src/components/ApiReference.astro +++ b/src/frontend/src/components/ApiReference.astro @@ -1,56 +1,169 @@ --- /** - * ApiReference — renders a single Aspire API name as inline, syntax-highlighted - * code that pivots between its C# and TypeScript forms with the page's C#/TS - * language selection (Starlight `syncKey="aspire-lang"`). + * ApiReference — renders a single Aspire API as inline, linked code that + * pivots between its C# and TypeScript forms with the page's C#/TS language + * selection (Starlight `syncKey="aspire-lang"`). * - * Accepts the name in either language's casing (C# PascalCase or TypeScript - * camelCase); only the last dot-separated segment's casing pivots between - * languages; any declaring-type prefix (e.g. `StripeResource.`) and a - * trailing `()` are preserved as-is. Mirrors the API-label derivation in - * ContainerImages.astro. + * Renders a plain `` (optionally wrapped in ``) rather than + * Starlight's `` component: `` emits block-level Expressive Code + * markup (`
...`), which is invalid inside the `

`/`

  • ` + * phrasing content this component is used from — browsers reparent that + * block content out of its containing paragraph/list item, leaving an empty + * `.api-reference` behind and a detached code block elsewhere on the page. + * Mirrors the same plain-`` approach ContainerImages.astro uses for + * its inline API label. + * + * Accepts only a canonical, fully qualified C# name (declaring type + member, + * e.g. `Aspire.Hosting.PostgresBuilderExtensions.AddPostgres`) — never a + * free-form label or an example call expression. The FQN is resolved against + * the generated package reference data (`@utils/packages`) to find the real + * member; the build fails if it doesn't resolve, so a broken or renamed API + * can't silently ship as unlinked text. + * + * From that resolved member this derives, for each language: + * - the display name in that language's casing (C# PascalCase / TypeScript + * camelCase), with a trailing `()` when the member is callable + * - the canonical href into `/reference/api/csharp/...` (always present) + * and `/reference/api/typescript/...` (present only when the API is + * exported to TypeScript — see `AspireExportAttribute` — otherwise the + * TypeScript form renders as plain, unlinked code) */ -import { Code } from '@astrojs/starlight/components'; +import { + genericArity, + getPackages, + memberKindSlugs, + memberNameSlug, + typeHref, + type PackageMember, + type PackageType, +} from '@utils/packages'; +import { getTsModules, tsModuleSlug } from '@utils/ts-modules'; +import { + getTsItemSlug, + getTsMethodSlug, + getTsStandaloneFunctions, + getTsTopLevelRouteItems, +} from '@utils/ts-api-routes'; interface Props { /** - * Fully qualified Aspire API name, e.g. `AddPostgres`, `addPostgres`, - * `StripeResource.SetWebhookSigningSecret`, or `WithReference()`. + * Canonical fully qualified C# API name: the declaring type's full name + * plus the member name, dot-separated, e.g. + * `Aspire.Hosting.PostgresBuilderExtensions.AddPostgres` or + * `Aspire.Hosting.ApplicationModel.IResourceBuilder.WithAnnotation`. */ name: string; } const { name } = Astro.props; -function apiNames(raw: string) { - const hasParens = raw.endsWith('()'); - const base = hasParens ? raw.slice(0, -2) : raw; - const lastDot = base.lastIndexOf('.'); - const prefix = lastDot >= 0 ? base.slice(0, lastDot + 1) : ''; - const leaf = lastDot >= 0 ? base.slice(lastDot + 1) : base; - const suffix = hasParens ? '()' : ''; +const FQN_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)+$/; + +if (!FQN_PATTERN.test(name)) { + throw new Error( + `ApiReference: "${name}" is not a canonical fully qualified name. Expected a ` + + `dot-separated declaring-type + member name, e.g. "Aspire.Hosting.PostgresBuilderExtensions.AddPostgres".` + ); +} + +const lastDot = name.lastIndexOf('.'); +const declaringTypeFqn = name.slice(0, lastDot); +const memberName = name.slice(lastDot + 1); + +/** Strip generic arity (`` `1 `` or ``) so callers can name generic types without it. */ +function stripGenericArity(fullName: string): string { + return fullName.replace(/`\d+$/, '').replace(/<.*>$/, ''); +} + +const base = import.meta.env.BASE_URL.replace(/\/$/, ''); + +const packages = await getPackages(); + +let resolvedType: PackageType | undefined; +let resolvedMember: PackageMember | undefined; +let resolvedPackageName: string | undefined; - if (!leaf) return { csharp: raw, typescript: raw }; +for (const pkg of packages) { + const type = pkg.data.types.find( + (t) => t.fullName === declaringTypeFqn || stripGenericArity(t.fullName ?? '') === declaringTypeFqn + ); + if (!type) continue; - const pascalLeaf = leaf.charAt(0).toUpperCase() + leaf.slice(1); - const camelLeaf = leaf.charAt(0).toLowerCase() + leaf.slice(1); + const member = type.members?.find((m) => m.name === memberName); + if (!member) continue; - return { - csharp: `${prefix}${pascalLeaf}${suffix}`, - typescript: `${prefix}${camelLeaf}${suffix}`, - }; + resolvedType = type; + resolvedMember = member; + resolvedPackageName = pkg.data.package.name; + break; } -const { csharp, typescript } = apiNames(name); +if (!resolvedType || !resolvedMember || !resolvedPackageName) { + throw new Error( + `ApiReference: could not resolve "${name}" to a known API member in the generated package ` + + `reference data (looked for member "${memberName}" on type "${declaringTypeFqn}").` + ); +} + +const isCallable = resolvedMember.kind === 'method' || resolvedMember.kind === 'constructor'; +const csharp = resolvedMember.name + (isCallable ? '()' : ''); + +const csharpHref = `${typeHref(base, resolvedPackageName, resolvedType.name, genericArity(resolvedType))}${ + memberKindSlugs[resolvedMember.kind ?? 'method'] +}/#${memberNameSlug(resolvedMember)}`; + +const camelLeaf = memberName.charAt(0).toLowerCase() + memberName.slice(1); + +let typescriptName: string | undefined; +let typescriptHref: string | undefined; + +const tsModules = await getTsModules(); +const tsModule = tsModules.find((m) => m.data.package.name === resolvedPackageName); + +if (tsModule) { + const standaloneFn = getTsStandaloneFunctions(tsModule.data).find( + (fn) => fn.name.toLowerCase() === camelLeaf.toLowerCase() + ); + + if (standaloneFn) { + const topLevelItems = getTsTopLevelRouteItems(tsModule.data); + const itemSlug = getTsItemSlug(standaloneFn, topLevelItems); + typescriptName = standaloneFn.name; + typescriptHref = `${base}/reference/api/typescript/${tsModuleSlug(tsModule.data.package.name)}/${itemSlug}/`; + } else { + for (const handle of tsModule.data.handleTypes ?? []) { + const methods = (handle.capabilities ?? []).filter( + (c) => c.kind === 'Method' || c.kind === 'InstanceMethod' + ); + const method = methods.find((m) => m.name.toLowerCase() === camelLeaf.toLowerCase()); + if (!method) continue; + + const topLevelItems = getTsTopLevelRouteItems(tsModule.data); + const itemSlug = getTsItemSlug(handle, topLevelItems); + const methodSlug = getTsMethodSlug(method, methods, handle.name); + typescriptName = method.name; + typescriptHref = `${base}/reference/api/typescript/${tsModuleSlug(tsModule.data.package.name)}/${itemSlug}/${methodSlug}/`; + break; + } + } +} + +const typescript = (typescriptName ?? camelLeaf) + (isCallable ? '()' : ''); --- - - - - - - + + {csharp} + + {typescriptHref ? ( + + {typescript} + + ) : ( + + {typescript} + + )} {/* Reflect the page-wide C#/TS pivot (Starlight `syncKey="aspire-lang"`) onto @@ -128,35 +241,27 @@ const { csharp, typescript } = apiNames(name); display: inline-flex; } - /* Collapse the Expressive Code block chrome (frame, empty caption, copy - button) so the name reads as inline code matching prose, rather than a - fenced code block with its own affordances. */ - .ar-lang :global(.expressive-code) { - display: inline-flex; - margin: 0; - } - - .ar-lang :global(figure.frame) { - margin: 0; - box-shadow: none; - } - - .ar-lang :global(.expressive-code .header), - .ar-lang :global(.expressive-code .copy) { - display: none; + a.ar-lang { + text-decoration: none; + color: inherit; } - .ar-lang :global(.expressive-code pre) { - margin: 0; - padding: 0.125rem 0.375rem; - border-radius: 0.25rem; + /* Plain inline `` styled to match prose code spans — mirrors + ContainerImages.astro's `.ci-api`. Deliberately not Starlight's `` + component: that emits block-level markup that isn't valid inside the + `

    `/`

  • ` phrasing content this component renders from. */ + .ar-lang code { + font-family: var(--sl-font-mono, ui-monospace, SFMono-Regular, monospace); + font-size: var(--sl-text-body); + font-weight: 400; + color: var(--aspire-inline-code-text); background-color: var(--aspire-inline-code-bg); border: 1px solid var(--aspire-inline-code-border); + padding: 0.125rem 0.375rem; + border-radius: 0.25rem; } - .ar-lang :global(.expressive-code code) { - display: inline; - color: var(--aspire-inline-code-text); - font-size: var(--sl-text-body); + a.ar-lang:hover code { + border-color: var(--sl-color-text-accent); } 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 84a8984f7..d42c6dd6c 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 @@ -65,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 , , 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` @@ -454,7 +454,7 @@ await builder.build().run(); -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. +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/). 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 d690c60cf..b2bb12125 100644 --- a/src/frontend/src/content/docs/get-started/app-host.mdx +++ b/src/frontend/src/content/docs/get-started/app-host.mdx @@ -157,12 +157,12 @@ You can represent that architecture in an AppHost like this: -

    Uses with for ASGI apps like FastAPI.

    +

    Uses with for ASGI apps like FastAPI.

    -

    Uses with for Node.js applications.

    +

    Uses with for Node.js applications.

    @@ -359,7 +359,7 @@ With the builder ready, define resources and services. The snippet below shows h @@ -749,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 to make wiring obvious. +- Define explicit dependencies with to make 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 fda2831eb..98504860a 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 @@ -124,7 +124,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 : +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 +207,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 to restrict sensitive resources +- **Granular access control** — use to restrict sensitive resources - **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 ae7063080..589520e5c 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 @@ -91,7 +91,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 and , 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/). diff --git a/src/frontend/src/content/docs/get-started/faq.mdx b/src/frontend/src/content/docs/get-started/faq.mdx index 6ff6acca7..33ffa58d7 100644 --- a/src/frontend/src/content/docs/get-started/faq.mdx +++ b/src/frontend/src/content/docs/get-started/faq.mdx @@ -99,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** — , , with uv/pip/venv package management and automatic Dockerfile generation -- **JavaScript / TypeScript** — , , with npm/yarn/pnpm auto-detection +- **Python** — , , with uv/pip/venv package management and automatic Dockerfile generation +- **JavaScript / TypeScript** — , , 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/glossary.mdx b/src/frontend/src/content/docs/get-started/glossary.mdx index e70a6aff5..5144c30ec 100644 --- a/src/frontend/src/content/docs/get-started/glossary.mdx +++ b/src/frontend/src/content/docs/get-started/glossary.mdx @@ -113,7 +113,7 @@ These are the key APIs and patterns you'll use when building Aspire applications ### WithReference - is how you connect resources together. When you call , Aspire: + is how you connect resources together. When you call `.WithReference(otherResource)`, Aspire: 1. Injects the connection information as environment variables 2. Sets up service discovery so your code can find the other resource @@ -140,7 +140,7 @@ The API project will receive environment variables like: ### WaitFor - tells Aspire to delay starting a resource until its dependency is ready: + tells Aspire to delay starting a resource until its dependency is ready: ```csharp title="AppHost.cs" @@ -160,12 +160,12 @@ await builder.addProject("api", "../Api/Api.csproj") ### WaitForCompletion - waits for a resource to finish and exit (not just start). Useful for setup scripts: + waits for a resource to finish and exit (not just start). Useful for setup scripts: ```csharp title="AppHost.cs" @@ -184,7 +184,7 @@ await builder.addProject("api", "../Api/Api.csproj") ### WaitForStart - waits only for a resource to reach the running state, without waiting for health checks to pass: + waits only for a resource to reach the running state, without waiting for health checks to pass: ```csharp title="AppHost.cs" @@ -202,7 +202,7 @@ await builder.addProject("api", "../Api/Api.csproj") --- @@ -227,7 +227,7 @@ Example: `Host=localhost;Port=5432;Database=mydb;Username=postgres;Password=secr - Resources are addressable by their resource name (e.g., `http://apiservice`) - The AppHost configures DNS/environment variables so services can resolve each other -- Works automatically when you use +- Works automatically when you use For details, see [Service Discovery](/fundamentals/service-discovery/). @@ -235,7 +235,7 @@ For details, see [Service Discovery](/fundamentals/service-discovery/). A **health check** is a mechanism to determine if a resource is ready and functioning. Aspire uses health checks in two ways: -1. **AppHost level**: Determines when dependencies are ready (controls behavior) +1. **AppHost level**: Determines when dependencies are ready (controls behavior) 2. **Application level**: Exposes `/health` and `/alive` endpoints for load balancers For details, see [Health Checks](/fundamentals/health-checks/). @@ -246,7 +246,7 @@ Aspire uses **environment variables** to pass configuration from the AppHost to - Connection strings: `ConnectionStrings__resourcename` - Service endpoints: `services__servicename__https__0` -- Custom values via +- Custom values via Your application reads these using standard .NET configuration (`IConfiguration`). @@ -264,7 +264,7 @@ A **hosting integration** is an Aspire package that helps you add and configure - Configure networking, health checks, and volumes - Handle connection string generation -Example: `Aspire.Hosting.PostgreSQL` adds the method. +Example: `Aspire.Hosting.PostgreSQL` adds the method. ### Client integration @@ -400,7 +400,7 @@ These terms appear frequently in API documentation and advanced usage: | Term | Description | |------|-------------| | `IResourceAnnotation` | Typed metadata object attached to resources. | -| | Fluent method to attach typed annotations. | +| | Fluent method to attach typed annotations. | | `ReferenceExpression` | Structured formatter preserving value references. | | `ResourceNotificationService` | Publishes observable state updates. | 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 a16048713..7b825b355 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 @@ -13,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: +When a resource is annotated with 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 extension method to declare that a resource hosts an MCP server: +Use the extension method to declare that a resource hosts an MCP server: @@ -57,7 +57,7 @@ await builder.build().run(); @@ -92,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 to tell Aspire where the MCP endpoint lives: +You can add any container that implements the MCP protocol as an MCP-enabled resource. Use to tell Aspire where the MCP endpoint lives: @@ -126,10 +126,10 @@ await builder.build().run(); -The method accepts an optional path and endpoint name: +The method accepts an optional path and endpoint name: -- — uses the default HTTP endpoint at the root path -- — uses the default HTTP endpoint at `/mcp` +- — 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` ## See also diff --git a/src/frontend/src/content/docs/get-started/troubleshooting.mdx b/src/frontend/src/content/docs/get-started/troubleshooting.mdx index 8073c542f..05dd93728 100644 --- a/src/frontend/src/content/docs/get-started/troubleshooting.mdx +++ b/src/frontend/src/content/docs/get-started/troubleshooting.mdx @@ -73,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 in your AppHost: +**Solution**: Make sure you're using in your AppHost: ```csharp title="AppHost.cs" builder.AddProject("frontend") @@ -85,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 to connect resources. +**Solution**: Verify you're using to connect resources. ### Example: Connecting a database to your service @@ -161,7 +161,7 @@ var client = new HttpClient { BaseAddress = new Uri("http://apiservice") }; Also verify: - Both services have `AddServiceDefaults()` called -- The consuming service has in the AppHost +- The consuming service has `.WithReference(api)` in the AppHost For a deeper understanding of how service discovery works in Aspire, see diff --git a/src/frontend/tests/e2e/custom-components.spec.ts b/src/frontend/tests/e2e/custom-components.spec.ts index eaed1158a..601b42614 100644 --- a/src/frontend/tests/e2e/custom-components.spec.ts +++ b/src/frontend/tests/e2e/custom-components.spec.ts @@ -363,3 +363,45 @@ 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)$/); + } + + // 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); +}); diff --git a/src/frontend/tests/unit/custom-components.vitest.test.ts b/src/frontend/tests/unit/custom-components.vitest.test.ts index 6330292d5..c6b850640 100644 --- a/src/frontend/tests/unit/custom-components.vitest.test.ts +++ b/src/frontend/tests/unit/custom-components.vitest.test.ts @@ -80,6 +80,7 @@ type BasicRenderCase = { props?: Record; slots?: Record; includes: string[]; + excludes?: string[]; requestUrl?: string; }; @@ -195,22 +196,30 @@ const journeySteps = [ const basicRenderCases: BasicRenderCase[] = [ { - name: 'ApiReference renders both C# and TypeScript casings and the language pivot script', + name: 'ApiReference resolves a canonical FQN to both C# and TypeScript casings, linked to their reference pages, plus the language pivot script', Component: ApiReference, - props: { name: 'AddPostgres()' }, + props: { name: 'Aspire.Hosting.PostgresBuilderExtensions.AddPostgres' }, includes: [ 'data-lang="csharp"', 'AddPostgres()', 'data-lang="typescript"', 'addPostgres()', + '/reference/api/csharp/aspire.hosting.postgresql/postgresbuilderextensions/methods/#addpostgres', + '/reference/api/typescript/aspire.hosting.postgresql/addpostgres/', 'starlight-synced-tabs__aspire-lang', ], + // Only plain phrasing content (``) is valid here — this renders + // from inside a `

    `/`

  • ` in real docs content, so any block-level + // markup (Starlight's `` / Expressive Code) would get reparented + // out of its containing paragraph or list item by the HTML parser. + excludes: [' { for (const fragment of testCase.includes) { expect(html).toContain(fragment); } + for (const fragment of testCase.excludes ?? []) { + expect(html).not.toContain(fragment); + } }); } From d1bd33c7d892c93fe93dad68ba83d5952acaa3fe Mon Sep 17 00:00:00 2001 From: AJ Matthews Date: Wed, 2 Sep 2026 16:03:41 +0100 Subject: [PATCH 05/16] Updated to use the existing data-apphost-lang storage key instead of creating a new one. --- .../src/components/ApiReference.astro | 74 ++++--------------- .../unit/custom-components.vitest.test.ts | 13 +++- 2 files changed, 24 insertions(+), 63 deletions(-) diff --git a/src/frontend/src/components/ApiReference.astro b/src/frontend/src/components/ApiReference.astro index f96c396b6..bc167ba50 100644 --- a/src/frontend/src/components/ApiReference.astro +++ b/src/frontend/src/components/ApiReference.astro @@ -1,8 +1,13 @@ --- /** * ApiReference — renders a single Aspire API as inline, linked code that - * pivots between its C# and TypeScript forms with the page's C#/TS language - * selection (Starlight `syncKey="aspire-lang"`). + * pivots between its C# and TypeScript forms with the site's canonical C#/TS + * language preference: the `data-apphost-lang` attribute that + * src/components/starlight/Head.astro sets on `` before paint and keeps + * current (query string, localStorage, Starlight `` + * pointer clicks and keyboard nav). This component binds to that attribute + * via CSS only — it must not track its own copy of the language state, which + * would drift out of sync with any update path Head.astro handles. * * Renders a plain `` (optionally wrapped in ``) rather than * Starlight's `` component: `` emits block-level Expressive Code @@ -166,60 +171,6 @@ const typescript = (typescriptName ?? camelLeaf) + (isCallable ? '()' : ''); )} -{/* Reflect the page-wide C#/TS pivot (Starlight `syncKey="aspire-lang"`) onto - so the name above can swap language with CSS. - Inlined + guarded so it runs once per page and before paint. Shares its - guard flag and storage key with ContainerImages.astro's identical script, - so pages that use both stay in sync through one listener registration. */} - - 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 d42c6dd6c..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 @@ -99,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. @@ -395,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 . + @@ -475,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 c876ca24f..88a38f876 100644 --- a/src/frontend/src/content/docs/get-started/app-host.mdx +++ b/src/frontend/src/content/docs/get-started/app-host.mdx @@ -368,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 : @@ -749,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 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 68e835ac1..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 @@ -208,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 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 589520e5c..2ece87145 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 @@ -242,9 +242,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. + @@ -304,8 +304,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. - diff --git a/src/frontend/src/content/docs/get-started/faq.mdx b/src/frontend/src/content/docs/get-started/faq.mdx index 33ffa58d7..bc0444a26 100644 --- a/src/frontend/src/content/docs/get-started/faq.mdx +++ b/src/frontend/src/content/docs/get-started/faq.mdx @@ -100,7 +100,7 @@ Aspire is a multi-language platform. The AppHost can be written in **C# or TypeS - **C# / .NET** — First-class project references, service defaults, and integrations - **Python** — , , with uv/pip/venv package management and automatic Dockerfile generation -- **JavaScript / TypeScript** — , , with npm/yarn/pnpm auto-detection +- **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 81d817ed5..cfc9b0c22 100644 --- a/src/frontend/src/content/docs/get-started/first-app.mdx +++ b/src/frontend/src/content/docs/get-started/first-app.mdx @@ -5,6 +5,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'; @@ -152,6 +153,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,10 +211,10 @@ 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 + - 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 + - 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,11 +293,11 @@ 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 + - adds a Node.js application (the Express API) + - 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 + - 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/glossary.mdx b/src/frontend/src/content/docs/get-started/glossary.mdx index c74b3d72c..cfc8c48cd 100644 --- a/src/frontend/src/content/docs/get-started/glossary.mdx +++ b/src/frontend/src/content/docs/get-started/glossary.mdx @@ -113,7 +113,7 @@ These are the key APIs and patterns you'll use when building Aspire applications ### WithReference - is how you connect resources together. When you call `.WithReference(otherResource)`, Aspire: + is how you connect resources together. When you reference another resource, Aspire: 1. Injects the connection information as environment variables 2. Sets up service discovery so your code can find the other resource @@ -160,7 +160,7 @@ await builder.addProject("api", "../Api/Api.csproj") ### WaitForCompletion @@ -202,7 +202,7 @@ await builder.addProject("api", "../Api/Api.csproj") --- @@ -227,7 +227,7 @@ Example: `Host=localhost;Port=5432;Database=mydb;Username=postgres;Password=secr - Resources are addressable by their resource name (e.g., `http://apiservice`) - The AppHost configures DNS/environment variables so services can resolve each other -- Works automatically when you use +- Works automatically when you connect resources with a reference For details, see [Service Discovery](/fundamentals/service-discovery/). @@ -235,7 +235,7 @@ For details, see [Service Discovery](/fundamentals/service-discovery/). A **health check** is a mechanism to determine if a resource is ready and functioning. Aspire uses health checks in two ways: -1. **AppHost level**: Determines when dependencies are ready (controls behavior) +1. **AppHost level**: Determines when dependencies are ready so resources waiting for them can start 2. **Application level**: Exposes `/health` and `/alive` endpoints for load balancers For details, see [Health Checks](/fundamentals/health-checks/). 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 e22421dd2..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 @@ -20,7 +20,7 @@ When a resource is annotated with extension method to declare that a resource hosts an MCP server: +For PostgreSQL, use to expose MCP tools for a database: @@ -57,7 +57,7 @@ await builder.build().run(); @@ -92,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 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: @@ -126,10 +126,10 @@ await builder.build().run(); -The method accepts an optional path and endpoint name: +The MCP annotation method accepts an optional path and endpoint name: -- — 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 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); diff --git a/src/frontend/src/utils/api-reference-validator.ts b/src/frontend/src/utils/api-reference-validator.ts index ad0b8042a..f284d9cda 100644 --- a/src/frontend/src/utils/api-reference-validator.ts +++ b/src/frontend/src/utils/api-reference-validator.ts @@ -36,13 +36,13 @@ interface ApiReferenceSyntax { interface SourceAttribute { name?: string; - value?: string; + value?: string | string[]; spread: boolean; } interface ResolvedAttribute { present: boolean; - value?: string; + value?: string | string[]; } const mdxProcessor = createProcessor({ format: 'mdx' }); @@ -59,13 +59,19 @@ function isSyntaxNode(value: unknown): value is SyntaxNode { return isRecord(value) && typeof value.type === 'string'; } -function readStaticStringExpression(value: unknown): string | undefined { +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 readStaticStringExpression(value.expression); + 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' && @@ -82,14 +88,14 @@ function readStaticStringExpression(value: unknown): string | undefined { return undefined; } -function readStaticStringProgram(value: unknown): string | 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' - ? readStaticStringExpression(statement.expression) + ? readStaticExpression(statement.expression) : undefined; } @@ -110,7 +116,7 @@ function readMdxAttribute(value: unknown): SourceAttribute | undefined { const data = expressionValue && isRecord(expressionValue.data) ? expressionValue.data : undefined; return { name: value.name, - value: readStaticStringProgram(data?.estree), + value: readStaticProgram(data?.estree), spread: false, }; } @@ -141,7 +147,7 @@ function readJsxAttribute(value: unknown): SourceAttribute | undefined { : undefined; return { name, - value: readStaticStringExpression(expression), + value: readStaticExpression(expression), spread: false, }; } @@ -231,7 +237,7 @@ export function validateApiReferenceSource( const packageAttribute = resolveAttribute(node.attributes, 'package'); const packageName = packageAttribute.value; - if (!name) { + if (typeof name !== 'string' || !name) { diagnostics.push({ filePath: file.path, line: node.line, @@ -244,7 +250,7 @@ export function validateApiReferenceSource( continue; } - if (packageAttribute.present && !packageName) { + if (packageAttribute.present && (typeof packageName !== 'string' || !packageName)) { diagnostics.push({ filePath: file.path, line: node.line, @@ -257,14 +263,38 @@ export function validateApiReferenceSource( continue; } - const resolution = index.resolve(name, packageName); + const parameterTypesAttribute = resolveAttribute(node.attributes, 'parameterTypes'); + const parameterTypes = parameterTypesAttribute.value; + if ( + parameterTypesAttribute.present && + (!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, + packageName: resolvedPackage, })) ); } diff --git a/src/frontend/src/utils/api-reference.ts b/src/frontend/src/utils/api-reference.ts index 51161d63c..39dc97319 100644 --- a/src/frontend/src/utils/api-reference.ts +++ b/src/frontend/src/utils/api-reference.ts @@ -39,10 +39,11 @@ export function getApiReferenceIndex(requestScope?: object): Promise { const index = await getApiReferenceIndex(requestScope); - return index.resolve(name, packageName); + return index.resolve(name, packageName, parameterTypes); } export type { diff --git a/src/frontend/tests/e2e/pivot-selector.spec.ts b/src/frontend/tests/e2e/pivot-selector.spec.ts index 056056b7d..1132026ae 100644 --- a/src/frontend/tests/e2e/pivot-selector.spec.ts +++ b/src/frontend/tests/e2e/pivot-selector.spec.ts @@ -159,9 +159,21 @@ test('ApiReference chips follow the language selection through every entry point await dismissCookieConsentIfVisible(page); await expect(csharpChip).toBeVisible(); - await expect(csharpChip).toHaveText('WithMcpServer()'); + await expect(csharpChip).toHaveText('WithMcpServer'); await expect(typeScriptChip).toBeHidden(); - await expect(typeScriptChip).toHaveText('withMcpServer()'); + 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(); @@ -169,6 +181,23 @@ test('ApiReference chips follow the language selection through every entry point 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(); @@ -194,13 +223,13 @@ test('ApiReference chips follow the language selection through every entry point await page.goto('/get-started/glossary/?aspire-lang=typescript'); await expect(typeScriptChip).toBeVisible(); - await expect(typeScriptChip).toHaveText('withReference()'); + await expect(typeScriptChip).toHaveText('withReference'); 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('WithReference()'); + await expect(csharpChip).toHaveText('WithReference'); await expect(typeScriptChip).toBeHidden(); }); diff --git a/src/frontend/tests/typecheck/component-props.contracts.ts b/src/frontend/tests/typecheck/component-props.contracts.ts index 0989658e7..0f67434a7 100644 --- a/src/frontend/tests/typecheck/component-props.contracts.ts +++ b/src/frontend/tests/typecheck/component-props.contracts.ts @@ -179,6 +179,15 @@ const validApiReferenceProps = { 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', @@ -753,6 +762,8 @@ const invalidYouTubeGridProps: PropsOf = { void [ validApiReferenceProps, validUnqualifiedApiReferenceProps, + validOverloadApiReferenceProps, + invalidOverloadApiReferenceProps, invalidApiReferenceProps, validAsciinemaPlayerProps, invalidAsciinemaPlayerProps, diff --git a/src/frontend/tests/unit/api-reference.vitest.test.ts b/src/frontend/tests/unit/api-reference.vitest.test.ts index 023054155..9fb7fee18 100644 --- a/src/frontend/tests/unit/api-reference.vitest.test.ts +++ b/src/frontend/tests/unit/api-reference.vitest.test.ts @@ -25,6 +25,7 @@ import { 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'; @@ -87,11 +88,11 @@ describe('API reference index', () => { expect(first).toMatchObject({ status: 'resolved', csharp: { - label: 'AddWidget()', + label: 'AddWidget', path: '/reference/api/csharp/aspire.hosting.widget/widgetbuilderextensions/methods/#addwidget', }, typescript: { - label: 'addWidget()', + label: 'addWidget', path: '/reference/api/typescript/aspire.hosting.widget/addwidget/', }, diagnostics: [], @@ -138,7 +139,7 @@ describe('API reference index', () => { expect( index.resolve('Aspire.Hosting.ResourceBuilderExtensions.WaitForCompletion').typescript ).toEqual({ - label: 'waitForCompletion()', + label: 'waitForCompletion', path: '/reference/api/typescript/aspire.hosting/waitforcompletion/', }); }); @@ -163,10 +164,10 @@ describe('API reference index', () => { ); expect(resolution.csharp).toEqual({ - label: 'WithAnnotation()', + label: 'WithAnnotation', path: '/reference/api/csharp/aspire.hosting/iresourcebuilder-1/methods/#withannotation', }); - expect(resolution.typescript).toEqual({ label: 'WithAnnotation()' }); + expect(resolution.typescript).toEqual({ label: 'WithAnnotation' }); expect(resolution.diagnostics).toMatchObject([ { code: 'missing-typescript', severity: 'warning' }, ]); @@ -276,7 +277,7 @@ describe('API reference index', () => { expect( index.resolve('Aspire.Hosting.YarpResourceExtensions.WithStaticFiles').typescript ).toEqual({ - label: 'withStaticFiles()', + label: 'withStaticFiles', path: '/reference/api/typescript/aspire.hosting.yarp/withstaticfiles/', }); }); @@ -407,7 +408,7 @@ describe('API reference index', () => { expect( index.resolve('Aspire.Hosting.AzureBicepResourceExtensions.WithEnvironment').typescript ).toEqual({ - label: 'withEnvironment()', + label: 'withEnvironment', path: '/reference/api/typescript/aspire.hosting/withenvironment/', }); }); @@ -513,7 +514,7 @@ describe('API reference index', () => { expect( index.resolve('Aspire.Hosting.ResourceBuilderExtensions.WithReference').typescript ).toEqual({ - label: 'withReference()', + label: 'withReference', path: '/reference/api/typescript/aspire.hosting/withreference/', }); }); @@ -625,6 +626,190 @@ describe('API reference index', () => { }); }); +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: 'invalid-overload' }, + { 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]); @@ -710,7 +895,7 @@ describe('API reference authoring validator', () => { { path: 'src/content/docs/test.mdx', content: [ - '', + '', '{true && }', '', ].join('\n'), @@ -797,6 +982,23 @@ describe('API reference authoring validator', () => { }) ); + 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/'); @@ -876,11 +1078,11 @@ describe('ApiReference component', () => { name: 'Aspire.Hosting.WidgetBuilderExtensions.AddWidget', status: 'resolved', csharp: { - label: 'AddWidget()', + label: 'AddWidget', path: '/reference/api/csharp/aspire.hosting.widget/widgetbuilderextensions/methods/#addwidget', }, typescript: { - label: 'addWidget()', + label: 'addWidget', path: '/reference/api/typescript/aspire.hosting.widget/addwidget/', }, diagnostics: [], @@ -895,9 +1097,18 @@ describe('ApiReference component', () => { ); expect(html).toContain('data-lang="csharp"'); - expect(html).toContain('AddWidget()'); + expect(html).toContain('AddWidget'); expect(html).toContain('data-lang="typescript"'); - expect(html).toContain('addWidget()'); + 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* safely."'); + expect(html).toContain('title="Adds a named widget."'); + }); }); From 0fcf30c5e0c774bb102aaeb00beeb37c00d99547 Mon Sep 17 00:00:00 2001 From: David Pine <7679720+IEvangelist@users.noreply.github.com> Date: Mon, 21 Sep 2026 23:43:05 -0500 Subject: [PATCH 13/16] Refine API reference lists and tooltip navigation Allow repeated API links in explanation lists, cap tooltip titles at 160 characters, and initialize title tooltips across ClientRouter navigation with cleanup and regression coverage. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .agents/skills/doc-writer/SKILL.md | 20 ++- src/frontend/package.json | 4 +- src/frontend/pnpm-lock.yaml | 14 +- .../src/components/ApiReference.astro | 22 ++- .../src/components/starlight/Head.astro | 30 +--- .../docs/get-started/deploy-first-app.mdx | 16 +- .../content/docs/get-started/first-app.mdx | 12 +- src/frontend/src/scripts/tooltips.ts | 52 ++++++ .../tests/unit/api-reference.vitest.test.ts | 98 +++++++++++ .../unit/tooltips-lifecycle.vitest.test.ts | 157 ++++++++++++++++++ 10 files changed, 359 insertions(+), 66 deletions(-) create mode 100644 src/frontend/src/scripts/tooltips.ts create mode 100644 src/frontend/tests/unit/tooltips-lifecycle.vitest.test.ts diff --git a/.agents/skills/doc-writer/SKILL.md b/.agents/skills/doc-writer/SKILL.md index 2321896f9..7785dec6f 100644 --- a/.agents/skills/doc-writer/SKILL.md +++ b/.agents/skills/doc-writer/SKILL.md @@ -1007,10 +1007,11 @@ For more information, see [Service Defaults](/fundamentals/service-defaults/). Use `ApiReference` selectively to connect an explanation to API reference documentation, not to turn every API mention into a link. -- Use `` only on the **first named mention of a given API in an article's prose**, 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 the article body, including callouts, lists, 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 this limit **per API, per article**, not per section or language tab. A different API can have its own first reference. -- An optional API reference link in **See also** is the only exception to the no-repeat rule. +- 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. @@ -1022,7 +1023,12 @@ import ApiReference from '@components/ApiReference.astro'; For PostgreSQL, use to expose MCP tools for a database. ``` -Later in the same article, refer to "the PostgreSQL MCP helper" rather than repeating the linked API name. +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 @@ -1043,7 +1049,9 @@ This links to the exact C# overload and displays `WithEnvironment(string name, s 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. +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. ### Reference NuGet Packages diff --git a/src/frontend/package.json b/src/frontend/package.json index cd5c72087..4a6b0210d 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -82,7 +82,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 +96,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 393a24363..adb2cc703 100644 --- a/src/frontend/pnpm-lock.yaml +++ b/src/frontend/pnpm-lock.yaml @@ -45,6 +45,7 @@ overrides: patchedDependencies: '@astrojs/starlight@0.41.3': ad8925179f0e3050ae6c804196faa6d38a2be2b718acf138001215a18648d3b5 starlight-plugin-icons@1.1.6: ecdc00afa0722d3cdc77871f9debee551f67d1c02d82fea11c5aa41bf0f48a54 + importers: .: @@ -106,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 @@ -154,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 @@ -1983,9 +1984,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==} @@ -6073,10 +6071,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 index ba400c7c5..411df3d5e 100644 --- a/src/frontend/src/components/ApiReference.astro +++ b/src/frontend/src/components/ApiReference.astro @@ -19,6 +19,14 @@ interface Props { 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(/\/$/, ''); @@ -39,7 +47,9 @@ const typescriptDiagnostic = resolution.diagnostics.length > 0 ? diagnosticText data-lang="csharp" href={csharpHref} aria-label={`${resolution.csharp.label} — C# API reference`} - title={resolution.csharp.description ?? `${resolution.csharp.label} — C# API reference`} + title={formatTitle( + resolution.csharp.description ?? `${resolution.csharp.label} — C# API reference` + )} data-tooltip-placement="top" data-tippy-allowhtml="false" > @@ -52,7 +62,7 @@ const typescriptDiagnostic = resolution.diagnostics.length > 0 ? diagnosticText @@ -67,10 +77,10 @@ const typescriptDiagnostic = resolution.diagnostics.length > 0 ? diagnosticText data-lang="typescript" href={typescriptHref} aria-label={`${resolution.typescript.label} — TypeScript API reference`} - title={ + title={formatTitle( resolution.typescript.description ?? - `${resolution.typescript.label} — TypeScript API reference` - } + `${resolution.typescript.label} — TypeScript API reference` + )} data-tooltip-placement="top" data-tippy-allowhtml="false" > @@ -83,7 +93,7 @@ const typescriptDiagnostic = resolution.diagnostics.length > 0 ? diagnosticText 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/deploy-first-app.mdx b/src/frontend/src/content/docs/get-started/deploy-first-app.mdx index 2ece87145..4dec91d55 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 @@ -267,8 +267,8 @@ In the AppHost, use - 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 +291,8 @@ In the AppHost, use - 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. @@ -330,8 +330,8 @@ After installing a new deployment package, you can run `aspire deploy --list-ste 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. @@ -359,8 +359,8 @@ After installing a new deployment package, you can run `aspire deploy --list-ste 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/first-app.mdx b/src/frontend/src/content/docs/get-started/first-app.mdx index cfc9b0c22..9128aa2b3 100644 --- a/src/frontend/src/content/docs/get-started/first-app.mdx +++ b/src/frontend/src/content/docs/get-started/first-app.mdx @@ -210,10 +210,10 @@ Both templates start with creates the distributed application builder - 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 + - 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] @@ -292,11 +292,11 @@ Both templates start with creates the distributed application builder - adds a Node.js application (the Express API) - 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 + - 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 diff --git a/src/frontend/src/scripts/tooltips.ts b/src/frontend/src/scripts/tooltips.ts new file mode 100644 index 000000000..c01e3be2b --- /dev/null +++ b/src/frontend/src/scripts/tooltips.ts @@ -0,0 +1,52 @@ +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: true, + 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(); +} + +document.addEventListener('astro:before-swap', destroyTooltips); +document.addEventListener('astro:page-load', initializeTooltips); +document.addEventListener('keydown', (event) => { + if (event.key === 'Escape') { + const activeElement: ReferenceElement | null = document.activeElement; + activeElement?._tippy?.hide(); + } +}); + +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initializeTooltips, { once: true }); +} else { + initializeTooltips(); +} diff --git a/src/frontend/tests/unit/api-reference.vitest.test.ts b/src/frontend/tests/unit/api-reference.vitest.test.ts index 9fb7fee18..cb4b69f38 100644 --- a/src/frontend/tests/unit/api-reference.vitest.test.ts +++ b/src/frontend/tests/unit/api-reference.vitest.test.ts @@ -1116,6 +1116,104 @@ describe('ApiReference component', () => { expect(html).not.toMatch(/ { + const resolution = { + name: 'Aspire.Hosting.WidgetBuilderExtensions.AddWidget', + status: 'resolved', + csharp: { + label: 'AddWidget(string name)', + description, + path: '/reference/api/csharp/widget/#addwidget-string', + }, + typescript: { + label: 'addWidget(name: string)', + description, + path: '/reference/api/typescript/widget/addwidget/', + }, + diagnostics: [], + } satisfies ApiReferenceResolution; + apiReferenceMocks.resolve.mockResolvedValue(resolution); + + const html = normalizeHtml( + await renderComponent(ApiReference, { props: { name: resolution.name } }) + ); + + expect([...html.matchAll(/\btitle="([^"]*)"/g)].map((match) => 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.'; 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..f88f87b8b --- /dev/null +++ b/src/frontend/tests/unit/tooltips-lifecycle.vitest.test.ts @@ -0,0 +1,157 @@ +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[]; + }; + + 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); + }); +}); From b469371aed6f238268468e180f427b571edb295c Mon Sep 17 00:00:00 2001 From: David Pine Date: Tue, 22 Sep 2026 21:02:03 -0500 Subject: [PATCH 14/16] Cover API reference navigation across ClientRouter swaps Exercise native and fallback swaps, tooltip cleanup and reinitialization, language selection, and back/forward history without a document reload. Move the old glossary scenario to the first-app tutorial. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/frontend/tests/e2e/pivot-selector.spec.ts | 78 ++++++++++++++++++- 1 file changed, 75 insertions(+), 3 deletions(-) diff --git a/src/frontend/tests/e2e/pivot-selector.spec.ts b/src/frontend/tests/e2e/pivot-selector.spec.ts index dbdb49da9..c6fe470af 100644 --- a/src/frontend/tests/e2e/pivot-selector.spec.ts +++ b/src/frontend/tests/e2e/pivot-selector.spec.ts @@ -237,16 +237,16 @@ test('ApiReference chips follow the language selection through every entry point // 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/glossary/?aspire-lang=typescript'); + await page.goto('/get-started/first-app/?aspire-lang=typescript'); await expect(typeScriptChip).toBeVisible(); - await expect(typeScriptChip).toHaveText('withReference'); + 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('WithReference'); + await expect(csharpChip).toHaveText('CreateBuilder'); await expect(typeScriptChip).toBeHidden(); }); @@ -346,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, }) => { From fa0a481663d38412ada4a2fc45c8e7ad9bf01332 Mon Sep 17 00:00:00 2001 From: AJ Matthews Date: Wed, 23 Sep 2026 12:51:12 +0100 Subject: [PATCH 15/16] Integrated feedback from @Copilot. --- src/frontend/astro.config.mjs | 2 +- src/frontend/src/scripts/tooltips.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/frontend/astro.config.mjs b/src/frontend/astro.config.mjs index 8cf82f004..e7b697807 100644 --- a/src/frontend/astro.config.mjs +++ b/src/frontend/astro.config.mjs @@ -54,7 +54,7 @@ const buildConcurrency = Number(process.env.ASPIRE_BUILD_CONCURRENCY) || 4; // https://astro.build/config export default defineConfig({ - cacheDir: './.astro', + cacheDir: './node_modules/.astro', ...(outDir ? { outDir } : {}), vite: { define: { diff --git a/src/frontend/src/scripts/tooltips.ts b/src/frontend/src/scripts/tooltips.ts index c01e3be2b..c4cd83df4 100644 --- a/src/frontend/src/scripts/tooltips.ts +++ b/src/frontend/src/scripts/tooltips.ts @@ -11,7 +11,7 @@ function initializeTooltips() { const interactive = element.getAttribute('data-tooltip-interactive'); const instance = tippy(element, { content: title, - allowHTML: true, + allowHTML: element.getAttribute('data-tippy-allowhtml') !== 'false', theme: 'default', maxWidth: 'none', placement: placement ?? 'auto', From 9677ac8725132e8567f5fcd0773e91e1ed62f0e3 Mon Sep 17 00:00:00 2001 From: David Pine Date: Wed, 23 Sep 2026 07:12:12 -0500 Subject: [PATCH 16/16] Address tooltip lifecycle and API reference spread feedback Guard shared tooltip registration and dispose listeners during HMR. Distinguish spread-sourced props with actionable authoring diagnostics while preserving explicit override precedence. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .agents/skills/doc-writer/SKILL.md | 3 ++ src/frontend/src/scripts/tooltips.ts | 33 +++++++++--- src/frontend/src/utils/api-reference-core.ts | 1 + .../src/utils/api-reference-validator.ts | 32 ++++++++--- .../tests/unit/api-reference.vitest.test.ts | 36 ++++++++++++- .../unit/tooltips-lifecycle.vitest.test.ts | 54 +++++++++++++++++++ 6 files changed, 142 insertions(+), 17 deletions(-) diff --git a/.agents/skills/doc-writer/SKILL.md b/.agents/skills/doc-writer/SKILL.md index 7785dec6f..1dc395c0c 100644 --- a/.agents/skills/doc-writer/SKILL.md +++ b/.agents/skills/doc-writer/SKILL.md @@ -1032,6 +1032,8 @@ Later in the same article's ordinary prose, refer to "the PostgreSQL MCP helper" #### 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 @@ -1052,6 +1054,7 @@ Linked references reuse the code-block headers' C# and TypeScript icons from `ma 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 diff --git a/src/frontend/src/scripts/tooltips.ts b/src/frontend/src/scripts/tooltips.ts index c4cd83df4..832479124 100644 --- a/src/frontend/src/scripts/tooltips.ts +++ b/src/frontend/src/scripts/tooltips.ts @@ -36,17 +36,34 @@ function destroyTooltips() { tooltips.clear(); } -document.addEventListener('astro:before-swap', destroyTooltips); -document.addEventListener('astro:page-load', initializeTooltips); -document.addEventListener('keydown', (event) => { +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(); + } -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 index ac45502bb..fef0e56e6 100644 --- a/src/frontend/src/utils/api-reference-core.ts +++ b/src/frontend/src/utils/api-reference-core.ts @@ -91,6 +91,7 @@ export interface ApiReferenceTarget { } export type ApiReferenceDiagnosticCode = + | 'unsupported-spread' | 'invalid-fqn' | 'missing-csharp' | 'ambiguous-csharp' diff --git a/src/frontend/src/utils/api-reference-validator.ts b/src/frontend/src/utils/api-reference-validator.ts index f284d9cda..240d68f87 100644 --- a/src/frontend/src/utils/api-reference-validator.ts +++ b/src/frontend/src/utils/api-reference-validator.ts @@ -41,7 +41,7 @@ interface SourceAttribute { } interface ResolvedAttribute { - present: boolean; + source: 'absent' | 'explicit' | 'spread'; value?: string | string[]; } @@ -155,10 +155,10 @@ function readJsxAttribute(value: unknown): SourceAttribute | undefined { 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 { present: true }; - if (attribute.name === name) return { present: true, value: attribute.value }; + if (attribute.spread) return { source: 'spread' }; + if (attribute.name === name) return { source: 'explicit', value: attribute.value }; } - return { present: false }; + return { source: 'absent' }; } function findApiReferenceNodes(content: string): ApiReferenceSyntax[] { @@ -236,6 +236,25 @@ export function validateApiReferenceSource( 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({ @@ -250,7 +269,7 @@ export function validateApiReferenceSource( continue; } - if (packageAttribute.present && (typeof packageName !== 'string' || !packageName)) { + if (packageAttribute.source === 'explicit' && (typeof packageName !== 'string' || !packageName)) { diagnostics.push({ filePath: file.path, line: node.line, @@ -263,10 +282,9 @@ export function validateApiReferenceSource( continue; } - const parameterTypesAttribute = resolveAttribute(node.attributes, 'parameterTypes'); const parameterTypes = parameterTypesAttribute.value; if ( - parameterTypesAttribute.present && + parameterTypesAttribute.source === 'explicit' && (!Array.isArray(parameterTypes) || parameterTypes.some((type) => !type.trim())) ) { diagnostics.push({ diff --git a/src/frontend/tests/unit/api-reference.vitest.test.ts b/src/frontend/tests/unit/api-reference.vitest.test.ts index cb4b69f38..2138de733 100644 --- a/src/frontend/tests/unit/api-reference.vitest.test.ts +++ b/src/frontend/tests/unit/api-reference.vitest.test.ts @@ -788,7 +788,7 @@ describe('API reference overloads', () => { index ); expect(diagnostics).toMatchObject([ - { line: 4, code: 'invalid-overload' }, + { line: 4, code: 'unsupported-spread', message: expect.stringContaining('parameterTypes') }, { line: 5, code: 'missing-overload' }, ]); }); @@ -911,11 +911,43 @@ describe('API reference authoring validator', () => { }, { line: 3, - code: 'invalid-fqn', + 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( diff --git a/src/frontend/tests/unit/tooltips-lifecycle.vitest.test.ts b/src/frontend/tests/unit/tooltips-lifecycle.vitest.test.ts index f88f87b8b..994f25f0a 100644 --- a/src/frontend/tests/unit/tooltips-lifecycle.vitest.test.ts +++ b/src/frontend/tests/unit/tooltips-lifecycle.vitest.test.ts @@ -38,6 +38,7 @@ describe('title tooltip navigation lifecycle', () => { readyState: string; activeElement: TooltipElement | null; querySelectorAll: () => TooltipElement[]; + __aspireTooltipsCleanup?: () => void; }; beforeEach(() => { @@ -154,4 +155,57 @@ describe('title tooltip navigation lifecycle', () => { 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 }); + } + }); });