Scaffold full-stack solution and CI baseline

This commit is contained in:
2026-02-24 21:27:51 +01:00
parent f3e3178f2f
commit d9f0c7b7ac
27 changed files with 853 additions and 1 deletions

67
scripts/format-check.mjs Normal file
View File

@@ -0,0 +1,67 @@
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", "wwwroot"),
path.join(repoRoot, "openapi")
];
const filesToScan = [
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

@@ -0,0 +1,93 @@
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", "wwwroot", "generated", "api-client.js");
function escapePathSegment(segment) {
return segment.replaceAll("\\", "\\\\").replaceAll("\"", "\\\"");
}
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") {
continue;
}
if (typeof operation.operationId !== "string" || operation.operationId.length === 0) {
throw new Error(`Missing operationId for ${method.toUpperCase()} ${pathKey}`);
}
operations.push({
operationId: operation.operationId,
method: method.toUpperCase(),
path: pathKey
});
}
}
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 buildClientSource(operations) {
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 sendFunction = `
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}\`);
}
return response.json();
}
`.trim();
const exports = operations
.map((operation) => {
const params = [...operation.path.matchAll(/\{([^}]+)\}/g)].map((match) => match[1]);
const signature = params.length === 0 ? "" : params.join(", ");
const pathParams = params.length === 0
? "{}"
: `{ ${params.map((name) => `${name}: ${name}`).join(", ")} }`;
return `export async function ${operation.operationId}(${signature}) {\n return send(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`;
}
const openApiText = await readFile(openApiPath, "utf8");
const document = JSON.parse(openApiText);
const operations = collectOperations(document);
const clientSource = buildClientSource(operations);
await mkdir(path.dirname(outputPath), { recursive: true });
await writeFile(outputPath, clientSource, "utf8");
console.log(`Generated API client: ${path.relative(repoRoot, outputPath)}`);

42
scripts/lint-frontend.mjs Normal file
View File

@@ -0,0 +1,42 @@
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 openApi = JSON.parse(await readFile(openApiPath, "utf8"));
const generatedClient = await readFile(generatedClientPath, "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}`);
}
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.");