2026-09-21 16:59:35 -07:00
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" ;
2026-09-22 14:42:54 -07:00
import { componentEntries , nixLiteral } from "./component-nix.js" ;
2026-09-21 16:59:35 -07:00
/** 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 );
2026-09-22 13:16:23 -07:00
const declarations = [... walkSyntax ( syntax . root )]. filter (( node ) => node . kind === "moduleExport" );
2026-09-21 16:59:35 -07:00
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" );
}
2026-09-22 14:42:54 -07:00
const filename = "components.nix" ;
2026-09-21 16:59:35 -07:00
const before = await fs . readFile ( path . join ( root , prefix , filename ), "utf8" );
2026-09-22 14:42:54 -07:00
const entries = await componentEntries ( before );
2026-09-21 16:59:35 -07:00
if ( ! entries || typeof entries !== "object" || Array . isArray ( entries ) || Object . hasOwn ( entries , spec . id ))
throw Error ( "Invalid or duplicate component entry" );
2026-09-22 14:42:54 -07:00
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 );
2026-09-21 16:59:35 -07:00
const parentID = parent . children . find (( node ) => node . kind === "stringLiteral" ) ! ;
2026-09-27 15:53:34 -07:00
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" ])];
2026-09-27 16:34:53 -07:00
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" ;
2026-09-21 16:59:35 -07:00
return {
kind : "package" ,
source : spec.source ,
resourceRoot : spec.directory ,
validation : "syntax" ,
files : [
2026-09-27 16:34:53 -07:00
preparationBefore === undefined
? { file : preparationFile , create : preparationText }
: { file : preparationFile , expected : preparationBefore , replace : preparationText },
2026-09-27 15:53:34 -07:00
{ file : configFile , expected : configBefore , replace : JSON.stringify ( config , null , 2 ) + "\n" },
2026-09-21 16:59:35 -07:00
{
file : prefix + "package.qx" ,
edits : [
{
operation : "append" ,
parent : { kind : "packageResourceDecl" , id : JSON.parse ( authored . slice ( parentID . start , parentID . end )) },
source : spec.declaration ,
},
],
},
2026-09-22 14:42:54 -07:00
{ file : prefix + filename , expected : before , replace : after },
2026-09-21 16:59:35 -07:00
{
file : prefix + `src/components/ ${ spec . name } .tsx` ,
2026-09-27 16:34:53 -07:00
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` ,
2026-09-21 16:59:35 -07:00
},
],
};
}