Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/fix-cli-typegen-system-export-error.md
Original file line number Diff line number Diff line change
@@ -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.
98 changes: 98 additions & 0 deletions packages/cli/__tests__/io.test.ts
Original file line number Diff line number Diff line change
@@ -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"')
})
})
68 changes: 54 additions & 14 deletions packages/cli/src/utils/io.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,12 +73,12 @@
}
}

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 {
Expand All @@ -89,12 +89,55 @@
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<string, unknown> => {
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<string, unknown>
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 (
Expand All @@ -106,13 +149,10 @@

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 }
Expand Down Expand Up @@ -144,7 +184,7 @@
}

export function watch(paths: string[], cb: () => Promise<void>) {
const watcher = chokidar.watch(paths, { ignoreInitial: true })

Check warning on line 187 in packages/cli/src/utils/io.ts

View workflow job for this annotation

GitHub Actions / ESLint

Caution: `chokidar` also has a named export `watch`. Check if you meant to write `import {watch} from 'chokidar'` instead

Check warning on line 187 in packages/cli/src/utils/io.ts

View workflow job for this annotation

GitHub Actions / ESLint

Caution: `chokidar` also has a named export `watch`. Check if you meant to write `import {watch} from 'chokidar'` instead

watcher.on("ready", cb).on("change", async (filePath) => {
log.info(`📦 File changed: ${filePath}`)
Expand Down
Loading