diff --git a/src/frontend/scripts/aspire-terminology.ts b/src/frontend/scripts/aspire-terminology.ts new file mode 100644 index 000000000..61764d095 --- /dev/null +++ b/src/frontend/scripts/aspire-terminology.ts @@ -0,0 +1,119 @@ +const horizontalWhitespace = String.raw`[ \t]+`; + +// A deprecated term only matches when it isn't fused to an adjacent alphanumeric +// character, so longer tokens like `ASP.NET Aspire` or `.NET AspireX` stay intact +// while punctuation and Markdown wrappers (`_`, `*`, `[`) still count as +// boundaries around a standalone term. +const termStart = String.raw`(? `an Aspire`). + */ + readonly article?: 'a' | 'an'; +} + +// Adding a rule here normalizes a new term everywhere generated sample and +// integration data is written. Keep this list in sync with the deprecated terms +// in `.github/forbidden-words.json`. +const terminologyRules: readonly TerminologyRule[] = [ + { + term: String.raw`\.NET${horizontalWhitespace}Aspire`, + replacement: 'Aspire', + article: 'an', + }, + { + term: String.raw`dotnet${horizontalWhitespace}aspire`, + replacement: 'Aspire', + article: 'an', + }, + { + term: String.raw`app${horizontalWhitespace}host`, + replacement: 'AppHost', + }, +]; + +export function normalizeAspireTerminology(text: string): string; +export function normalizeAspireTerminology( + text: string | null | undefined +): string | null | undefined; +export function normalizeAspireTerminology( + text: string | null | undefined +): string | null | undefined { + if (text == null) { + return text; + } + + return normalizeProse(text); +} + +// Apply every terminology rule to prose only, copying fenced and inline code +// regions through untouched so sample commands stay runnable. +function normalizeProse(text: string): string { + let result = ''; + let lastIndex = 0; + + for (const match of text.matchAll(codeRegion)) { + result += applyRules(text.slice(lastIndex, match.index)); + result += match[0]; + lastIndex = match.index + match[0].length; + } + + return result + applyRules(text.slice(lastIndex)); +} + +function applyRules(text: string): string { + return terminologyRules.reduce(applyRule, text); +} + +function applyRule(text: string, rule: TerminologyRule): string { + const corrected = rule.article ? correctArticle(text, rule, rule.article) : text; + return corrected.replace(new RegExp(boundedTerm(rule), 'gi'), rule.replacement); +} + +// Bound a term core with alphanumeric edges so it can't fuse into a longer token +// while still allowing Markdown wrappers (`_`, `*`, `[`) as boundaries. +function boundedTerm(rule: TerminologyRule): string { + return `${termStart}${rule.term}${termEnd}`; +} + +// Rewrite a preceding indefinite article when the replacement changes the +// leading sound, tolerating Markdown wrappers between the article and the term. +function correctArticle(text: string, rule: TerminologyRule, article: 'a' | 'an'): string { + const capitalized = article === 'a' ? 'A' : 'An'; + const withArticle = new RegExp( + String.raw`\b([Aa])${horizontalWhitespace}(${markdownOpeners})` + boundedTerm(rule), + 'gi' + ); + return text.replace( + withArticle, + (_match, matchedArticle: string, opener: string) => + `${matchedArticle === 'A' ? capitalized : article} ${opener}${rule.replacement}` + ); +} 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..b3018dac6 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,20 @@ interface SampleResult { appHostCode: string | null; } +// `appHostCode` is intentionally excluded: it is rendered as C# (not prose), so +// rewriting terminology inside identifiers, string literals, or comments would +// risk corrupting compilable code without fixing any user-facing prose. +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), + }; +} + type AppHostKind = 'typescript' | 'csproj' | 'file-based'; interface AppHostInfo { @@ -531,7 +548,7 @@ async function processSample( const thumbnail = extractThumbnail(name, readme); const href = `${TREE_BASE}/${SAMPLES_DIR}/${name}`; - return { + return normalizeSampleTerminology({ name, title, description, @@ -543,7 +560,7 @@ async function processSample( appHost: appHostInfo?.kind ?? null, appHostPath: appHostInfo?.entryPath ?? null, appHostCode, - }; + }); } async function main(): Promise { @@ -577,7 +594,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..ecab6d314 --- /dev/null +++ b/src/frontend/tests/unit/update-samples.vitest.test.ts @@ -0,0 +1,145 @@ +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 legacyDotnetAspireName = ['dotnet', '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); + }); + + test.each([ + ['bold wrapper', `A **${legacyAspireName}** project`, 'An **Aspire** project'], + [ + 'inline link', + `This is a [${legacyAspireName}](https://aspire.dev/) sample`, + 'This is an [Aspire](https://aspire.dev/) sample', + ], + ['underscore emphasis', `A _${legacyAspireName}_ project`, 'An _Aspire_ project'], + ])('corrects the article across a %s', (_scenario, input, expected) => { + expect(normalizeAspireTerminology(input)).toBe(expected); + }); + + test.each([ + ['a prefixed framework token', `ASP${legacyAspireName} guidance`], + ['a dotted namespace', `Ship Microsoft${legacyAspireName} today`], + ['a suffixed token', `${legacyAspireName}Server guidance`], + ['a suffixed token after an article', `A ${legacyAspireName}Server sample`], + ])('leaves %s untouched', (_scenario, input) => { + expect(normalizeAspireTerminology(input)).toBe(input); + }); + + test.each([ + ['uppercase article', `A ${legacyDotnetAspireName} sample`, 'An Aspire sample'], + ['lowercase article', `Build a ${legacyDotnetAspireName} sample`, 'Build an Aspire sample'], + ['no article', `Deploy the ${legacyDotnetAspireName} app`, 'Deploy the Aspire app'], + ])('normalizes the "dotnet aspire" spelling (%s)', (_scenario, input, expected) => { + expect(normalizeAspireTerminology(input)).toBe(expected); + }); + + test.each([ + ['a fenced block', '```bash\n' + `${legacyDotnetAspireName} run` + '\n```'], + ['an inline code command', `Run \`${legacyDotnetAspireName} run\` to start.`], + ['a fenced legacy brand', '```\n' + legacyAspireName + '\n```'], + ])('preserves %s so sample commands stay runnable', (_scenario, input) => { + expect(normalizeAspireTerminology(input)).toBe(input); + }); + + test('normalizes prose while preserving adjacent code', () => { + const input = `Build a ${legacyAspireName} app, then run \`${legacyDotnetAspireName} run\`.`; + expect(normalizeAspireTerminology(input)).toBe( + 'Build an Aspire app, then run `dotnet aspire run`.' + ); + }); + + test.each([ + ['prose', `A ${legacyAspireName} ${legacyAppHostName} sample`], + ['mixed prose and code', `Run \`${legacyDotnetAspireName} run\` in a ${legacyAspireName} app`], + ])('is idempotent for %s', (_scenario, input) => { + const once = normalizeAspireTerminology(input); + expect(normalizeAspireTerminology(once)).toBe(once); + }); + + test('passes null and undefined through unchanged', () => { + expect(normalizeAspireTerminology(null)).toBeNull(); + expect(normalizeAspireTerminology(undefined)).toBeUndefined(); + }); +}); + +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\n` + + `Run the ${legacyAppHostName.toUpperCase()}.\n\n` + + '```bash\n' + + `${legacyDotnetAspireName} run\n` + + '```\n', + 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\n' + 'Run the AppHost.\n\n' + '```bash\n' + 'dotnet aspire run\n' + '```\n', + }); + }); + + 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 prose normalized', () => { + const proseFields = ['title', 'description', 'readme', 'readmeRaw'] as const; + const violations = samples.flatMap((sample) => + proseFields + .filter((field) => { + const value = sample[field]; + return value != null && normalizeAspireTerminology(value) !== value; + }) + .map((field) => `${sample.name}.${field}`) + ); + + expect(violations).toEqual([]); + }); +});