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
3 changes: 2 additions & 1 deletion src/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,8 @@
"update:integrations": "tsx ./scripts/update-integrations.ts",
"update:ts-api": "tsx ./scripts/update-ts-api.ts",
"update:github-stats": "tsx ./scripts/update-github-stats.ts",
"update:samples": "tsx ./scripts/update-samples.ts"
"update:samples": "tsx ./scripts/update-samples.ts",
"normalize:api-data": "tsx ./scripts/normalize-generated-api-data.ts"
},
"dependencies": {
"@astro-community/astro-embed-vimeo": "^0.3.12",
Expand Down
44 changes: 44 additions & 0 deletions src/frontend/scripts/aspire-terminology.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,16 @@ const markdownOpeners = String.raw`[*\[_]*`;
// preserving code exactly matters.
const codeRegion = /```[\s\S]*?```|~~~[\s\S]*?~~~|`[^`\n]*`/g;

// Raw sample source (`appHostCode`) has no Markdown fences to protect, so
// `normalizeProse`'s code skipping doesn't apply. This tokenizer instead splits
// C#/TypeScript source into, in priority order at each position: block comments,
// line comments, and string/char/template literals. Only the comment tokens are
// rewritten (see `normalizeAspireTerminologyInCode`); every literal — and all
// executable code between tokens — is copied through verbatim so identifiers,
// string values, and CLI command samples stay intact.
const codeCommentToken =
/(\/\*[\s\S]*?\*\/)|(\/\/[^\n]*)|(@?\$?"(?:[^"\\]|\\.|"")*")|(`(?:[^`\\]|\\.)*`)|('(?:[^'\\]|\\.)*')/g;

