From 757f9a259e04c25b365a30ce7bad0c712751f8c7 Mon Sep 17 00:00:00 2001 From: Frank Tovar Date: Tue, 24 Feb 2026 21:35:15 +0100 Subject: [PATCH] Migrate frontend sources to TypeScript --- FAQ.md | 7 +- README.md | 6 +- RpgRoller/frontend/app.ts | 53 +++++ RpgRoller/frontend/generated/api-client.ts | 57 ++++++ RpgRoller/wwwroot/app.js | 33 ++-- RpgRoller/wwwroot/generated/api-client.js | 16 +- TECH.md | 4 +- package-lock.json | 19 +- package.json | 8 +- scripts/format-check.mjs | 3 + scripts/generate-api-client.mjs | 217 +++++++++++++++++++-- scripts/lint-frontend.mjs | 11 +- tsconfig.frontend.json | 20 ++ 13 files changed, 403 insertions(+), 51 deletions(-) create mode 100644 RpgRoller/frontend/app.ts create mode 100644 RpgRoller/frontend/generated/api-client.ts create mode 100644 tsconfig.frontend.json diff --git a/FAQ.md b/FAQ.md index 14cf09d..c2ac55b 100644 --- a/FAQ.md +++ b/FAQ.md @@ -5,7 +5,12 @@ The kickoff scaffold is intentionally lightweight and keeps only strictly relevant tooling: - API client generation from the OpenAPI contract -- basic frontend syntax/contract checks +- TypeScript compilation for frontend source files +- basic frontend contract checks - deterministic formatting checks used by `scripts/ci-local.ps1` This keeps the first commit small while preserving CI discipline. Additional tooling can be introduced when the frontend stack is finalized. + +## Is frontend JavaScript handwritten? + +No. Frontend source code lives in `RpgRoller/frontend/*.ts` and is compiled to `RpgRoller/wwwroot/*.js` for browser delivery. diff --git a/README.md b/README.md index 1cf043d..758347b 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,8 @@ Fresh full-stack starter scaffold: -- `RpgRoller/`: ASP.NET Core backend + static frontend (`wwwroot`) +- `RpgRoller/`: ASP.NET Core backend + static frontend output (`wwwroot`) +- `RpgRoller/frontend/`: TypeScript frontend source - `RpgRoller.Tests/`: xUnit integration-heavy test project - `RpgRoller.sln`: solution used by local CI script @@ -27,7 +28,8 @@ Fresh full-stack starter scaffold: ## Frontend Tooling - OpenAPI contract: `openapi/RpgRoller.json` -- API client generation: `npm run generate:api-client` +- TypeScript build output config: `tsconfig.frontend.json` +- API client generation + frontend compile: `npm run generate:api-client` - Frontend lint checks: `npm run lint` - Frontend format checks: `npm run format:check` diff --git a/RpgRoller/frontend/app.ts b/RpgRoller/frontend/app.ts new file mode 100644 index 0000000..23f255f --- /dev/null +++ b/RpgRoller/frontend/app.ts @@ -0,0 +1,53 @@ +import { getHealth, rollDice } from "./generated/api-client.js"; + +const healthElementRaw = document.getElementById("health"); +const resultElementRaw = document.getElementById("result"); +const formElementRaw = document.getElementById("roll-form"); +const sidesInputRaw = document.getElementById("sides"); + +if ( + !(healthElementRaw instanceof HTMLElement) || + !(resultElementRaw instanceof HTMLElement) || + !(formElementRaw instanceof HTMLFormElement) || + !(sidesInputRaw instanceof HTMLInputElement) +) { + throw new Error("Required UI elements are missing from index.html."); +} + +const healthElement = healthElementRaw; +const resultElement = resultElementRaw; +const formElement = formElementRaw; +const sidesInput = sidesInputRaw; + +function errorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + + return String(error); +} + +async function refreshHealth(): Promise { + try { + const health = await getHealth(); + healthElement.textContent = `API status: ${health.status}`; + } + catch (error: unknown) { + healthElement.textContent = `API status check failed: ${errorMessage(error)}`; + } +} + +formElement.addEventListener("submit", async (event: SubmitEvent) => { + event.preventDefault(); + + const sides = Number.parseInt(sidesInput.value, 10); + try { + const roll = await rollDice(sides); + resultElement.textContent = `Rolled d${roll.sides}: ${roll.value}`; + } + catch (error: unknown) { + resultElement.textContent = `Roll failed: ${errorMessage(error)}`; + } +}); + +await refreshHealth(); diff --git a/RpgRoller/frontend/generated/api-client.ts b/RpgRoller/frontend/generated/api-client.ts new file mode 100644 index 0000000..9644ee7 --- /dev/null +++ b/RpgRoller/frontend/generated/api-client.ts @@ -0,0 +1,57 @@ +/* This file is generated by scripts/generate-api-client.mjs. */ + +export interface ApiError { + error: string; +} + +export interface HealthResponse { + status: string; +} + +export interface RollResponse { + sides: number; + value: number; +} + +type ApiOperation = { + method: string; + path: string; +}; + +export const apiOperations = { + getHealth: { method: "GET", path: "/api/health" }, + rollDice: { method: "GET", path: "/api/roll/{sides}" } +} as const satisfies Record; + +async function send(operation: ApiOperation, pathParams: Record = {}): Promise { + let resolvedPath = operation.path; + for (const [key, value] of Object.entries(pathParams)) { + resolvedPath = resolvedPath.replace(`{${key}}`, encodeURIComponent(String(value))); + } + + const response = await fetch(resolvedPath, { + method: operation.method, + headers: { + "Accept": "application/json" + } + }); + + 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}`); + } + + return response.json() as Promise; +} + +export async function getHealth(): Promise { + return send(apiOperations.getHealth, {}); +} + +export async function rollDice(sides: number): Promise { + return send(apiOperations.rollDice, { sides: sides }); +} diff --git a/RpgRoller/wwwroot/app.js b/RpgRoller/wwwroot/app.js index cc4a76f..28429eb 100644 --- a/RpgRoller/wwwroot/app.js +++ b/RpgRoller/wwwroot/app.js @@ -1,31 +1,42 @@ import { getHealth, rollDice } from "./generated/api-client.js"; - -const healthElement = document.getElementById("health"); -const resultElement = document.getElementById("result"); -const formElement = document.getElementById("roll-form"); -const sidesInput = document.getElementById("sides"); - +const healthElementRaw = document.getElementById("health"); +const resultElementRaw = document.getElementById("result"); +const formElementRaw = document.getElementById("roll-form"); +const sidesInputRaw = document.getElementById("sides"); +if (!(healthElementRaw instanceof HTMLElement) || + !(resultElementRaw instanceof HTMLElement) || + !(formElementRaw instanceof HTMLFormElement) || + !(sidesInputRaw instanceof HTMLInputElement)) { + throw new Error("Required UI elements are missing from index.html."); +} +const healthElement = healthElementRaw; +const resultElement = resultElementRaw; +const formElement = formElementRaw; +const sidesInput = sidesInputRaw; +function errorMessage(error) { + if (error instanceof Error) { + return error.message; + } + return String(error); +} async function refreshHealth() { try { const health = await getHealth(); healthElement.textContent = `API status: ${health.status}`; } catch (error) { - healthElement.textContent = `API status check failed: ${error.message}`; + healthElement.textContent = `API status check failed: ${errorMessage(error)}`; } } - formElement.addEventListener("submit", async (event) => { event.preventDefault(); - const sides = Number.parseInt(sidesInput.value, 10); try { const roll = await rollDice(sides); resultElement.textContent = `Rolled d${roll.sides}: ${roll.value}`; } catch (error) { - resultElement.textContent = `Roll failed: ${error.message}`; + resultElement.textContent = `Roll failed: ${errorMessage(error)}`; } }); - await refreshHealth(); diff --git a/RpgRoller/wwwroot/generated/api-client.js b/RpgRoller/wwwroot/generated/api-client.js index 69c6ebd..c709498 100644 --- a/RpgRoller/wwwroot/generated/api-client.js +++ b/RpgRoller/wwwroot/generated/api-client.js @@ -1,37 +1,31 @@ /* This file is generated by scripts/generate-api-client.mjs. */ - -export { apiOperations }; - -const apiOperations = { +export const apiOperations = { getHealth: { method: "GET", path: "/api/health" }, rollDice: { method: "GET", path: "/api/roll/{sides}" } }; - async function send(operation, pathParams = {}) { let resolvedPath = operation.path; for (const [key, value] of Object.entries(pathParams)) { resolvedPath = resolvedPath.replace(`{${key}}`, encodeURIComponent(String(value))); } - const response = await fetch(resolvedPath, { method: operation.method, headers: { "Accept": "application/json" } }); - if (!response.ok) { const errorPayload = await response.json().catch(() => ({ error: "Unknown API error." })); - throw new Error(errorPayload.error ?? `Request failed with status ${response.status}`); + 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}`); } - return response.json(); } - export async function getHealth() { return send(apiOperations.getHealth, {}); } - export async function rollDice(sides) { return send(apiOperations.rollDice, { sides: sides }); } diff --git a/TECH.md b/TECH.md index b132741..e87372c 100644 --- a/TECH.md +++ b/TECH.md @@ -4,9 +4,11 @@ - Root solution: `RpgRoller.sln` - Backend/full-stack project: `RpgRoller` (Minimal API + static `wwwroot` frontend) +- Frontend source: `RpgRoller/frontend` (TypeScript) - Test project: `RpgRoller.Tests` (xUnit + `WebApplicationFactory` integration tests) - OpenAPI source: `openapi/RpgRoller.json` -- Generated client target: `RpgRoller/wwwroot/generated/api-client.js` +- Generated client source: `RpgRoller/frontend/generated/api-client.ts` +- Generated client output: `RpgRoller/wwwroot/generated/api-client.js` - Local CI parity entrypoint: `scripts/ci-local.ps1` ## 1) Stack and baseline choices diff --git a/package-lock.json b/package-lock.json index 1efbcaf..e5de1db 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,7 +6,24 @@ "packages": { "": { "name": "rpgroller", - "version": "0.1.0" + "version": "0.1.0", + "devDependencies": { + "typescript": "5.9.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } } } } diff --git a/package.json b/package.json index 572eef5..05fa218 100644 --- a/package.json +++ b/package.json @@ -4,8 +4,12 @@ "version": "0.1.0", "type": "module", "scripts": { - "generate:api-client": "node ./scripts/generate-api-client.mjs", - "lint": "node ./scripts/lint-frontend.mjs", + "build:frontend": "tsc -p ./tsconfig.frontend.json", + "generate:api-client": "node ./scripts/generate-api-client.mjs && npm run build:frontend", + "lint": "tsc -p ./tsconfig.frontend.json --noEmit && node ./scripts/lint-frontend.mjs", "format:check": "node ./scripts/format-check.mjs" + }, + "devDependencies": { + "typescript": "5.9.3" } } diff --git a/scripts/format-check.mjs b/scripts/format-check.mjs index 66e0601..7854a88 100644 --- a/scripts/format-check.mjs +++ b/scripts/format-check.mjs @@ -6,11 +6,14 @@ 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") diff --git a/scripts/generate-api-client.mjs b/scripts/generate-api-client.mjs index 086abd0..8682e79 100644 --- a/scripts/generate-api-client.mjs +++ b/scripts/generate-api-client.mjs @@ -5,15 +5,119 @@ 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", "wwwroot", "generated", "api-client.js"); +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 collectOperations(document) { +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, components, 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, components); + return `Array<${itemType}>`; + } + + if (schema.type === "object") { + const propertyEntries = Object.entries(schema.properties ?? {}); + if (propertyEntries.length === 0) { + return "Record"; + } + + const required = new Set(Array.isArray(schema.required) ? schema.required : []); + const fields = propertyEntries.map(([name, propertySchema]) => { + const typeName = toTypeScriptType(propertySchema, components, 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, components, true)).join(" | "); + } + + if (Array.isArray(schema.anyOf) && schema.anyOf.length > 0) { + return schema.anyOf.map((option) => toTypeScriptType(option, components, true)).join(" | "); + } + + return "unknown"; +} + +function schemaToInterface(name, schema, components) { + const required = new Set(Array.isArray(schema.required) ? schema.required : []); + const fields = Object.entries(schema.properties ?? {}).map(([propertyName, propertySchema]) => { + const typeName = toTypeScriptType(propertySchema, components, true); + const optional = required.has(propertyName) ? "" : "?"; + return ` ${propertyName}${optional}: ${typeName};`; + }); + + if (fields.length === 0) { + return `export type ${name} = Record;`; + } + + return `export interface ${name} {\n${fields.join("\n")}\n}`; +} + +function collectOperations(document, components) { const operations = []; for (const [pathKey, pathItem] of Object.entries(document.paths ?? {})) { + const pathLevelParameters = Array.isArray(pathItem?.parameters) ? pathItem.parameters : []; + for (const [method, operation] of Object.entries(pathItem ?? {})) { if (operation === null || typeof operation !== "object") { continue; @@ -26,7 +130,9 @@ function collectOperations(document) { operations.push({ operationId: operation.operationId, method: method.toUpperCase(), - path: pathKey + path: pathKey, + parameters: collectPathParameters([...pathLevelParameters, ...(operation.parameters ?? [])]), + responseType: resolveResponseType(operation, components) }); } } @@ -38,15 +144,86 @@ function collectOperations(document) { return operations.sort((left, right) => left.operationId.localeCompare(right.operationId)); } -function buildClientSource(operations) { +function collectPathParameters(parameters) { + const seen = new Set(); + const result = []; + + for (const parameter of parameters) { + if (!parameter || parameter.in !== "path" || typeof parameter.name !== "string") { + continue; + } + + if (seen.has(parameter.name)) { + continue; + } + + seen.add(parameter.name); + + const simpleType = mapSimpleType(parameter.schema); + result.push({ + name: parameter.name, + type: simpleType ?? "string" + }); + } + + return result; +} + +function resolveResponseType(operation, components) { + const responses = operation.responses ?? {}; + const successCodes = Object.keys(responses) + .filter((code) => code.startsWith("2")) + .sort(); + + if (successCodes.length === 0) { + return "void"; + } + + for (const code of successCodes) { + const content = responses[code]?.content?.["application/json"]; + const schema = content?.schema; + if (schema) { + return toTypeScriptType(schema, components); + } + } + + return "void"; +} + +function buildSchemaTypes(document) { + const components = document.components ?? {}; + const schemas = 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, components); + }); + + return declarations.join("\n\n"); +} + +function buildClientSource(operations, schemaTypes) { const operationEntries = operations .map((operation) => ` ${operation.operationId}: { method: "${operation.method}", path: "${escapePathSegment(operation.path)}" }`) .join(",\n"); - const helper = `const apiOperations = {\n${operationEntries}\n};\n`; + const helper = ` +type ApiOperation = { + method: string; + path: string; +}; + +export const apiOperations = { +${operationEntries} +} as const satisfies Record; +`.trim(); const sendFunction = ` -async function send(operation, pathParams = {}) { +async function send(operation: ApiOperation, pathParams: Record = {}): Promise { let resolvedPath = operation.path; for (const [key, value] of Object.entries(pathParams)) { resolvedPath = resolvedPath.replace(\`{\${key}}\`, encodeURIComponent(String(value))); @@ -60,33 +237,41 @@ async function send(operation, pathParams = {}) { }); if (!response.ok) { - const errorPayload = await response.json().catch(() => ({ error: "Unknown API error." })); - throw new Error(errorPayload.error ?? \`Request failed with status \${response.status}\`); + 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}\`); } - return response.json(); + return response.json() as Promise; } `.trim(); const exports = operations .map((operation) => { - const params = [...operation.path.matchAll(/\{([^}]+)\}/g)].map((match) => match[1]); - const signature = params.length === 0 ? "" : params.join(", "); + const params = operation.parameters; + const signature = params.length === 0 ? "" : params.map((parameter) => `${parameter.name}: ${parameter.type}`).join(", "); const pathParams = params.length === 0 ? "{}" - : `{ ${params.map((name) => `${name}: ${name}`).join(", ")} }`; + : `{ ${params.map((parameter) => `${parameter.name}: ${parameter.name}`).join(", ")} }`; - return `export async function ${operation.operationId}(${signature}) {\n return send(apiOperations.${operation.operationId}, ${pathParams});\n}`; + const responseType = operation.responseType ?? "unknown"; + + return `export async function ${operation.operationId}(${signature}): Promise<${responseType}> {\n return send<${responseType}>(apiOperations.${operation.operationId}, ${pathParams});\n}`; }) .join("\n\n"); - return `/* This file is generated by scripts/generate-api-client.mjs. */\n\nexport { apiOperations };\n\n${helper}\n${sendFunction}\n\n${exports}\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 clientSource = buildClientSource(operations); +const components = document.components ?? {}; +const operations = collectOperations(document, components); +const schemaTypes = buildSchemaTypes(document); +const clientSource = buildClientSource(operations, schemaTypes); await mkdir(path.dirname(outputPath), { recursive: true }); await writeFile(outputPath, clientSource, "utf8"); diff --git a/scripts/lint-frontend.mjs b/scripts/lint-frontend.mjs index d4d0bb5..4a1321a 100644 --- a/scripts/lint-frontend.mjs +++ b/scripts/lint-frontend.mjs @@ -1,21 +1,20 @@ import { readFile } from "node:fs/promises"; import path from "node:path"; -import { spawnSync } from "node:child_process"; 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 appJsPath = path.join(repoRoot, "RpgRoller", "wwwroot", "app.js"); -const generatedClientPath = path.join(repoRoot, "RpgRoller", "wwwroot", "generated", "api-client.js"); +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 = []; -const appSyntaxCheck = spawnSync(process.execPath, ["--check", appJsPath], { encoding: "utf8" }); -if (appSyntaxCheck.status !== 0) { - errors.push(`Syntax error in ${path.relative(repoRoot, appJsPath)}:\n${appSyntaxCheck.stderr}`); +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 ?? {})) { diff --git a/tsconfig.frontend.json b/tsconfig.frontend.json new file mode 100644 index 0000000..6b32790 --- /dev/null +++ b/tsconfig.frontend.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "Bundler", + "lib": [ + "ES2022", + "DOM", + "DOM.Iterable" + ], + "strict": true, + "noEmitOnError": true, + "rootDir": "./RpgRoller/frontend", + "outDir": "./RpgRoller/wwwroot", + "newLine": "lf" + }, + "include": [ + "RpgRoller/frontend/**/*.ts" + ] +}