Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
82 changes: 82 additions & 0 deletions src/frontend/scripts/aspire-terminology.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
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 `_`, `*`, backticks, and `[` still count as boundaries (Markdown emphasis,
// inline code, and links wrap terms with those characters).
const termStart = String.raw`(?<![A-Za-z0-9])`;
const termEnd = String.raw`(?![A-Za-z0-9])`;

// Markdown emphasis/link/code openers can sit between an article and a term in
// raw README/AppHost 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.
const markdownOpeners = String.raw`[*\[_\x60]*`;

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 {
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 (`_`, `*`, backticks, `[`) 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
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
116 changes: 116 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,116 @@
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'],
['inline code', `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);
});
});

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, legacyDotnetAspireName, 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