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
19 changes: 19 additions & 0 deletions src/frontend/scripts/aspire-terminology.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
const horizontalWhitespace = String.raw`[ \t]+`;
const legacyAspirePattern = String.raw`\.NET${horizontalWhitespace}Aspire`;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This pattern also matches the .NET Aspire substring inside a longer term. For example, ASP.NET Aspire application becomes ASPAspire application, and Microsoft.NET Aspire becomes MicrosoftAspire. Because this helper runs over arbitrary README and AppHost source text, a future input can be corrupted rather than merely normalized. Please require .NET not to be preceded by a word character and add regression cases for these inputs.

const legacyAppHostPattern = String.raw`\bapp${horizontalWhitespace}host\b`;

const aspireWithArticle = new RegExp(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The article correction only recognizes unformatted text, but readme and readmeRaw are raw Markdown. A **.NET Aspire** project currently becomes A **Aspire** project, and A [.NET Aspire](https://aspire.dev/) project becomes A [Aspire](https://aspire.dev/) project. That leaves the generated docs grammatically incorrect. Please handle Markdown wrappers/text nodes when correcting the article and cover bold/link cases in the tests.

String.raw`\b([Aa])${horizontalWhitespace}${legacyAspirePattern}\b`,
'gi'
);
const legacyAspire = new RegExp(legacyAspirePattern, 'gi');
const legacyAppHost = new RegExp(legacyAppHostPattern, 'gi');

export function normalizeAspireTerminology(text: string): string {
return text
.replace(aspireWithArticle, (_match, article: string) =>
article === 'A' ? 'An Aspire' : 'an Aspire'
)
.replace(legacyAspire, 'Aspire')
.replace(legacyAppHost, 'AppHost');
}
6 changes: 3 additions & 3 deletions src/frontend/scripts/update-integrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
isOfficialAspirePackage,
resolveOfficialAspirePackageSource,
} from './aspire-package-source';
import { normalizeAspireTerminology } from './aspire-terminology';

