Files
homarr/scripts/feature-platform/generate-feature.mts
T
2026-08-14 02:18:46 +02:00

476 lines
20 KiB
TypeScript

import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { mkdirSync } from "node:fs";
import { format } from "oxfmt";
export interface IntegrationFeature {
kind: string;
name: string;
slug: string;
description: string;
category: string;
secretKinds: string[];
iconUrl: string;
}
export interface WidgetFeature {
kind: string;
name: string;
slug: string;
description: string;
icon: string;
supportedIntegrations: string[];
}
export interface FeatureRequest {
integration?: IntegrationFeature;
widget?: WidgetFeature;
}
export interface PlannedChange {
path: string;
content: string;
action: "create" | "update";
}
const pascalCase = (value: string) =>
value.replace(/(^|-)([a-z])/g, (_, _separator, letter: string) => letter.toUpperCase());
const identifier = (value: string) => value.replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase());
const key = (value: string) => (/^[A-Za-z_$][\w$]*$/.test(value) ? value : JSON.stringify(value));
const read = (root: string, path: string) => readFileSync(join(root, path), "utf8");
const containerRange = (source: string, marker: string, opener: "{" | "[") => {
const declaration = source.indexOf(marker);
if (declaration < 0) throw new Error(`Generator stopped: expected marker ${marker}`);
const start = source.indexOf(opener, declaration);
if (start < 0) throw new Error(`Generator stopped: expected ${opener} after ${marker}`);
const closer = opener === "{" ? "}" : "]";
let depth = 0;
let quote = "";
let escaped = false;
for (let index = start; index < source.length; index += 1) {
const character = source[index];
if (quote) {
if (escaped) escaped = false;
else if (character === "\\") escaped = true;
else if (character === quote) quote = "";
continue;
}
if (character === '"' || character === "'" || character === "`") quote = character;
else if (character === opener) depth += 1;
else if (character === closer && --depth === 0) return { start, end: index };
}
throw new Error(`Generator stopped: could not find closing ${closer} for ${marker}`);
};
const appendToContainer = (source: string, marker: string, opener: "{" | "[", entry: string) => {
const { end } = containerRange(source, marker, opener);
return `${source.slice(0, end)}${entry}${source.slice(end)}`;
};
const insertBeforeUnique = (source: string, anchor: string, entry: string, path: string) => {
const first = source.indexOf(anchor);
if (first < 0 || source.indexOf(anchor, first + anchor.length) >= 0) {
throw new Error(
`Generator stopped: expected exactly one ${JSON.stringify(anchor)} in ${path}; update the generator anchor.`,
);
}
return `${source.slice(0, first)}${entry}${source.slice(first)}`;
};
const addJsonProperty = (source: string, property: "integration" | "widget", name: string, value: unknown) => {
const marker = `\n "${property}":`;
const { start, end } = containerRange(source, marker, "{");
const body = source.slice(start + 1, end);
if (new RegExp(`^ ${JSON.stringify(name)}:`, "m").test(body)) {
throw new Error(`Generator stopped: translation ${property}.${name} already exists.`);
}
const serialized = JSON.stringify(value, null, 2)
.split("\n")
.map((line, index) => (index === 0 ? line : ` ${line}`))
.join("\n");
const beforeClose = source.slice(0, end);
const trimmed = beforeClose.trimEnd();
return `${trimmed},\n ${JSON.stringify(name)}: ${serialized}\n ${source.slice(end)}`;
};
const addChange = (
changes: PlannedChange[],
root: string,
path: string,
content: string,
action: PlannedChange["action"],
) => {
if (action === "create" && existsSync(join(root, path)))
throw new Error(`Generator stopped: ${path} already exists.`);
changes.push({ path, content, action });
};
const validate = (request: FeatureRequest) => {
if (!request.integration && !request.widget)
throw new Error("Generator stopped: choose an integration, a widget, or both.");
for (const feature of [request.integration, request.widget]) {
if (!feature) continue;
if (!/^[a-z][A-Za-z0-9]*$/.test(feature.kind))
throw new Error(`Generator stopped: ${feature.kind} must be lower camel case.`);
if (!/^[a-z][a-z0-9]*(?:-[a-z][a-z0-9]*)*$/.test(feature.slug))
throw new Error(`Generator stopped: ${feature.slug} must start with a lowercase letter and use kebab-case.`);
}
if (request.widget && !/^Icon[A-Z][A-Za-z0-9]+$/.test(request.widget.icon)) {
throw new Error(`Generator stopped: ${request.widget.icon} must be a Tabler icon export such as IconBox.`);
}
};
const planIntegration = (root: string, feature: IntegrationFeature, changes: PlannedChange[]) => {
const definitionSource = read(root, "packages/definitions/src/integration.ts");
const categoryRange = containerRange(definitionSource, "const integrationCategories", "[");
const categories = new Set(
[...definitionSource.slice(categoryRange.start, categoryRange.end).matchAll(/"([^"]+)"/g)].map((match) => match[1]),
);
if (!categories.has(feature.category)) {
throw new Error(`Generator stopped: ${feature.category} is not a current integration category.`);
}
const secretObject = definitionSource.slice(
definitionSource.indexOf("const integrationSecretKindObject"),
definitionSource.indexOf("satisfies Record<string", definitionSource.indexOf("const integrationSecretKindObject")),
);
const validSecrets = new Set([...secretObject.matchAll(/^ (\w+):/gm)].map((match) => match[1]));
const unknownSecrets = feature.secretKinds.filter((kind) => !validSecrets.has(kind));
if (unknownSecrets.length > 0) {
throw new Error(`Generator stopped: unknown credential kind(s): ${unknownSecrets.join(", ")}.`);
}
const className = `${pascalCase(feature.slug)}Integration`;
const implementationPath = `packages/integrations/src/${feature.slug}/${feature.slug}-integration.ts`;
const testPath = `packages/integrations/src/${feature.slug}/test/${feature.slug}-integration.spec.ts`;
const parserName = `parse${pascalCase(feature.slug)}ResponseAsync`;
const implementation = `import { z } from "zod/v4";
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";
// Replace this minimal shape with the upstream service contract used by the feature.
const responseSchema = z.object({});
export const ${parserName} = async (response: { json: () => Promise<unknown> }) =>
responseSchema.parse(await response.json());
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);
await ${parserName}(response);
return { success: true };
}
}
`;
const test = `import { describe, expect, test } from "vitest";
import { simulateResponseContractAsync } from "../../base/response-contract";
import { ${className}, ${parserName} } from "../${feature.slug}-integration";
describe("${className}", () => {
test("exposes its public integration data", () => {
const integration = new ${className}({
id: "test",
name: ${JSON.stringify(feature.name)},
url: "http://localhost",
externalUrl: null,
decryptedSecrets: [],
});
expect(integration.publicIntegration.name).toBe(${JSON.stringify(feature.name)});
});
test("parses documented response fixtures", async () => {
const results = await simulateResponseContractAsync(${parserName}, [
{ name: "minimal valid response", payload: {}, expected: {} },
{ name: "invalid response", payload: null, rejects: true },
]);
expect(
results.every((result) => result.passed),
results,
).toBe(true);
});
});
`;
addChange(changes, root, implementationPath, implementation, "create");
addChange(changes, root, testPath, test, "create");
const definitionPath = "packages/definitions/src/integration.ts";
let definitions = definitionSource;
if (new RegExp(`^ ${feature.kind}:`, "m").test(definitions)) {
throw new Error(`Generator stopped: integration ${feature.kind} already exists.`);
}
definitions = appendToContainer(
definitions,
"const integrationDefs",
"{",
` ${key(feature.kind)}: {\n name: ${JSON.stringify(feature.name)},\n secretKinds: [${JSON.stringify(feature.secretKinds)}],\n iconUrl: ${JSON.stringify(feature.iconUrl)},\n category: [${JSON.stringify(feature.category)}],\n documentationUrl: createDocumentationLink("/docs/integrations/${feature.slug}"),\n },\n`,
);
addChange(changes, root, definitionPath, definitions, "update");
const creatorPath = "packages/integrations/src/base/creator.ts";
let creator = read(root, creatorPath);
creator = insertBeforeUnique(
creator,
'import type { Integration, IntegrationInput } from "./integration";',
`import { ${className} } from "../${feature.slug}/${feature.slug}-integration";\n`,
creatorPath,
);
creator = appendToContainer(creator, "const integrationCreators", "{", ` ${key(feature.kind)}: ${className},\n`);
addChange(changes, root, creatorPath, creator, "update");
const indexPath = "packages/integrations/src/index.ts";
const integrationIndex = insertBeforeUnique(
read(root, indexPath),
"// Types",
`export { ${className} } from "./${feature.slug}/${feature.slug}-integration";\n\n`,
indexPath,
);
addChange(changes, root, indexPath, integrationIndex, "update");
const slugPath = "packages/definitions/src/docs/integration-doc-slugs.ts";
addChange(
changes,
root,
slugPath,
appendToContainer(
read(root, slugPath),
"const integrationDocSlugs",
"{",
` ${key(feature.kind)}: "${feature.slug}",\n`,
),
"update",
);
const translationPath = "packages/translation/src/lang/en.json";
addChange(
changes,
root,
translationPath,
addJsonProperty(read(root, translationPath), "integration", feature.kind, {
name: feature.name,
description: feature.description,
}),
"update",
);
const docsBase = `apps/docs/docs/integrations/${feature.slug}`;
addChange(
changes,
root,
`${docsBase}/index.ts`,
`import type { IntegrationDefinition } from "@site/src/types";\n\nexport const ${identifier(feature.kind)}Integration: IntegrationDefinition = {\n name: ${JSON.stringify(feature.name)},\n description: ${JSON.stringify(feature.description)},\n iconUrl: ${JSON.stringify(feature.iconUrl)},\n path: "../../integrations/${feature.slug}",\n};\n`,
"create",
);
addChange(
changes,
root,
`${docsBase}/index.mdx`,
`---\ntitle: ${JSON.stringify(feature.name)}\ndescription: ${JSON.stringify(feature.description)}\nhide_title: true\n---\n\nimport { AddingIntegration } from "@site/src/components/integrations/adding";\nimport { IntegrationHeader } from "@site/src/components/integrations/header";\nimport { ${identifier(feature.kind)}Integration } from ".";\n\n<IntegrationHeader integration={${identifier(feature.kind)}Integration} categories={[${JSON.stringify(feature.category)}]} />\n\n${feature.description}\n\n### Adding the integration\n\n<AddingIntegration />\n\n### Contributor checklist\n\nDocument credentials, supported widgets, API version requirements, and a tested example URL before merging.\n`,
"create",
);
};
const planWidget = (root: string, feature: WidgetFeature, changes: PlannedChange[]) => {
if (read(root, "packages/definitions/src/widget.ts").includes(` "${feature.kind}",`)) {
throw new Error(`Generator stopped: widget ${feature.kind} already exists.`);
}
const widgetPath = `packages/widgets/src/${feature.slug}`;
const supportedLine =
feature.supportedIntegrations.length > 0
? `\n supportedIntegrations: [...nativeFeatureCapabilities.${feature.kind}.integrations],`
: "";
const capabilityImport =
feature.supportedIntegrations.length > 0
? 'import { nativeFeatureCapabilities } from "@homarr/definitions";\n\n'
: "";
addChange(
changes,
root,
`${widgetPath}/index.ts`,
`import { ${feature.icon} } from "@tabler/icons-react";\n\n${capabilityImport}import { createWidgetDefinition } from "../definition";\nimport { optionsBuilder } from "../options";\n\nexport const { definition, componentLoader } = createWidgetDefinition("${feature.kind}", {\n icon: ${feature.icon},${supportedLine}\n createOptions() {\n return optionsBuilder.from(() => ({}));\n },\n}).withDynamicImport(() => import("./component"));\n`,
"create",
);
addChange(
changes,
root,
`${widgetPath}/component.tsx`,
`"use client";\n\nimport { Center, Text } from "@mantine/core";\n\nimport type { WidgetComponentProps } from "../definition";\n\nexport default function ${pascalCase(feature.slug)}Widget(_props: WidgetComponentProps<"${feature.kind}">) {\n return (\n <Center h="100%">\n <Text>{${JSON.stringify(feature.name)}}</Text>\n </Center>\n );\n}\n`,
"create",
);
addChange(
changes,
root,
`${widgetPath}/definition.spec.ts`,
`import { describe, expect, test } from "vitest";\n\nimport { componentLoader, definition } from ".";\n\ndescribe(${JSON.stringify(`${feature.name} widget definition`)}, () => {\n test("registers its kind and loader", () => {\n expect(definition.kind).toBe("${feature.kind}");\n expect(componentLoader).toBeTypeOf("function");\n });\n});\n`,
"create",
);
const kindsPath = "packages/definitions/src/widget.ts";
addChange(
changes,
root,
kindsPath,
appendToContainer(read(root, kindsPath), "const widgetKinds", "[", ` "${feature.kind}",\n`),
"update",
);
const manifestPath = "packages/widgets/src/manifest.ts";
let manifest = read(root, manifestPath);
manifest = appendToContainer(
manifest,
"const moduleLoaders",
"{",
` ${key(feature.kind)}: () => import("./${feature.slug}") as Promise<WidgetModule>,\n`,
);
manifest = appendToContainer(
manifest,
"const componentLoaders",
"{",
` ${key(feature.kind)}: () => import("./${feature.slug}/component") as Promise<WidgetComponentModule>,\n`,
);
addChange(changes, root, manifestPath, manifest, "update");
const catalogPath = "packages/widgets/src/catalog.ts";
let catalog = read(root, catalogPath);
const iconBlockEnd = catalog.indexOf('} from "@tabler/icons-react";');
if (iconBlockEnd < 0) throw new Error(`Generator stopped: expected Tabler icon import block in ${catalogPath}.`);
if (!catalog.includes(` ${feature.icon},`)) {
catalog = `${catalog.slice(0, iconBlockEnd)} ${feature.icon},\n${catalog.slice(iconBlockEnd)}`;
}
catalog = appendToContainer(catalog, "const widgetCatalogIcons", "{", ` ${key(feature.kind)}: ${feature.icon},\n`);
addChange(changes, root, catalogPath, catalog, "update");
const indexPath = "packages/widgets/src/index.tsx";
const importName = identifier(feature.kind);
let index = insertBeforeUnique(
read(root, indexPath),
"export type {\n NormalizedWidgetQuery,",
`import * as ${importName} from "./${feature.slug}";\n\n`,
indexPath,
);
index = appendToContainer(index, "const widgetImports", "{", ` ${key(feature.kind)}: ${importName},\n`);
addChange(changes, root, indexPath, index, "update");
const slugPath = "packages/definitions/src/docs/widget-doc-slugs.ts";
addChange(
changes,
root,
slugPath,
appendToContainer(
read(root, slugPath),
"const widgetDocSlugs",
"{",
` ${key(feature.kind)}: "${feature.slug}",\n`,
),
"update",
);
if (feature.supportedIntegrations.length > 0) {
const supportPath = "packages/definitions/src/widget-integration-map.ts";
addChange(
changes,
root,
supportPath,
appendToContainer(
read(root, supportPath),
"const nativeFeatureCapabilities",
"{",
` ${key(feature.kind)}: { integrations: ${JSON.stringify(feature.supportedIntegrations)} },\n`,
),
"update",
);
}
const translationPath = "packages/translation/src/lang/en.json";
addChange(
changes,
root,
translationPath,
addJsonProperty(read(root, translationPath), "widget", feature.kind, {
name: feature.name,
description: feature.description,
}),
"update",
);
const docsBase = `apps/docs/docs/widgets/${feature.slug}`;
addChange(
changes,
root,
`${docsBase}/index.ts`,
`import type { WidgetDefinition } from "@site/src/types";\nimport { ${feature.icon} } from "@tabler/icons-react";\n\nexport const ${identifier(feature.kind)}Widget: WidgetDefinition = {\n icon: ${feature.icon},\n name: ${JSON.stringify(feature.name)},\n description: ${JSON.stringify(feature.description)},\n path: "../../widgets/${feature.slug}",\n configuration: { items: [] },\n};\n`,
"create",
);
addChange(
changes,
root,
`${docsBase}/index.mdx`,
`---\ntitle: ${JSON.stringify(feature.name)}\ndescription: ${JSON.stringify(feature.description)}\nhide_title: true\n---\n\nimport { AddingWidget } from "@site/src/components/widgets/adding";\nimport { WidgetHeader } from "@site/src/components/widgets/header";\nimport { ${identifier(feature.kind)}Widget } from ".";\n\n<WidgetHeader widget={${identifier(feature.kind)}Widget} categories={["Other"]} />\n\n${feature.description}\n\n### Adding the widget\n\n<AddingWidget />\n\n### Contributor checklist\n\nDocument configuration, supported integrations, empty/loading/error states, and screenshots before merging.\n`,
"create",
);
};
export const planFeatureGeneration = (root: string, request: FeatureRequest): PlannedChange[] => {
validate(request);
const changes: PlannedChange[] = [];
if (request.integration) planIntegration(root, request.integration, changes);
if (request.widget) {
const knownIntegrations = new Set(
[...read(root, "packages/definitions/src/integration.ts").matchAll(/^ (\w+): \{/gm)].map((match) => match[1]),
);
if (request.integration) knownIntegrations.add(request.integration.kind);
const unknown = request.widget.supportedIntegrations.filter((kind) => !knownIntegrations.has(kind));
if (unknown.length > 0)
throw new Error(`Generator stopped: unknown supported integration(s): ${unknown.join(", ")}.`);
const widgetChanges: PlannedChange[] = [];
planWidget(root, request.widget, widgetChanges);
for (const change of widgetChanges) {
const existing = changes.find((candidate) => candidate.path === change.path);
if (existing && change.path === "packages/translation/src/lang/en.json") {
existing.content = addJsonProperty(existing.content, "widget", request.widget.kind, {
name: request.widget.name,
description: request.widget.description,
});
} else {
changes.push(change);
}
}
}
const duplicatePaths = changes.filter(
(change, index) => changes.findIndex((candidate) => candidate.path === change.path) !== index,
);
if (duplicatePaths.length > 0) {
throw new Error(
`Generator stopped: combined generation would update ${duplicatePaths[0].path} twice; merge planning is not safe yet.`,
);
}
return changes;
};
const isFormatterManaged = (path: string) =>
/\.(?:json|mdx|ts|tsx)$/.test(path) && path !== "packages/translation/src/lang/en.json";
export const formatFeatureChanges = async (changes: PlannedChange[]) =>
await Promise.all(
changes.map(async (change) => {
if (!isFormatterManaged(change.path)) return change;
const result = await format(change.path, change.content, { endOfLine: "lf", printWidth: 120 });
if (result.errors.length > 0) {
throw new Error(
`Generator stopped: oxfmt could not format ${change.path}: ${result.errors.map(({ message }) => message).join("; ")}`,
);
}
return { ...change, content: result.code };
}),
);
export const generateFeature = async (root: string, request: FeatureRequest) => {
const changes = await formatFeatureChanges(planFeatureGeneration(root, request));
for (const change of changes) {
mkdirSync(dirname(join(root, change.path)), { recursive: true });
writeFileSync(join(root, change.path), change.content);
}
return changes;
};