Skip to content
Merged
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
119 changes: 119 additions & 0 deletions src/frontend/scripts/aspire-terminology.ts
Original file line number Diff line number Diff line change
@@ -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`(?<![A-Za-z0-9])`;
const termEnd = String.raw`(?![A-Za-z0-9])`;

// Markdown emphasis/link openers can sit between an article and a term in raw
// README content (e.g. `a **.NET Aspire**`, `a [.NET Aspire](url)`, or
// `a _.NET Aspire_`), so the article corrector consumes them to stay grammatical
// after the term shrinks. Backticks are intentionally excluded: inline code is
// skipped wholesale (see `codeRegion`), so a term inside it is never rewritten.
const markdownOpeners = String.raw`[*\[_]*`;

// Fenced code blocks and inline code spans are copied through verbatim so sample
// commands like `dotnet aspire run` are never rewritten into an unrunnable
// `Aspire run`. Everything outside these regions is treated as prose. This runs
// over raw README Markdown (`readmeRaw` feeds the Copy/View Markdown actions), so
// preserving code exactly matters.
const codeRegion = /```[\s\S]*?```|~~~[\s\S]*?~~~|`[^`\n]*`/g;

interface TerminologyRule {
/**
* Case-insensitive regex source matching the deprecated term core, without
* boundaries. Use `${horizontalWhitespace}` for internal spaces; alphanumeric
* boundaries are applied automatically so a rule can never corrupt a longer
* token like `ASP.NET Aspire` or `.NET AspireX`.
*/
readonly term: string;
/** Canonical replacement text. */
readonly replacement: string;
/**
* Indefinite article the replacement should take. Set this only when the
* replacement's leading sound differs from the term's, so a preceding `a`/`an`
* is corrected (e.g. `a .NET Aspire` -> `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}`
);
}
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
37 changes: 30 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,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 {
Expand Down Expand Up @@ -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,
Expand All @@ -543,7 +560,7 @@ async function processSample(
appHost: appHostInfo?.kind ?? null,
appHostPath: appHostInfo?.entryPath ?? null,
appHostCode,
};
});
}

async function main(): Promise<void> {
Expand Down Expand Up @@ -577,7 +594,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
145 changes: 145 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,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([]);
});
});
Loading