diff --git a/src/frontend/scripts/aspire-terminology.ts b/src/frontend/scripts/aspire-terminology.ts new file mode 100644 index 000000000..b21093df1 --- /dev/null +++ b/src/frontend/scripts/aspire-terminology.ts @@ -0,0 +1,19 @@ +const horizontalWhitespace = String.raw`[ \t]+`; +const legacyAspirePattern = String.raw`\.NET${horizontalWhitespace}Aspire`; +const legacyAppHostPattern = String.raw`\bapp${horizontalWhitespace}host\b`; + +const aspireWithArticle = new RegExp( + 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'); +} diff --git a/src/frontend/scripts/update-integrations.ts b/src/frontend/scripts/update-integrations.ts index 06787b78a..56fc3b533 100644 --- a/src/frontend/scripts/update-integrations.ts +++ b/src/frontend/scripts/update-integrations.ts @@ -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.']; @@ -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()) ?? [], diff --git a/src/frontend/scripts/update-samples.ts b/src/frontend/scripts/update-samples.ts index c0b24a2c0..25bfa8838 100644 --- a/src/frontend/scripts/update-samples.ts +++ b/src/frontend/scripts/update-samples.ts @@ -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, @@ -97,7 +100,7 @@ interface GitTreeResponse { truncated?: boolean; } -interface SampleResult { +export interface SampleResult { name: string; title: string; description: string | null; @@ -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 { @@ -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, @@ -543,7 +559,7 @@ async function processSample( appHost: appHostInfo?.kind ?? null, appHostPath: appHostInfo?.entryPath ?? null, appHostCode, - }; + }); } async function main(): Promise { @@ -577,7 +593,13 @@ async function main(): Promise { 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; + }); +} diff --git a/src/frontend/src/utils/samples.ts b/src/frontend/src/utils/samples.ts index f83f731f0..ff27849a7 100644 --- a/src/frontend/src/utils/samples.ts +++ b/src/frontend/src/utils/samples.ts @@ -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); diff --git a/src/frontend/tests/unit/update-samples.vitest.test.ts b/src/frontend/tests/unit/update-samples.vitest.test.ts new file mode 100644 index 000000000..025f2c0bd --- /dev/null +++ b/src/frontend/tests/unit/update-samples.vitest.test.ts @@ -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([]); + }); +});