From aec27bf55e4be836b00da0b30a358866efbef6b8 Mon Sep 17 00:00:00 2001 From: David Pine <7679720+IEvangelist@users.noreply.github.com> Date: Wed, 9 Sep 2026 08:12:24 -0500 Subject: [PATCH 01/16] Add config-driven AppHost language foundation Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../config/apphost-language-markdown.mjs | 144 +++++++++ ...spire-version-placeholders-integration.mjs | 5 +- src/frontend/scripts/migrate-apphost-tabs.mjs | 224 ++++++++++++++ .../components/AppHostLanguageSelector.astro | 21 ++ src/frontend/src/components/AppHostTabs.astro | 66 +++++ .../src/components/IntegrationCard.astro | 60 ++-- .../src/components/PivotSelector.astro | 49 ++- .../src/components/SimpleAppHostCode.astro | 15 +- .../src/components/starlight/Head.astro | 102 +++++-- .../app-host/certificate-configuration.mdx | 148 +++++----- .../content/docs/app-host/configuration.mdx | 9 +- .../content/docs/app-host/container-files.mdx | 112 +++---- .../docs/app-host/container-registry.mdx | 124 ++++---- .../src/content/docs/app-host/eventing.mdx | 52 ++-- .../docs/app-host/executable-resources.mdx | 112 +++---- .../docs/app-host/hot-reload-and-watch.mdx | 14 +- .../app-host/migrate-from-docker-compose.mdx | 124 ++++---- .../docs/app-host/resource-lifetimes.mdx | 100 ++++--- .../content/docs/app-host/with-terminal.mdx | 40 +-- .../content/docs/app-host/withdockerfile.mdx | 124 ++++---- .../architecture/resource-api-patterns.mdx | 148 +++++----- .../architecture/resource-hierarchies.mdx | 16 +- .../docs/community/contributor-guide.mdx | 22 +- .../blazor-webassembly.mdx | 16 +- .../content/docs/deployment/app-lifecycle.mdx | 21 +- .../docs/deployment/azure/app-service.mdx | 100 ++++--- .../deployment/azure/azure-developer-cli.mdx | 16 +- .../azure/azure-security-best-practices.mdx | 52 ++-- .../docs/deployment/azure/container-apps.mdx | 88 +++--- .../docs/deployment/custom-deployments.mdx | 16 +- .../docs/deployment/deploy-with-aspire.mdx | 33 +-- .../docs/deployment/docker-compose.mdx | 136 ++++----- .../content/docs/deployment/environments.mdx | 52 ++-- .../docs/deployment/javascript-apps.mdx | 145 +++++---- .../deployment/kubernetes-gateway-aks.mdx | 40 +-- .../deployment/kubernetes-ingress-aks.mdx | 40 +-- .../docs/deployment/kubernetes-ingress.mdx | 28 +- .../docs/deployment/kubernetes/aks.mdx | 124 ++++---- .../docs/deployment/kubernetes/clusters.mdx | 64 ++-- .../deployment/kubernetes/helm-charts.mdx | 76 ++--- .../kubernetes/persistent-volumes.mdx | 76 ++--- .../src/content/docs/deployment/pipelines.mdx | 16 +- .../extensibility/interaction-service.mdx | 112 +++---- .../fundamentals/container-networking.mdx | 16 +- .../fundamentals/custom-resource-commands.mdx | 232 +++++++-------- .../docs/fundamentals/external-parameters.mdx | 122 ++++---- .../docs/fundamentals/health-checks.mdx | 34 ++- .../docs/fundamentals/http-commands.mdx | 16 +- .../docs/fundamentals/networking-overview.mdx | 124 ++++---- .../fundamentals/persist-data-volumes.mdx | 88 +++--- .../docs/fundamentals/service-discovery.mdx | 50 ++-- .../get-started/add-aspire-existing-app.mdx | 74 ++--- .../docs/get-started/aspire-mcp-server.mdx | 26 +- .../docs/get-started/deploy-first-app.mdx | 9 +- .../content/docs/get-started/first-app.mdx | 9 +- .../src/content/docs/get-started/glossary.mdx | 9 +- .../docs/get-started/prerequisites.mdx | 28 +- .../docs/get-started/resource-mcp-servers.mdx | 28 +- .../docs/get-started/what-is-aspire.mdx | 16 +- .../ai/github-models/github-models-host.mdx | 76 ++--- .../integrations/ai/ollama/ollama-host.mdx | 100 ++++--- .../integrations/ai/openai/openai-host.mdx | 76 ++--- .../caching/garnet/garnet-host.mdx | 100 ++++--- .../redis-distributed-host.mdx | 28 +- .../integrations/caching/redis-extensions.mdx | 40 +-- .../redis-output/redis-output-host.mdx | 28 +- .../integrations/caching/redis/redis-host.mdx | 148 +++++----- .../caching/valkey/valkey-host.mdx | 100 ++++--- .../cloud/azure/ai-compatibility-matrix.mdx | 52 ++-- .../integrations/cloud/azure/aks/index.mdx | 52 ++-- .../azure-ai-foundry-host.mdx | 160 +++++----- .../azure-ai-inference-host.mdx | 16 +- .../azure-ai-search/azure-ai-search-host.mdx | 64 ++-- .../azure-app-configuration-host.mdx | 147 +++++---- .../azure-app-service-host.mdx | 117 ++++---- .../azure/azure-application-insights.mdx | 64 ++-- .../azure-cache-redis-host.mdx | 76 ++--- .../azure-container-registry-get-started.mdx | 23 +- .../azure-container-registry-host.mdx | 100 ++++--- .../azure-cosmos-db/azure-cosmos-db-host.mdx | 160 +++++----- .../cloud/azure/azure-data-explorer.mdx | 76 ++--- .../cloud/azure/azure-default-credential.mdx | 16 +- .../azure-event-hubs-host.mdx | 136 ++++----- .../cloud/azure/azure-front-door.mdx | 40 +-- .../azure-functions/azure-functions-host.mdx | 76 ++--- .../azure-key-vault/azure-key-vault-host.mdx | 88 +++--- .../cloud/azure/azure-log-analytics.mdx | 40 +-- .../azure/azure-openai/azure-openai-host.mdx | 88 +++--- .../azure-postgresql-host.mdx | 88 +++--- .../azure-service-bus-host.mdx | 112 +++---- .../azure-signalr/azure-signalr-host.mdx | 76 ++--- .../azure-sql-database-host.mdx | 88 +++--- .../azure-storage-blobs-host.mdx | 148 +++++----- .../cloud/azure/azure-storage-datalake.mdx | 52 ++-- .../azure-storage-queues-host.mdx | 100 ++++--- .../azure-storage-tables-host.mdx | 100 ++++--- .../cloud/azure/azure-virtual-network.mdx | 196 ++++++------ .../azure-web-pubsub-host.mdx | 88 +++--- .../cloud/azure/configure-container-apps.mdx | 84 +++--- .../cloud/azure/container-app-jobs.mdx | 112 +++---- .../cloud/azure/customize-resources.mdx | 160 +++++----- .../cloud/azure/local-provisioning.mdx | 14 +- .../integrations/cloud/azure/overview.mdx | 52 ++-- .../cloud/azure/role-assignments.mdx | 28 +- .../cloud/azure/user-assigned-identity.mdx | 40 +-- .../docs/integrations/compute/docker.mdx | 206 ++++++------- .../content/docs/integrations/compute/k3s.mdx | 112 +++---- .../docs/integrations/compute/kubernetes.mdx | 100 ++++--- .../hosting-integrations.mdx | 14 +- .../secure-communication.mdx | 16 +- .../azure-cosmos-db/azure-cosmos-db-host.mdx | 52 ++-- .../efcore/azure-sql/azure-sql-host.mdx | 52 ++-- .../elasticsearch/elasticsearch-host.mdx | 76 ++--- .../databases/kurrentdb/kurrentdb-host.mdx | 52 ++-- .../meilisearch/meilisearch-host.mdx | 64 ++-- .../databases/milvus/milvus-host.mdx | 100 ++++--- .../databases/mongodb/mongodb-extensions.mdx | 28 +- .../databases/mongodb/mongodb-host.mdx | 124 ++++---- .../databases/mysql/mysql-extensions.mdx | 52 ++-- .../databases/mysql/mysql-host.mdx | 124 ++++---- .../databases/postgres/postgres-host.mdx | 160 +++++----- .../postgres/postgresql-extensions.mdx | 64 ++-- .../databases/qdrant/qdrant-host.mdx | 64 ++-- .../databases/ravendb/ravendb-host.mdx | 76 ++--- .../sql-server/sql-server-extensions.mdx | 52 ++-- .../databases/sql-server/sql-server-host.mdx | 110 +++---- .../databases/sqlite/sqlite-host.mdx | 52 ++-- .../databases/surrealdb/surrealdb-host.mdx | 76 ++--- .../integrations/devtools/browser-logs.mdx | 40 +-- .../integrations/devtools/dab/dab-host.mdx | 64 ++-- .../integrations/devtools/dev-tunnels.mdx | 64 ++-- .../devtools/flagd/flagd-connect.mdx | 14 +- .../devtools/flagd/flagd-host.mdx | 46 ++- .../integrations/devtools/goff/goff-host.mdx | 64 ++-- .../devtools/k6/k6-get-started.mdx | 16 +- .../docs/integrations/devtools/k6/k6-host.mdx | 64 ++-- .../devtools/mailpit/mailpit-host.mdx | 58 ++-- .../sql-projects/sql-projects-get-started.mdx | 21 +- .../sql-projects/sql-projects-host.mdx | 64 ++-- .../integrations/dotnet/blazor-connect.mdx | 14 +- .../integrations/dotnet/blazor-hosting.mdx | 52 ++-- .../dotnet/csharp-file-based-apps.mdx | 52 ++-- .../dotnet/dotnet-tool-resources.mdx | 112 +++---- .../integrations/dotnet/project-resources.mdx | 148 +++++----- .../docs/integrations/frameworks/bun-apps.mdx | 52 ++-- .../frameworks/dapr/dapr-host.mdx | 76 ++--- .../frameworks/deno/deno-get-started.mdx | 21 +- .../frameworks/deno/deno-host.mdx | 52 ++-- .../frameworks/dotnet/dotnet-host.mdx | 52 ++-- .../integrations/frameworks/go/go-host.mdx | 112 +++---- .../frameworks/java/java-get-started.mdx | 21 +- .../frameworks/java/java-host.mdx | 88 +++--- .../integrations/frameworks/javascript.mdx | 278 ++++++++---------- .../frameworks/nodejs-extensions.mdx | 40 +-- .../docs/integrations/frameworks/orleans.mdx | 64 ++-- .../frameworks/perl/perl-get-started.mdx | 21 +- .../frameworks/perl/perl-host.mdx | 100 ++++--- .../powershell/powershell-get-started.mdx | 22 +- .../frameworks/powershell/powershell-host.mdx | 64 ++-- .../docs/integrations/frameworks/python.mdx | 184 ++++++------ .../frameworks/rust/rust-get-started.mdx | 22 +- .../frameworks/rust/rust-host.mdx | 64 ++-- .../apache-kafka/apache-kafka-host.mdx | 112 +++---- .../messaging/lavinmq/lavinmq-connect.mdx | 14 +- .../messaging/lavinmq/lavinmq-host.mdx | 76 ++--- .../integrations/messaging/nats/nats-host.mdx | 88 +++--- .../messaging/rabbitmq/rabbitmq-host.mdx | 112 +++---- .../observability/seq/seq-host.mdx | 52 ++-- .../content/docs/integrations/overview.mdx | 14 +- .../integrations/reverse-proxies/yarp.mdx | 136 ++++----- .../docs/integrations/security/keycloak.mdx | 124 ++++---- src/frontend/src/data/apphost-languages.json | 89 ++++++ src/frontend/src/styles/site.css | 52 ++++ src/frontend/src/utils/apphost-languages.ts | 188 ++++++++++++ .../tests/e2e/integrations-gallery.spec.ts | 6 +- src/frontend/tests/e2e/pivot-selector.spec.ts | 25 ++ .../typecheck/component-props.contracts.ts | 27 ++ .../unit/apphost-languages.vitest.test.ts | 180 ++++++++++++ .../unit/custom-components.vitest.test.ts | 42 +++ 179 files changed, 7261 insertions(+), 5916 deletions(-) create mode 100644 src/frontend/config/apphost-language-markdown.mjs create mode 100644 src/frontend/scripts/migrate-apphost-tabs.mjs create mode 100644 src/frontend/src/components/AppHostLanguageSelector.astro create mode 100644 src/frontend/src/components/AppHostTabs.astro create mode 100644 src/frontend/src/data/apphost-languages.json create mode 100644 src/frontend/src/utils/apphost-languages.ts create mode 100644 src/frontend/tests/unit/apphost-languages.vitest.test.ts diff --git a/src/frontend/config/apphost-language-markdown.mjs b/src/frontend/config/apphost-language-markdown.mjs new file mode 100644 index 000000000..cabdef42c --- /dev/null +++ b/src/frontend/config/apphost-language-markdown.mjs @@ -0,0 +1,144 @@ +import { createProcessor } from '@mdx-js/mdx'; + +const markdownParser = createProcessor(); +const appHostTabsSourcePattern = / candidate?.type === 'mdxJsxAttribute' && candidate.name === name + ); + return typeof attribute?.value === 'string' ? attribute.value : undefined; +} + +function getLimitations(node) { + const attribute = node.attributes?.find( + (candidate) => candidate?.type === 'mdxJsxAttribute' && candidate.name === 'limitations' + ); + const expression = attribute?.value?.data?.estree?.body?.[0]?.expression; + if (expression?.type !== 'ObjectExpression') { + return new Map(); + } + + const limitations = new Map(); + for (const property of expression.properties ?? []) { + if (property.type !== 'Property') continue; + + const key = + property.key.type === 'Identifier' + ? property.key.name + : property.key.type === 'Literal' + ? property.key.value + : undefined; + const value = + property.value.type === 'Literal' && typeof property.value.value === 'string' + ? property.value.value + : property.value.type === 'TemplateLiteral' && + property.value.expressions.length === 0 && + property.value.quasis.length === 1 + ? property.value.quasis[0].value.cooked + : undefined; + + if (typeof key === 'string' && typeof value === 'string') { + limitations.set(key, value); + } + } + + return limitations; +} + +function getInnerSource(node, markdown) { + const start = node.position?.start.offset; + const end = node.position?.end.offset; + if (typeof start !== 'number' || typeof end !== 'number' || !node.name) { + throw new Error('Unable to locate AppHost language content in the Markdown source.'); + } + + const openingEnd = markdown.indexOf('>', start); + const closingStart = markdown.lastIndexOf(``, end); + if (openingEnd < 0 || closingStart <= openingEnd) { + throw new Error('Unable to extract AppHost language content from the Markdown source.'); + } + + return markdown.slice(openingEnd + 1, closingStart).replace(/^\r?\n/, '').replace(/\r?\n$/, ''); +} + +function collectAppHostTabEdits(node, markdown, enabledLanguages, edits) { + if (!node || typeof node !== 'object') { + return; + } + + if ( + node.type === 'mdxJsxFlowElement' && + node.name === 'AppHostTabs' && + Array.isArray(node.children) + ) { + const start = node.position?.start.offset; + const end = node.position?.end.offset; + if (typeof start !== 'number' || typeof end !== 'number') { + throw new Error('Unable to locate AppHostTabs in the Markdown source.'); + } + const lineStart = markdown.lastIndexOf('\n', start - 1) + 1; + const indent = markdown.slice(lineStart, start); + + const fragments = new Map( + node.children + .filter( + (child) => + child?.type === 'mdxJsxFlowElement' && + child.name === 'Fragment' && + typeof getStringAttribute(child, 'slot') === 'string' + ) + .map((child) => [getStringAttribute(child, 'slot'), child]) + ); + const limitations = getLimitations(node); + const sections = []; + + for (const language of enabledLanguages) { + const fragment = fragments.get(language.id); + if (fragment) { + const status = language.experimental ? ' (Experimental)' : ''; + sections.push( + `### ${language.label}${status}\n\n${getInnerSource(fragment, markdown).trim()}` + ); + continue; + } + + const limitation = limitations.get(language.id); + if (limitation) { + sections.push( + `### ${language.label}${language.experimental ? ' (Experimental)' : ''}\n\n` + + `${indent}> [!NOTE]\n` + + `${indent}> **${language.label} AppHost limitation:** ${limitation}` + ); + } + } + + edits.push({ start, end, replacement: sections.join(`\n\n${indent}`) }); + return; + } + + if (Array.isArray(node.children)) { + for (const child of node.children) { + collectAppHostTabEdits(child, markdown, enabledLanguages, edits); + } + } +} + +export function renderAppHostTabsInMarkdown(markdown, languages) { + if (!appHostTabsSourcePattern.test(markdown)) { + return markdown; + } + + const normalizedMarkdown = markdown.replace(/\r\n?/g, '\n'); + const enabledLanguages = languages.filter((language) => language.enabled); + const tree = markdownParser.parse(normalizedMarkdown); + const edits = []; + collectAppHostTabEdits(tree, normalizedMarkdown, enabledLanguages, edits); + + let updated = normalizedMarkdown; + for (const edit of edits.sort((left, right) => right.start - left.start)) { + updated = + updated.slice(0, edit.start) + edit.replacement + updated.slice(edit.end); + } + return updated; +} diff --git a/src/frontend/config/aspire-version-placeholders-integration.mjs b/src/frontend/config/aspire-version-placeholders-integration.mjs index 0bc1197c3..1889b9717 100644 --- a/src/frontend/config/aspire-version-placeholders-integration.mjs +++ b/src/frontend/config/aspire-version-placeholders-integration.mjs @@ -3,6 +3,8 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { replaceAspireVersionPlaceholders } from './remark-aspire-version-placeholders.mjs'; import { orderTypeScriptFirstAppHostTabsInMarkdown } from './remark-typescript-first-apphost-tabs.mjs'; +import { renderAppHostTabsInMarkdown } from './apphost-language-markdown.mjs'; +import appHostLanguageConfig from '../src/data/apphost-languages.json' with { type: 'json' }; // Per-page Markdown copies emitted by `starlight-page-actions` bypass the // remark transforms that replace Aspire version placeholders and order AppHost @@ -85,7 +87,8 @@ async function collectMarkdownCopies(directory, files) { async function processMarkdownCopy(filePath) { const content = await readFile(filePath, 'utf8'); - const ordered = orderTypeScriptFirstAppHostTabsInMarkdown(content); + const rendered = renderAppHostTabsInMarkdown(content, appHostLanguageConfig.languages); + const ordered = orderTypeScriptFirstAppHostTabsInMarkdown(rendered); const updated = replaceAspireVersionPlaceholders(ordered); if (updated !== content) { diff --git a/src/frontend/scripts/migrate-apphost-tabs.mjs b/src/frontend/scripts/migrate-apphost-tabs.mjs new file mode 100644 index 000000000..c92d35a8c --- /dev/null +++ b/src/frontend/scripts/migrate-apphost-tabs.mjs @@ -0,0 +1,224 @@ +import { createProcessor } from '@mdx-js/mdx'; +import { readFile, readdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const frontendRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const docsRoot = path.join(frontendRoot, 'src', 'content', 'docs'); +const excludedTopLevel = new Set([ + 'da', + 'de', + 'diagnostics', + 'es', + 'fr', + 'hi', + 'id', + 'it', + 'ja', + 'ko', + 'pt-br', + 'ru', + 'tr', + 'uk', + 'whats-new', + 'zh-cn', +]); +const parser = createProcessor(); + +function getStringAttribute(node, name) { + const attribute = node.attributes?.find( + (candidate) => candidate?.type === 'mdxJsxAttribute' && candidate.name === name + ); + return typeof attribute?.value === 'string' ? attribute.value : undefined; +} + +function normalizeLanguageId(node) { + const id = getStringAttribute(node, 'id')?.toLowerCase(); + if (id) return id; + + const label = getStringAttribute(node, 'label')?.toLowerCase(); + if (label?.includes('typescript')) return 'typescript'; + if (label === 'c#' || label?.includes('c# apphost')) return 'csharp'; + if (label?.includes('python')) return 'python'; + if (label === 'go' || label?.includes('go apphost')) return 'go'; + if (label?.includes('java')) return 'java'; + if (label?.includes('rust')) return 'rust'; + return undefined; +} + +function getInnerSource(node, source) { + const start = node.position?.start.offset; + const end = node.position?.end.offset; + if (typeof start !== 'number' || typeof end !== 'number' || !node.name) { + throw new Error('Unable to locate a TabItem in the MDX source.'); + } + const openingEnd = source.indexOf('>', start); + const closingStart = source.lastIndexOf(``, end); + if (openingEnd < 0 || closingStart <= openingEnd) { + throw new Error('Unable to extract a TabItem body from the MDX source.'); + } + return source.slice(openingEnd + 1, closingStart); +} + +function normalizeAppHostTabsIndentation(source) { + const lines = source.split('\n'); + const indents = []; + + return lines + .map((line) => { + const trimmed = line.trimStart(); + if (trimmed.startsWith('' || + trimmed === '' + ) { + const normalized = `${indent}${trimmed}`; + if (trimmed === '') { + indents.pop(); + } + return normalized; + } + + return line; + }) + .join('\n'); +} + +function collectEdits(node, source, edits) { + if (!node || typeof node !== 'object') return; + + if ( + node.type === 'mdxJsxFlowElement' && + node.name === 'Tabs' && + getStringAttribute(node, 'syncKey') === 'aspire-lang' + ) { + const start = node.position?.start.offset; + const nodeEnd = node.position?.end.offset; + if (typeof start !== 'number' || typeof nodeEnd !== 'number') { + throw new Error('Unable to locate an aspire-lang Tabs block in the MDX source.'); + } + const closingStart = source.lastIndexOf('', Math.min(source.length, nodeEnd + 1)); + if (closingStart < start) { + throw new Error('Unable to locate the closing tag for an aspire-lang Tabs block.'); + } + + const tabItems = node.children.filter( + (child) => child?.type === 'mdxJsxFlowElement' && child.name === 'TabItem' + ); + const lineStart = source.lastIndexOf('\n', start - 1) + 1; + const indent = source.slice(lineStart, start); + const fragments = tabItems.map((tab) => { + const language = normalizeLanguageId(tab); + if (!language) { + throw new Error('Unable to identify an AppHost language TabItem.'); + } + return `${indent}${getInnerSource(tab, source)}`; + }); + + edits.push({ + start, + end: closingStart + ''.length, + replacement: `\n${fragments.join('\n')}\n${indent}`, + }); + return; + } + + if (Array.isArray(node.children)) { + for (const child of node.children) { + collectEdits(child, source, edits); + } + } +} + +function updateStarlightImports(source) { + if (source.includes(' { + const kept = members + .split(',') + .map((member) => member.trim()) + .filter(Boolean) + .filter((member) => member !== 'Tabs' && member !== 'TabItem'); + return kept.length > 0 + ? `import { ${kept.join(', ')} } from ${quote}@astrojs/starlight/components${quote};` + : ''; + } + ); +} + +function addAppHostTabsImport(source) { + if (source.includes("from '@components/AppHostTabs.astro'")) { + return source; + } + + const frontmatterEnd = source.indexOf('\n---', 4); + if (frontmatterEnd < 0) { + throw new Error('Expected MDX frontmatter before AppHost tabs.'); + } + const insertAt = frontmatterEnd + 4; + return ( + source.slice(0, insertAt) + + "\n\nimport AppHostTabs from '@components/AppHostTabs.astro';" + + source.slice(insertAt) + ); +} + +async function collectMdxFiles(directory, files = []) { + for (const entry of await readdir(directory, { withFileTypes: true })) { + const resolved = path.join(directory, entry.name); + if (entry.isDirectory()) { + if (directory === docsRoot && excludedTopLevel.has(entry.name.toLowerCase())) { + continue; + } + await collectMdxFiles(resolved, files); + } else if (entry.isFile() && entry.name.endsWith('.mdx')) { + files.push(resolved); + } + } + return files; +} + +let changedFiles = 0; +let changedGroups = 0; +for (const file of await collectMdxFiles(docsRoot)) { + const original = (await readFile(file, 'utf8')).replace(/\r\n?/g, '\n'); + const source = normalizeAppHostTabsIndentation(original); + if (!/]*\bsyncKey\s*=\s*(['"])aspire-lang\1/i.test(source)) { + if (source !== original) { + await writeFile(file, source); + changedFiles++; + } + continue; + } + + const tree = parser.parse(source); + const edits = []; + collectEdits(tree, source, edits); + if (edits.length === 0) continue; + + let updated = source; + for (const edit of edits.sort((left, right) => right.start - left.start)) { + updated = + updated.slice(0, edit.start) + edit.replacement + updated.slice(edit.end); + } + updated = addAppHostTabsImport(updateStarlightImports(updated)); + + await writeFile(file, updated); + changedFiles++; + changedGroups += edits.length; +} + +console.log(`Migrated ${changedGroups} AppHost tab groups across ${changedFiles} files.`); diff --git a/src/frontend/src/components/AppHostLanguageSelector.astro b/src/frontend/src/components/AppHostLanguageSelector.astro new file mode 100644 index 000000000..a70892a77 --- /dev/null +++ b/src/frontend/src/components/AppHostLanguageSelector.astro @@ -0,0 +1,21 @@ +--- +import PivotSelector from '@components/PivotSelector.astro'; +import { getEnabledAppHostLanguages } from '@utils/apphost-languages'; + +interface Props { + title?: string; + marginTop?: number | null; +} + +const { + title = 'Select your AppHost language', + marginTop, +} = Astro.props; +const options = getEnabledAppHostLanguages().map((language) => ({ + id: language.id, + title: language.label, + badge: language.experimental ? 'Experimental' : undefined, +})); +--- + + diff --git a/src/frontend/src/components/AppHostTabs.astro b/src/frontend/src/components/AppHostTabs.astro new file mode 100644 index 000000000..918a306da --- /dev/null +++ b/src/frontend/src/components/AppHostTabs.astro @@ -0,0 +1,66 @@ +--- +import { TabItem, Tabs } from '@astrojs/starlight/components'; +import { + getEnabledAppHostLanguages, + type AppHostLanguageId, +} from '@utils/apphost-languages'; + +interface Props { + limitations?: Partial>; +} + +const { limitations = {} } = Astro.props; +const languages = getEnabledAppHostLanguages(); +const entries = await Promise.all( + languages.map(async (language) => { + const supported = Astro.slots.has(language.id); + const limitation = limitations[language.id]; + + if (!supported && !limitation) { + throw new Error( + `AppHostTabs requires either a "${language.id}" slot or a "${language.id}" limitation.` + ); + } + + return { + language, + supported, + limitation, + content: supported ? await Astro.slots.render(language.id) : '', + }; + }) +); +const supportedEntries = entries.filter((entry) => entry.supported); +const limitationEntries = entries.filter((entry) => !entry.supported); +const supportAttributes = Object.fromEntries( + supportedEntries.map((entry) => [`data-supports-${entry.language.id}`, '']) +); +--- + +
+ { + supportedEntries.length > 0 && ( +
+ + {supportedEntries.map(({ language, content }) => ( + + + + ))} + +
+ ) + } + { + limitationEntries.map(({ language, limitation }) => ( + + )) + } +
diff --git a/src/frontend/src/components/IntegrationCard.astro b/src/frontend/src/components/IntegrationCard.astro index bd63953ee..58f9143da 100644 --- a/src/frontend/src/components/IntegrationCard.astro +++ b/src/frontend/src/components/IntegrationCard.astro @@ -4,7 +4,15 @@ import { Code } from '@astrojs/starlight/components'; import { Image } from 'astro:assets'; import csharpIcon from '@assets/icons/csharp.svg'; import typescriptIcon from '@assets/icons/typescript.svg'; +import pythonIcon from '@assets/icons/python.svg'; +import goIcon from '@assets/icons/go.svg'; +import javaIcon from '@assets/icons/java.svg'; +import rustIcon from '@assets/icons/rust.svg'; import { generateFriendlyName } from '@utils/helpers'; +import { + getEnabledAppHostLanguages, + type AppHostLanguageId, +} from '@utils/apphost-languages'; // Helper to abbreviate download counts function formatDownloads(n?: number) { @@ -56,7 +64,7 @@ function hostingDocsUrl(docs: string): string { // Append the global aspire-lang query param so the destination page's synced // Tabs (and any PivotSelector with key="aspire-lang") pre-select the right // language without depending on prior browser state. -function withLang(docs: string, lang: 'csharp' | 'typescript'): string { +function withLang(docs: string, lang: AppHostLanguageId): string { const url = hostingDocsUrl(docs); const sep = url.includes('?') ? '&' : '?'; return `${url}${sep}aspire-lang=${lang}`; @@ -66,6 +74,15 @@ function withLang(docs: string, lang: 'csharp' | 'typescript'): string { // that can render ``. External hrefs (AWS, etc.) // are skipped. const supportsLangTabs = !!pkg.docs && pkg.docs.startsWith('/'); +const appHostLanguageIcons = { + typescript: typescriptIcon, + csharp: csharpIcon, + python: pythonIcon, + go: goIcon, + java: javaIcon, + rust: rustIcon, +}; +const enabledAppHostLanguages = getEnabledAppHostLanguages(); // Title links to the integration's get-started landing page (or external docs // page when the integration ships docs off-site). The docs map already points @@ -163,24 +180,28 @@ const titleIsExternal = !!titleHref && !titleHref.startsWith('/'); {supportsLangTabs ? (
- - - - - - + {enabledAppHostLanguages.map((language) => { + const languageLabel = `${Astro.locals.t('integrations.viewLanguageDocs', { + title: pkg.title, + })}: ${language.label}`; + return ( + + + + ); + })}
) : ( pkg.docs && ( @@ -478,4 +499,3 @@ const titleIsExternal = !!titleHref && !titleHref.startsWith('/'); } - diff --git a/src/frontend/src/components/PivotSelector.astro b/src/frontend/src/components/PivotSelector.astro index 58241bd7f..007c785f6 100644 --- a/src/frontend/src/components/PivotSelector.astro +++ b/src/frontend/src/components/PivotSelector.astro @@ -1,8 +1,11 @@ --- +import { getEnabledAppHostLanguages } from '@utils/apphost-languages'; + interface Option { id: string; title: string; disabled?: boolean; + badge?: string; } type Props = { @@ -13,13 +16,18 @@ type Props = { }; const { options, key, title, marginTop } = Astro.props; +const enabledAppHostLanguages = getEnabledAppHostLanguages(); +const appHostLanguageOrder = new Map( + enabledAppHostLanguages.map((language, index) => [language.id, index]) +); const renderedOptions = key === 'aspire-lang' - ? [ - ...options.filter((option) => option.id === 'typescript'), - ...options.filter((option) => option.id === 'csharp'), - ...options.filter((option) => option.id !== 'typescript' && option.id !== 'csharp'), - ] + ? options + .filter((option) => appHostLanguageOrder.has(option.id)) + .sort( + (left, right) => + appHostLanguageOrder.get(left.id)! - appHostLanguageOrder.get(right.id)! + ) : options; --- @@ -29,7 +37,8 @@ const renderedOptions = { renderedOptions.map((o) => ( )) } @@ -159,6 +168,17 @@ const renderedOptions = min-width: 4rem; } + .pivot-option-badge { + border: 1px solid currentColor; + border-radius: 999px; + font-size: 0.65rem; + font-weight: 700; + line-height: 1; + margin-inline-start: 0.35rem; + padding: 0.15rem 0.35rem; + text-transform: uppercase; + } + .pivot-selector button:hover { background: var(--sl-color-gray-4); color: var(--sl-color-white); @@ -469,13 +489,11 @@ const renderedOptions = // pages with `` default to the same language. // Mirrors the label map in src/components/starlight/Head.astro. if (qsKey === 'aspire-lang') { - const appHostLangLabels = { csharp: 'C#', typescript: 'TypeScript' }; - const label = appHostLangLabels[id]; - if (label) { - try { - localStorage.setItem('starlight-synced-tabs__aspire-lang', label); - } catch (e) {} - } + window.dispatchEvent( + new CustomEvent('apphost-language-select', { + detail: { language: id }, + }) + ); } // sync URL @@ -506,8 +524,9 @@ const renderedOptions = } selector.addEventListener('click', (e) => { - if (e.target.dataset.pivotOption) { - apply(e.target.dataset.pivotOption); + const button = e.target.closest('button[data-pivot-option]'); + if (button && selector.contains(button)) { + apply(button.dataset.pivotOption); } }); diff --git a/src/frontend/src/components/SimpleAppHostCode.astro b/src/frontend/src/components/SimpleAppHostCode.astro index 38e76a635..3f2bacbca 100644 --- a/src/frontend/src/components/SimpleAppHostCode.astro +++ b/src/frontend/src/components/SimpleAppHostCode.astro @@ -1,5 +1,6 @@ --- -import { Code, TabItem, Tabs } from '@astrojs/starlight/components'; +import { Code } from '@astrojs/starlight/components'; +import AppHostTabs from './AppHostTabs.astro'; import { getSimpleAppHostCode, shiftCollapseValue, @@ -24,8 +25,8 @@ const typescriptCollapse = shiftCollapseValue(collapse, typescriptLineOffset); { typescriptCode ? ( - - + + - - + + - - + + ) : ( <> } -