#!/usr/bin/env -S deno run --allow-read --allow-env --allow-run --allow-write import ts from "typescript"; import { fromFileUrl, join } from "@std/path"; /** * Generates `packages/static/assets/types/cfc.ts` from the public * `commonfabric/cfc` authoring module (`packages/api/cfc-authoring.ts`). * * The in-memory pattern compiler loads the generated file as the type module * for `commonfabric/cfc`. That compiler serves each module as a single * self-contained text and cannot follow relative imports, so the authoring * module's `export ... from "./cfc.ts"` re-exports must be flattened into one * declaration-only module. The flattening keeps exactly the public authoring * surface plus the private helper types it depends on, and drops the * runtime-only constants that live alongside it in `cfc.ts`. * * Run with `--check` to fail when the checked-in file is out of date; run with * no arguments to rewrite it. */ const SCRIPT_REL_PATH = "packages/static/scripts/generate-cfc-types.ts"; const SOURCE_REL_PATH = "packages/api/cfc-authoring.ts"; const GEN_TASK = "deno task gen-cfc-types"; const CHECK_TASK = "deno task check-cfc-types"; const apiDir = fromFileUrl(new URL("../../api/", import.meta.url)); const authoringPath = join(apiDir, "cfc-authoring.ts"); const targetPath = fromFileUrl( new URL("../assets/types/cfc.ts", import.meta.url), ); const HEADER = [ `// Generated by ${SCRIPT_REL_PATH}. Do not edit by hand.`, "//", "// Declaration-only mirror of the public `commonfabric/cfc` authoring surface", `// defined in ${SOURCE_REL_PATH}. The in-memory pattern compiler loads this`, "// file as the type module for `commonfabric/cfc`, so it stays", "// declaration-only and must not emit runtime JavaScript.", "//", "// To regenerate after changing the authoring surface, run (from", "// packages/static):", `// ${GEN_TASK}`, "// To verify it is up to date, run:", `// ${CHECK_TASK}`, ].join("\n"); /** Emits `.d.ts` text for the authoring module and the module it re-exports. */ export function emitDeclarations(): Map { const options: ts.CompilerOptions = { target: ts.ScriptTarget.ESNext, module: ts.ModuleKind.ESNext, moduleResolution: ts.ModuleResolutionKind.Bundler, declaration: true, emitDeclarationOnly: true, allowImportingTsExtensions: true, skipLibCheck: true, strict: true, noEmitOnError: false, }; const host = ts.createCompilerHost(options); const emitted = new Map(); host.writeFile = (fileName, text) => { emitted.set(fileName, text); }; const program = ts.createProgram([authoringPath], options, host); const result = program.emit(undefined, undefined, undefined, true); assertNoEmitErrors(result.diagnostics); return emitted; } /** Throws with formatted text if the emit produced any error diagnostics. */ export function assertNoEmitErrors( diagnostics: readonly ts.Diagnostic[], ): void { const errors = diagnostics.filter((d) => d.category === ts.DiagnosticCategory.Error ); if (errors.length === 0) return; const text = ts.formatDiagnostics(errors, { getCurrentDirectory: () => Deno.cwd(), getCanonicalFileName: (name) => name, getNewLine: () => "\n", }); throw new Error(`Declaration emit reported errors:\n${text}`); } export function findEmitted( emitted: Map, suffix: string, ): string { for (const [fileName, text] of emitted) { if (fileName.endsWith(suffix)) return text; } throw new Error( `Expected declaration emit to produce a file ending in ${suffix}`, ); } /** Collects the names re-exported by `export { ... } from "..."` statements. */ export function collectReExportedNames( dtsText: string, fileName: string, ): Set { const source = ts.createSourceFile( fileName, dtsText, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS, ); const names = new Set(); for (const statement of source.statements) { if ( ts.isExportDeclaration(statement) && statement.exportClause && ts.isNamedExports(statement.exportClause) ) { for (const element of statement.exportClause.elements) { names.add(element.name.text); } } } return names; } export function declarationName(statement: ts.Statement): string | undefined { if ( ts.isTypeAliasDeclaration(statement) || ts.isInterfaceDeclaration(statement) ) { return statement.name.text; } if (ts.isVariableStatement(statement)) { const declaration = statement.declarationList.declarations[0]; return declaration && ts.isIdentifier(declaration.name) ? declaration.name.text : undefined; } return undefined; } export function leftmostIdentifier(name: ts.EntityName): string { let current: ts.EntityName = name; while (ts.isQualifiedName(current)) current = current.left; return current.text; } /** * Collects the names a declaration depends on. Only type references and * `typeof` queries carry real dependencies in declaration text, so member and * property names cannot create spurious edges. */ export function collectDependencies(node: ts.Node): Set { const names = new Set(); const visit = (current: ts.Node) => { if (ts.isTypeReferenceNode(current)) { names.add(leftmostIdentifier(current.typeName)); } else if (ts.isTypeQueryNode(current)) { names.add(leftmostIdentifier(current.exprName)); } ts.forEachChild(current, visit); }; visit(node); return names; } export function withExport( statement: ts.Statement, shouldExport: boolean, ): ts.Statement { const factory = ts.factory; const keep = (modifiers?: readonly ts.ModifierLike[]) => (modifiers ?? []).filter((modifier) => modifier.kind !== ts.SyntaxKind.ExportKeyword && modifier.kind !== ts.SyntaxKind.DefaultKeyword ); const exportModifier = factory.createModifier(ts.SyntaxKind.ExportKeyword); const prefix = shouldExport ? [exportModifier] : []; if (ts.isTypeAliasDeclaration(statement)) { return factory.updateTypeAliasDeclaration( statement, [...prefix, ...keep(statement.modifiers)], statement.name, statement.typeParameters, statement.type, ); } if (ts.isInterfaceDeclaration(statement)) { return factory.updateInterfaceDeclaration( statement, [...prefix, ...keep(statement.modifiers)], statement.name, statement.typeParameters, statement.heritageClauses, statement.members, ); } if (ts.isVariableStatement(statement)) { return factory.updateVariableStatement( statement, [...prefix, ...keep(statement.modifiers)], statement.declarationList, ); } throw new Error( `Unsupported declaration kind: ${ts.SyntaxKind[statement.kind]}`, ); } /** * Flattens the emitted declarations into a single module that exports exactly * the public surface. Declarations reachable from the public names are kept; * helper types that are not part of the public surface are kept but not * exported; everything else (the runtime-only constants) is dropped. */ export function flatten( cfcDts: string, cfcDtsName: string, publicNames: Set, ): string { const source = ts.createSourceFile( cfcDtsName, cfcDts, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS, ); const declarations = new Map(); const dependencies = new Map>(); const order: string[] = []; for (const statement of source.statements) { const name = declarationName(statement); if (name === undefined) continue; declarations.set(name, statement); const refs = collectDependencies(statement); refs.delete(name); dependencies.set(name, refs); order.push(name); } for (const name of publicNames) { if (!declarations.has(name)) { throw new Error( `Public export "${name}" has no declaration in the authoring module`, ); } } const kept = new Set(); const queue = [...publicNames]; while (queue.length > 0) { const name = queue.pop()!; if (kept.has(name)) continue; kept.add(name); for (const dependency of dependencies.get(name) ?? []) { if (declarations.has(dependency) && !kept.has(dependency)) { queue.push(dependency); } } } const statements: ts.Statement[] = []; for (const name of order) { if (!kept.has(name)) continue; statements.push(withExport(declarations.get(name)!, publicNames.has(name))); } const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed, removeComments: true, }); return printer.printFile(ts.factory.updateSourceFile(source, statements)); } export async function formatTypeScript(text: string): Promise { const command = new Deno.Command("deno", { args: ["fmt", "--ext", "ts", "-"], stdin: "piped", stdout: "piped", stderr: "piped", }); const process = command.spawn(); const writer = process.stdin.getWriter(); await writer.write(new TextEncoder().encode(text)); await writer.close(); const { code, stdout, stderr } = await process.output(); if (code !== 0) { throw new Error(`deno fmt failed:\n${new TextDecoder().decode(stderr)}`); } return new TextDecoder().decode(stdout); } export async function generateCfcTypes(): Promise { const emitted = emitDeclarations(); const authoringDts = findEmitted(emitted, "cfc-authoring.d.ts"); const cfcDts = findEmitted(emitted, "/cfc.d.ts"); const publicNames = collectReExportedNames( authoringDts, "cfc-authoring.d.ts", ); const body = flatten(cfcDts, "cfc.d.ts", publicNames); return await formatTypeScript(`${HEADER}\n\n${body}`); } /** * Runs the command-line interface and returns the process exit code. With * `--check` it reports whether the file at `target` already matches the * generated output; otherwise it rewrites that file. */ export async function runCli( args: string[], target: string = targetPath, ): Promise { const check = args.includes("--check"); const generated = await generateCfcTypes(); if (check) { let existing = ""; try { existing = await Deno.readTextFile(target); } catch { existing = ""; } if (existing !== generated) { console.error( `${target} is out of date. Run \`${GEN_TASK}\` to regenerate it.`, ); return 1; } console.log(`${target} is up to date.`); return 0; } await Deno.writeTextFile(target, generated); console.log(`Wrote ${target}`); return 0; } /** * Entry point: runs the CLI and exits with its status, but only when this * module is the program's entry point. `isMain` and `exit` are injectable so * the entry behavior can be exercised without terminating the test runner. */ export async function cliMain( args: string[] = Deno.args, isMain: boolean = import.meta.main, exit: (code: number) => void = Deno.exit, ): Promise { if (!isMain) return; exit(await runCli(args)); } await cliMain();