Files
quixos-protocol/src/capability-language/component-scaffold.ts
T

101 lines
5.1 KiB
TypeScript

import fs from "node:fs/promises";
import path from "node:path";
import { parseQx, walkSyntax } from "./source.js";
import type { ScaffoldRecipe } from "./scaffold-recipes.js";
import type { StructuralRequest } from "./structural-plan.js";
import { componentEntries, nixLiteral } from "./component-nix.js";
/** Add one ordinary component entry without regenerating existing application code. */
export async function scaffoldComponent(
root: string,
prefix: string,
spec: ScaffoldRecipe,
): Promise<StructuralRequest> {
if (!spec.declaration || !spec.name || !/^[A-Z][A-Za-z0-9_]*$/.test(spec.name) || !spec.id)
throw Error("Component scaffold requires a PascalCase name, ID and QX declaration");
const source = `package Draft id "draft" revision "draft@1" { ${spec.declaration} }`;
const syntax = parseQx(source);
const declarations = [...walkSyntax(syntax.root)].filter((node) => node.kind === "moduleExport");
const pkg = syntax.root.children.find((node) => node.kind === "packageResourceDecl");
if (
syntax.diagnostics.length ||
syntax.root.children.length !== 1 ||
declarations.length !== 1 ||
pkg?.children.filter((node) => node.kind === "packageExport").length !== 1
)
throw Error("Expected exactly one component declaration");
const declaration = declarations[0]!;
const identifier = declaration.children.find((node) => node.kind === "identifier");
const id = declaration.children.find((node) => node.kind === "stringLiteral");
if (
!identifier ||
source.slice(identifier.start, identifier.end) !== spec.name ||
!id ||
JSON.parse(source.slice(id.start, id.end)) !== spec.id
)
throw Error("Component declaration name/ID must match its registration");
const authored = await fs.readFile(path.join(root, prefix, "package.qx"), "utf8");
const existing = parseQx(authored);
const parent = existing.root.children.find((node) => node.kind === "packageResourceDecl");
if (existing.diagnostics.length || !parent) throw Error("Fix package.qx before adding a component");
for (const item of parent.children.filter((node) => node.kind === "packageExport").flatMap((node) => node.children)) {
const name = item.children.find((node) => node.kind === "identifier");
const literal = item.children.find((node) => node.kind === "stringLiteral");
if (
(name && authored.slice(name.start, name.end) === spec.name) ||
(literal && JSON.parse(authored.slice(literal.start, literal.end)) === spec.id)
)
throw Error("Component requires a unique name and ID");
}
const filename = "components.nix";
const before = await fs.readFile(path.join(root, prefix, filename), "utf8");
const entries = await componentEntries(before);
if (!entries || typeof entries !== "object" || Array.isArray(entries) || Object.hasOwn(entries, spec.id))
throw Error("Invalid or duplicate component entry");
const insertion = ` ${nixLiteral(spec.id)} = { entry = ${nixLiteral(`src/components/${spec.name}.tsx`)}; export = ${nixLiteral(spec.name)}; };\n`;
const after = before.replace(/\n}(\s*)$/, (_match, trailing) => "\n" + insertion + "}" + trailing);
const parentID = parent.children.find((node) => node.kind === "stringLiteral")!;
const configFile = prefix + "quixos.check.json";
const configBefore = await fs.readFile(path.join(root, configFile), "utf8");
const config = JSON.parse(configBefore);
config.queryFrontends = [...new Set([...(config.queryFrontends ?? []), "react"])];
const preparationFile = prefix + "quixos.react.json";
let preparationBefore: string | undefined;
try {
preparationBefore = await fs.readFile(path.join(root, preparationFile), "utf8");
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
const preparation = preparationBefore ? JSON.parse(preparationBefore) : { modules: {} };
if (Object.hasOwn(preparation.modules, spec.name)) throw Error("Duplicate presentation preparation");
preparation.modules[spec.name] = { inputs: [] };
const preparationText = JSON.stringify(preparation, null, 2) + "\n";
return {
kind: "package",
source: spec.source,
resourceRoot: spec.directory,
validation: "syntax",
files: [
preparationBefore === undefined
? { file: preparationFile, create: preparationText }
: { file: preparationFile, expected: preparationBefore, replace: preparationText },
{ file: configFile, expected: configBefore, replace: JSON.stringify(config, null, 2) + "\n" },
{
file: prefix + "package.qx",
edits: [
{
operation: "append",
parent: { kind: "packageResourceDecl", id: JSON.parse(authored.slice(parentID.start, parentID.end)) },
source: spec.declaration,
},
],
},
{ file: prefix + filename, expected: before, replace: after },
{
file: prefix + `src/components/${spec.name}.tsx`,
create: `import type { Modules } from "../gen/client.js";\n\n// Declare initial query inputs in quixos.react.json; use interfaces for actions.\nexport const ${spec.name}: Modules[${JSON.stringify(spec.name)}] = ({subject}) => <p>{subject.object}</p>;\n`,
},
],
};
}