const OFFICIAL_NUGET_ORG_QUERIES = ['owner:aspire', 'Aspire.Hosting.'];
const OFFICIAL_RELEASE_FEED_QUERIES = ['Aspire.'];
Expand Down Expand Up @@ -285,9 +286,8 @@ function filterAndTransform(pkgs: PackageRecord[]): IntegrationOutput[] {
})
.map((pkg) => ({
title: pkg.id,
description: pkg.description
?.replace(/\bA \.NET Aspire\b/gi, 'An Aspire')
.replace(/\.NET Aspire/gi, 'Aspire'),
description:
pkg.description === undefined ? undefined : normalizeAspireTerminology(pkg.description),
icon: resolveIconUrl(pkg),
href: `https://www.nuget.org/packages/${pkg.id}`,
tags: pkg.tags?.map((tag) => tag.toLowerCase()) ?? [],
Expand Down
36 changes: 29 additions & 7 deletions src/frontend/scripts/update-samples.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import fs from 'fs';
import path from 'path';
import { pipeline } from 'stream/promises';
import { fileURLToPath } from 'url';

import fetch from 'node-fetch';

import { normalizeAspireTerminology } from './aspire-terminology';

const REPO = 'microsoft/aspire-samples';
const DEFAULT_BRANCH = 'main';
// `BRANCH` controls which ref of `microsoft/aspire-samples` is fetched (README,
Expand Down Expand Up @@ -97,7 +100,7 @@ interface GitTreeResponse {
truncated?: boolean;
}

interface SampleResult {
export interface SampleResult {
name: string;
title: string;
description: string | null;
Expand All @@ -111,6 +114,19 @@ interface SampleResult {
appHostCode: string | null;
}

export function normalizeSampleTerminology(sample: SampleResult): SampleResult {
return {
...sample,
title: normalizeAspireTerminology(sample.title),
description:
sample.description === null ? null : normalizeAspireTerminology(sample.description),
readme: normalizeAspireTerminology(sample.readme),
readmeRaw: normalizeAspireTerminology(sample.readmeRaw),
appHostCode:
sample.appHostCode === null ? null : normalizeAspireTerminology(sample.appHostCode),
};
}

type AppHostKind = 'typescript' | 'csproj' | 'file-based';

interface AppHostInfo {
Expand Down Expand Up @@ -531,7 +547,7 @@ async function processSample(
const thumbnail = extractThumbnail(name, readme);
const href = `${TREE_BASE}/${SAMPLES_DIR}/${name}`;

return {
return normalizeSampleTerminology({
name,
title,
description,
Expand All @@ -543,7 +559,7 @@ async function processSample(
appHost: appHostInfo?.kind ?? null,
appHostPath: appHostInfo?.entryPath ?? null,
appHostCode,
};
});
}

async function main(): Promise<void> {
Expand Down Expand Up @@ -577,7 +593,13 @@ async function main(): Promise<void> {
console.log(`\n✅ Saved ${results.length} samples to ${OUTPUT_PATH}`);
}

main().catch((error: unknown) => {
console.error('❌ Error:', getErrorMessage(error));
process.exit(1);
});
const isMainModule = process.argv[1]
? path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
: false;

if (isMainModule) {
void main().catch((error: unknown) => {
console.error('❌ Error:', getErrorMessage(error));
process.exitCode = 1;
});
}
6 changes: 3 additions & 3 deletions src/frontend/src/utils/samples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,9 +188,9 @@ interface BuildSampleMarkdownOptions {
* page-actions plugin's "Copy Markdown" and "View Markdown" actions return a
* portable, LLM-friendly document.
*
* The base content is the original upstream README (readmeRaw); relative image
* paths are rewritten to absolute GitHub raw URLs so they resolve when the
* markdown is opened in a browser or pasted into another tool.
* The base content is the terminology-normalized upstream README (readmeRaw);
* relative image paths are rewritten to absolute GitHub raw URLs so they
* resolve when the markdown is opened in a browser or pasted into another tool.
*/
export function buildSampleMarkdown(sample: Sample, options: BuildSampleMarkdownOptions): string {
const body = rewriteSampleImageUrls(sample.readmeRaw ?? sample.readme, sample.name);
Expand Down
85 changes: 85 additions & 0 deletions src/frontend/tests/unit/update-samples.vitest.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { describe, expect, test } from 'vitest';

import samples from '@data/samples.json';

import { normalizeAspireTerminology } from '../../scripts/aspire-terminology';
import { type SampleResult, normalizeSampleTerminology } from '../../scripts/update-samples';

const legacyAspireName = ['.NET', 'Aspire'].join(' ');
const legacyAppHostName = ['app', 'host'].join(' ');

describe('Aspire terminology normalization', () => {
test.each([
['uppercase article', `A ${legacyAspireName} project`, 'An Aspire project'],
['lowercase article', `Build a ${legacyAspireName} project`, 'Build an Aspire project'],
[
'extra horizontal whitespace',
`A ${['.NET', 'Aspire'].join('\t')} project`,
'An Aspire project',
],
])('uses one space for the %s case', (_scenario, input, expected) => {
expect(normalizeAspireTerminology(input)).toBe(expected);
});
});

describe('sample terminology normalization', () => {
test('normalizes every generated text field', () => {
const sample: SampleResult = {
name: 'terminology-sample',
title: `${legacyAspireName} ${legacyAppHostName} sample`,
description: `A ${legacyAspireName} ${legacyAppHostName} project.`,
href: 'https://github.com/microsoft/aspire-samples/tree/main/samples/terminology-sample',
readme: `# ${legacyAspireName} sample\n\nRun the ${legacyAppHostName}.`,
readmeRaw: `# ${legacyAspireName} sample\n\nRun the ${legacyAppHostName.toUpperCase()}.`,
tags: ['csharp'],
thumbnail: null,
appHost: 'csproj',
appHostPath: 'Terminology.AppHost/AppHost.cs',
appHostCode: `// Keep the container running between ${legacyAppHostName} sessions.`,
};

expect(normalizeSampleTerminology(sample)).toEqual({
...sample,
title: 'Aspire AppHost sample',
description: 'An Aspire AppHost project.',
readme: '# Aspire sample\n\nRun the AppHost.',
readmeRaw: '# Aspire sample\n\nRun the AppHost.',
appHostCode: '// Keep the container running between AppHost sessions.',
});
});

test('does not rewrite related words that are not deprecated terms', () => {
const sample: SampleResult = {
name: 'hosting-sample',
title: 'App hosting sample',
description: null,
href: 'https://github.com/microsoft/aspire-samples/tree/main/samples/hosting-sample',
readme: 'This sample demonstrates app hosting.',
readmeRaw: 'This sample demonstrates app hosting.',
tags: [],
thumbnail: null,
appHost: null,
appHostPath: null,
appHostCode: null,
};

expect(normalizeSampleTerminology(sample)).toEqual(sample);
});

test('keeps generated sample data free of deprecated terminology', () => {
const textFields = ['title', 'description', 'readme', 'readmeRaw', 'appHostCode'] as const;
const deprecatedTerminology = new RegExp(
[legacyAspireName, legacyAppHostName]
.map((term) => term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
.join('|'),
'i'
);
const violations = samples.flatMap((sample) =>
textFields
.filter((field) => deprecatedTerminology.test(sample[field] ?? ''))
.map((field) => `${sample.name}.${field}`)
);

expect(violations).toEqual([]);
});
});
Loading