import fs from "node:fs/promises"; import path from "node:path"; import { execFileSync } from "node:child_process"; const config = JSON.parse(await fs.readFile(process.argv[2], "utf8")); const resource = JSON.parse(await fs.readFile(config.interface, "utf8")); if (resource.kind !== "interface") throw Error("React props belong to an interface resource"); const iface = resource.revision; const output = process.env.out; if (!output) throw Error("Missing Nix output"); const work = path.resolve("contracts-source"); const declarations = []; await fs.cp(config.source, work, { recursive: true }); const writable = async (directory) => { await fs.chmod(directory, 0o755); for (const entry of await fs.readdir(directory, { withFileTypes: true })) { const file = path.join(directory, entry.name); if (entry.isSymbolicLink()) throw Error("Contract source cannot contain symlinks"); if (entry.isDirectory()) await writable(file); else { await fs.chmod(file, 0o644); if (/\.d\.[cm]?ts$/.test(file)) declarations.push(path.relative(work, file)); } } }; await writable(work); const packageFile = path.join(work, "package.json"); const metadata = await fs.readFile(packageFile, "utf8").then(JSON.parse, (error) => { if (error.code === "ENOENT") return {}; throw error; }); await fs.writeFile(packageFile, JSON.stringify({ ...metadata, type: "module" })); if (config.dependencies) await fs.symlink(config.dependencies, path.join(work, "node_modules")); const members = Object.create(null); let index = 0; for (const [id, entry] of Object.entries(config.members)) { const member = (iface.template?.members ?? iface.members).find((member) => member.id === id); if ( member?.kind !== "module" || !["org.quixos.react.component/1", "org.quixos.react.subject-only/1"].includes(member.contract) ) throw Error("Props entry is not a React module member: " + id); if ( !/^[$A-Z_a-z][$\w]*$/.test(entry.export) || path.isAbsolute(entry.entry) || entry.entry.split("/").some((part) => part === ".." || !part) ) throw Error("Invalid props type entry"); const filename = `props-${index++}.ts`; const typeImport = JSON.stringify("./" + entry.entry.replace(/\.[cm]?tsx?$/, ".js")); await fs.writeFile( path.join(work, filename), `import type {${entry.export} as Props} from ${typeImport}; type Assert = T; type ObjectProps = Assert; type ReservedSubject = Assert<"subject" extends keyof Props ? false : true>; ${member.contract === "org.quixos.react.subject-only/1" ? "type SubjectOnly = Assert<{} extends Props ? true : false>;" : ""} export type {Props};\n`, ); members[id] = { path: filename.replace(/\.ts$/, ".d.ts"), export: "Props" }; } await fs.mkdir(path.join(output, "types"), { recursive: true }); if (!Object.keys(members).length) throw Error("React type companion declares no module members"); execFileSync( config.node, [ config.tsc, "--declaration", "--emitDeclarationOnly", "--strict", "--target", "es2024", "--module", "nodenext", "--moduleResolution", "nodenext", "--rootDir", work, "--outDir", path.join(output, "types"), ...Object.values(members).map((entry) => path.join(work, entry.path.replace(/\.d\.ts$/, ".ts"))), ], { cwd: work, stdio: "inherit" }, ); // TypeScript emits declarations for implementation sources, but does not copy // authored declarations which those emitted files may import. for (const file of declarations) { await fs.mkdir(path.dirname(path.join(output, "types", file)), { recursive: true }); await fs.copyFile(path.join(work, file), path.join(output, "types", file)); } await fs.writeFile(path.join(output, "package.json"), JSON.stringify({ type: "module" })); if (config.dependencies) await fs.symlink(config.dependencies, path.join(output, "node_modules")); await fs.writeFile( path.join(output, "react-contracts.json"), JSON.stringify({ schemaVersion: 1, interfaceRevisionId: iface.revisionId, members }, null, 2), );