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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 79 additions & 60 deletions .agents/skills/doc-writer/SKILL.md

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions .agents/skills/whatsnew/references/02-research.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,8 @@ This is the phase that turns the skeleton into a real, reviewable page.
the section (next step).
7. **Author the draft.** Populate the scaffolded MDX from the dossier: write the lede,
the "This release introduces" bullets (1:1 with the `##` sections, same order), and
each section body with impact-first prose, `LearnMore` deep-links, and TypeScript/C#
`<Tabs syncKey='aspire-lang'>` with TypeScript first where a feature spans AppHost languages. Credit each
each section body with impact-first prose, `LearnMore` deep-links, and registry-backed
`AppHostTabs` where a feature spans AppHost languages. Credit each
merged community PR by `@handle`. **Only include sections that apply** — delete any
standard section with no content. In particular, when the release has **no breaking
changes**, remove the "⚠️ Breaking changes" section *and* the breaking-changes
Expand Down
2 changes: 1 addition & 1 deletion .agents/skills/whatsnew/references/03-critique.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ actionable, severity-ranked findings report. **Makes no edits** — {polish} act
- `<Aside>` used **judiciously** (not eye-burning). Breaking-changes caution present
**only** when the release has breaking changes — when it has none, both the caution
*and* the "⚠️ Breaking changes" section are omitted (no empty section left behind).
- TypeScript/C# **tab parity** (`syncKey='aspire-lang'`, TypeScript first) wherever a feature spans AppHost languages.
- **AppHost language parity** through `AppHostTabs`, with every enabled registry language implemented or explicitly limited.
- `publishDate` frontmatter set (the `Released MMMM D, YYYY` badge + GitHub release-notes link auto-render; no hand-placed header).