interface TerminologyRule {
/**
* Case-insensitive regex source matching the deprecated term core, without
Expand Down Expand Up @@ -73,6 +83,40 @@ export function normalizeAspireTerminology(
return normalizeProse(text);
}

// Normalize terminology inside C#/TypeScript source by rewriting **code comments
// only**. Comments are ignored by the compiler, so this can never corrupt
// compilable code, yet it still fixes the deprecated terms that render in a
// sample's code block (and would otherwise trip the forbidden-words check).
// Strings, char/template literals, and executable code are preserved exactly.
export function normalizeAspireTerminologyInCode(code: string): string;
export function normalizeAspireTerminologyInCode(
code: string | null | undefined
): string | null | undefined;
export function normalizeAspireTerminologyInCode(
code: string | null | undefined
): string | null | undefined {
if (code == null) {
return code;
}

let result = '';
let lastIndex = 0;

for (const match of code.matchAll(codeCommentToken)) {
const token = match[0];
// Executable code between tokens is copied verbatim: the deprecated phrases
// all contain a space, so they can never be valid identifiers there anyway.
result += code.slice(lastIndex, match.index);
// Groups 1 (block) and 2 (line) are comments; the remaining groups are
// string-like literals that must stay byte-for-byte identical.
const isComment = match[1] !== undefined || match[2] !== undefined;
result += isComment ? normalizeProse(token) : token;
lastIndex = (match.index ?? 0) + token.length;
}

return result + code.slice(lastIndex);
}

// 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 {
Expand Down
169 changes: 169 additions & 0 deletions src/frontend/scripts/normalize-generated-api-data.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
/**
* normalize-generated-api-data.ts — Enforces Aspire terminology in the generated
* C#/TypeScript API reference JSON (`src/data/pkgs/*.json`, `src/data/ts-modules/*.json`).
*
* The C# API JSON is produced by the .NET `PackageJsonGenerator`; the TS API JSON
* by `AtsJsonGenerator`. Both copy XML/JSDoc documentation text verbatim from the
* upstream packages, so deprecated Aspire terminology leaks into the committed
* data and trips the Forbidden Words CI check (see `.github/forbidden-words.json`).
* This pass rewrites only prose fields, reusing the single source of truth in
* `aspire-terminology.ts` (kept in sync with `.github/forbidden-words.json`).
*
* It transforms the raw file text (never a JSON re-serialization) so that files
* without deprecated terms stay byte-for-byte identical to the generators'
* output — the .NET serializer escapes some characters (astral code points,
* U+00A0, ...) that `JSON.stringify` would emit raw, and a round-trip would
* churn every such line. The deprecated phrases are pure ASCII with no
* JSON-escaped characters, so applying the terminology rules directly to the
* escaped string content yields exactly the correctly-escaped normalized value.
*
* Usage:
* tsx ./scripts/normalize-generated-api-data.ts # both areas
* tsx ./scripts/normalize-generated-api-data.ts --pkgs # C# API only
* tsx ./scripts/normalize-generated-api-data.ts --ts-modules # TS API only
*/

import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';

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

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const DATA_DIR = path.resolve(__dirname, '..', 'src', 'data');
export const PKGS_DIR = path.join(DATA_DIR, 'pkgs');
export const TS_MODULES_DIR = path.join(DATA_DIR, 'ts-modules');

// Per-line matcher (multiline) for EITHER a documentation node's `kind` marker
// OR a prose string field. Groups:
// 1 = kind value (kind-marker alternative)
// 2 = indent + opening quote of the key
// 3 = prose key name
// 4 = closing key quote, colon, opening value quote
// 5 = escaped value content
// 6 = closing value quote
// JSON strings can't contain literal newlines, so every value sits on one line.
// The `text` field is prose only inside `kind:"text"` nodes; code-bearing nodes
// (code, codeblock, cref, langword, paramref, ...) also carry `text` and must be
// left intact, hence the kind gating below. `description`/`returns`/`remarks`
// are always prose (and appear as string values only in the TS API + member
// summaries; the C# doc arrays open with `[` and are skipped, their inner text
// nodes handled by the `text` rule).
const nodeLine =
/^[ \t]*"kind"[ \t]*:[ \t]*"([^"]*)"|^([ \t]*")(text|description|returns|remarks)("[ \t]*:[ \t]*")((?:[^"\\]|\\.)*)(")/gm;

/**
* Rewrite deprecated Aspire terminology in the prose fields of a generated API
* JSON document, preserving every byte outside the changed phrases.
*/
export function normalizeApiJsonText(raw: string): { text: string; changes: number } {
let result = '';
let lastIndex = 0;
let lastKind: string | null = null;
let changes = 0;

for (const match of raw.matchAll(nodeLine)) {
const start = match.index ?? 0;
result += raw.slice(lastIndex, start);
lastIndex = start + match[0].length;

if (match[1] !== undefined) {
// `kind` marker — remember it so the next `text` field can be classified.
lastKind = match[1];
result += match[0];
continue;
}

const [, , open, key, separator, value, close] = match;
const isProse = key !== 'text' || lastKind === 'text';
if (!isProse) {
result += match[0];
continue;
}

const normalized = normalizeAspireTerminology(value);
if (normalized !== value) {
changes++;
}
result += `${open}${key}${separator}${normalized}${close}`;
}

return { text: result + raw.slice(lastIndex), changes };
}

/** Normalize a single API JSON file in place; returns the number of changes. */
export function normalizeApiFile(filePath: string): number {
const raw = fs.readFileSync(filePath, 'utf8');
const { text, changes } = normalizeApiJsonText(raw);
if (changes > 0) {
fs.writeFileSync(filePath, text);
}
return changes;
}

/** Normalize every `*.json` file in a directory; returns per-run counts. */
export function normalizeApiDir(dir: string): {
files: number;
changes: number;
changedFiles: string[];
} {
if (!fs.existsSync(dir)) {
return { files: 0, changes: 0, changedFiles: [] };
}

const changedFiles: string[] = [];
let files = 0;
let changes = 0;

for (const name of fs.readdirSync(dir).sort()) {
if (!name.endsWith('.json')) {
continue;
}
files++;
const fileChanges = normalizeApiFile(path.join(dir, name));
if (fileChanges > 0) {
changes += fileChanges;
changedFiles.push(name);
}
}

return { files, changes, changedFiles };
}

function main(): void {
const args = process.argv.slice(2);
const explicit = args.includes('--pkgs') || args.includes('--ts-modules');
const targets: Array<{ label: string; dir: string }> = [];
if (!explicit || args.includes('--pkgs')) {
targets.push({ label: 'pkgs', dir: PKGS_DIR });
}
if (!explicit || args.includes('--ts-modules')) {
targets.push({ label: 'ts-modules', dir: TS_MODULES_DIR });
}

let total = 0;
for (const { label, dir } of targets) {
const { files, changes, changedFiles } = normalizeApiDir(dir);
total += changes;
console.log(
` ${label}: normalized ${changes} occurrence(s) across ${changedFiles.length}/${files} file(s)`
);
for (const file of changedFiles) {
console.log(` • ${file}`);
}
}

console.log(
total > 0
? `✅ Aspire terminology normalized (${total} occurrence(s)).`
: '✅ Aspire terminology already normalized.'
);
}

const isMainModule = process.argv[1]
? path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
: false;

if (isMainModule) {
main();
}
18 changes: 18 additions & 0 deletions src/frontend/scripts/update-integration-data.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,24 @@ if ($versionsChanged -and -not $SkipRegen) {
$pkgSummary = if ($pkgDone) { ($pkgDone -replace '.*Done!\s+', '').Trim() } else { 'summary unavailable' }
Write-Host " C# API: $pkgSummary"

# 3a-normalize. Enforce Aspire terminology in the freshly generated C# API
# JSON so regenerated pkgs/ prose never trips the Forbidden Words check. The
# ts-modules JSON is normalized inside update:ts-api below (before its
# twoslash bundle), so only pkgs/ is handled here.
Write-Host "→ pnpm normalize:api-data --pkgs (Aspire terminology → pkgs/)" -ForegroundColor Cyan
Push-Location $FrontendDir
try {
$pkgNormLog = & pnpm run normalize:api-data -- --pkgs 2>&1 | Tee-Object -Variable pkgNormTeed | Out-String
$pkgNormExit = $LASTEXITCODE
}
finally {
Pop-Location
}
if ($pkgNormExit -ne 0) {
Write-Error "normalize:api-data (pkgs) failed (exit $pkgNormExit).`n$pkgNormLog`nAborting; no PR will be opened."
exit 1
}

# 3b. TS API JSON (+ chained twoslash bundle). Requires the Aspire CLI; the
# script honours ASPIRE_CLI_PATH. A non-zero exit here IS fatal — we must not
# ship a PR with C#-only pkgs updates.
Expand Down
11 changes: 7 additions & 4 deletions src/frontend/scripts/update-samples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { fileURLToPath } from 'url';

import fetch from 'node-fetch';

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

const REPO = 'microsoft/aspire-samples';
const DEFAULT_BRANCH = 'main';
Expand Down Expand Up @@ -114,9 +114,10 @@ export 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.
// `appHostCode` is rendered as C# (not prose), so only its **comments** are
// normalized — identifiers, string literals, and CLI commands stay byte-for-byte
// identical, keeping the code compilable while still fixing deprecated terms that
// would otherwise render in the sample's code block and trip forbidden-words CI.
export function normalizeSampleTerminology(sample: SampleResult): SampleResult {
return {
...sample,
Expand All @@ -125,6 +126,8 @@ export function normalizeSampleTerminology(sample: SampleResult): SampleResult {
sample.description === null ? null : normalizeAspireTerminology(sample.description),
readme: normalizeAspireTerminology(sample.readme),
readmeRaw: normalizeAspireTerminology(sample.readmeRaw),
appHostCode:
sample.appHostCode === null ? null : normalizeAspireTerminologyInCode(sample.appHostCode),
};
}

Expand Down
11 changes: 11 additions & 0 deletions src/frontend/scripts/update-ts-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ import { existsSync } from 'fs';
import { dirname, resolve } from 'path';
import { fileURLToPath } from 'url';

import { normalizeApiDir, TS_MODULES_DIR } from './normalize-generated-api-data';

const __dirname = dirname(fileURLToPath(import.meta.url));
const SCRIPT_PATH = resolve(
__dirname,
Expand Down Expand Up @@ -83,6 +85,15 @@ function main(): void {
process.exit(1);
}

// Enforce Aspire terminology in the freshly generated ts-modules JSON before
// the twoslash bundle is derived from it, so both the JSON and the .d.ts hover
// tooltips stay free of the deprecated Aspire terminology that upstream
// JSDoc/XML docs may carry. Reuses the single source of truth in
// aspire-terminology.ts.
console.log('🔄 Normalizing Aspire terminology in ts-modules JSON...');
const { changes: tsModuleChanges } = normalizeApiDir(TS_MODULES_DIR);
console.log(`✅ Normalized ${tsModuleChanges} occurrence(s) in ts-modules JSON.`);

// Refresh the twoslash .d.ts bundle so docs hover tooltips stay in sync
// with the regenerated ts-modules JSON. The bundle is source-controlled
// at src/data/twoslash/aspire.d.ts — commit the diff alongside the JSON.
Expand Down
Loading
Loading