Skip to content
Open
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
6 changes: 4 additions & 2 deletions src/frontend/astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ import Icons from 'starlight-plugin-icons';
const modeArgIndex = process.argv.indexOf('--mode');
const isSkipSearchBuild = modeArgIndex >= 0 && process.argv[modeArgIndex + 1] === 'skip-search';
const isBuildTimingEnabled = process.env.BUILD_TIMING === '1';
const siteDescription =
'Aspire is a multi-language local dev-time orchestration tool chain for building, running, debugging, and deploying distributed applications.';

// Astro renders pages mostly on the main JS thread. Default `build.concurrency`
// is 1, so a multi-vCPU CI runner is largely idle during the generate phase.
Expand Down Expand Up @@ -61,6 +63,7 @@ export default defineConfig({
starlight: {
pagefind: !isSkipSearchBuild,
title: 'Aspire',
description: siteDescription,
routeMiddleware: ['./src/route-data-middleware'],
defaultLocale: 'root',
locales,
Expand Down Expand Up @@ -149,8 +152,7 @@ export default defineConfig({
starlightGitHubAlerts(),
starlightLlmsTxt({
projectName: 'Aspire',
description:
'Aspire is a multi-language local dev-time orchestration tool chain for building, running, debugging, and deploying distributed applications.',
description: siteDescription,
// Strip transient annotations injected by expressive-code-twoslash from the
// rendered HTML before it's converted back to Markdown. Without this, the
// TypeScript hover popovers (type signatures, JSDoc, error boxes, etc.)
Expand Down
53 changes: 53 additions & 0 deletions src/frontend/config/apphost-examples.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { createHash } from 'node:crypto';
import { select, selectAll } from 'hast-util-select';
import rehypeParse from 'rehype-parse';
import { unified } from 'unified';

/**
* Move the builder's highlighted examples to one lazy-loaded static file.
* @param {string} html
* @returns {{ html: string; examples: string; filename: string }}
*/
export function deferAppHostExamples(html) {
const tree = unified().use(rehypeParse).parse(html);
const builder = select('[data-apphost-builder]', tree);
const groups = selectAll('[data-apphost-builder] .code-lang-group', tree);
const initial = select(
'[data-apphost-builder] [data-code-lang="typescript"] [data-variant="frontend"]',
tree
);
if (!builder || groups.length !== 2 || !initial) {
throw new Error('Cannot defer AppHost examples: missing builder or default example.');
}

const source = (node) => html.slice(node.position.start.offset, node.position.end.offset);
const sourceWithoutCopyControls = (node) => {
let markup = source(node);
for (const copy of selectAll('.copy', node).toReversed()) {
const start = copy.position.start.offset - node.position.start.offset;
const end = copy.position.end.offset - node.position.start.offset;
markup = markup.slice(0, start) + markup.slice(end);
}
return markup;
};
const examples = groups.map(sourceWithoutCopyControls).join('\n');
const hash = createHash('sha256').update(examples).digest('hex').slice(0, 16);
const filename = `apphost-examples.${hash}.html`;

// Edit the original source ranges so unrelated homepage markup stays byte-identical.
for (const group of groups.toReversed()) {
const start = group.position.start.offset;
const openingTag = html.slice(start, html.indexOf('>', start) + 1);
const body =
group.properties.dataCodeLang === 'typescript' ? sourceWithoutCopyControls(initial) : '';
html =
html.slice(0, start) + openingTag + body + '</div>' + html.slice(group.position.end.offset);
}
const attributeOffset = builder.position.start.offset + builder.tagName.length + 1;
html =
html.slice(0, attributeOffset) +
` data-apphost-examples="/_astro/${filename}"` +
html.slice(attributeOffset);

return { html, examples, filename };
}
30 changes: 24 additions & 6 deletions src/frontend/config/aspire-version-placeholders-integration.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,15 @@ 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 { renderHomepageMarkdown } from './homepage-markdown.mjs';
import { locales } from './locales.ts';
import { deferAppHostExamples } from './apphost-examples.mjs';

// Per-page Markdown copies emitted by `starlight-page-actions` bypass the
// remark transforms that replace Aspire version placeholders and order AppHost
// language tabs:
// that plugin `viteStaticCopy`s `src/content/docs/**/*.{md,mdx}` straight to
// `dist/**/*.md` through a regex-only transform, so it never runs through the
// `dist/**/*.md` through source cleanup, so it never runs through the
// configured remark pipeline.
//
// Everything else is already handled before it reaches `dist`:
Expand All @@ -17,10 +20,9 @@ import { orderTypeScriptFirstAppHostTabsInMarkdown } from './remark-typescript-f
// - `llms*.txt` -> `starlight-llms-txt` sources rendered HTML (`render(entry)`)
// - `reference/**.md` -> generated from API/sample data, not docs content
//
// So this post-build pass only needs to touch `.md` files. Scoping it this way
// (instead of walking every `.html`/`.txt` in `dist`) avoids re-reading the bulk
// of the output — including the large `llms-full.txt` assets — which is what
// previously exhausted the Node heap.
// Version normalization only walks `.md` files. The homepage finalization
// below also reads the known homepage HTML files, not every `.html`/`.txt`
// asset in `dist`, which previously exhausted the Node heap.
const markdownCopyExtensions = new Set(['.md']);

// Process the Markdown copies through a small worker pool rather than a single
Expand All @@ -33,7 +35,23 @@ export function aspireVersionPlaceholdersIntegration() {
name: 'aspire-version-placeholders',
hooks: {
'astro:build:done': async ({ dir }) => {
await replaceAspireVersionPlaceholdersInDirectory(fileURLToPath(dir));
const directory = fileURLToPath(dir);
// Page-actions copies raw MDX, so component-only homepages need their
// rendered content instead. Ordinary documentation keeps its existing path.
for (const locale of Object.keys(locales)) {
const localePath = locale === 'root' ? '' : locale;
const html = await readFile(path.join(directory, localePath, 'index.html'), 'utf8');
const markdown = await renderHomepageMarkdown(html);
await writeFile(path.join(directory, `${localePath || 'index'}.md`), markdown, 'utf8');
const deferred = deferAppHostExamples(html);
await writeFile(
path.join(directory, '_astro', deferred.filename),
deferred.examples,
'utf8'
);
await writeFile(path.join(directory, localePath, 'index.html'), deferred.html, 'utf8');
}
await replaceAspireVersionPlaceholdersInDirectory(directory);
},
},
};
Expand Down
8 changes: 0 additions & 8 deletions src/frontend/config/head.attrs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,6 @@ export type HeadAttr = {

export const headAttrs: HeadAttr[] = [
// SEO meta tags for discoverability (including legacy ".NET Aspire" branding)
{
tag: 'meta',
attrs: {
name: 'description',
content:
'Aspire is a multi-language local dev-time orchestration tool chain for building, running, debugging, and deploying distributed applications.',
},
},
{
tag: 'meta',
attrs: {
Expand Down
111 changes: 111 additions & 0 deletions src/frontend/config/homepage-markdown.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { select, selectAll } from 'hast-util-select';
import rehypeParse from 'rehype-parse';
import rehypeRemark from 'rehype-remark';
import remarkGfm from 'remark-gfm';
import remarkStringify from 'remark-stringify';
import { unified } from 'unified';
import { remove } from 'unist-util-remove';

const decorativeContent = [
'script',
'style',
'svg',
'button',
'input',
'label',
'img[alt=""]',
'i[aria-hidden="true"]',
'span[aria-hidden="true"]',
'.home-hero-eyebrow',
'.section-index > span',
'.principle-number',
'.quote-mark',
'.model-terminal',
'.model-graph',
'[data-model-dashboard]',
'.dashboard-stage',
'.agent-visual',
'.environment-topology',
'.environment-stage-bar',
'[data-home-integration-rail]',
'.agent-badge-popover > strong',
].join(', ');

/**
* Convert the homepage's authored content, not its animated demonstrations.
* @param {string} html
* @returns {Promise<string>}
*/
export async function renderHomepageMarkdown(html) {
const tree = unified().use(rehypeParse).parse(html);
const hero = select('main .home-hero-copy', tree);
const content = select('main .aspire-home', tree);
if (!hero || !content || !select('h1', hero)) {
throw new Error('Cannot export homepage Markdown: missing homepage content landmarks.');
}

tree.children = [hero, content];
const decorativeNodes = new Set(selectAll(decorativeContent, tree));
remove(tree, (node) => node.type === 'comment' || decorativeNodes.has(node));

for (const actions of selectAll(
'.home-hero-actions, .observability-links, .closing-actions',
tree
)) {
const links = selectAll('a', actions);
actions.tagName = 'ul';
actions.children = links.map((link) => ({
type: 'element',
tagName: 'li',
properties: {},
children: [link],
}));
}
for (const command of selectAll('.environment-command', tree)) {
command.tagName = 'ul';
command.children = command.children
.filter((child) => child.type === 'element')
.map((child) => ({
type: 'element',
tagName: 'li',
properties: {},
children: [child],
}));
}
for (const caption of selectAll('.testimonial figcaption', tree)) {
const name = select('strong', caption);
const attribution = select('small', caption);
if (name && attribution) {
caption.children = [name, { type: 'text', value: ' — ' }, ...attribution.children];
}
}

// These are meaningful examples and environment descriptions, even when
// the interactive homepage initially hides their tab or animation stage.
for (const element of selectAll('[hidden], [aria-hidden]', tree)) {
delete element.properties.hidden;
delete element.properties.ariaHidden;
}
for (const pre of selectAll('pre[data-language]', tree)) {
const code = select('code', pre);
if (code) {
code.properties.className = [`language-${pre.properties.dataLanguage}`];
const lines = selectAll('.ec-line .code', code);
if (lines.length > 0) {
code.children = [
{
type: 'text',
value: lines.map((line) => textContent(line).replace(/\n/g, '')).join('\n'),
},
];
}
}
}

const processor = unified().use(rehypeRemark).use(remarkGfm).use(remarkStringify);
return processor.stringify(await processor.run(tree));
}

function textContent(node) {
return node.type === 'text' ? node.value : (node.children ?? []).map(textContent).join('');
}
14 changes: 12 additions & 2 deletions src/frontend/src/components/AppHostBuilder.astro
Original file line number Diff line number Diff line change
Expand Up @@ -1022,7 +1022,12 @@ await builder.build().run();`,
};
---

<div class="container" data-apphost-builder data-editor-motion-enabled="true">
<div
class="container"
data-apphost-builder
data-editor-motion-enabled="true"
data-examples-error={Astro.locals.t('appHostBuilder.examplesError')}
>
<div class="header" dir={Astro.locals.t.dir()}>
<Heading>{heading}</Heading>
{description && <p>{description}</p>}
Expand Down Expand Up @@ -1092,7 +1097,12 @@ await builder.build().run();`,
</div>
</div>

<div class="code-display not-content" data-apphost-code-display data-disable-copy>
<div
class="code-display not-content"
data-apphost-code-display
data-disable-copy
data-pagefind-ignore
>
<div class="code-stage" data-code-stage hidden>
<span class="editor-caret" data-editor-caret aria-hidden="true"></span>
</div>
Expand Down
51 changes: 45 additions & 6 deletions src/frontend/src/components/AppHostBuilder.client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,11 +148,16 @@ function initializeAppHostBuilder(root: HTMLElement): void {
let processing = false;
let caretLineIndex = 0;
let caretColumn = 0;

const getTemplate = (language: AppHostLanguage, variant: string): HTMLElement | undefined =>
root.querySelector<HTMLElement>(
`.code-lang-group[data-code-lang="${language}"] .code-variant[data-variant="${variant}"]`
) ?? undefined;
const examples = document.createElement('template');

const getTemplate = (language: AppHostLanguage, variant: string): HTMLElement | undefined => {
const selector = `.code-lang-group[data-code-lang="${language}"] .code-variant[data-variant="${variant}"]`;
return (
root.querySelector<HTMLElement>(selector) ??
examples.content.querySelector<HTMLElement>(selector) ??
undefined
);
};

const setEditorState = (state: EditorState) => {
stage.dataset.editorState = state;
Expand Down Expand Up @@ -518,6 +523,34 @@ function initializeAppHostBuilder(root: HTMLElement): void {
processing = true;
codeDisplay.setAttribute('aria-busy', 'true');

const examplesUrl = root.dataset.apphostExamples;
if (examplesUrl && !examples.content.childElementCount) {
try {
const response = await fetch(examplesUrl);
if (!response.ok) {
throw new Error(`AppHost examples request failed: ${response.status}`);
}
const content = await response.text();
const parsed = document.createElement('template');
parsed.innerHTML = content;
if (
!parsed.content.querySelector('[data-code-lang="csharp"] .code-variant') ||
!parsed.content.querySelector('[data-code-lang="typescript"] .code-variant')
) {
throw new Error('AppHost examples response does not contain both languages.');
}
examples.content.append(parsed.content);
} catch (error) {
console.error('Could not load AppHost examples.', error);
status.textContent = root.dataset.examplesError ?? 'Could not load AppHost examples.';
status.classList.remove('sr-only');
codeDisplay.setAttribute('aria-busy', 'false');
processing = false;
return;
}
}
status.classList.add('sr-only');

try {
while (
root.isConnected &&
Expand Down Expand Up @@ -617,7 +650,13 @@ function initializeAppHostBuilder(root: HTMLElement): void {
languageButtons.forEach((button) => {
button.addEventListener('click', () => {
const language = button.dataset.lang;
if (!isAppHostLanguage(language) || desiredLanguage === language) return;
if (!isAppHostLanguage(language)) return;
if (desiredLanguage === language) {
if (currentLanguage !== desiredLanguage || currentVariant !== desiredVariant) {
void processRequestedState();
}
return;
}

languageButtons.forEach((candidate) => {
const isSelected = candidate === button;
Expand Down
Loading
Loading