Remove legacy TypeScript frontend and npm pipeline

This commit is contained in:
2026-02-25 12:31:39 +01:00
parent 35c60c4ea2
commit 0f44cc466b
31 changed files with 20 additions and 2526 deletions

View File

@@ -1,5 +1,4 @@
param(
[switch]$SkipNpmInstall,
[switch]$SkipDotnetRestore,
[switch]$SkipBuild
)
@@ -25,12 +24,6 @@ $repoRoot = Split-Path -Parent $scriptDir
Push-Location $repoRoot
try {
if (-not $SkipNpmInstall) {
Invoke-Step -Name "Install frontend tooling (npm install)" -Action {
npm install
}
}
if (-not $SkipDotnetRestore) {
Invoke-Step -Name "Restore .NET solution" -Action {
dotnet restore RpgRoller.sln
@@ -43,18 +36,6 @@ try {
}
}
Invoke-Step -Name "Generate frontend API client from OpenAPI" -Action {
npm run generate:api-client
}
Invoke-Step -Name "Lint frontend" -Action {
npm run lint
}
Invoke-Step -Name "Check frontend formatting" -Action {
npm run format:check
}
Invoke-Step -Name "Run tests" -Action {
if ($SkipBuild) {
dotnet test RpgRoller.Tests/RpgRoller.Tests.csproj --verbosity normal --collect:"XPlat Code Coverage" --settings RpgRoller.Tests/coverlet.runsettings

View File

@@ -54,91 +54,6 @@ function Resolve-ProfilePath {
return [System.IO.Path]::GetFullPath((Join-Path $BaseDirectory $expanded))
}
function Normalize-BasePath {
param([string]$Value)
if ([string]::IsNullOrWhiteSpace($Value)) {
return ""
}
$normalized = $Value.Trim()
if (-not $normalized.StartsWith("/")) {
$normalized = "/$normalized"
}
if ($normalized.Length -gt 1) {
$normalized = $normalized.TrimEnd("/")
}
return $normalized
}
function Infer-BasePathFromRemoteDir {
param([string]$RemoteDir)
if ([string]::IsNullOrWhiteSpace($RemoteDir)) {
return ""
}
$segments = @($RemoteDir -split "[/\\]" | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
if ($segments.Count -eq 0) {
return ""
}
$candidate = $segments[$segments.Count - 1]
if ($candidate -in @("httpdocs", "wwwroot", "www", "public_html", "site")) {
return ""
}
return Normalize-BasePath $candidate
}
function Resolve-AppBasePath {
param([Parameter(Mandatory = $true)][hashtable]$Config)
if ($Config.ContainsKey("BasePath")) {
$configured = Normalize-BasePath ([string]$Config.BasePath)
if (-not [string]::IsNullOrWhiteSpace($configured)) {
return $configured
}
}
return Infer-BasePathFromRemoteDir ([string]$Config.RemoteDir)
}
function Set-FrontendAppBaseMeta {
param(
[Parameter(Mandatory = $true)][string]$PublishDir,
[Parameter(Mandatory = $true)][string]$BasePath
)
$candidatePaths = @(
(Join-Path $PublishDir "wwwroot\index.html"),
(Join-Path $PublishDir "index.html")
)
$indexPath = $candidatePaths | Where-Object { Test-Path $_ } | Select-Object -First 1
if ([string]::IsNullOrWhiteSpace($indexPath)) {
throw "Publish output is missing index.html. Checked: $($candidatePaths -join ", ")."
}
$pattern = '<meta\s+name=["'']app-base["'']\s+content=["''][^"'']*["'']\s*/?>'
$content = Get-Content -Path $indexPath -Raw
if ($content -notmatch $pattern) {
throw "Could not find <meta name=`"app-base`"> in '$indexPath'."
}
$replacement = "<meta name=`"app-base`" content=`"$BasePath`">"
$updated = [System.Text.RegularExpressions.Regex]::Replace(
$content,
$pattern,
[System.Text.RegularExpressions.MatchEvaluator]{ param($match) $replacement },
1
)
Set-Content -Path $indexPath -Value $updated -Encoding UTF8
}
function Read-PlainOrPrompt {
param(
[string]$Value,
@@ -259,9 +174,7 @@ if (-not $selfContained) {
}
dotnet @publishArgs
$appBasePath = Resolve-AppBasePath -Config $config
Set-FrontendAppBaseMeta -PublishDir $publishDir -BasePath $appBasePath
Write-Host "2) Frontend app-base configured as '$appBasePath'." -ForegroundColor Cyan
Write-Host "2) Skipping legacy index.html app-base rewrite (Blazor frontend)." -ForegroundColor Cyan
if ($recycleAppPool) {
Require-ConfigValue $config "AppPoolName"

View File

@@ -1,70 +0,0 @@
import { readdir, readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(scriptDirectory, "..");
const directoriesToScan = [
path.join(repoRoot, "RpgRoller", "frontend"),
path.join(repoRoot, "RpgRoller", "wwwroot"),
path.join(repoRoot, "openapi")
];
const filesToScan = [
path.join(repoRoot, "package.json"),
path.join(repoRoot, "tsconfig.frontend.json"),
path.join(repoRoot, "scripts", "generate-api-client.mjs"),
path.join(repoRoot, "scripts", "lint-frontend.mjs"),
path.join(repoRoot, "scripts", "format-check.mjs")
];
async function collectFiles(directory) {
const entries = await readdir(directory, { withFileTypes: true });
const results = [];
for (const entry of entries) {
const fullPath = path.join(directory, entry.name);
if (entry.isDirectory()) {
const children = await collectFiles(fullPath);
results.push(...children);
}
else {
results.push(fullPath);
}
}
return results;
}
const allFiles = [...filesToScan];
for (const directory of directoriesToScan) {
const directoryFiles = await collectFiles(directory);
allFiles.push(...directoryFiles);
}
const failures = [];
for (const filePath of allFiles) {
const text = await readFile(filePath, "utf8");
const relativePath = path.relative(repoRoot, filePath);
const lines = text.split(/\r?\n/);
for (let lineNumber = 0; lineNumber < lines.length; lineNumber += 1) {
if (/[ \t]+$/.test(lines[lineNumber])) {
failures.push(`${relativePath}:${lineNumber + 1} has trailing whitespace.`);
}
}
if (text.includes("\t")) {
failures.push(`${relativePath} contains tab characters.`);
}
if (text.length > 0 && !text.endsWith("\n") && !text.endsWith("\r\n")) {
failures.push(`${relativePath} is missing a trailing newline.`);
}
}
if (failures.length > 0) {
throw new Error(failures.join("\n"));
}
console.log("Frontend format checks passed.");

View File

@@ -1,360 +0,0 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(scriptDirectory, "..");
const openApiPath = path.join(repoRoot, "openapi", "RpgRoller.json");
const outputPath = path.join(repoRoot, "RpgRoller", "frontend", "generated", "api-client.ts");
function pascalCase(value) {
return value
.replace(/[^a-zA-Z0-9]+/g, " ")
.trim()
.split(/\s+/)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join("");
}
function escapePathSegment(segment) {
return segment.replaceAll("\\", "\\\\").replaceAll("\"", "\\\"");
}
function mapSimpleType(schema) {
switch (schema?.type) {
case "integer":
case "number":
return "number";
case "boolean":
return "boolean";
case "string":
return "string";
default:
return null;
}
}
function schemaRefName(schema) {
if (typeof schema?.$ref !== "string") {
return null;
}
const refPrefix = "#/components/schemas/";
if (!schema.$ref.startsWith(refPrefix)) {
throw new Error(`Unsupported schema ref: ${schema.$ref}`);
}
return schema.$ref.substring(refPrefix.length);
}
function toTypeScriptType(schema, forProperty = false) {
if (!schema || typeof schema !== "object") {
return "unknown";
}
const refName = schemaRefName(schema);
if (refName !== null) {
return refName;
}
const simpleType = mapSimpleType(schema);
if (simpleType !== null) {
return simpleType;
}
if (schema.type === "array") {
const itemType = toTypeScriptType(schema.items, true);
return `Array<${itemType}>`;
}
if (schema.type === "object") {
const propertyEntries = Object.entries(schema.properties ?? {});
if (propertyEntries.length === 0) {
return "Record<string, unknown>";
}
const required = new Set(Array.isArray(schema.required) ? schema.required : []);
const fields = propertyEntries.map(([name, propertySchema]) => {
const typeName = toTypeScriptType(propertySchema, true);
const optional = required.has(name) ? "" : "?";
return `${name}${optional}: ${typeName};`;
});
if (forProperty) {
return `{ ${fields.join(" ")} }`;
}
return `{\n${fields.map((field) => ` ${field}`).join("\n")}\n}`;
}
if (Array.isArray(schema.oneOf) && schema.oneOf.length > 0) {
return schema.oneOf.map((option) => toTypeScriptType(option, true)).join(" | ");
}
if (Array.isArray(schema.anyOf) && schema.anyOf.length > 0) {
return schema.anyOf.map((option) => toTypeScriptType(option, true)).join(" | ");
}
return "unknown";
}
function schemaToInterface(name, schema) {
const required = new Set(Array.isArray(schema.required) ? schema.required : []);
const fields = Object.entries(schema.properties ?? {}).map(([propertyName, propertySchema]) => {
const typeName = toTypeScriptType(propertySchema, true);
const optional = required.has(propertyName) ? "" : "?";
return ` ${propertyName}${optional}: ${typeName};`;
});
if (fields.length === 0) {
return `export type ${name} = Record<string, unknown>;`;
}
return `export interface ${name} {\n${fields.join("\n")}\n}`;
}
function collectParameters(pathItem, operation) {
const rawParameters = [...(pathItem.parameters ?? []), ...(operation.parameters ?? [])];
const deduped = [];
const keys = new Set();
for (const parameter of rawParameters) {
if (!parameter || typeof parameter !== "object" || typeof parameter.name !== "string") {
continue;
}
const key = `${parameter.in}:${parameter.name}`;
if (keys.has(key)) {
continue;
}
keys.add(key);
const inferredType = mapSimpleType(parameter.schema) ?? "string";
deduped.push({
name: parameter.name,
location: parameter.in,
required: parameter.required === true,
type: inferredType
});
}
return deduped;
}
function resolveRequestBodyType(operation) {
const bodySchema = operation.requestBody?.content?.["application/json"]?.schema;
if (!bodySchema) {
return null;
}
return {
type: toTypeScriptType(bodySchema, true),
required: operation.requestBody.required === true
};
}
function resolveResponse(operation) {
const responses = operation.responses ?? {};
const successCodes = Object.keys(responses)
.filter((code) => code.startsWith("2"))
.sort();
if (successCodes.length === 0) {
return { type: "void", expectsJson: false };
}
for (const code of successCodes) {
const jsonSchema = responses[code]?.content?.["application/json"]?.schema;
if (jsonSchema) {
return { type: toTypeScriptType(jsonSchema), expectsJson: true };
}
if (code === "204") {
return { type: "void", expectsJson: false };
}
}
return { type: "void", expectsJson: false };
}
function collectOperations(document) {
const operations = [];
for (const [pathKey, pathItem] of Object.entries(document.paths ?? {})) {
for (const [method, operation] of Object.entries(pathItem ?? {})) {
if (operation === null || typeof operation !== "object" || typeof operation.operationId !== "string") {
continue;
}
if (operation.operationId.length === 0) {
throw new Error(`Missing operationId for ${method.toUpperCase()} ${pathKey}`);
}
const parameters = collectParameters(pathItem, operation);
const pathParameters = parameters.filter((parameter) => parameter.location === "path");
const queryParameters = parameters.filter((parameter) => parameter.location === "query");
const requestBody = resolveRequestBodyType(operation);
const response = resolveResponse(operation);
operations.push({
operationId: operation.operationId,
method: method.toUpperCase(),
path: pathKey,
pathParameters,
queryParameters,
requestBody,
response
});
}
}
if (operations.length === 0) {
throw new Error("OpenAPI document does not define any operations.");
}
return operations.sort((left, right) => left.operationId.localeCompare(right.operationId));
}
function buildSchemaTypes(document) {
const schemas = document.components?.schemas ?? {};
const schemaEntries = Object.entries(schemas).sort(([left], [right]) => left.localeCompare(right));
const declarations = schemaEntries.map(([name, schema]) => {
if (!schema || typeof schema !== "object") {
return `export type ${name} = unknown;`;
}
return schemaToInterface(name, schema);
});
return declarations.join("\n\n");
}
function buildFunctionSource(operation) {
const pathParameters = operation.pathParameters;
const queryParameters = operation.queryParameters;
const body = operation.requestBody;
const parts = [];
if (pathParameters.length > 0) {
parts.push(...pathParameters.map((parameter) => `${parameter.name}: ${parameter.type}`));
}
if (body) {
const optional = body.required ? "" : "?";
parts.push(`body${optional}: ${body.type}`);
}
if (queryParameters.length > 0) {
const queryType = queryParameters
.map((parameter) => `${parameter.name}${parameter.required ? "" : "?"}: ${parameter.type};`)
.join(" ");
parts.push(`query: { ${queryType} }`);
}
const signature = parts.join(", ");
const pathParameterAssignment = pathParameters.length === 0
? "{}"
: `{ ${pathParameters.map((parameter) => `${parameter.name}: ${parameter.name}`).join(", ")} }`;
const bodyArgument = body ? "body" : "undefined";
const queryArgument = queryParameters.length > 0 ? "query" : "undefined";
return `export async function ${operation.operationId}(${signature}): Promise<${operation.response.type}> {\n return send<${operation.response.type}>(apiOperations.${operation.operationId}, {\n pathParams: ${pathParameterAssignment},\n query: ${queryArgument},\n body: ${bodyArgument}\n });\n}`;
}
function buildClientSource(operations, schemaTypes) {
const operationEntries = operations
.map((operation) => ` ${operation.operationId}: { method: "${operation.method}", path: "${escapePathSegment(operation.path)}", expectsJson: ${operation.response.expectsJson ? "true" : "false"} }`)
.join(",\n");
const helper = `
type ApiOperation = {
method: string;
path: string;
expectsJson: boolean;
};
type RequestOptions = {
pathParams?: Record<string, string | number | boolean>;
query?: Record<string, string | number | boolean | undefined>;
body?: unknown;
};
export const apiOperations = {
${operationEntries}
} as const satisfies Record<string, ApiOperation>;
`.trim();
const sendFunction = `
function withPathParams(pathTemplate: string, pathParams: Record<string, string | number | boolean>): string {
let pathValue = pathTemplate;
for (const [key, value] of Object.entries(pathParams)) {
pathValue = pathValue.replace(\`{\${key}}\`, encodeURIComponent(String(value)));
}
return pathValue;
}
function withQuery(pathValue: string, query: Record<string, string | number | boolean | undefined>): string {
const entries = Object.entries(query).filter(([, value]) => value !== undefined);
if (entries.length === 0) {
return pathValue;
}
const queryString = new URLSearchParams(entries.map(([key, value]) => [key, String(value)])).toString();
return queryString.length === 0 ? pathValue : \`\${pathValue}?\${queryString}\`;
}
async function send<TResult>(operation: ApiOperation, options: RequestOptions): Promise<TResult> {
const resolvedPath = withQuery(withPathParams(operation.path, options.pathParams ?? {}), options.query ?? {});
const headers: Record<string, string> = {
"Accept": "application/json"
};
let body: string | undefined;
if (options.body !== undefined) {
headers["Content-Type"] = "application/json";
body = JSON.stringify(options.body);
}
const response = await fetch(resolvedPath, {
method: operation.method,
headers,
body
});
if (!response.ok) {
const errorPayload: unknown = await response.json().catch(() => ({ error: "Unknown API error." }));
if (errorPayload && typeof errorPayload === "object" && "error" in errorPayload && typeof errorPayload.error === "string") {
throw new Error(errorPayload.error);
}
throw new Error(\`Request failed with status \${response.status}\`);
}
if (!operation.expectsJson) {
return undefined as TResult;
}
return response.json() as Promise<TResult>;
}
`.trim();
const exports = operations.map(buildFunctionSource).join("\n\n");
return `/* This file is generated by scripts/generate-api-client.mjs. */\n\n${schemaTypes}\n\n${helper}\n\n${sendFunction}\n\n${exports}\n`;
}
const openApiText = await readFile(openApiPath, "utf8");
const document = JSON.parse(openApiText);
const operations = collectOperations(document);
const schemaTypes = buildSchemaTypes(document);
const clientSource = buildClientSource(operations, schemaTypes);
await mkdir(path.dirname(outputPath), { recursive: true });
await writeFile(outputPath, clientSource, "utf8");
console.log(`Generated API client: ${path.relative(repoRoot, outputPath)}`);

View File

@@ -1,41 +0,0 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(scriptDirectory, "..");
const openApiPath = path.join(repoRoot, "openapi", "RpgRoller.json");
const appTsPath = path.join(repoRoot, "RpgRoller", "frontend", "app.ts");
const generatedClientPath = path.join(repoRoot, "RpgRoller", "frontend", "generated", "api-client.ts");
const openApi = JSON.parse(await readFile(openApiPath, "utf8"));
const generatedClient = await readFile(generatedClientPath, "utf8");
const appSource = await readFile(appTsPath, "utf8");
const errors = [];
if (!appSource.includes("from \"./generated/api-client.js\"")) {
errors.push("Frontend app.ts must import the generated api-client module.");
}
for (const [pathKey, pathItem] of Object.entries(openApi.paths ?? {})) {
for (const [method, operation] of Object.entries(pathItem ?? {})) {
if (operation === null || typeof operation !== "object") {
continue;
}
if (typeof operation.operationId !== "string" || operation.operationId.length === 0) {
errors.push(`Missing operationId for ${method.toUpperCase()} ${pathKey}`);
continue;
}
if (!generatedClient.includes(`apiOperations.${operation.operationId}`)) {
errors.push(`Generated client is missing operation export for ${operation.operationId}`);
}
}
}
if (errors.length > 0) {
throw new Error(errors.join("\n"));
}
console.log("Frontend lint checks passed.");