refactor: simplify widget and integration architecture
This commit is contained in:
1 parent
c988c35b29
commit
8f9efd0074
179 files changed
+1509
-4187
No files matched your search
@@ -1,120 +0,0 @@
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const integrationsRoot = path.join(repositoryRoot, "packages/integrations/src");
|
||||
|
||||
const readOption = (name: string) => {
|
||||
const optionIndex = process.argv.indexOf(name);
|
||||
if (optionIndex < 0) return undefined;
|
||||
return process.argv[optionIndex + 1];
|
||||
};
|
||||
|
||||
const kind = process.argv[2];
|
||||
if (!kind || kind.startsWith("--")) {
|
||||
throw new Error(
|
||||
"Usage: pnpm integrations:new <kind> --icon-url <url> [--slug <docs-slug>] [--name <display-name>] [--category <category>] [--default-port <port>]",
|
||||
);
|
||||
}
|
||||
if (!/^[a-z][A-Za-z0-9]*$/.test(kind)) {
|
||||
throw new Error("Integration kind must be a stable lower-camel-case identifier");
|
||||
}
|
||||
|
||||
const defaultSlug = kind.replace(/([a-z0-9])([A-Z])/gu, "$1-$2").toLowerCase();
|
||||
const slug = readOption("--slug") ?? defaultSlug;
|
||||
const name =
|
||||
readOption("--name") ??
|
||||
slug
|
||||
.split("-")
|
||||
.map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1)}`)
|
||||
.join(" ");
|
||||
const category = readOption("--category") ?? "miscellaneous";
|
||||
const iconUrl = readOption("--icon-url");
|
||||
const defaultPortInput = readOption("--default-port");
|
||||
|
||||
if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) throw new Error("Integration slug must use lower-case kebab-case");
|
||||
if (!iconUrl || !URL.canParse(iconUrl)) throw new Error("--icon-url must be a valid absolute URL");
|
||||
if (!/^[a-z][A-Za-z0-9]*$/.test(category)) throw new Error("Integration category must be a stable identifier");
|
||||
|
||||
let defaultPort: number | undefined;
|
||||
if (defaultPortInput !== undefined) {
|
||||
defaultPort = Number(defaultPortInput);
|
||||
if (!Number.isSafeInteger(defaultPort) || defaultPort < 1 || defaultPort > 65_535) {
|
||||
throw new Error("--default-port must be an integer between 1 and 65535");
|
||||
}
|
||||
}
|
||||
|
||||
const className = `${kind.charAt(0).toUpperCase()}${kind.slice(1)}Integration`;
|
||||
const metadataExportName = `${kind}Integration`;
|
||||
const directory = path.join(integrationsRoot, slug);
|
||||
await mkdir(directory);
|
||||
await mkdir(path.join(directory, "docs"));
|
||||
|
||||
const defaultPortLine = defaultPort === undefined ? "" : ` defaultPort: ${defaultPort},\n`;
|
||||
const files = {
|
||||
"module.ts": `import { defineIntegrationModule } from "@homarr/definitions";
|
||||
|
||||
export default defineIntegrationModule({
|
||||
kind: ${JSON.stringify(kind)},
|
||||
name: ${JSON.stringify(name)},
|
||||
iconUrl: ${JSON.stringify(iconUrl)},
|
||||
secretKinds: [[]],
|
||||
categories: [${JSON.stringify(category)}],
|
||||
${defaultPortLine} documentation: {
|
||||
slug: ${JSON.stringify(slug)},
|
||||
sourceDirectory: "docs",
|
||||
},
|
||||
creator: {
|
||||
type: "constructor",
|
||||
module: "./${slug}-integration",
|
||||
exportName: ${JSON.stringify(className)},
|
||||
},
|
||||
});
|
||||
`,
|
||||
[`${slug}-integration.ts`]: `import type { IntegrationTestingInput } from "../base/integration";
|
||||
import { Integration } from "../base/integration";
|
||||
import { TestConnectionError } from "../base/test-connection/test-connection-error";
|
||||
import type { TestingResult } from "../base/test-connection/test-connection-service";
|
||||
|
||||
export class ${className} extends Integration {
|
||||
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||
const response = await input.fetchAsync(this.url("/"));
|
||||
if (!response.ok) return TestConnectionError.StatusResult(response);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
`,
|
||||
"docs/index.ts": `export const ${metadataExportName} = {
|
||||
name: ${JSON.stringify(name)},
|
||||
description: ${JSON.stringify(`Connect Homarr to ${name}.`)},
|
||||
iconUrl: ${JSON.stringify(iconUrl)},
|
||||
path: "../../integrations/${slug}",
|
||||
} as const;
|
||||
`,
|
||||
"docs/index.mdx": `---
|
||||
title: ${JSON.stringify(name)}
|
||||
description: ${JSON.stringify(`Connect Homarr to ${name}.`)}
|
||||
hide_title: true
|
||||
---
|
||||
|
||||
import { IntegrationHeader } from "@site/src/components/integrations/header";
|
||||
import { AddingIntegration } from "@site/src/components/integrations/adding";
|
||||
import { ${metadataExportName} } from ".";
|
||||
|
||||
<IntegrationHeader integration={${metadataExportName}} categories={["Miscellaneous"]} />
|
||||
|
||||
Describe the service, supported Homarr widgets, required permissions, and setup prerequisites here.
|
||||
|
||||
### Adding the integration
|
||||
|
||||
<AddingIntegration />
|
||||
`,
|
||||
} as const;
|
||||
|
||||
for (const [relativePath, content] of Object.entries(files)) {
|
||||
await writeFile(path.join(directory, relativePath), content, { flag: "wx" });
|
||||
}
|
||||
|
||||
await import("./sync-integration-modules.mts");
|
||||
console.log(`Created integration module ${kind} in ${path.relative(repositoryRoot, directory)}`);
|
||||
@@ -1,392 +0,0 @@
|
||||
import { mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
import { format } from "oxfmt";
|
||||
|
||||
import type { IntegrationModuleDefinition } from "../../packages/definitions/src/integration-module";
|
||||
import { integrationCategories, integrationSecretKinds } from "../../packages/definitions/src/integration";
|
||||
|
||||
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const integrationsRoot = path.join(repositoryRoot, "packages/integrations/src");
|
||||
const generatedRoot = path.join(integrationsRoot, "generated");
|
||||
const docsRoot = path.join(repositoryRoot, "apps/docs/docs/integrations");
|
||||
const integrationDefinitionsSource = path.join(repositoryRoot, "packages/definitions/src/integration.ts");
|
||||
const integrationCreatorsSource = path.join(integrationsRoot, "base/creator.ts");
|
||||
const checkOnly = process.argv.includes("--check");
|
||||
const generatedHeader = "// This file is generated by pnpm integrations:sync. Do not edit it directly.\n\n";
|
||||
const generatedDocsMarker = ".integration-module-generated";
|
||||
const integrationCategorySet = new Set<string>(integrationCategories);
|
||||
const integrationSecretKindSet = new Set<string>(integrationSecretKinds);
|
||||
|
||||
interface LoadedIntegrationModule {
|
||||
definition: IntegrationModuleDefinition;
|
||||
directory: string;
|
||||
}
|
||||
|
||||
interface GeneratedFile {
|
||||
path: string;
|
||||
content: Buffer;
|
||||
}
|
||||
|
||||
const toPosixPath = (value: string) => value.split(path.sep).join("/");
|
||||
|
||||
const pathExistsAsync = async (target: string) => {
|
||||
try {
|
||||
await stat(target);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const assertPathInside = (parent: string, target: string, label: string) => {
|
||||
const relative = path.relative(parent, target);
|
||||
if (relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative))) return;
|
||||
throw new Error(`${label} must stay inside ${parent}`);
|
||||
};
|
||||
|
||||
const findSourceEntryAsync = async (target: string) => {
|
||||
const candidates = [
|
||||
target,
|
||||
`${target}.ts`,
|
||||
`${target}.tsx`,
|
||||
path.join(target, "index.ts"),
|
||||
path.join(target, "index.tsx"),
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
if (await pathExistsAsync(candidate)) return candidate;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const findIntegrationModuleFilesAsync = async (directory: string): Promise<string[]> => {
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
const files: string[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.name === "generated" || entry.name === "docs" || entry.name.startsWith(".")) continue;
|
||||
const target = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...(await findIntegrationModuleFilesAsync(target)));
|
||||
continue;
|
||||
}
|
||||
if (entry.isFile() && entry.name === "module.ts") files.push(target);
|
||||
}
|
||||
|
||||
return files;
|
||||
};
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
|
||||
const assertNonEmptyString = (value: unknown, label: string): asserts value is string => {
|
||||
if (typeof value !== "string" || value.trim() === "") throw new Error(`${label} must be a non-empty string`);
|
||||
};
|
||||
|
||||
const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
|
||||
const validateExportAsync = async (sourcePath: string, exportName: string, kind: string) => {
|
||||
const source = await readFile(sourcePath, "utf8");
|
||||
const escapedName = escapeRegExp(exportName);
|
||||
const declaration = new RegExp(
|
||||
`export\\s+(?:declare\\s+)?(?:abstract\\s+)?(?:class|const|function)\\s+${escapedName}\\b`,
|
||||
);
|
||||
const exportList = new RegExp(`export\\s*\\{[^}]*\\b${escapedName}\\b[^}]*\\}`, "s");
|
||||
if (declaration.test(source) || exportList.test(source)) return;
|
||||
throw new Error(`Integration ${kind} creator export ${exportName} was not found in ${sourcePath}`);
|
||||
};
|
||||
|
||||
const validateIntegrationModuleAsync = async (module: LoadedIntegrationModule) => {
|
||||
const { definition } = module;
|
||||
assertNonEmptyString(definition.kind, "Integration kind");
|
||||
if (!/^[A-Za-z][A-Za-z0-9]*$/.test(definition.kind)) {
|
||||
throw new Error(`Integration kind ${definition.kind} contains unsupported characters`);
|
||||
}
|
||||
assertNonEmptyString(definition.name, `Integration ${definition.kind} name`);
|
||||
assertNonEmptyString(definition.iconUrl, `Integration ${definition.kind} icon URL`);
|
||||
if (!URL.canParse(definition.iconUrl)) throw new Error(`Integration ${definition.kind} icon URL is invalid`);
|
||||
if (!Array.isArray(definition.secretKinds) || definition.secretKinds.length === 0) {
|
||||
throw new Error(`Integration ${definition.kind} must declare at least one secret alternative`);
|
||||
}
|
||||
for (const secretAlternative of definition.secretKinds) {
|
||||
if (!Array.isArray(secretAlternative)) {
|
||||
throw new Error(`Integration ${definition.kind} secret alternatives must be arrays`);
|
||||
}
|
||||
for (const secretKind of secretAlternative) {
|
||||
if (!integrationSecretKindSet.has(secretKind)) {
|
||||
throw new Error(`Integration ${definition.kind} has unknown secret kind ${secretKind}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!Array.isArray(definition.categories) || definition.categories.length === 0) {
|
||||
throw new Error(`Integration ${definition.kind} must declare at least one category`);
|
||||
}
|
||||
for (const category of definition.categories) {
|
||||
if (!integrationCategorySet.has(category)) {
|
||||
throw new Error(`Integration ${definition.kind} has unknown category ${category}`);
|
||||
}
|
||||
}
|
||||
if (!/^[a-z0-9][a-z0-9-]*$/.test(definition.documentation.slug)) {
|
||||
throw new Error(`Integration ${definition.kind} has invalid documentation slug ${definition.documentation.slug}`);
|
||||
}
|
||||
if (
|
||||
definition.defaultPort !== undefined &&
|
||||
(!Number.isSafeInteger(definition.defaultPort) || definition.defaultPort < 1 || definition.defaultPort > 65_535)
|
||||
) {
|
||||
throw new Error(`Integration ${definition.kind} default port must be an integer between 1 and 65535`);
|
||||
}
|
||||
if (definition.defaultUrl !== undefined && !URL.canParse(definition.defaultUrl)) {
|
||||
throw new Error(`Integration ${definition.kind} default URL is invalid`);
|
||||
}
|
||||
|
||||
const docsDirectory = path.resolve(module.directory, definition.documentation.sourceDirectory);
|
||||
assertPathInside(module.directory, docsDirectory, `Integration ${definition.kind} documentation`);
|
||||
if (!(await pathExistsAsync(path.join(docsDirectory, "index.mdx")))) {
|
||||
throw new Error(`Integration ${definition.kind} documentation must contain index.mdx: ${docsDirectory}`);
|
||||
}
|
||||
if (!(await pathExistsAsync(path.join(docsDirectory, "index.ts")))) {
|
||||
throw new Error(`Integration ${definition.kind} documentation must contain index.ts: ${docsDirectory}`);
|
||||
}
|
||||
|
||||
const creatorTarget = path.resolve(module.directory, definition.creator.module);
|
||||
assertPathInside(module.directory, creatorTarget, `Integration ${definition.kind} creator`);
|
||||
const creatorSource = await findSourceEntryAsync(creatorTarget);
|
||||
if (!creatorSource) {
|
||||
throw new Error(`Integration ${definition.kind} creator module does not exist: ${definition.creator.module}`);
|
||||
}
|
||||
assertNonEmptyString(definition.creator.exportName, `Integration ${definition.kind} creator export`);
|
||||
await validateExportAsync(creatorSource, definition.creator.exportName, definition.kind);
|
||||
};
|
||||
|
||||
const loadHandAuthoredRegistryEntriesAsync = async () => {
|
||||
const definitionsSource = await readFile(integrationDefinitionsSource, "utf8");
|
||||
const startMarker = "createIntegrationDefinitions({";
|
||||
const endMarker = "} as const satisfies Record<string, integrationDefinition>);";
|
||||
const start = definitionsSource.indexOf(startMarker);
|
||||
const end = definitionsSource.indexOf(endMarker, start);
|
||||
if (start < 0 || end < 0) throw new Error("Unable to locate the hand-authored integration registry");
|
||||
|
||||
const registry = definitionsSource.slice(start + startMarker.length, end);
|
||||
const kinds = new Set([...registry.matchAll(/^ ([A-Za-z][A-Za-z0-9]*): \{/gm)].map((match) => match[1]));
|
||||
const docsSlugs = new Set(
|
||||
[...registry.matchAll(/^ documentationSlug: "([a-z0-9][a-z0-9-]*)",/gm)].map((match) => match[1]),
|
||||
);
|
||||
|
||||
const creatorsSource = await readFile(integrationCreatorsSource, "utf8");
|
||||
const creatorStartMarker = "const integrationCreatorLoaders = {";
|
||||
const creatorEndMarker = "\n};\n\ntype AssertIntegrationCreatorLoaders";
|
||||
const creatorStart = creatorsSource.indexOf(creatorStartMarker);
|
||||
const creatorEnd = creatorsSource.indexOf(creatorEndMarker, creatorStart);
|
||||
if (creatorStart < 0 || creatorEnd < 0) throw new Error("Unable to locate the hand-authored creator registry");
|
||||
const creatorRegistry = creatorsSource.slice(creatorStart + creatorStartMarker.length, creatorEnd);
|
||||
const creatorKinds = new Set(
|
||||
[...creatorRegistry.matchAll(/^ ([A-Za-z][A-Za-z0-9]*): async /gm)].map((match) => match[1]),
|
||||
);
|
||||
return { kinds, docsSlugs, creatorKinds };
|
||||
};
|
||||
|
||||
const loadIntegrationModulesAsync = async () => {
|
||||
const moduleFiles = (await findIntegrationModuleFilesAsync(integrationsRoot)).toSorted();
|
||||
const handAuthored = await loadHandAuthoredRegistryEntriesAsync();
|
||||
const modules: LoadedIntegrationModule[] = [];
|
||||
const kinds = new Set<string>();
|
||||
const docsSlugs = new Set<string>();
|
||||
|
||||
for (const moduleFile of moduleFiles) {
|
||||
const loaded: unknown = await import(`${pathToFileURL(moduleFile).href}?sync=${Date.now()}`);
|
||||
if (!isRecord(loaded) || !("default" in loaded) || !isRecord(loaded.default)) {
|
||||
throw new Error(`${moduleFile} must default-export defineIntegrationModule(...)`);
|
||||
}
|
||||
|
||||
const directory = path.dirname(moduleFile);
|
||||
const module: LoadedIntegrationModule = {
|
||||
definition: loaded.default as unknown as IntegrationModuleDefinition,
|
||||
directory,
|
||||
};
|
||||
await validateIntegrationModuleAsync(module);
|
||||
if (handAuthored.kinds.has(module.definition.kind)) {
|
||||
throw new Error(`Integration kind ${module.definition.kind} exists in both a module and the central registry`);
|
||||
}
|
||||
if (handAuthored.creatorKinds.has(module.definition.kind)) {
|
||||
throw new Error(`Integration kind ${module.definition.kind} exists in both a module and the central creator`);
|
||||
}
|
||||
if (handAuthored.docsSlugs.has(module.definition.documentation.slug)) {
|
||||
throw new Error(
|
||||
`Integration documentation slug ${module.definition.documentation.slug} exists in both a module and the central registry`,
|
||||
);
|
||||
}
|
||||
if (kinds.has(module.definition.kind)) throw new Error(`Duplicate integration kind ${module.definition.kind}`);
|
||||
kinds.add(module.definition.kind);
|
||||
if (docsSlugs.has(module.definition.documentation.slug)) {
|
||||
throw new Error(`Duplicate integration documentation slug ${module.definition.documentation.slug}`);
|
||||
}
|
||||
docsSlugs.add(module.definition.documentation.slug);
|
||||
modules.push(module);
|
||||
}
|
||||
|
||||
return modules.toSorted((left, right) => left.definition.kind.localeCompare(right.definition.kind));
|
||||
};
|
||||
|
||||
const renderDefinition = (definition: IntegrationModuleDefinition) => {
|
||||
const projected: Record<string, unknown> = {
|
||||
name: definition.name,
|
||||
secretKinds: definition.secretKinds,
|
||||
iconUrl: definition.iconUrl,
|
||||
category: definition.categories,
|
||||
documentationSlug: definition.documentation.slug,
|
||||
};
|
||||
if (definition.defaultUrl !== undefined) projected.defaultUrl = definition.defaultUrl;
|
||||
if (definition.defaultPort !== undefined) projected.defaultPort = definition.defaultPort;
|
||||
if (definition.apiKeySettingsPath !== undefined) projected.apiKeySettingsPath = definition.apiKeySettingsPath;
|
||||
if (definition.docker || definition.onboarding) {
|
||||
const features: Record<string, unknown> = {};
|
||||
if (definition.docker) features.docker = definition.docker;
|
||||
if (definition.onboarding) features.onboarding = definition.onboarding;
|
||||
projected.features = features;
|
||||
}
|
||||
return JSON.stringify(projected, null, 2);
|
||||
};
|
||||
|
||||
const renderDefinitionsRegistry = (modules: LoadedIntegrationModule[]) => {
|
||||
const entries = modules
|
||||
.map((module) => ` ${JSON.stringify(module.definition.kind)}: ${renderDefinition(module.definition)},`)
|
||||
.join("\n");
|
||||
const documentationSources = modules
|
||||
.map((module) => {
|
||||
const source = path.resolve(module.directory, module.definition.documentation.sourceDirectory);
|
||||
const relativeSource = toPosixPath(path.relative(repositoryRoot, source));
|
||||
return ` ${JSON.stringify(module.definition.documentation.slug)}: ${JSON.stringify(relativeSource)},`;
|
||||
})
|
||||
.join("\n");
|
||||
return `${generatedHeader}export const generatedIntegrationDefinitions = {\n${entries}\n} as const;\n\nexport const generatedIntegrationDocumentationSources = {\n${documentationSources}\n} as const;\n`;
|
||||
};
|
||||
|
||||
const getCreatorImportPath = (module: LoadedIntegrationModule) => {
|
||||
const target = path.resolve(module.directory, module.definition.creator.module);
|
||||
const relative = toPosixPath(path.relative(generatedRoot, target));
|
||||
if (relative.startsWith(".")) return relative;
|
||||
return `./${relative}`;
|
||||
};
|
||||
|
||||
const renderCreatorLoader = (module: LoadedIntegrationModule) => {
|
||||
const { creator, kind } = module.definition;
|
||||
const importPath = getCreatorImportPath(module);
|
||||
if (creator.type === "factory") {
|
||||
return ` ${JSON.stringify(kind)}: async () => (await import(${JSON.stringify(importPath)})).${creator.exportName},`;
|
||||
}
|
||||
return ` ${JSON.stringify(kind)}: async () => {\n const IntegrationClass = (await import(${JSON.stringify(importPath)})).${creator.exportName};\n return async (input: IntegrationInput) => new IntegrationClass(input);\n },`;
|
||||
};
|
||||
|
||||
const renderCreatorRegistry = (modules: LoadedIntegrationModule[]) => {
|
||||
const entries = modules.map(renderCreatorLoader).join("\n");
|
||||
return `${generatedHeader}import type { Integration, IntegrationInput } from "../base/integration";\n\ntype GeneratedIntegrationCreator = (input: IntegrationInput) => Promise<Integration>;\n\nexport const generatedIntegrationCreatorLoaders = {\n${entries}\n} satisfies Record<string, () => Promise<GeneratedIntegrationCreator>>;\n`;
|
||||
};
|
||||
|
||||
const collectFilesAsync = async (directory: string, relativeDirectory = ""): Promise<GeneratedFile[]> => {
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
const files: GeneratedFile[] = [];
|
||||
for (const entry of entries) {
|
||||
const relativePath = path.join(relativeDirectory, entry.name);
|
||||
const sourcePath = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...(await collectFilesAsync(sourcePath, relativePath)));
|
||||
continue;
|
||||
}
|
||||
if (entry.isFile()) files.push({ path: relativePath, content: await readFile(sourcePath) });
|
||||
}
|
||||
return files;
|
||||
};
|
||||
|
||||
const createDocsOutputsAsync = async (modules: LoadedIntegrationModule[]) => {
|
||||
const outputs: GeneratedFile[] = [];
|
||||
for (const module of modules) {
|
||||
const source = path.resolve(module.directory, module.definition.documentation.sourceDirectory);
|
||||
const target = path.join(docsRoot, module.definition.documentation.slug);
|
||||
for (const file of await collectFilesAsync(source)) {
|
||||
outputs.push({ path: path.join(target, file.path), content: file.content });
|
||||
}
|
||||
outputs.push({
|
||||
path: path.join(target, generatedDocsMarker),
|
||||
content: Buffer.from(`${toPosixPath(path.relative(repositoryRoot, source))}\n`),
|
||||
});
|
||||
}
|
||||
return outputs;
|
||||
};
|
||||
|
||||
const readOrEmptyAsync = async (target: string) => {
|
||||
try {
|
||||
return await readFile(target);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const createGeneratedTypeScriptAsync = async (target: string, source: string): Promise<GeneratedFile> => {
|
||||
const result = await format(target, source, { printWidth: 120, endOfLine: "lf" });
|
||||
const errors = result.errors.filter((error) => error.severity === "Error");
|
||||
if (errors.length > 0) {
|
||||
throw new Error(`Failed to format ${target}: ${errors.map((error) => error.message).join(", ")}`);
|
||||
}
|
||||
return { path: target, content: Buffer.from(result.code) };
|
||||
};
|
||||
|
||||
const synchronizeFileAsync = async (file: GeneratedFile) => {
|
||||
const current = await readOrEmptyAsync(file.path);
|
||||
if (current?.equals(file.content)) return false;
|
||||
if (checkOnly) return true;
|
||||
await mkdir(path.dirname(file.path), { recursive: true });
|
||||
await writeFile(file.path, file.content);
|
||||
return true;
|
||||
};
|
||||
|
||||
const removeStaleGeneratedDocsAsync = async (outputs: GeneratedFile[]) => {
|
||||
const expected = new Set(outputs.map((output) => path.resolve(output.path)));
|
||||
const stale: string[] = [];
|
||||
const entries = await readdir(docsRoot, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const target = path.join(docsRoot, entry.name);
|
||||
if (!(await pathExistsAsync(path.join(target, generatedDocsMarker)))) continue;
|
||||
for (const file of await collectFilesAsync(target)) {
|
||||
const absolutePath = path.resolve(target, file.path);
|
||||
if (!expected.has(absolutePath)) stale.push(absolutePath);
|
||||
}
|
||||
}
|
||||
if (!checkOnly) {
|
||||
for (const file of stale) await rm(file);
|
||||
}
|
||||
return stale;
|
||||
};
|
||||
|
||||
const mainAsync = async () => {
|
||||
const modules = await loadIntegrationModulesAsync();
|
||||
if (modules.length === 0) throw new Error("No vertical integration modules were found");
|
||||
|
||||
const definitionsTarget = path.join(repositoryRoot, "packages/definitions/src/generated/integration-modules.ts");
|
||||
const creatorsTarget = path.join(generatedRoot, "integration-creators.ts");
|
||||
const outputs: GeneratedFile[] = [
|
||||
await createGeneratedTypeScriptAsync(definitionsTarget, renderDefinitionsRegistry(modules)),
|
||||
await createGeneratedTypeScriptAsync(creatorsTarget, renderCreatorRegistry(modules)),
|
||||
...(await createDocsOutputsAsync(modules)),
|
||||
];
|
||||
|
||||
const staleDocs = await removeStaleGeneratedDocsAsync(outputs);
|
||||
const drifted: string[] = [];
|
||||
for (const output of outputs) {
|
||||
if (await synchronizeFileAsync(output)) drifted.push(path.relative(repositoryRoot, output.path));
|
||||
}
|
||||
drifted.push(...staleDocs.map((file) => path.relative(repositoryRoot, file)));
|
||||
|
||||
if (checkOnly && drifted.length > 0) {
|
||||
throw new Error(
|
||||
`Integration module artifacts are out of date:\n${drifted.map((file) => ` - ${file}`).join("\n")}\nRun pnpm integrations:sync.`,
|
||||
);
|
||||
}
|
||||
|
||||
let action = "synchronized";
|
||||
if (checkOnly) action = "verified";
|
||||
console.log(`Integration modules ${action}: ${modules.map((module) => module.definition.kind).join(", ")}`);
|
||||
};
|
||||
|
||||
await mainAsync();
|
||||
@@ -1,82 +0,0 @@
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const widgetsRoot = path.join(repositoryRoot, "packages/widgets/src");
|
||||
|
||||
const readOption = (name: string) => {
|
||||
const optionIndex = process.argv.indexOf(name);
|
||||
if (optionIndex < 0) return undefined;
|
||||
return process.argv[optionIndex + 1];
|
||||
};
|
||||
|
||||
const kind = process.argv[2];
|
||||
if (!kind || kind.startsWith("--")) {
|
||||
throw new Error("Usage: pnpm widgets:new <kind> [--slug <docs-slug>] [--icon <TablerIconExport>]");
|
||||
}
|
||||
if (!/^[a-z][A-Za-z0-9]*$/.test(kind)) {
|
||||
throw new Error("Widget kind must be a stable lower-camel-case identifier");
|
||||
}
|
||||
|
||||
const defaultSlug = kind.replace(/([a-z0-9])([A-Z])/gu, "$1-$2").toLowerCase();
|
||||
const slug = readOption("--slug") ?? defaultSlug;
|
||||
const icon = readOption("--icon") ?? "IconLayoutGrid";
|
||||
if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) throw new Error("Widget slug must use lower-case kebab-case");
|
||||
if (!/^Icon[A-Za-z0-9]+$/.test(icon)) throw new Error("Widget icon must be a named Tabler Icon export");
|
||||
|
||||
const title = slug
|
||||
.split("-")
|
||||
.map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1)}`)
|
||||
.join(" ");
|
||||
const directory = path.join(widgetsRoot, slug);
|
||||
await mkdir(directory);
|
||||
await mkdir(path.join(directory, "docs"));
|
||||
|
||||
const files = {
|
||||
"module.ts": `import { defineWidgetModule } from "@homarr/definitions";
|
||||
|
||||
export default defineWidgetModule({
|
||||
kind: ${JSON.stringify(kind)},
|
||||
icon: ${JSON.stringify(icon)},
|
||||
clientEntry: ".",
|
||||
documentation: {
|
||||
slug: ${JSON.stringify(slug)},
|
||||
sourceDirectory: "docs",
|
||||
},
|
||||
});
|
||||
`,
|
||||
"index.ts": `import { createWidgetDefinition } from "../definition";
|
||||
import { optionsBuilder } from "../options";
|
||||
import { widgetIcon } from "./module.generated";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition(${JSON.stringify(kind)}, {
|
||||
icon: widgetIcon,
|
||||
createOptions() {
|
||||
return optionsBuilder.from(() => ({}));
|
||||
},
|
||||
}).withDynamicImport(() => import("./component"));
|
||||
`,
|
||||
"component.tsx": `"use client";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
|
||||
const Component = (_props: WidgetComponentProps<${JSON.stringify(kind)}>) => null;
|
||||
|
||||
export default Component;
|
||||
`,
|
||||
"docs/index.mdx": `---
|
||||
title: ${JSON.stringify(title)}
|
||||
description: ${JSON.stringify(`Documentation for the ${title} widget.`)}
|
||||
---
|
||||
|
||||
Describe the widget, its configuration, data source, permissions, and interactions here.
|
||||
`,
|
||||
} as const;
|
||||
|
||||
for (const [relativePath, content] of Object.entries(files)) {
|
||||
await writeFile(path.join(directory, relativePath), content, { flag: "wx" });
|
||||
}
|
||||
|
||||
await import("./sync-widget-modules.mts");
|
||||
console.log(`Created widget module ${kind} in ${path.relative(repositoryRoot, directory)}`);
|
||||
@@ -1,475 +0,0 @@
|
||||
import { mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
import { format } from "oxfmt";
|
||||
|
||||
import { getIntegrationKindsByCategory } from "../../packages/definitions/src/integration";
|
||||
import type {
|
||||
WidgetModuleDefinition,
|
||||
WidgetModuleIntegrationCapability,
|
||||
WidgetModuleOperation,
|
||||
WidgetModuleRouterRegistration,
|
||||
} from "../../packages/definitions/src/widget-module";
|
||||
|
||||
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const widgetsRoot = path.join(repositoryRoot, "packages/widgets/src");
|
||||
const apiRoutersRoot = path.join(repositoryRoot, "packages/api/src/router/widgets");
|
||||
const checkOnly = process.argv.includes("--check");
|
||||
const generatedHeader = "// This file is generated by pnpm widgets:sync. Do not edit it directly.\n\n";
|
||||
const generatedDocsMarker = ".widget-module-generated";
|
||||
|
||||
interface LoadedWidgetModule {
|
||||
definition: WidgetModuleDefinition;
|
||||
directory: string;
|
||||
relativeDirectory: string;
|
||||
}
|
||||
|
||||
interface GeneratedFile {
|
||||
path: string;
|
||||
content: Buffer;
|
||||
}
|
||||
|
||||
interface GroupedRouter extends WidgetModuleRouterRegistration {
|
||||
widgetKinds: string[];
|
||||
}
|
||||
|
||||
const toPosixPath = (value: string) => value.split(path.sep).join("/");
|
||||
const quote = (value: string) => JSON.stringify(value);
|
||||
const indent = (value: string, spaces: number) => {
|
||||
const prefix = " ".repeat(spaces);
|
||||
return value
|
||||
.split("\n")
|
||||
.map((line) => `${prefix}${line}`)
|
||||
.join("\n");
|
||||
};
|
||||
|
||||
const pathExists = async (target: string) => {
|
||||
try {
|
||||
await stat(target);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const assertPathInside = (parent: string, target: string, label: string) => {
|
||||
const relative = path.relative(parent, target);
|
||||
if (relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative))) return;
|
||||
throw new Error(`${label} must stay inside ${parent}`);
|
||||
};
|
||||
|
||||
const findSourceEntryAsync = async (target: string) => {
|
||||
const candidates = [
|
||||
target,
|
||||
`${target}.ts`,
|
||||
`${target}.tsx`,
|
||||
path.join(target, "index.ts"),
|
||||
path.join(target, "index.tsx"),
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
if (await pathExists(candidate)) return candidate;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const findWidgetModuleFilesAsync = async (directory: string): Promise<string[]> => {
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
const files: string[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.name === "generated" || entry.name === "docs" || entry.name.startsWith(".")) continue;
|
||||
const target = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...(await findWidgetModuleFilesAsync(target)));
|
||||
continue;
|
||||
}
|
||||
if (entry.isFile() && entry.name === "module.ts") files.push(target);
|
||||
}
|
||||
|
||||
return files;
|
||||
};
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
|
||||
const assertNonEmptyString = (value: unknown, label: string): asserts value is string => {
|
||||
if (typeof value !== "string" || value.trim() === "") throw new Error(`${label} must be a non-empty string`);
|
||||
};
|
||||
|
||||
const validateOperation = (module: LoadedWidgetModule, operation: WidgetModuleOperation, names: Set<string>) => {
|
||||
assertNonEmptyString(operation.name, `${module.definition.kind} operation name`);
|
||||
if (names.has(operation.name)) {
|
||||
throw new Error(`Widget ${module.definition.kind} declares operation ${operation.name} more than once`);
|
||||
}
|
||||
names.add(operation.name);
|
||||
|
||||
if (!Array.isArray(operation.path) || operation.path.length === 0) {
|
||||
throw new Error(`Widget ${module.definition.kind} operation ${operation.name} must declare a non-empty path`);
|
||||
}
|
||||
for (const part of operation.path) {
|
||||
assertNonEmptyString(part, `${module.definition.kind}.${operation.name} path segment`);
|
||||
}
|
||||
if (operation.client?.staleTimeSeconds !== undefined && operation.client.refetchIntervalSeconds === undefined) {
|
||||
throw new Error(
|
||||
`Widget ${module.definition.kind} operation ${operation.name} must declare a refetch interval with stale time`,
|
||||
);
|
||||
}
|
||||
|
||||
const cache = operation.serverCache;
|
||||
if (!cache) return;
|
||||
if (!/^[a-z0-9][a-z0-9:-]*$/.test(cache.namespace)) {
|
||||
throw new Error(`Widget ${module.definition.kind} has invalid cache namespace ${cache.namespace}`);
|
||||
}
|
||||
if (!Number.isSafeInteger(cache.ttlMs) || cache.ttlMs < 0) {
|
||||
throw new Error(`Widget ${module.definition.kind} cache TTL must be a non-negative integer`);
|
||||
}
|
||||
};
|
||||
|
||||
const validateWidgetModuleAsync = async (module: LoadedWidgetModule) => {
|
||||
const { definition } = module;
|
||||
if ("enabled" in definition || "disabled" in definition) {
|
||||
throw new Error(`Widget ${definition.kind} must not declare a feature availability flag`);
|
||||
}
|
||||
assertNonEmptyString(definition.kind, "Widget kind");
|
||||
if (!/^[A-Za-z][A-Za-z0-9-]*$/.test(definition.kind)) {
|
||||
throw new Error(`Widget kind ${definition.kind} contains unsupported characters`);
|
||||
}
|
||||
if (!/^Icon[A-Za-z0-9]+$/.test(definition.icon)) {
|
||||
throw new Error(`Widget ${definition.kind} has invalid Tabler icon export ${definition.icon}`);
|
||||
}
|
||||
if (!/^[a-z0-9][a-z0-9-]*$/.test(definition.documentation.slug)) {
|
||||
throw new Error(`Widget ${definition.kind} has invalid documentation slug ${definition.documentation.slug}`);
|
||||
}
|
||||
|
||||
const clientTarget = path.resolve(module.directory, definition.clientEntry);
|
||||
assertPathInside(module.directory, clientTarget, `Widget ${definition.kind} client entry`);
|
||||
if (!(await findSourceEntryAsync(clientTarget))) {
|
||||
throw new Error(`Widget ${definition.kind} client entry does not exist: ${definition.clientEntry}`);
|
||||
}
|
||||
|
||||
const docsDirectory = path.resolve(module.directory, definition.documentation.sourceDirectory);
|
||||
assertPathInside(module.directory, docsDirectory, `Widget ${definition.kind} documentation`);
|
||||
if (!(await pathExists(path.join(docsDirectory, "index.mdx")))) {
|
||||
throw new Error(`Widget ${definition.kind} documentation must contain index.mdx: ${docsDirectory}`);
|
||||
}
|
||||
|
||||
const operationNames = new Set<string>();
|
||||
for (const operation of definition.operations ?? []) validateOperation(module, operation, operationNames);
|
||||
|
||||
for (const router of definition.routers ?? []) {
|
||||
assertNonEmptyString(router.namespace, `${definition.kind} router namespace`);
|
||||
assertNonEmptyString(router.module, `${definition.kind} router module`);
|
||||
assertNonEmptyString(router.exportName, `${definition.kind} router export`);
|
||||
const routerTarget = path.resolve(apiRoutersRoot, router.module);
|
||||
assertPathInside(apiRoutersRoot, routerTarget, `Widget ${definition.kind} router module`);
|
||||
if (!(await findSourceEntryAsync(routerTarget))) {
|
||||
throw new Error(`Widget ${definition.kind} router module does not exist: ${router.module}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const loadWidgetModulesAsync = async () => {
|
||||
const moduleFiles = (await findWidgetModuleFilesAsync(widgetsRoot)).toSorted();
|
||||
const modules: LoadedWidgetModule[] = [];
|
||||
const kinds = new Set<string>();
|
||||
const docsSlugs = new Set<string>();
|
||||
const cacheNamespaces = new Set<string>();
|
||||
|
||||
for (const moduleFile of moduleFiles) {
|
||||
const loaded: unknown = await import(`${pathToFileURL(moduleFile).href}?sync=${Date.now()}`);
|
||||
if (!isRecord(loaded) || !("default" in loaded) || !isRecord(loaded.default)) {
|
||||
throw new Error(`${moduleFile} must default-export defineWidgetModule(...)`);
|
||||
}
|
||||
|
||||
const directory = path.dirname(moduleFile);
|
||||
const module: LoadedWidgetModule = {
|
||||
definition: loaded.default as unknown as WidgetModuleDefinition,
|
||||
directory,
|
||||
relativeDirectory: toPosixPath(path.relative(widgetsRoot, directory)),
|
||||
};
|
||||
await validateWidgetModuleAsync(module);
|
||||
|
||||
if (kinds.has(module.definition.kind)) throw new Error(`Duplicate widget kind ${module.definition.kind}`);
|
||||
kinds.add(module.definition.kind);
|
||||
if (docsSlugs.has(module.definition.documentation.slug)) {
|
||||
throw new Error(`Duplicate widget documentation slug ${module.definition.documentation.slug}`);
|
||||
}
|
||||
docsSlugs.add(module.definition.documentation.slug);
|
||||
|
||||
for (const operation of module.definition.operations ?? []) {
|
||||
const namespace = operation.serverCache?.namespace;
|
||||
if (!namespace) continue;
|
||||
if (cacheNamespaces.has(namespace)) throw new Error(`Duplicate widget cache namespace ${namespace}`);
|
||||
cacheNamespaces.add(namespace);
|
||||
}
|
||||
modules.push(module);
|
||||
}
|
||||
|
||||
return modules.toSorted((left, right) => left.definition.kind.localeCompare(right.definition.kind));
|
||||
};
|
||||
|
||||
const resolveIntegrationKinds = (capability: WidgetModuleIntegrationCapability) => {
|
||||
const kinds = new Set(capability.kinds ?? []);
|
||||
for (const category of capability.categories ?? []) {
|
||||
for (const kind of getIntegrationKindsByCategory(category)) kinds.add(kind);
|
||||
}
|
||||
for (const excludedKind of capability.excludeKinds ?? []) kinds.delete(excludedKind);
|
||||
if (kinds.size === 0) throw new Error("A widget integration capability must resolve to at least one kind");
|
||||
return [...kinds];
|
||||
};
|
||||
|
||||
const renderFeatureDescriptor = (module: LoadedWidgetModule) => {
|
||||
const { definition } = module;
|
||||
const fields = [`documentationSlug: ${quote(definition.documentation.slug)}`];
|
||||
if (definition.defaultSize) {
|
||||
fields.push(`defaultSize: { width: ${definition.defaultSize.width}, height: ${definition.defaultSize.height} }`);
|
||||
}
|
||||
if (definition.displayNameFromIntegration) fields.push("displayNameFromIntegration: true");
|
||||
if (definition.integration) {
|
||||
const capability = [`integrations: ${JSON.stringify(resolveIntegrationKinds(definition.integration))} as const`];
|
||||
if (definition.integration.connectionOptional) capability.push("connectionOptional: true");
|
||||
if (definition.integration.serverMaxIntegrations !== undefined) {
|
||||
capability.push(`serverMaxIntegrations: ${definition.integration.serverMaxIntegrations}`);
|
||||
}
|
||||
fields.push(`capability: { ${capability.join(", ")} }`);
|
||||
}
|
||||
|
||||
const queryOperations = (definition.operations ?? []).filter((operation) => (operation.kind ?? "query") === "query");
|
||||
if (queryOperations.length > 0) {
|
||||
const intervals = new Set(
|
||||
queryOperations
|
||||
.map((operation) => operation.client?.refetchIntervalSeconds)
|
||||
.filter((interval) => interval !== undefined),
|
||||
);
|
||||
if (intervals.size > 1) {
|
||||
throw new Error(`Widget ${definition.kind} has conflicting root refetch intervals; split the router path policy`);
|
||||
}
|
||||
const queryFields = [`paths: ${JSON.stringify(queryOperations.map((operation) => operation.path))} as const`];
|
||||
const interval = intervals.values().next().value;
|
||||
if (interval !== undefined) queryFields.push(`refetchIntervalSeconds: ${String(interval)}`);
|
||||
fields.push(`query: { ${queryFields.join(", ")} }`);
|
||||
}
|
||||
return `{\n${indent(fields.map((field) => `${field},`).join("\n"), 4)}\n }`;
|
||||
};
|
||||
|
||||
const renderDefinitionsRegistry = (modules: LoadedWidgetModule[]) => {
|
||||
const catalogEntries = modules
|
||||
.map((module) => ` ${quote(module.definition.kind)}: ${renderFeatureDescriptor(module)},`)
|
||||
.join("\n");
|
||||
const cacheEntries = modules
|
||||
.filter((module) => (module.definition.operations ?? []).some((operation) => operation.serverCache))
|
||||
.map((module) => {
|
||||
const operations = (module.definition.operations ?? [])
|
||||
.filter((operation) => operation.serverCache)
|
||||
.map((operation) => ` ${quote(operation.name)}: ${JSON.stringify(operation.serverCache)},`)
|
||||
.join("\n");
|
||||
return ` ${quote(module.definition.kind)}: {\n${operations}\n },`;
|
||||
})
|
||||
.join("\n");
|
||||
const operationPolicies = modules.flatMap((module) =>
|
||||
(module.definition.operations ?? []).map((operation) => ({
|
||||
widgetKind: module.definition.kind,
|
||||
operation: operation.name,
|
||||
kind: operation.kind ?? "query",
|
||||
path: operation.path,
|
||||
refetchIntervalSeconds: operation.client?.refetchIntervalSeconds,
|
||||
staleTimeSeconds: operation.client?.staleTimeSeconds,
|
||||
persist: operation.client?.persist ?? false,
|
||||
})),
|
||||
);
|
||||
const documentationSources = Object.fromEntries(
|
||||
modules.map((module) => [
|
||||
module.definition.documentation.slug,
|
||||
toPosixPath(
|
||||
path.join("packages/widgets/src", module.relativeDirectory, module.definition.documentation.sourceDirectory),
|
||||
),
|
||||
]),
|
||||
);
|
||||
|
||||
return `${generatedHeader}export const generatedWidgetFeatureCatalog = {\n${catalogEntries}\n} as const;\n\nexport const widgetServerCachePolicies = {\n${cacheEntries}\n} as const;\n\nexport const generatedWidgetOperationPolicies = ${JSON.stringify(operationPolicies, null, 2)} as const;\n\nexport const generatedWidgetDocumentationSources = ${JSON.stringify(documentationSources, null, 2)} as const;\n\nexport const getWidgetServerCachePolicy = <\n TKind extends keyof typeof widgetServerCachePolicies,\n TOperation extends keyof (typeof widgetServerCachePolicies)[TKind],\n>(\n kind: TKind,\n operation: TOperation,\n): (typeof widgetServerCachePolicies)[TKind][TOperation] => widgetServerCachePolicies[kind][operation];\n`;
|
||||
};
|
||||
|
||||
const getClientImportPath = (module: LoadedWidgetModule) => {
|
||||
const base = `../${module.relativeDirectory}`;
|
||||
if (module.definition.clientEntry === ".") return base;
|
||||
return `${base}/${module.definition.clientEntry.replace(/^\.\//, "")}`;
|
||||
};
|
||||
|
||||
const renderWidgetRegistry = (modules: LoadedWidgetModule[]) => {
|
||||
const typeImports = modules
|
||||
.map((module, index) => `import type * as widgetModule${index} from ${quote(getClientImportPath(module))};`)
|
||||
.join("\n");
|
||||
const loaders = modules
|
||||
.map((module) => ` ${quote(module.definition.kind)}: () => import(${quote(getClientImportPath(module))}),`)
|
||||
.join("\n");
|
||||
const imports = modules
|
||||
.map((module, index) => ` ${quote(module.definition.kind)}: typeof widgetModule${index};`)
|
||||
.join("\n");
|
||||
return `${generatedHeader}${typeImports}\n\nexport const generatedWidgetModuleLoaders = {\n${loaders}\n};\n\nexport interface GeneratedWidgetImports {\n${imports}\n}\n`;
|
||||
};
|
||||
|
||||
const renderIconRegistry = (modules: LoadedWidgetModule[]) => {
|
||||
const icons = [...new Set(modules.map((module) => module.definition.icon))].toSorted();
|
||||
const entries = modules.map((module) => ` ${quote(module.definition.kind)}: ${module.definition.icon},`).join("\n");
|
||||
return `${generatedHeader}import { ${icons.join(", ")} } from "@tabler/icons-react";\nimport type { TablerIcon } from "@tabler/icons-react";\n\nimport type { WidgetKind } from "@homarr/definitions";\n\nexport const generatedWidgetCatalogIcons = {\n${entries}\n} satisfies Partial<Record<WidgetKind, TablerIcon>>;\n`;
|
||||
};
|
||||
|
||||
const renderLocalWidgetProjection = (module: LoadedWidgetModule) =>
|
||||
`${generatedHeader}export { ${module.definition.icon} as widgetIcon } from "@tabler/icons-react";\n`;
|
||||
|
||||
const groupRouters = (modules: LoadedWidgetModule[]) => {
|
||||
const routers = new Map<string, GroupedRouter>();
|
||||
for (const module of modules) {
|
||||
for (const registration of module.definition.routers ?? []) {
|
||||
const widgetKinds = [...(registration.additionalWidgetKinds ?? []), module.definition.kind];
|
||||
const existing = routers.get(registration.namespace);
|
||||
if (!existing) {
|
||||
routers.set(registration.namespace, { ...registration, widgetKinds });
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
existing.module !== registration.module ||
|
||||
existing.exportName !== registration.exportName ||
|
||||
existing.mcp !== registration.mcp
|
||||
) {
|
||||
throw new Error(`Router namespace ${registration.namespace} has conflicting registrations`);
|
||||
}
|
||||
existing.widgetKinds = [...new Set([...existing.widgetKinds, ...widgetKinds])];
|
||||
}
|
||||
}
|
||||
return [...routers.values()].toSorted((left, right) => left.namespace.localeCompare(right.namespace));
|
||||
};
|
||||
|
||||
const renderApiRegistry = (modules: LoadedWidgetModule[]) => {
|
||||
const entries = groupRouters(modules)
|
||||
.map((router) => {
|
||||
const mcp = router.mcp === undefined ? "false" : JSON.stringify(router.mcp);
|
||||
return ` ${quote(router.namespace)}: registerGeneratedWidgetRouter(\n ${JSON.stringify(router.widgetKinds)},\n () => import(${quote(router.module)}).then((module) => module.${router.exportName}),\n ${mcp},\n ),`;
|
||||
})
|
||||
.join("\n");
|
||||
return `${generatedHeader}import type { AnyTRPCRouter } from "@trpc/server";\n\nimport type { WidgetKind } from "@homarr/definitions";\n\nconst registerGeneratedWidgetRouter = <TRouter extends AnyTRPCRouter>(\n widgetKinds: readonly [WidgetKind, ...WidgetKind[]],\n loadRouter: () => Promise<TRouter>,\n mcp: false | true | string,\n) => ({ widgetKinds, loadRouter, mcp });\n\nexport const generatedWidgetRouterRegistry = {\n${entries}\n};\n`;
|
||||
};
|
||||
|
||||
const collectFilesAsync = async (directory: string, relativeDirectory = ""): Promise<GeneratedFile[]> => {
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
const files: GeneratedFile[] = [];
|
||||
for (const entry of entries) {
|
||||
const relativePath = path.join(relativeDirectory, entry.name);
|
||||
const sourcePath = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...(await collectFilesAsync(sourcePath, relativePath)));
|
||||
continue;
|
||||
}
|
||||
if (entry.isFile()) files.push({ path: relativePath, content: await readFile(sourcePath) });
|
||||
}
|
||||
return files;
|
||||
};
|
||||
|
||||
const createDocsOutputsAsync = async (modules: LoadedWidgetModule[]) => {
|
||||
const outputs: GeneratedFile[] = [];
|
||||
for (const module of modules) {
|
||||
const source = path.resolve(module.directory, module.definition.documentation.sourceDirectory);
|
||||
const target = path.join(repositoryRoot, "apps/docs/docs/widgets", module.definition.documentation.slug);
|
||||
const sourceFiles = await collectFilesAsync(source);
|
||||
for (const file of sourceFiles) {
|
||||
outputs.push({ path: path.join(target, file.path), content: file.content });
|
||||
}
|
||||
outputs.push({
|
||||
path: path.join(target, generatedDocsMarker),
|
||||
content: Buffer.from(`${toPosixPath(path.relative(repositoryRoot, source))}\n`),
|
||||
});
|
||||
}
|
||||
return outputs;
|
||||
};
|
||||
|
||||
const readOrEmptyAsync = async (target: string) => {
|
||||
try {
|
||||
return await readFile(target);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const createGeneratedTypeScriptAsync = async (target: string, source: string): Promise<GeneratedFile> => {
|
||||
const result = await format(target, source, { printWidth: 120, endOfLine: "lf" });
|
||||
const errors = result.errors.filter((error) => error.severity === "Error");
|
||||
if (errors.length > 0) {
|
||||
throw new Error(`Failed to format ${target}: ${errors.map((error) => error.message).join(", ")}`);
|
||||
}
|
||||
return { path: target, content: Buffer.from(result.code) };
|
||||
};
|
||||
|
||||
const synchronizeFileAsync = async (file: GeneratedFile) => {
|
||||
const current = await readOrEmptyAsync(file.path);
|
||||
if (current?.equals(file.content)) return false;
|
||||
if (checkOnly) return true;
|
||||
await mkdir(path.dirname(file.path), { recursive: true });
|
||||
await writeFile(file.path, file.content);
|
||||
return true;
|
||||
};
|
||||
|
||||
const removeStaleGeneratedDocsAsync = async (modules: LoadedWidgetModule[], outputs: GeneratedFile[]) => {
|
||||
const expected = new Set(outputs.map((output) => path.resolve(output.path)));
|
||||
const stale: string[] = [];
|
||||
const docsRoot = path.join(repositoryRoot, "apps/docs/docs/widgets");
|
||||
const targetDirectories = new Set(modules.map((module) => path.join(docsRoot, module.definition.documentation.slug)));
|
||||
for (const entry of await readdir(docsRoot, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const target = path.join(docsRoot, entry.name);
|
||||
if (await pathExists(path.join(target, generatedDocsMarker))) targetDirectories.add(target);
|
||||
}
|
||||
|
||||
for (const target of targetDirectories) {
|
||||
if (!(await pathExists(path.join(target, generatedDocsMarker)))) continue;
|
||||
for (const file of await collectFilesAsync(target)) {
|
||||
const absolutePath = path.resolve(target, file.path);
|
||||
if (!expected.has(absolutePath)) stale.push(absolutePath);
|
||||
}
|
||||
}
|
||||
if (!checkOnly) {
|
||||
for (const file of stale) await rm(file);
|
||||
}
|
||||
return stale;
|
||||
};
|
||||
|
||||
const mainAsync = async () => {
|
||||
const modules = await loadWidgetModulesAsync();
|
||||
if (modules.length === 0) throw new Error("No vertical widget modules were found");
|
||||
|
||||
const definitionsTarget = path.join(repositoryRoot, "packages/definitions/src/generated/widget-modules.ts");
|
||||
const widgetsTarget = path.join(repositoryRoot, "packages/widgets/src/generated/widget-modules.ts");
|
||||
const iconsTarget = path.join(repositoryRoot, "packages/ui/src/generated/widget-icons.ts");
|
||||
const apiTarget = path.join(repositoryRoot, "packages/api/src/router/widgets/generated-registry.ts");
|
||||
const outputs: GeneratedFile[] = [
|
||||
await createGeneratedTypeScriptAsync(definitionsTarget, renderDefinitionsRegistry(modules)),
|
||||
await createGeneratedTypeScriptAsync(widgetsTarget, renderWidgetRegistry(modules)),
|
||||
await createGeneratedTypeScriptAsync(iconsTarget, renderIconRegistry(modules)),
|
||||
await createGeneratedTypeScriptAsync(apiTarget, renderApiRegistry(modules)),
|
||||
...(await Promise.all(
|
||||
modules.map(async (module) => {
|
||||
const target = path.join(module.directory, "module.generated.ts");
|
||||
return await createGeneratedTypeScriptAsync(target, renderLocalWidgetProjection(module));
|
||||
}),
|
||||
)),
|
||||
...(await createDocsOutputsAsync(modules)),
|
||||
];
|
||||
|
||||
const staleDocs = await removeStaleGeneratedDocsAsync(modules, outputs);
|
||||
const drifted: string[] = [];
|
||||
for (const output of outputs) {
|
||||
if (await synchronizeFileAsync(output)) drifted.push(path.relative(repositoryRoot, output.path));
|
||||
}
|
||||
drifted.push(...staleDocs.map((file) => path.relative(repositoryRoot, file)));
|
||||
|
||||
if (checkOnly && drifted.length > 0) {
|
||||
throw new Error(
|
||||
`Widget module artifacts are out of date:\n${drifted.map((file) => ` - ${file}`).join("\n")}\nRun pnpm widgets:sync.`,
|
||||
);
|
||||
}
|
||||
|
||||
const action = checkOnly ? "verified" : "synchronized";
|
||||
console.log(`Widget modules ${action}: ${modules.map((module) => module.definition.kind).join(", ")}`);
|
||||
};
|
||||
|
||||
await mainAsync();
|
||||
Reference in new issue
Block a user