diff --git a/cli/build/transpile/index.ts b/cli/build/transpile/index.ts index f4994561d..a79892c20 100644 --- a/cli/build/transpile/index.ts +++ b/cli/build/transpile/index.ts @@ -1,33 +1,12 @@ import path from "node:path" import fs from "node:fs" -import { rollup } from "rollup" -import typescript from "@rollup/plugin-typescript" -import resolve from "@rollup/plugin-node-resolve" -import commonjs from "@rollup/plugin-commonjs" -import json from "@rollup/plugin-json" -import dts from "rollup-plugin-dts" import kleur from "kleur" -import ts from "typescript" import { createStaticAssetPlugin, STATIC_ASSET_EXTENSIONS, } from "./static-asset-plugin" -const [typescriptMajor, typescriptMinor] = ts.versionMajorMinor - .split(".") - .map(Number) -// TypeScript 5.7+ can emit projects that import .ts/.tsx paths by rewriting -// those specifiers, instead of reporting TS5097 or requiring noEmit. -const supportsRewriteRelativeImportExtensions = - typescriptMajor > 5 || (typescriptMajor === 5 && typescriptMinor >= 7) -const typescriptExtensionEmitOptions = supportsRewriteRelativeImportExtensions - ? { - allowImportingTsExtensions: true, - rewriteRelativeImportExtensions: true, - } - : { allowImportingTsExtensions: false } - const createExternalFunction = (projectDir: string, tsconfigPath?: string) => (id: string): boolean => { @@ -100,6 +79,52 @@ export const transpileFile = async ({ projectDir: string }): Promise => { try { + const { default: ts } = await import("typescript") + if ( + !ts.ModuleKind || + !ts.SyntaxKind || + typeof ts.createProgram !== "function" + ) { + throw new Error( + `TypeScript ${ts.version} does not provide the JavaScript compiler API required for transpilation. ` + + "Install a compatible compiler in your project with `bun add --dev --exact typescript@5.9.3` " + + "or `npm install --save-dev --save-exact typescript@5.9.3`, then retry.", + ) + } + + // These plugins access the compiler API during module initialization. + // Load them only after checking compatibility, so help/version output and + // circuit builds without transpilation can run without that API. + const [ + { rollup }, + { default: typescript }, + { default: resolve }, + { default: commonjs }, + { default: json }, + { default: dts }, + ] = await Promise.all([ + import("rollup"), + import("@rollup/plugin-typescript"), + import("@rollup/plugin-node-resolve"), + import("@rollup/plugin-commonjs"), + import("@rollup/plugin-json"), + import("rollup-plugin-dts"), + ]) + + const [typescriptMajor, typescriptMinor] = ts.versionMajorMinor + .split(".") + .map(Number) + // TypeScript 5.7+ can rewrite explicit .ts/.tsx imports during emit. + const supportsRewriteRelativeImportExtensions = + typescriptMajor > 5 || (typescriptMajor === 5 && typescriptMinor >= 7) + const typescriptExtensionEmitOptions = + supportsRewriteRelativeImportExtensions + ? { + allowImportingTsExtensions: true, + rewriteRelativeImportExtensions: true, + } + : { allowImportingTsExtensions: false } + fs.mkdirSync(outputDir, { recursive: true }) // Check if user has a tsconfig.json diff --git a/tests/cli/transpile/typescript-compiler-compatibility.test.ts b/tests/cli/transpile/typescript-compiler-compatibility.test.ts new file mode 100644 index 000000000..25ea91ef2 --- /dev/null +++ b/tests/cli/transpile/typescript-compiler-compatibility.test.ts @@ -0,0 +1,97 @@ +import { expect, test } from "bun:test" +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import path from "node:path" + +const cliPath = path.resolve(import.meta.dir, "../../../cli/main.ts") + +test("help, version and non-transpiling builds work without the TypeScript compiler API", async () => { + const projectDir = await mkdtemp(path.join(tmpdir(), "tsci-typescript7-")) + try { + // TypeScript 7's main entry point only exports version information. + // Isolate the mock in child processes so other tests retain their compiler. + const preloadPath = path.join(projectDir, "typescript7-preload.ts") + await writeFile( + preloadPath, + `import { mock } from "bun:test" +const ts = { version: "7.0.2", versionMajorMinor: "7.0" } +mock.module(${JSON.stringify(import.meta.resolve("typescript"))}, () => ({ ...ts, default: ts })) +`, + ) + // importFromUserLand falls back to the CLI's own React/tscircuit packages + // when the project has no local node_modules. + await writeFile(path.join(projectDir, "package.json"), "{}") + await writeFile( + path.join(projectDir, "index.tsx"), + `export default () => + +`, + ) + + const run = async (...args: string[]) => { + const child = Bun.spawn( + [process.execPath, "--preload", preloadPath, cliPath, ...args], + { + cwd: projectDir, + env: { ...process.env, TSCI_TEST_MODE: "true", FORCE_COLOR: "0" }, + stdout: "pipe", + stderr: "pipe", + }, + ) + const [exitCode, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]) + return { exitCode, stdout, stderr } + } + + const expectIncompatibleCompiler = ( + result: Awaited>, + ) => { + expect(result.exitCode).toBe(1) + expect(result.stderr).toContain("TypeScript 7.0.2") + expect(result.stderr).toContain("JavaScript compiler API") + expect(result.stderr).toContain("typescript@5.9.3") + expect(result.stderr).not.toContain("ModuleKind.ES2015") + } + + for (const arg of ["--help", "--version"]) { + const result = await run(arg) + expect(result.exitCode, result.stderr).toBe(0) + expect(result.stderr).not.toContain("ES2015") + } + + const build = await run("build", "index.tsx") + expect(build.exitCode, build.stderr).toBe(0) + const circuitJson = JSON.parse( + await readFile(path.join(projectDir, "dist/index/circuit.json"), "utf8"), + ) + expect( + circuitJson.some( + (element: { type: string }) => element.type === "pcb_board", + ), + ).toBe(true) + expect( + circuitJson.some( + (element: { type: string; name?: string }) => + element.type === "source_component" && element.name === "R1", + ), + ).toBe(true) + + for (const args of [ + ["transpile", "index.tsx"], + ["build", "index.tsx", "--transpile"], + ]) { + expectIncompatibleCompiler(await run(...args)) + } + + await writeFile( + path.join(projectDir, "tscircuit.config.json"), + JSON.stringify({ build: { typescriptLibrary: true } }), + ) + expectIncompatibleCompiler(await run("build", "index.tsx")) + } finally { + await rm(projectDir, { recursive: true, force: true }) + } +}, 60_000)