**Content standards (mandated)**
Expand Down
2 changes: 1 addition & 1 deletion .agents/skills/whatsnew/references/05-polish.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ lands *after* the `{validate}` gate and must not ship unchecked.
- Tighten to **KISS**; smooth flow and voice per the `doc-writer` skill; dedupe.
- Confirm the "This release introduces" bullets map **1:1** to sections (same order).
- Right-size `<Aside>` usage (judicious — don't burn the reader's eyes).
- TypeScript/C# **tab parity** (`syncKey='aspire-lang'`, TypeScript first) complete and correct.
- `AppHostTabs` parity is complete: every enabled registry language has a verified implementation or explicit limitation.
- Reconcile **"✨ New integrations"**, **"📦 Integration updates"**, and **"🐳 Default
container image updates"** against the freshly regenerated data (below).
- Finalize **contributor thanks** (`@handle` + one-line note per merged community PR).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ The easiest way to upgrade to Aspire {{VERSION_MAJOR_MINOR}} is using the [`aspi
sections. Order sections by customer impact / DX. Add feature-specific
sections (e.g. "🌐 TypeScript AppHost", "🧱 Go and Bun") near the top when
they are the headline. Remove any standard section that doesn't apply.
Use <Tabs syncKey='aspire-lang'> with TypeScript first for TypeScript/C# parity, `LearnMore` to
Use AppHostTabs for registry-driven AppHost language parity, `LearnMore` to
deep-link, and <Aside> sparingly for genuinely important call-outs.
───────────────────────────────────────────────────────────────────────── */}

Expand Down
6 changes: 3 additions & 3 deletions .agents/skills/whatsnew/references/writing-guidelines.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,9 +94,9 @@ These are the specific quality gates critique/validate enforce:
Go) rather than defining any of them in opposition to another.
- **Second person, active voice, imperative mood.** Concise, professional-approachable.
- **Dates spelled out:** "August 18, 2025" — never "8/18/25".
- **TypeScript/C# parity:** when a feature spans AppHost languages, show both with
`<Tabs syncKey='aspire-lang'>` / `<TabItem>` so the tab choice syncs page-wide.
Put TypeScript first so `apphost.mts` is the default for readers without a saved preference.
- **AppHost language parity:** when a feature spans AppHost languages, use
`AppHostTabs`. Implement every enabled registry language or provide an explicit
operation-level limitation; the component owns order and page-wide selection.
- **Version tokens:** in prose for the *current* release you may use the build-time
placeholders `%ASPIRE_VERSION%` / `%ASPIRE_VERSION_MAJOR_MINOR%` (replaced by the
remark plugin). The article slug, sidebar, and header use the literal version.
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ This step ensures that the documentation you've created is properly indexed and

- Import `Badge` from '@astrojs/starlight/components' and add `<Badge text="⭐ Community Toolkit" variant="tip" size="large" />` at the top
- Import and use the `Aside` component for notes, tips, cautions, and warnings
- Import and use synced `Tabs` and `TabItem` from `@astrojs/starlight/components` for AppHost examples when both TypeScript and C# AppHost APIs are available. Put the `typescript` tab first, followed by `csharp`, and use `syncKey='aspire-lang'`.
- Import and use `AppHostTabs` from `@components/AppHostTabs.astro` for AppHost examples. Follow `src/frontend/src/data/apphost-languages.json`; provide verified language slots and operation-level `limitations` for generated SDKs that do not expose the API.
- Import and use `InstallPackage` for hosting packages
- Import and use `InstallDotNetPackage` for client packages
- Import `Image` from 'astro:assets' for icons
Expand Down Expand Up @@ -171,9 +171,9 @@ prerequisites. Do not present the add-on as a standalone service.
### AppHost language parity

- Follow the `doc-writer` skill's AppHost language parity guidance for all AppHost and hosting-integration examples.
- Always show both TypeScript AppHost (`apphost.mts`) and C# AppHost (`AppHost.cs`) variants inside synced `Tabs` (with `syncKey='aspire-lang'`) unless the feature is genuinely language-specific or TypeScript AppHost support does not exist yet. Put TypeScript first so it is the default.
- Before writing a TypeScript AppHost example, verify the API exists in the TypeScript AppHost SDK. Do not invent TypeScript samples.
- If TypeScript AppHost support is not available, show only the C# example without language tabs and add a note that TypeScript AppHost support for the integration is not yet available.
- Account for TypeScript, C#, Python, Go, Java, and Rust in every AppHost operation. Show complete variants only when the exact generated API exists; otherwise configure an explicit `AppHostTabs` limitation for that language.
- Verify every generated SDK independently. Do not infer Python, Go, Java, or Rust names, option shapes, callback signatures, or error handling from TypeScript or C#.
- Let `AppHostTabs` and the central registry control order, visibility, Experimental badges, and selection persistence.
- Use language-neutral prose around AppHost examples, such as "Add a resource to your AppHost" instead of C#-specific method instructions.

### Writing style
Expand Down
53 changes: 53 additions & 0 deletions src/frontend/config/apphost-language-docs-loader.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { docsLoader } from '@astrojs/starlight/loaders';
import appHostLanguageConfig from '../src/data/apphost-languages.json' with { type: 'json' };

export function getDisabledAppHostProjectPageIds(config = appHostLanguageConfig) {
return config.languages
.filter((language) => language.id !== 'csharp' && !language.enabled)
.map((language) => `app-host/${language.id}-apphost`);
}

export function appHostLanguageDocsLoader(
baseLoader = docsLoader(),
config = appHostLanguageConfig
) {
return {
...baseLoader,
name: 'aspire-apphost-language-docs-loader',
async load(context) {
const disabledIds = new Set(getDisabledAppHostProjectPageIds(config));

for (const id of disabledIds) {
context.store.delete(id);
}

const store = new Proxy(context.store, {
get(target, property) {
if (property === 'get') {
return (id) => (disabledIds.has(id) ? undefined : target.get(id));
}
if (property === 'set') {
return (entry) => (disabledIds.has(entry.id) ? false : target.set(entry));
}
if (property === 'has') {
return (id) => !disabledIds.has(id) && target.has(id);
}
if (property === 'keys') {
return () => target.keys().filter((id) => !disabledIds.has(id));
}
if (property === 'values') {
return () => target.values().filter((entry) => !disabledIds.has(entry.id));
}
if (property === 'entries') {
return () => target.entries().filter(([id]) => !disabledIds.has(id));
}

const value = Reflect.get(target, property, target);
return typeof value === 'function' ? value.bind(target) : value;
},
});

await baseLoader.load({ ...context, store });
},
};
}
161 changes: 161 additions & 0 deletions src/frontend/config/apphost-language-markdown.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import { createProcessor } from '@mdx-js/mdx';

const markdownParser = createProcessor();
const appHostLanguageSourcePattern = /<AppHost(?:Tabs|LanguagePivot)\b/i;

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 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(`</${node.name}>`, 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 (node.type === 'mdxJsxFlowElement' && node.name === 'AppHostLanguagePivot') {
const start = node.position?.start.offset;
const end = node.position?.end.offset;
const languageId = getStringAttribute(node, 'id');
if (typeof start !== 'number' || typeof end !== 'number' || !languageId) {
throw new Error('Unable to locate AppHostLanguagePivot in the Markdown source.');
}

const language = enabledLanguages.find((candidate) => candidate.id === languageId);
edits.push({
start,
end,
replacement: language ? getInnerSource(node, markdown).trim() : '',
});
return;
}

if (Array.isArray(node.children)) {
for (const child of node.children) {
collectAppHostTabEdits(child, markdown, enabledLanguages, edits);
}
}
}

export function renderAppHostTabsInMarkdown(markdown, languages) {
if (!appHostLanguageSourcePattern.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;
}
Loading
Loading