109 lines
4.3 KiB
JavaScript
109 lines
4.3 KiB
JavaScript
import { readdir, readFile } from "node:fs/promises";
|
|
import { extname, join, relative, resolve } from "node:path";
|
|
|
|
const packageRoot = resolve(import.meta.dirname, "..");
|
|
const repositoryRoot = resolve(packageRoot, "../..");
|
|
const sourceRoots = [
|
|
join(packageRoot, "src"),
|
|
join(repositoryRoot, "packages/api/src/router/custom-widget"),
|
|
join(repositoryRoot, "packages/widgets/src/custom-api"),
|
|
join(repositoryRoot, "apps/nextjs/src/app/[locale]/manage/custom-widgets"),
|
|
];
|
|
const productionFiles = [];
|
|
|
|
async function collect(directory) {
|
|
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
const path = join(directory, entry.name);
|
|
if (entry.isDirectory()) {
|
|
if (entry.name !== "test") await collect(path);
|
|
} else if ([".ts", ".tsx"].includes(extname(entry.name)) && !/\.spec\.tsx?$/u.test(entry.name)) {
|
|
productionFiles.push(path);
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const sourceRoot of sourceRoots) await collect(sourceRoot);
|
|
const failures = [];
|
|
const graph = new Map();
|
|
const algorithmicExceptions = new Map([
|
|
["packages/custom-widgets/src/jsx/analyzer.ts", 400],
|
|
["packages/custom-widgets/src/jsx/interpreter.tsx", 400],
|
|
]);
|
|
|
|
for (const file of productionFiles) {
|
|
const source = await readFile(file, "utf8");
|
|
const name = relative(repositoryRoot, file);
|
|
const lineCount = source.trimEnd().split("\n").length;
|
|
const limit = algorithmicExceptions.get(name) ?? 300;
|
|
if (lineCount > limit) failures.push(`${name} has ${lineCount} lines (limit ${limit})`);
|
|
if (file.startsWith(join(packageRoot, "src")) && /from\s+["']@homarr\/(?:api|widgets)(?:\/|["'])/u.test(source)) {
|
|
failures.push(`${name} imports a forbidden adapter package`);
|
|
}
|
|
if (extname(file) === ".tsx" && /(?:runtime|workbench|custom-widgets|custom-api)/u.test(name)) {
|
|
for (const line of source.split("\n")) {
|
|
const match = line.match(/>\s*([A-Za-z][A-Za-z0-9 .,!?'-]*)\s*</u);
|
|
if (match) failures.push(`${name} contains untranslated JSX text: ${JSON.stringify(match[1]?.trim())}`);
|
|
}
|
|
if (/\b(?:aria-label|description|label|placeholder|title)=["'][A-Za-z]/u.test(source)) {
|
|
failures.push(`${name} contains an untranslated user-facing attribute`);
|
|
}
|
|
}
|
|
const dependencies = [];
|
|
for (const match of source.matchAll(/from\s+["'](\.[^"']+)["']/gu)) {
|
|
const candidate = resolve(file, "..", match[1]);
|
|
const resolved = productionFiles.find(
|
|
(item) =>
|
|
item === candidate ||
|
|
item === `${candidate}.ts` ||
|
|
item === `${candidate}.tsx` ||
|
|
item === join(candidate, "index.ts"),
|
|
);
|
|
if (resolved) dependencies.push(resolved);
|
|
}
|
|
graph.set(file, dependencies);
|
|
}
|
|
|
|
const visiting = new Set();
|
|
const visited = new Set();
|
|
function visit(file, path = []) {
|
|
if (visiting.has(file)) {
|
|
failures.push(`circular dependency: ${[...path, file].map((item) => relative(repositoryRoot, item)).join(" -> ")}`);
|
|
return;
|
|
}
|
|
if (visited.has(file)) return;
|
|
visiting.add(file);
|
|
for (const dependency of graph.get(file) ?? []) visit(dependency, [...path, file]);
|
|
visiting.delete(file);
|
|
visited.add(file);
|
|
}
|
|
for (const file of productionFiles) visit(file);
|
|
|
|
const widgetAdapter = await readFile(join(repositoryRoot, "packages/widgets/src/custom-api/component.tsx"), "utf8");
|
|
if (!/dynamic\(\(\)\s*=>\s*import\(["']\.\/custom-jsx-display["']\)/u.test(widgetAdapter)) {
|
|
failures.push("Custom JSX board rendering must remain dynamically loaded");
|
|
}
|
|
const editorAdapter = await readFile(
|
|
join(repositoryRoot, "packages/custom-widgets/src/workbench/code-editor.tsx"),
|
|
"utf8",
|
|
);
|
|
if (!/lazy\(\(\)\s*=>\s*import\(["'].\/direct-code-mirror["']\)\)/u.test(editorAdapter)) {
|
|
failures.push("CodeMirror must remain dynamically loaded after the client mounts");
|
|
}
|
|
const componentRegistry = await readFile(
|
|
join(repositoryRoot, "packages/custom-widgets/src/core/component-registry.ts"),
|
|
"utf8",
|
|
);
|
|
if (!/from\s+["'].\/component-catalog["']/u.test(componentRegistry)) {
|
|
failures.push("The Custom JSX runtime registry must use the canonical authoring catalog");
|
|
}
|
|
if (/component-runtime\.generated\.json/u.test(componentRegistry)) {
|
|
failures.push("The Custom JSX runtime registry must not use a second generated component catalog");
|
|
}
|
|
|
|
if (failures.length > 0) {
|
|
console.error(failures.join("\n"));
|
|
process.exitCode = 1;
|
|
} else {
|
|
console.log(`Custom Widgets architecture checks passed for ${productionFiles.length} production modules.`);
|
|
}
|