diff --git a/.changeset/fix-cli-typegen-system-export-error.md b/.changeset/fix-cli-typegen-system-export-error.md new file mode 100644 index 00000000000..d8f60d5917d --- /dev/null +++ b/.changeset/fix-cli-typegen-system-export-error.md @@ -0,0 +1,7 @@ +--- +"@chakra-ui/cli": patch +--- + +Improve the `chakra typegen` error when the input file does not export a Chakra +system, including the discovered exports and a `createSystem(...)` example for +files that export `defineConfig(...)` configs. diff --git a/packages/cli/__tests__/io.test.ts b/packages/cli/__tests__/io.test.ts new file mode 100644 index 00000000000..52f36c7c534 --- /dev/null +++ b/packages/cli/__tests__/io.test.ts @@ -0,0 +1,98 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { TextEncoder } from "node:util" +import { afterEach, beforeEach, describe, expect, it } from "vitest" + +const nodeUint8Array = new TextEncoder().encode("").constructor + +const readInput = async (file: string, cwd: string) => { + Object.defineProperties(globalThis, { + TextEncoder: { + configurable: true, + value: TextEncoder, + }, + Uint8Array: { + configurable: true, + value: nodeUint8Array, + }, + }) + + const { read } = await import("../src/utils/io") + return read(file, { cwd }) +} + +const getReadError = async (file: string, cwd: string) => { + try { + await readInput(file, cwd) + } catch (error) { + expect(error).toBeInstanceOf(Error) + return error as Error + } + + throw new Error("Expected io.read to throw") +} + +describe("io.read", () => { + let testDir: string + + beforeEach(() => { + testDir = mkdtempSync(join(tmpdir(), "chakra-cli-io-test-")) + }) + + afterEach(() => { + rmSync(testDir, { recursive: true, force: true }) + }) + + it("accepts a default Chakra system export", async () => { + const source = join(testDir, "system.ts") + writeFileSync(source, "export default { $$chakra: true }") + + const result = await readInput(source, testDir) + + expect(result.mod).toMatchObject({ $$chakra: true }) + }) + + it.each(["preset", "system"] as const)( + "accepts a named %s Chakra system export", + async (exportName) => { + const source = join(testDir, `${exportName}.ts`) + writeFileSync(source, `export const ${exportName} = { $$chakra: true }`) + + const result = await readInput(source, testDir) + + expect(result.mod).toMatchObject({ $$chakra: true }) + }, + ) + + it("accepts a CommonJS Chakra system export", async () => { + const source = join(testDir, "system.cjs") + writeFileSync(source, "module.exports = { $$chakra: true }") + + const result = await readInput(source, testDir) + + expect(result.mod).toMatchObject({ $$chakra: true }) + }) + + it("explains when the default export is not a Chakra system", async () => { + const source = join(testDir, "config.ts") + writeFileSync(source, "export default { theme: { tokens: {} } }") + + const error = await getReadError(source, testDir) + + expect(error.message).toContain("No Chakra system export found") + expect(error.message).toContain('Found export: "default".') + expect(error.message).toContain("defineConfig(...)") + expect(error.message).toContain("createSystem(defaultConfig, config)") + }) + + it("lists named exports when no Chakra system export is found", async () => { + const source = join(testDir, "theme.ts") + writeFileSync(source, "export const theme = { tokens: {} }") + + const error = await getReadError(source, testDir) + + expect(error.message).toContain('Found export: "theme".') + expect(error.message).toContain('expects "default", "preset", "system"') + }) +}) diff --git a/packages/cli/src/utils/io.ts b/packages/cli/src/utils/io.ts index 2152c5c7a7e..59d7fddaf8b 100644 --- a/packages/cli/src/utils/io.ts +++ b/packages/cli/src/utils/io.ts @@ -73,12 +73,12 @@ function loadViaRequire(file: string, code: string): any { } } - delete require.cache[require.resolve(file)] - const raw = require(file) - const result = raw.default ?? raw - - require.extensions[ext] = defaultLoader - return result + try { + delete require.cache[require.resolve(file)] + return require(file) + } finally { + require.extensions[ext] = defaultLoader + } } function loadViaVm(code: string): any { @@ -89,12 +89,55 @@ function loadViaVm(code: string): any { require, }) vm.runInContext(code, ctx) - const raw = mod.exports as any - return raw.default ?? raw + return mod.exports +} + +const systemExports = ["default", "preset", "system"] as const + +const isRecord = (mod: unknown): mod is Record => { + return typeof mod === "object" && mod !== null } const isValidSystem = (mod: unknown): mod is SystemContext => { - return Object.hasOwnProperty.call(mod, "$$chakra") + return isRecord(mod) && Object.hasOwnProperty.call(mod, "$$chakra") +} + +function resolveSystemExport(mod: unknown) { + const candidate = isRecord(mod) && mod.default != null ? mod.default : mod + + if (!isRecord(candidate) || isValidSystem(candidate)) return candidate + + const exports = candidate as Record + return exports.default || exports.preset || exports.system || candidate +} + +function formatExportName(name: string) { + return `"${name}"` +} + +function getExportNames(mod: unknown) { + if (!isRecord(mod) || isValidSystem(mod)) return [] + return Object.keys(mod).filter((name) => name !== "__esModule") +} + +function formatInvalidSystemError(file: string, mod: unknown) { + const exportNames = getExportNames(mod) + const foundExports = exportNames.length + ? `Found export${exportNames.length === 1 ? "" : "s"}: ${exportNames + .map(formatExportName) + .join(", ")}.` + : "Found no named exports." + + return [ + `No Chakra system export found in ${file}.`, + `The chakra typegen command expects ${systemExports + .map(formatExportName) + .join( + ", ", + )}, or a CommonJS export to be a Chakra system created with createSystem(...).`, + foundExports, + "If this file exports a config from defineConfig(...), wrap it first: export default createSystem(defaultConfig, config).", + ].join(" ") } export const read = async ( @@ -106,13 +149,10 @@ export const read = async ( const bundle = await bundleFile(filePath, cwd, tsconfig) const mod = loadBundledCode(filePath, bundle.code) - - const resolvedMod = mod.default || mod.preset || mod.system || mod + const resolvedMod = resolveSystemExport(mod) if (!isValidSystem(resolvedMod)) { - throw new Error( - `No default export found in ${file}. Did you forget to provide an export default?`, - ) + throw new Error(formatInvalidSystemError(file, mod)) } return { mod: resolvedMod, dependencies: bundle.dependencies }