Repository: honojs/honox
Pull request #249 (2025-01-08 03:43:35)

fix: Read deno.json in islandComponents vite plugin
When I use HonoX from Deno, I got an error that vite cannot read tsconfig.json because Deno does not support tsconfig.json but directly deno.json.

I have added deno.json support to HonoX.


Patch:
diff --git a/README.md b/README.md
index e34eed7..b32c00e 100644
--- a/README.md
+++ b/README.md
@@ -326,11 +326,9 @@ import { createRoute } from 'honox/factory'
 import { secureHeaders, NONCE } from 'hono/secure-headers'
 
 secureHeaders({
-  contentSecurityPolicy: import.meta.env.PROD
-    ? {
-        scriptSrc: [NONCE],
-      }
-    : undefined,
+  contentSecurityPolicy: {
+    scriptSrc: [NONCE]
+  }
 })
 ```
 
diff --git a/src/server/components/link.tsx b/src/server/components/link.tsx
index e29ebe4..9c549af 100644
--- a/src/server/components/link.tsx
+++ b/src/server/components/link.tsx
@@ -4,7 +4,7 @@ import type { Manifest } from 'vite'
 
 type Options = { manifest?: Manifest; prod?: boolean } & JSX.IntrinsicElements['link']
 
-export const Link: FC<Options> = async (options) => {
+export const Link: FC<Options> = (options) => {
   let { href, prod, manifest, ...rest } = options
   if (href) {
     if (prod ?? import.meta.env.PROD) {
diff --git a/src/vite/island-components.ts b/src/vite/island-components.ts
index 4a1aef4..666aa92 100644
--- a/src/vite/island-components.ts
+++ b/src/vite/island-components.ts
@@ -218,16 +218,23 @@ export function islandComponents(options?: IslandComponentsOptions): Plugin {
 
       if (!reactApiImportSource) {
         const tsConfigPath = path.resolve(process.cwd(), 'tsconfig.json')
+        const denoJsonPath = path.resolve(process.cwd(), 'deno.json')
+        let tsConfigRaw: string
         try {
-          const tsConfigRaw = await fs.readFile(tsConfigPath, 'utf8')
-          const tsConfig = parseJsonc(tsConfigRaw)
-
-          reactApiImportSource = tsConfig.compilerOptions?.jsxImportSource
-          if (reactApiImportSource === 'hono/jsx/dom') {
-            reactApiImportSource = 'hono/jsx' // we should use hono/jsx instead of hono/jsx/dom
+          tsConfigRaw = await fs.readFile(denoJsonPath, 'utf8')
+        } catch (error1) {
+          try {
+            tsConfigRaw = await fs.readFile(tsConfigPath, 'utf8')
+          } catch (error2) {
+            console.warn('Cannot find neither tsconfig.json nor deno.json', error1, error2)
+            return
           }
-        } catch (error) {
-          console.warn('Error reading tsconfig.json:', error)
+        }
+        const tsConfig = parseJsonc(tsConfigRaw)
+
+        reactApiImportSource = tsConfig?.compilerOptions?.jsxImportSource
+        if (reactApiImportSource === 'hono/jsx/dom') {
+          reactApiImportSource = 'hono/jsx' // we should use hono/jsx instead of hono/jsx/dom
         }
       }
     },


Test patch:
diff --git a/src/vite/island-components.test.ts b/src/vite/island-components.test.ts
index 038c096..f442637 100644
--- a/src/vite/island-components.test.ts
+++ b/src/vite/island-components.test.ts
@@ -1,4 +1,4 @@
-import fs from 'fs'
+import fs from 'fs/promises'
 import os from 'os'
 import path from 'path'
 import { islandComponents, transformJsxTags } from './island-components.js'
@@ -220,35 +220,94 @@ export { utilityFn, WrappedExportViaVariable as default };`
 
 describe('options', () => {
   describe('reactApiImportSource', () => {
-    describe('vite/components', () => {
-      // get full path of honox-island.tsx
-      const component = path
-        .resolve(__dirname, '../vite/components/honox-island.tsx')
-        // replace backslashes for Windows
-        .replace(/\\/g, '/')
+    describe('vite/components', async () => {
+      const honoPattern = /'hono\/jsx'/
+      const reactPattern = /'react'/
+      const setup = async () => {
+        // get full path of honox-island.tsx
+        const component = path
+          .resolve(__dirname, '../vite/components/honox-island.tsx')
+          // replace backslashes for Windows
+          .replace(/\\/g, '/')
+        const componentContent = await fs.readFile(component, 'utf8')
+        return { component, componentContent }
+      }
 
-      // prettier-ignore
-      it('use \'hono/jsx\' by default', async () => {
-        const plugin = islandComponents()
-        await (plugin.configResolved as Function)({ root: 'root' })
-        const res = await (plugin.load as Function)(component)
-        expect(res.code).toMatch(/'hono\/jsx'/)
-        expect(res.code).not.toMatch(/'react'/)
-      })
+      test.each([
+        {
+          name: 'default with both config files (deno.json preferred)',
+          configFiles: {
+            'deno.json': { jsxImportSource: 'react' },
+            'tsconfig.json': { jsxImportSource: 'hono/jsx' },
+          },
+          config: {},
+          expect: { hono: false, react: true },
+        },
+        {
+          name: 'default with only deno.json',
+          configFiles: {
+            'deno.json': { jsxImportSource: 'react' },
+          },
+          config: {},
+          expect: { hono: false, react: true },
+        },
+        {
+          name: 'default with only tsconfig.json',
+          configFiles: {
+            'tsconfig.json': { jsxImportSource: 'react' },
+          },
+          config: {},
+          expect: { hono: false, react: true },
+        },
+        {
+          name: 'explicit react config overrides all',
+          configFiles: {
+            'deno.json': { jsxImportSource: 'hono/jsx' },
+            'tsconfig.json': { jsxImportSource: 'hono/jsx' },
+          },
+          config: { reactApiImportSource: 'react' },
+          expect: { hono: false, react: true },
+        },
+      ])('should handle $name', async (testCase) => {
+        const { component, componentContent } = await setup()
 
-      // prettier-ignore
-      it('enable to specify \'react\'', async () => {
-        const plugin = islandComponents({
-          reactApiImportSource: 'react',
+        vi.spyOn(fs, 'readFile').mockImplementation(async (filePath) => {
+          if (filePath.toString().includes('honox-island.tsx')) {
+            return componentContent
+          }
+          for (const [fileName, config] of Object.entries(testCase.configFiles)) {
+            if (filePath.toString().includes(fileName)) {
+              return JSON.stringify({ compilerOptions: config })
+            }
+            throw new Error('Config file does not exist')
+          }
+          return ''
         })
+
+        const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
+
+        const plugin = islandComponents(testCase.config)
         await (plugin.configResolved as Function)({ root: 'root' })
         const res = await (plugin.load as Function)(component)
-        expect(res.code).not.toMatch(/'hono\/jsx'/)
-        expect(res.code).toMatch(/'react'/)
+
+        expect(honoPattern.test(res.code)).toBe(testCase.expect.hono)
+        expect(reactPattern.test(res.code)).toBe(testCase.expect.react)
+
+        if (Object.keys(testCase.configFiles).length === 0) {
+          expect(consoleWarnSpy).toHaveBeenCalledWith(
+            'Cannot find neither tsconfig.json nor deno.json',
+            expect.any(Error),
+            expect.any(Error)
+          )
+        }
       })
     })
 
-    describe('server/components', () => {
+    describe('server/components', async () => {
+      beforeEach(() => {
+        vi.restoreAllMocks()
+      })
+
       const tmpdir = os.tmpdir()
 
       // has-islands.tsx under src/server/components does not contain 'hono/jsx'
@@ -258,9 +317,9 @@ describe('options', () => {
         .resolve(tmpdir, 'honox/dist/server/components/has-islands.js')
         // replace backslashes for Windows
         .replace(/\\/g, '/')
-      fs.mkdirSync(path.dirname(component), { recursive: true })
+      await fs.mkdir(path.dirname(component), { recursive: true })
       // prettier-ignore
-      fs.writeFileSync(component, 'import { jsx } from \'hono/jsx/jsx-runtime\'')
+      await fs.writeFile(component, 'import { jsx } from \'hono/jsx/jsx-runtime\'')
 
       // prettier-ignore
       it('use \'hono/jsx\' by default', async () => {