Migrate frontend sources to TypeScript

This commit is contained in:
2026-02-24 21:35:15 +01:00
parent fa3b46c8a9
commit 757f9a259e
13 changed files with 403 additions and 51 deletions

7
FAQ.md
View File

@@ -5,7 +5,12 @@
The kickoff scaffold is intentionally lightweight and keeps only strictly relevant tooling: The kickoff scaffold is intentionally lightweight and keeps only strictly relevant tooling:
- API client generation from the OpenAPI contract - 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` - 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. 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.

View File

@@ -2,7 +2,8 @@
Fresh full-stack starter scaffold: 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.Tests/`: xUnit integration-heavy test project
- `RpgRoller.sln`: solution used by local CI script - `RpgRoller.sln`: solution used by local CI script
@@ -27,7 +28,8 @@ Fresh full-stack starter scaffold:
## Frontend Tooling ## Frontend Tooling
- OpenAPI contract: `openapi/RpgRoller.json` - 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 lint checks: `npm run lint`
- Frontend format checks: `npm run format:check` - Frontend format checks: `npm run format:check`

53
RpgRoller/frontend/app.ts Normal file
View File

@@ -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<void> {
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();

View File

@@ -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<string, ApiOperation>;
async function send<TResult>(operation: ApiOperation, pathParams: Record<string, string | number | boolean> = {}): Promise<TResult> {
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<TResult>;
}
export async function getHealth(): Promise<HealthResponse> {
return send<HealthResponse>(apiOperations.getHealth, {});
}
export async function rollDice(sides: number): Promise<RollResponse> {
return send<RollResponse>(apiOperations.rollDice, { sides: sides });
}

View File

@@ -1,31 +1,42 @@
import { getHealth, rollDice } from "./generated/api-client.js"; import { getHealth, rollDice } from "./generated/api-client.js";
const healthElementRaw = document.getElementById("health");
const healthElement = document.getElementById("health"); const resultElementRaw = document.getElementById("result");
const resultElement = document.getElementById("result"); const formElementRaw = document.getElementById("roll-form");
const formElement = document.getElementById("roll-form"); const sidesInputRaw = document.getElementById("sides");
const sidesInput = 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() { async function refreshHealth() {
try { try {
const health = await getHealth(); const health = await getHealth();
healthElement.textContent = `API status: ${health.status}`; healthElement.textContent = `API status: ${health.status}`;
} }
catch (error) { catch (error) {
healthElement.textContent = `API status check failed: ${error.message}`; healthElement.textContent = `API status check failed: ${errorMessage(error)}`;
} }
} }
formElement.addEventListener("submit", async (event) => { formElement.addEventListener("submit", async (event) => {
event.preventDefault(); event.preventDefault();
const sides = Number.parseInt(sidesInput.value, 10); const sides = Number.parseInt(sidesInput.value, 10);
try { try {
const roll = await rollDice(sides); const roll = await rollDice(sides);
resultElement.textContent = `Rolled d${roll.sides}: ${roll.value}`; resultElement.textContent = `Rolled d${roll.sides}: ${roll.value}`;
} }
catch (error) { catch (error) {
resultElement.textContent = `Roll failed: ${error.message}`; resultElement.textContent = `Roll failed: ${errorMessage(error)}`;
} }
}); });
await refreshHealth(); await refreshHealth();

View File

@@ -1,37 +1,31 @@
/* This file is generated by scripts/generate-api-client.mjs. */ /* This file is generated by scripts/generate-api-client.mjs. */
export const apiOperations = {
export { apiOperations };
const apiOperations = {
getHealth: { method: "GET", path: "/api/health" }, getHealth: { method: "GET", path: "/api/health" },
rollDice: { method: "GET", path: "/api/roll/{sides}" } rollDice: { method: "GET", path: "/api/roll/{sides}" }
}; };
async function send(operation, pathParams = {}) { async function send(operation, pathParams = {}) {
let resolvedPath = operation.path; let resolvedPath = operation.path;
for (const [key, value] of Object.entries(pathParams)) { for (const [key, value] of Object.entries(pathParams)) {
resolvedPath = resolvedPath.replace(`{${key}}`, encodeURIComponent(String(value))); resolvedPath = resolvedPath.replace(`{${key}}`, encodeURIComponent(String(value)));
} }
const response = await fetch(resolvedPath, { const response = await fetch(resolvedPath, {
method: operation.method, method: operation.method,
headers: { headers: {
"Accept": "application/json" "Accept": "application/json"
} }
}); });
if (!response.ok) { if (!response.ok) {
const errorPayload = await response.json().catch(() => ({ error: "Unknown API error." })); 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(); return response.json();
} }
export async function getHealth() { export async function getHealth() {
return send(apiOperations.getHealth, {}); return send(apiOperations.getHealth, {});
} }
export async function rollDice(sides) { export async function rollDice(sides) {
return send(apiOperations.rollDice, { sides: sides }); return send(apiOperations.rollDice, { sides: sides });
} }

View File

@@ -4,9 +4,11 @@
- Root solution: `RpgRoller.sln` - Root solution: `RpgRoller.sln`
- Backend/full-stack project: `RpgRoller` (Minimal API + static `wwwroot` frontend) - Backend/full-stack project: `RpgRoller` (Minimal API + static `wwwroot` frontend)
- Frontend source: `RpgRoller/frontend` (TypeScript)
- Test project: `RpgRoller.Tests` (xUnit + `WebApplicationFactory` integration tests) - Test project: `RpgRoller.Tests` (xUnit + `WebApplicationFactory` integration tests)
- OpenAPI source: `openapi/RpgRoller.json` - 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` - Local CI parity entrypoint: `scripts/ci-local.ps1`
## 1) Stack and baseline choices ## 1) Stack and baseline choices

19
package-lock.json generated
View File

@@ -6,7 +6,24 @@
"packages": { "packages": {
"": { "": {
"name": "rpgroller", "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"
}
} }
} }
} }

View File

@@ -4,8 +4,12 @@
"version": "0.1.0", "version": "0.1.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"generate:api-client": "node ./scripts/generate-api-client.mjs", "build:frontend": "tsc -p ./tsconfig.frontend.json",
"lint": "node ./scripts/lint-frontend.mjs", "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" "format:check": "node ./scripts/format-check.mjs"
},
"devDependencies": {
"typescript": "5.9.3"
} }
} }

View File

@@ -6,11 +6,14 @@ const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(scriptDirectory, ".."); const repoRoot = path.resolve(scriptDirectory, "..");
const directoriesToScan = [ const directoriesToScan = [
path.join(repoRoot, "RpgRoller", "frontend"),
path.join(repoRoot, "RpgRoller", "wwwroot"), path.join(repoRoot, "RpgRoller", "wwwroot"),
path.join(repoRoot, "openapi") path.join(repoRoot, "openapi")
]; ];
const filesToScan = [ 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", "generate-api-client.mjs"),
path.join(repoRoot, "scripts", "lint-frontend.mjs"), path.join(repoRoot, "scripts", "lint-frontend.mjs"),
path.join(repoRoot, "scripts", "format-check.mjs") path.join(repoRoot, "scripts", "format-check.mjs")

View File

@@ -5,15 +5,119 @@ import { fileURLToPath } from "node:url";
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url)); const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(scriptDirectory, ".."); const repoRoot = path.resolve(scriptDirectory, "..");
const openApiPath = path.join(repoRoot, "openapi", "RpgRoller.json"); 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) { function escapePathSegment(segment) {
return segment.replaceAll("\\", "\\\\").replaceAll("\"", "\\\""); 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<string, unknown>";
}
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<string, unknown>;`;
}
return `export interface ${name} {\n${fields.join("\n")}\n}`;
}
function collectOperations(document, components) {
const operations = []; const operations = [];
for (const [pathKey, pathItem] of Object.entries(document.paths ?? {})) { 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 ?? {})) { for (const [method, operation] of Object.entries(pathItem ?? {})) {
if (operation === null || typeof operation !== "object") { if (operation === null || typeof operation !== "object") {
continue; continue;
@@ -26,7 +130,9 @@ function collectOperations(document) {
operations.push({ operations.push({
operationId: operation.operationId, operationId: operation.operationId,
method: method.toUpperCase(), 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)); 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 const operationEntries = operations
.map((operation) => ` ${operation.operationId}: { method: "${operation.method}", path: "${escapePathSegment(operation.path)}" }`) .map((operation) => ` ${operation.operationId}: { method: "${operation.method}", path: "${escapePathSegment(operation.path)}" }`)
.join(",\n"); .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<string, ApiOperation>;
`.trim();
const sendFunction = ` const sendFunction = `
async function send(operation, pathParams = {}) { async function send<TResult>(operation: ApiOperation, pathParams: Record<string, string | number | boolean> = {}): Promise<TResult> {
let resolvedPath = operation.path; let resolvedPath = operation.path;
for (const [key, value] of Object.entries(pathParams)) { for (const [key, value] of Object.entries(pathParams)) {
resolvedPath = resolvedPath.replace(\`{\${key}}\`, encodeURIComponent(String(value))); resolvedPath = resolvedPath.replace(\`{\${key}}\`, encodeURIComponent(String(value)));
@@ -60,33 +237,41 @@ async function send(operation, pathParams = {}) {
}); });
if (!response.ok) { if (!response.ok) {
const errorPayload = await response.json().catch(() => ({ error: "Unknown API error." })); const errorPayload: unknown = 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);
} }
return response.json(); throw new Error(\`Request failed with status \${response.status}\`);
}
return response.json() as Promise<TResult>;
} }
`.trim(); `.trim();
const exports = operations const exports = operations
.map((operation) => { .map((operation) => {
const params = [...operation.path.matchAll(/\{([^}]+)\}/g)].map((match) => match[1]); const params = operation.parameters;
const signature = params.length === 0 ? "" : params.join(", "); const signature = params.length === 0 ? "" : params.map((parameter) => `${parameter.name}: ${parameter.type}`).join(", ");
const pathParams = params.length === 0 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"); .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 openApiText = await readFile(openApiPath, "utf8");
const document = JSON.parse(openApiText); const document = JSON.parse(openApiText);
const operations = collectOperations(document); const components = document.components ?? {};
const clientSource = buildClientSource(operations); const operations = collectOperations(document, components);
const schemaTypes = buildSchemaTypes(document);
const clientSource = buildClientSource(operations, schemaTypes);
await mkdir(path.dirname(outputPath), { recursive: true }); await mkdir(path.dirname(outputPath), { recursive: true });
await writeFile(outputPath, clientSource, "utf8"); await writeFile(outputPath, clientSource, "utf8");

View File

@@ -1,21 +1,20 @@
import { readFile } from "node:fs/promises"; import { readFile } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url)); const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(scriptDirectory, ".."); const repoRoot = path.resolve(scriptDirectory, "..");
const openApiPath = path.join(repoRoot, "openapi", "RpgRoller.json"); const openApiPath = path.join(repoRoot, "openapi", "RpgRoller.json");
const appJsPath = path.join(repoRoot, "RpgRoller", "wwwroot", "app.js"); const appTsPath = path.join(repoRoot, "RpgRoller", "frontend", "app.ts");
const generatedClientPath = path.join(repoRoot, "RpgRoller", "wwwroot", "generated", "api-client.js"); const generatedClientPath = path.join(repoRoot, "RpgRoller", "frontend", "generated", "api-client.ts");
const openApi = JSON.parse(await readFile(openApiPath, "utf8")); const openApi = JSON.parse(await readFile(openApiPath, "utf8"));
const generatedClient = await readFile(generatedClientPath, "utf8"); const generatedClient = await readFile(generatedClientPath, "utf8");
const appSource = await readFile(appTsPath, "utf8");
const errors = []; const errors = [];
const appSyntaxCheck = spawnSync(process.execPath, ["--check", appJsPath], { encoding: "utf8" }); if (!appSource.includes("from \"./generated/api-client.js\"")) {
if (appSyntaxCheck.status !== 0) { errors.push("Frontend app.ts must import the generated api-client module.");
errors.push(`Syntax error in ${path.relative(repoRoot, appJsPath)}:\n${appSyntaxCheck.stderr}`);
} }
for (const [pathKey, pathItem] of Object.entries(openApi.paths ?? {})) { for (const [pathKey, pathItem] of Object.entries(openApi.paths ?? {})) {

20
tsconfig.frontend.json Normal file
View File

@@ -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"
]
}