enhance release v2 experience
This commit is contained in:
1 parent
9b4970583e
commit
ef2ed6c602
152 files changed
+7236
-764
No files matched your search
@@ -0,0 +1,14 @@
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { checkFeatureContracts } from "./feature-platform/contracts.mts";
|
||||
|
||||
const root = resolve(import.meta.dirname, "..");
|
||||
const problems = checkFeatureContracts(root);
|
||||
|
||||
if (problems.length > 0) {
|
||||
console.error("Feature contract check failed:");
|
||||
for (const problem of problems) console.error(`\n- ${problem.message}\n Repair: ${problem.repair}`);
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log("Feature contracts are consistent.");
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import {
|
||||
createFeatureCheckPlan,
|
||||
executeFeatureCheckPlan,
|
||||
formatFeatureCheckPlan,
|
||||
} from "./feature-platform/check-feature.mts";
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const planOnly = args.includes("--plan") || args.includes("--dry-run");
|
||||
const kind = args.find((arg) => !arg.startsWith("--"));
|
||||
|
||||
if (!kind) {
|
||||
console.error("Usage: pnpm check:feature <integration-or-widget-kind> [--plan]");
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
try {
|
||||
const plan = createFeatureCheckPlan(resolve(import.meta.dirname, ".."), kind);
|
||||
console.log(formatFeatureCheckPlan(plan));
|
||||
if (!planOnly) process.exitCode = executeFeatureCheckPlan(plan);
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { createFeatureCheckPlan, executeFeatureCheckPlan, formatFeatureCheckPlan } from "./check-feature.mts";
|
||||
|
||||
const root = resolve(import.meta.dirname, "../..");
|
||||
|
||||
test("plans a focused standalone widget check deterministically", () => {
|
||||
const plan = createFeatureCheckPlan(root, "clock");
|
||||
|
||||
assert.deepEqual(plan, createFeatureCheckPlan(root, "clock"));
|
||||
assert.deepEqual(plan.types, ["widget"]);
|
||||
assert.ok(plan.files.includes("packages/widgets/src/clock/index.ts"));
|
||||
assert.ok(plan.files.includes("apps/docs/docs/widgets/clock/index.mdx"));
|
||||
assert.ok(plan.files.includes("packages/translation/src/lang/en.json"));
|
||||
assert.ok(plan.commands.some((command) => command.args.includes("packages/widgets/src/manifest.spec.ts")));
|
||||
assert.match(formatFeatureCheckPlan(plan), /LIVE SERVICE \(optional, not run\)/);
|
||||
assert.match(formatFeatureCheckPlan(plan), /VISUAL \(optional, not run\)/);
|
||||
});
|
||||
|
||||
test("combines integration and widget checks for a shared kind", () => {
|
||||
const plan = createFeatureCheckPlan(root, "wud");
|
||||
|
||||
assert.deepEqual(plan.types, ["integration", "widget"]);
|
||||
assert.ok(plan.files.includes("packages/integrations/src/wud/wud-integration.ts"));
|
||||
assert.ok(plan.files.includes("packages/widgets/src/wud/index.ts"));
|
||||
assert.ok(plan.files.includes("apps/docs/docs/integrations/whats-up-docker/index.mdx"));
|
||||
assert.ok(plan.files.includes("apps/docs/docs/widgets/whats-up-docker/index.mdx"));
|
||||
assert.ok(
|
||||
plan.commands.some((command) =>
|
||||
command.args.includes("packages/integrations/src/wud/test/wud-integration.spec.ts"),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects unknown kinds before creating a command plan", () => {
|
||||
assert.throws(() => createFeatureCheckPlan(root, "notAFeature"), /Unknown feature kind/);
|
||||
});
|
||||
|
||||
test("executes commands in order and stops on the first failure", () => {
|
||||
const plan = createFeatureCheckPlan(root, "clock");
|
||||
const visited: string[] = [];
|
||||
const status = executeFeatureCheckPlan(plan, (command) => {
|
||||
visited.push(command.label);
|
||||
return visited.length === 3 ? 7 : 0;
|
||||
});
|
||||
|
||||
assert.equal(status, 7);
|
||||
assert.deepEqual(
|
||||
visited,
|
||||
plan.commands.slice(0, 3).map((command) => command.label),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
||||
import { dirname, join, relative, resolve } from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
import { readArrayRegistry, readObjectRegistry } from "./contracts.mts";
|
||||
|
||||
export interface FeatureCheckCommand {
|
||||
label: string;
|
||||
executable: string;
|
||||
args: string[];
|
||||
}
|
||||
|
||||
export interface FeatureCheckPlan {
|
||||
kind: string;
|
||||
types: ("integration" | "widget")[];
|
||||
files: string[];
|
||||
commands: FeatureCheckCommand[];
|
||||
optionalValidations: string[];
|
||||
}
|
||||
|
||||
const walkFiles = (directory: string): string[] => {
|
||||
if (!existsSync(directory)) return [];
|
||||
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
||||
const path = join(directory, entry.name);
|
||||
return entry.isDirectory() ? walkFiles(path) : [path];
|
||||
});
|
||||
};
|
||||
|
||||
const directString = (value: string | undefined) => value?.match(/^"([^"]+)"/)?.[1];
|
||||
|
||||
const resolveSourceImport = (root: string, importer: string, importPath: string) => {
|
||||
const base = relative(root, resolve(root, dirname(importer), importPath));
|
||||
for (const candidate of [`${base}.ts`, `${base}.tsx`, join(base, "index.ts"), join(base, "index.tsx")]) {
|
||||
if (existsSync(join(root, candidate))) return candidate;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const integrationImplementation = (root: string, kind: string) => {
|
||||
const creatorPath = "packages/integrations/src/base/creator.ts";
|
||||
const creatorSource = readFileSync(join(root, creatorPath), "utf8");
|
||||
const creator = readObjectRegistry(root, creatorPath, "integrationCreators")
|
||||
.get(kind)
|
||||
?.match(/[A-Za-z_$][\w$]*/)?.[0];
|
||||
if (!creator) return null;
|
||||
const escapedCreator = creator.replaceAll("$", "\\$");
|
||||
const importPath = creatorSource.match(
|
||||
new RegExp(`import \\{[^}]*\\b${escapedCreator}\\b[^}]*\\} from "([^"]+)"`),
|
||||
)?.[1];
|
||||
return importPath ? resolveSourceImport(root, creatorPath, importPath) : null;
|
||||
};
|
||||
|
||||
const widgetImplementation = (root: string, kind: string) => {
|
||||
const value = readObjectRegistry(root, "packages/widgets/src/manifest.ts", "moduleLoaders").get(kind);
|
||||
const importPath = value?.match(/import\("([^"]+)"\)/)?.[1];
|
||||
return importPath ? resolveSourceImport(root, "packages/widgets/src/manifest.ts", importPath) : null;
|
||||
};
|
||||
|
||||
const specsNear = (root: string, implementation: string | null) => {
|
||||
if (!implementation) return [];
|
||||
return walkFiles(dirname(join(root, implementation)))
|
||||
.filter((path) => /\.spec\.tsx?$/.test(path))
|
||||
.map((path) => relative(root, path))
|
||||
.toSorted();
|
||||
};
|
||||
|
||||
const packageCommand = (label: string, packages: string[]): FeatureCheckCommand => ({
|
||||
label,
|
||||
executable: "pnpm",
|
||||
args: [...packages.flatMap((name) => ["--filter", name]), "typecheck"],
|
||||
});
|
||||
|
||||
export const createFeatureCheckPlan = (root: string, kind: string): FeatureCheckPlan => {
|
||||
const integrationKinds = new Set(
|
||||
readObjectRegistry(root, "packages/definitions/src/integration.ts", "integrationDefs").keys(),
|
||||
);
|
||||
const widgetKinds = new Set(readArrayRegistry(root, "packages/definitions/src/widget.ts", "widgetKinds"));
|
||||
const types: FeatureCheckPlan["types"] = [];
|
||||
if (integrationKinds.has(kind)) types.push("integration");
|
||||
if (widgetKinds.has(kind)) types.push("widget");
|
||||
if (types.length === 0) {
|
||||
throw new Error(`Unknown feature kind ${JSON.stringify(kind)}. Use a key from integrationDefs or widgetKinds.`);
|
||||
}
|
||||
|
||||
const files = new Set<string>(["packages/translation/src/lang/en.json"]);
|
||||
const tests = new Set<string>();
|
||||
const packages = new Set(["@homarr/definitions", "@homarr/translation"]);
|
||||
|
||||
if (types.includes("integration")) {
|
||||
const implementation = integrationImplementation(root, kind);
|
||||
if (implementation) files.add(implementation);
|
||||
specsNear(root, implementation).forEach((path) => tests.add(path));
|
||||
tests.add("packages/integrations/src/base/response-contract.spec.ts");
|
||||
packages.add("@homarr/integrations");
|
||||
for (const path of [
|
||||
"packages/definitions/src/integration.ts",
|
||||
"packages/definitions/src/docs/integration-doc-slugs.ts",
|
||||
"packages/integrations/src/base/creator.ts",
|
||||
"packages/integrations/src/index.ts",
|
||||
])
|
||||
files.add(path);
|
||||
const slug = directString(
|
||||
readObjectRegistry(root, "packages/definitions/src/docs/integration-doc-slugs.ts", "integrationDocSlugs").get(
|
||||
kind,
|
||||
),
|
||||
);
|
||||
if (slug) {
|
||||
packages.add("@homarr/docs");
|
||||
files.add(`apps/docs/docs/integrations/${slug}/index.ts`);
|
||||
files.add(`apps/docs/docs/integrations/${slug}/index.mdx`);
|
||||
}
|
||||
}
|
||||
|
||||
if (types.includes("widget")) {
|
||||
const implementation = widgetImplementation(root, kind);
|
||||
if (implementation) {
|
||||
const directory = dirname(join(root, implementation));
|
||||
walkFiles(directory)
|
||||
.filter((path) => /\.(?:ts|tsx|css)$/.test(path))
|
||||
.map((path) => relative(root, path))
|
||||
.forEach((path) => files.add(path));
|
||||
}
|
||||
specsNear(root, implementation).forEach((path) => tests.add(path));
|
||||
tests.add("packages/widgets/src/manifest.spec.ts");
|
||||
tests.add("packages/definitions/src/test/widget-integration-map.spec.ts");
|
||||
packages.add("@homarr/widgets");
|
||||
for (const path of [
|
||||
"packages/definitions/src/widget.ts",
|
||||
"packages/definitions/src/widget-integration-map.ts",
|
||||
"packages/definitions/src/docs/widget-doc-slugs.ts",
|
||||
"packages/widgets/src/manifest.ts",
|
||||
"packages/widgets/src/catalog.ts",
|
||||
"packages/widgets/src/index.tsx",
|
||||
])
|
||||
files.add(path);
|
||||
const slug = directString(
|
||||
readObjectRegistry(root, "packages/definitions/src/docs/widget-doc-slugs.ts", "widgetDocSlugs").get(kind),
|
||||
);
|
||||
if (slug) {
|
||||
packages.add("@homarr/docs");
|
||||
const metadataTs = `apps/docs/docs/widgets/${slug}/index.ts`;
|
||||
files.add(existsSync(join(root, metadataTs)) ? metadataTs : `${metadataTs}x`);
|
||||
files.add(`apps/docs/docs/widgets/${slug}/index.mdx`);
|
||||
}
|
||||
}
|
||||
|
||||
const sortedTests = [...tests].filter((path) => existsSync(join(root, path))).toSorted();
|
||||
sortedTests.forEach((path) => files.add(path));
|
||||
const sortedFiles = [...files].filter((path) => existsSync(join(root, path))).toSorted();
|
||||
const lintFiles = sortedFiles.filter((path) => /\.(?:ts|tsx|mts)$/.test(path));
|
||||
const sortedPackages = [...packages].toSorted();
|
||||
const commands: FeatureCheckCommand[] = [
|
||||
{
|
||||
label: "Descriptor, docs, translation, and export contracts",
|
||||
executable: "node",
|
||||
args: ["--experimental-strip-types", "scripts/check-feature-contracts.mts"],
|
||||
},
|
||||
{ label: "Focused fixture and unit tests", executable: "pnpm", args: ["exec", "vitest", "run", ...sortedTests] },
|
||||
packageCommand("Affected package typechecks", sortedPackages),
|
||||
{ label: "Affected file lint", executable: "pnpm", args: ["exec", "oxlint", ...lintFiles] },
|
||||
{ label: "Affected file formatting", executable: "pnpm", args: ["exec", "oxfmt", "--check", ...sortedFiles] },
|
||||
];
|
||||
|
||||
return {
|
||||
kind,
|
||||
types,
|
||||
files: sortedFiles,
|
||||
commands,
|
||||
optionalValidations: [
|
||||
"LIVE SERVICE (optional, not run): exercise test-connection and feature requests against a disposable real service with non-production credentials.",
|
||||
"CONTAINER/E2E (optional, not run): validate the supported service image and network path when the feature depends on container-specific behavior.",
|
||||
...(types.includes("widget")
|
||||
? [
|
||||
"VISUAL (optional, not run): inspect shared states at /manage/tools/feature-workbench and the feature at compact and advanced sizes.",
|
||||
]
|
||||
: []),
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
const displayCommand = (command: FeatureCheckCommand) =>
|
||||
[command.executable, ...command.args]
|
||||
.map((part) => (/^[A-Za-z0-9_./:@=-]+$/.test(part) ? part : `'${part.replaceAll("'", "'\\''")}'`))
|
||||
.join(" ");
|
||||
|
||||
export const formatFeatureCheckPlan = (plan: FeatureCheckPlan) => {
|
||||
const lines = [`Feature check plan for ${plan.kind} (${plan.types.join(" + ")}):`, "", "Affected files:"];
|
||||
lines.push(...plan.files.map((path) => `- ${path}`), "", "Checks:");
|
||||
lines.push(...plan.commands.map((command, index) => `${index + 1}. ${command.label}\n ${displayCommand(command)}`));
|
||||
lines.push("", "Optional validation:", ...plan.optionalValidations.map((validation) => `- ${validation}`));
|
||||
return lines.join("\n");
|
||||
};
|
||||
|
||||
export type FeatureCommandRunner = (command: FeatureCheckCommand) => number;
|
||||
|
||||
export const executeFeatureCheckPlan = (
|
||||
plan: FeatureCheckPlan,
|
||||
runner: FeatureCommandRunner = (command) =>
|
||||
spawnSync(command.executable, command.args, { stdio: "inherit" }).status ?? 1,
|
||||
) => {
|
||||
for (const command of plan.commands) {
|
||||
console.log(`\n[check:feature] ${command.label}`);
|
||||
const status = runner(command);
|
||||
if (status !== 0) return status;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
@@ -0,0 +1,298 @@
|
||||
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
||||
import { dirname, join, relative, resolve } from "node:path";
|
||||
|
||||
export interface ContractProblem {
|
||||
message: string;
|
||||
repair: string;
|
||||
}
|
||||
|
||||
const read = (root: string, path: string) => readFileSync(join(root, path), "utf8");
|
||||
|
||||
const containerBody = (source: string, variable: string, opener: "{" | "[") => {
|
||||
const declaration = source.indexOf(`const ${variable}`);
|
||||
if (declaration < 0) throw new Error(`Could not find const ${variable}`);
|
||||
const start = source.indexOf(opener, declaration);
|
||||
if (start < 0) throw new Error(`Could not find ${opener} for ${variable}`);
|
||||
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;
|
||||
continue;
|
||||
}
|
||||
if (character === opener) depth += 1;
|
||||
if (character === closer) depth -= 1;
|
||||
if (depth === 0) return source.slice(start + 1, index);
|
||||
}
|
||||
throw new Error(`Could not find closing ${closer} for ${variable}`);
|
||||
};
|
||||
|
||||
const objectEntries = (source: string, variable: string) => {
|
||||
const body = containerBody(source, variable, "{");
|
||||
const matches = [...body.matchAll(/^ (?:"([^"]+)"|([\w-]+))(?::\s|,\s*$)/gm)];
|
||||
return new Map(
|
||||
matches.map((match, index) => [
|
||||
match[1] ?? match[2],
|
||||
body.slice((match.index ?? 0) + match[0].length, matches[index + 1]?.index ?? body.length).trim(),
|
||||
]),
|
||||
);
|
||||
};
|
||||
|
||||
const arrayStrings = (source: string, variable: string) =>
|
||||
[...containerBody(source, variable, "[").matchAll(/^ "([^"]+)",?$/gm)].map((match) => match[1]);
|
||||
|
||||
export const readObjectRegistry = (root: string, path: string, variable: string) =>
|
||||
objectEntries(read(root, path), variable);
|
||||
|
||||
export const readArrayRegistry = (root: string, path: string, variable: string) =>
|
||||
arrayStrings(read(root, path), variable);
|
||||
|
||||
const compareKeys = (
|
||||
problems: ContractProblem[],
|
||||
expectedName: string,
|
||||
expected: Iterable<string>,
|
||||
actualName: string,
|
||||
actual: Iterable<string>,
|
||||
) => {
|
||||
const expectedSet = new Set(expected);
|
||||
const actualSet = new Set(actual);
|
||||
const missing = [...expectedSet].filter((key) => !actualSet.has(key));
|
||||
const extra = [...actualSet].filter((key) => !expectedSet.has(key));
|
||||
if (missing.length === 0 && extra.length === 0) return;
|
||||
problems.push({
|
||||
message: `${actualName} does not match ${expectedName} (missing: ${missing.join(", ") || "none"}; extra: ${extra.join(", ") || "none"})`,
|
||||
repair: `Add or remove the named entries in ${actualName} so it has exactly the same keys as ${expectedName}.`,
|
||||
});
|
||||
};
|
||||
|
||||
const walkFiles = (directory: string): string[] => {
|
||||
if (!existsSync(directory)) return [];
|
||||
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
||||
if (entry.isDirectory() && [".git", ".turbo", "node_modules"].includes(entry.name)) return [];
|
||||
const path = join(directory, entry.name);
|
||||
return entry.isDirectory() ? walkFiles(path) : [path];
|
||||
});
|
||||
};
|
||||
|
||||
const directStringOrNull = (value: string | undefined) => {
|
||||
if (!value) return undefined;
|
||||
if (/^null,?$/.test(value)) return null;
|
||||
return value.match(/^"([^"]+)"/)?.[1];
|
||||
};
|
||||
|
||||
const documentationPath = (definition: string | undefined) => {
|
||||
if (!definition) return undefined;
|
||||
const value = definition.match(/^ documentationUrl: (.+),$/m)?.[1];
|
||||
if (value === "null") return null;
|
||||
return value?.match(/^createDocumentationLink\("([^"]+)"\)$/)?.[1];
|
||||
};
|
||||
|
||||
const collectWidgetDefinitions = (root: string) => {
|
||||
const definitions = new Map<string, { path: string; hasSupportedIntegrations: boolean }>();
|
||||
for (const path of walkFiles(join(root, "packages/widgets/src"))) {
|
||||
if (!/\/index\.tsx?$/.test(path)) continue;
|
||||
const source = readFileSync(path, "utf8");
|
||||
const kindConstants = new Map(
|
||||
[...source.matchAll(/(?:export )?const (\w+) = "([^"]+)";/g)].map((match) => [match[1], match[2]]),
|
||||
);
|
||||
for (const match of source.matchAll(/createWidgetDefinition\(("([^"]+)"|(\w+)),\s*\{/g)) {
|
||||
const body = source.slice(match.index, source.indexOf("}).withDynamicImport", match.index));
|
||||
const kind = match[2] ?? kindConstants.get(match[3]);
|
||||
if (!kind) continue;
|
||||
definitions.set(kind, {
|
||||
path: relative(root, path),
|
||||
hasSupportedIntegrations: /\n supportedIntegrations:/.test(body),
|
||||
});
|
||||
}
|
||||
}
|
||||
return definitions;
|
||||
};
|
||||
|
||||
const exportTargets = (value: unknown): string[] => {
|
||||
if (typeof value === "string") return [value];
|
||||
if (!value || typeof value !== "object") return [];
|
||||
return Object.values(value).flatMap(exportTargets);
|
||||
};
|
||||
|
||||
const escapeRegex = (value: string) => value.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
|
||||
|
||||
const targetExists = (packageDirectory: string, target: string) => {
|
||||
if (!target.startsWith("./")) return true;
|
||||
const absoluteTarget = resolve(packageDirectory, target);
|
||||
if (!target.includes("*")) return existsSync(absoluteTarget);
|
||||
const wildcardIndex = absoluteTarget.indexOf("*");
|
||||
const searchRoot = dirname(absoluteTarget.slice(0, wildcardIndex));
|
||||
if (!existsSync(searchRoot)) return false;
|
||||
const pattern = new RegExp(`^${escapeRegex(absoluteTarget).replaceAll("*", "[^/]+")}$`);
|
||||
return walkFiles(searchRoot).some((path) => pattern.test(path));
|
||||
};
|
||||
|
||||
export const checkPackageExports = (root: string): ContractProblem[] => {
|
||||
const packageFiles = walkFiles(root).filter(
|
||||
(path) => path.endsWith("package.json") && !path.includes("/node_modules/") && !path.includes("/.turbo/"),
|
||||
);
|
||||
const problems: ContractProblem[] = [];
|
||||
for (const packageFile of packageFiles) {
|
||||
const packageJson = JSON.parse(readFileSync(packageFile, "utf8")) as { exports?: unknown };
|
||||
for (const target of exportTargets(packageJson.exports)) {
|
||||
if (targetExists(dirname(packageFile), target)) continue;
|
||||
problems.push({
|
||||
message: `${relative(root, packageFile)} exports ${target}, but that target does not resolve`,
|
||||
repair:
|
||||
"Remove the stale export or add the intended source file; do not leave a public package path pointing at nothing.",
|
||||
});
|
||||
}
|
||||
}
|
||||
return problems;
|
||||
};
|
||||
|
||||
export const checkFeatureContracts = (root: string): ContractProblem[] => {
|
||||
const problems: ContractProblem[] = [];
|
||||
const integrationDefinitions = objectEntries(
|
||||
read(root, "packages/definitions/src/integration.ts"),
|
||||
"integrationDefs",
|
||||
);
|
||||
const creatorKeys = objectEntries(
|
||||
read(root, "packages/integrations/src/base/creator.ts"),
|
||||
"integrationCreators",
|
||||
).keys();
|
||||
const integrationSlugs = objectEntries(
|
||||
read(root, "packages/definitions/src/docs/integration-doc-slugs.ts"),
|
||||
"integrationDocSlugs",
|
||||
);
|
||||
compareKeys(problems, "integrationDefs", integrationDefinitions.keys(), "integrationCreators", creatorKeys);
|
||||
compareKeys(
|
||||
problems,
|
||||
"integrationDefs",
|
||||
integrationDefinitions.keys(),
|
||||
"integrationDocSlugs",
|
||||
integrationSlugs.keys(),
|
||||
);
|
||||
|
||||
for (const [kind, definition] of integrationDefinitions) {
|
||||
const slug = directStringOrNull(integrationSlugs.get(kind));
|
||||
const link = documentationPath(definition);
|
||||
const expected = slug === null ? null : `/docs/integrations/${slug}`;
|
||||
if (slug === undefined || link !== expected) {
|
||||
problems.push({
|
||||
message: `Integration ${kind} has docs slug ${String(slug)} but documentationUrl ${String(link)}`,
|
||||
repair:
|
||||
slug === null
|
||||
? "Set documentationUrl to null."
|
||||
: `Set documentationUrl to createDocumentationLink("${expected}").`,
|
||||
});
|
||||
}
|
||||
if (slug !== null && slug !== undefined) {
|
||||
for (const filename of ["index.ts", "index.mdx"]) {
|
||||
const path = `apps/docs/docs/integrations/${slug}/${filename}`;
|
||||
if (!existsSync(join(root, path)))
|
||||
problems.push({
|
||||
message: `Integration ${kind} points to missing ${path}`,
|
||||
repair: `Add ${path} or remove the docs slug.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const widgetKinds = arrayStrings(read(root, "packages/definitions/src/widget.ts"), "widgetKinds");
|
||||
const manifest = read(root, "packages/widgets/src/manifest.ts");
|
||||
const definitions = collectWidgetDefinitions(root);
|
||||
const widgetSlugs = objectEntries(read(root, "packages/definitions/src/docs/widget-doc-slugs.ts"), "widgetDocSlugs");
|
||||
const support = objectEntries(
|
||||
read(root, "packages/definitions/src/widget-integration-map.ts"),
|
||||
"nativeFeatureCapabilities",
|
||||
);
|
||||
compareKeys(problems, "widgetKinds", widgetKinds, "moduleLoaders", objectEntries(manifest, "moduleLoaders").keys());
|
||||
compareKeys(
|
||||
problems,
|
||||
"widgetKinds",
|
||||
widgetKinds,
|
||||
"componentLoaders",
|
||||
objectEntries(manifest, "componentLoaders").keys(),
|
||||
);
|
||||
compareKeys(
|
||||
problems,
|
||||
"widgetKinds",
|
||||
widgetKinds,
|
||||
"widgetCatalogIcons",
|
||||
objectEntries(read(root, "packages/widgets/src/catalog.ts"), "widgetCatalogIcons").keys(),
|
||||
);
|
||||
compareKeys(
|
||||
problems,
|
||||
"widgetKinds",
|
||||
widgetKinds,
|
||||
"widgetImports",
|
||||
objectEntries(read(root, "packages/widgets/src/index.tsx"), "widgetImports").keys(),
|
||||
);
|
||||
compareKeys(problems, "widgetKinds", widgetKinds, "widget definitions", definitions.keys());
|
||||
compareKeys(problems, "widgetKinds", widgetKinds, "widgetDocSlugs", widgetSlugs.keys());
|
||||
|
||||
const supportSource = read(root, "packages/definitions/src/widget-integration-map.ts");
|
||||
if (!supportSource.includes("widgetIntegrationSupport = createWidgetIntegrationSupport()")) {
|
||||
problems.push({
|
||||
message: "widgetIntegrationSupport is not derived from nativeFeatureCapabilities",
|
||||
repair: "Generate the forward widget support map from the canonical capability descriptor.",
|
||||
});
|
||||
}
|
||||
if (!supportSource.includes("integrationWidgetSupport = createIntegrationWidgetSupport()")) {
|
||||
problems.push({
|
||||
message: "integrationWidgetSupport is not derived from nativeFeatureCapabilities",
|
||||
repair: "Generate the reverse integration support map from the canonical capability descriptor.",
|
||||
});
|
||||
}
|
||||
|
||||
const translations = JSON.parse(read(root, "packages/translation/src/lang/en.json")) as {
|
||||
widget?: Record<string, { name?: string; description?: string }>;
|
||||
};
|
||||
for (const kind of widgetKinds) {
|
||||
const translation = translations.widget?.[kind];
|
||||
if (!translation?.name?.trim() || !translation.description?.trim()) {
|
||||
problems.push({
|
||||
message: `Widget ${kind} is missing a non-empty English name or description`,
|
||||
repair: `Add widget.${kind}.name and widget.${kind}.description to packages/translation/src/lang/en.json.`,
|
||||
});
|
||||
}
|
||||
const slug = directStringOrNull(widgetSlugs.get(kind));
|
||||
if (slug !== null && slug !== undefined) {
|
||||
const metadataPath = `apps/docs/docs/widgets/${slug}/index.ts`;
|
||||
if (!existsSync(join(root, metadataPath)) && !existsSync(join(root, `${metadataPath}x`)))
|
||||
problems.push({
|
||||
message: `Widget ${kind} points to missing ${metadataPath}(x)`,
|
||||
repair: `Add ${metadataPath} or remove the docs slug.`,
|
||||
});
|
||||
const pagePath = `apps/docs/docs/widgets/${slug}/index.mdx`;
|
||||
if (!existsSync(join(root, pagePath)))
|
||||
problems.push({
|
||||
message: `Widget ${kind} points to missing ${pagePath}`,
|
||||
repair: `Add ${pagePath} or remove the docs slug.`,
|
||||
});
|
||||
}
|
||||
const definition = definitions.get(kind);
|
||||
if (definition?.hasSupportedIntegrations !== support.has(kind)) {
|
||||
problems.push({
|
||||
message: `Widget ${kind} integration support differs between ${definition?.path ?? "its definition"} and nativeFeatureCapabilities`,
|
||||
repair:
|
||||
"Declare supportedIntegrations in both the widget definition and nativeFeatureCapabilities, or in neither.",
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const kind of support.keys()) {
|
||||
if (!widgetKinds.includes(kind))
|
||||
problems.push({
|
||||
message: `nativeFeatureCapabilities contains unknown widget ${kind}`,
|
||||
repair: "Remove it or add the widget kind first.",
|
||||
});
|
||||
}
|
||||
|
||||
problems.push(...checkPackageExports(root));
|
||||
return problems;
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { checkFeatureContracts } from "./contracts.mts";
|
||||
import { planFeatureGeneration } from "./generate-feature.mts";
|
||||
import "./check-feature.contract.mts";
|
||||
|
||||
const root = resolve(import.meta.dirname, "../..");
|
||||
|
||||
test("the checked-in feature contracts are consistent", () => {
|
||||
assert.deepEqual(checkFeatureContracts(root), []);
|
||||
});
|
||||
|
||||
test("paired generation plans every contract without writing", () => {
|
||||
const changes = planFeatureGeneration(root, {
|
||||
integration: {
|
||||
kind: "exampleService",
|
||||
name: "Example Service",
|
||||
slug: "example-service",
|
||||
description: "Example native integration.",
|
||||
category: "miscellaneous",
|
||||
secretKinds: ["apiKey"],
|
||||
iconUrl: "https://example.com/icon.svg",
|
||||
},
|
||||
widget: {
|
||||
kind: "exampleStatus",
|
||||
name: "Example Status",
|
||||
slug: "example-status",
|
||||
description: "Example native widget.",
|
||||
icon: "IconBox",
|
||||
supportedIntegrations: ["exampleService"],
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(changes.length, 20);
|
||||
assert.equal(new Set(changes.map((change) => change.path)).size, changes.length);
|
||||
assert.ok(changes.some((change) => change.path.endsWith("example-service-integration.spec.ts")));
|
||||
assert.ok(changes.some((change) => change.path.endsWith("example-status/definition.spec.ts")));
|
||||
assert.ok(changes.some((change) => change.path === "apps/docs/docs/integrations/example-service/index.mdx"));
|
||||
assert.ok(changes.some((change) => change.path === "apps/docs/docs/widgets/example-status/index.mdx"));
|
||||
const integrationTest = changes.find((change) => change.path.endsWith("example-service-integration.spec.ts"));
|
||||
assert.match(integrationTest?.content ?? "", /simulateResponseContractAsync/);
|
||||
const widgetDefinition = changes.find((change) => change.path.endsWith("example-status/index.ts"));
|
||||
assert.match(widgetDefinition?.content ?? "", /nativeFeatureCapabilities\.exampleStatus\.integrations/);
|
||||
const capabilityDescriptor = changes.find(
|
||||
(change) => change.path === "packages/definitions/src/widget-integration-map.ts",
|
||||
);
|
||||
assert.match(capabilityDescriptor?.content ?? "", /exampleStatus: \{ integrations: \["exampleService"\] \}/);
|
||||
const translations = changes.find((change) => change.path === "packages/translation/src/lang/en.json");
|
||||
assert.ok(translations);
|
||||
const parsed = JSON.parse(translations.content) as { integration: object; widget: object };
|
||||
assert.ok("exampleService" in parsed.integration);
|
||||
assert.ok("exampleStatus" in parsed.widget);
|
||||
});
|
||||
|
||||
test("generation stops before overwriting an existing feature", () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
planFeatureGeneration(root, {
|
||||
integration: {
|
||||
kind: "wud",
|
||||
name: "Duplicate",
|
||||
slug: "wud",
|
||||
description: "Must not overwrite WUD.",
|
||||
category: "miscellaneous",
|
||||
secretKinds: [],
|
||||
iconUrl: "https://example.com/icon.svg",
|
||||
},
|
||||
}),
|
||||
/already exists/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,454 @@
|
||||
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { mkdirSync } from "node:fs";
|
||||
|
||||
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-z0-9]+(?:-[a-z0-9]+)*$/.test(feature.slug))
|
||||
throw new Error(`Generator stopped: ${feature.slug} must be 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: "${feature.name}",
|
||||
url: "http://localhost",
|
||||
externalUrl: null,
|
||||
decryptedSecrets: [],
|
||||
});
|
||||
expect(integration.publicIntegration.name).toBe("${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>${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("${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;
|
||||
};
|
||||
|
||||
export const generateFeature = (root: string, request: FeatureRequest) => {
|
||||
const changes = 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;
|
||||
};
|
||||
Reference in new issue
Block a user