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 { 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}) =>

{subject.object}

;\n`, }, ], }; }