Repository: honojs/honox
Pull request #181 (2024-06-01 11:18:31)

feat: add `Link` component for external (built) css
### Motivation

For example, you may want to use TailwindCSS for styling HonoX components. Currently, it is possible to develop using the `<link />` tag, but the build result does not contain a unique value such as a file hash, which can lead to unintended caching.
This PR addresses such a use case.

### Example

As an example, we are considering the following use cases:

```tsx
// _renderer.tsx
import { jsxRenderer } from "hono/jsx-renderer";
import { Script, Link } from "honox/server";

export default jsxRenderer(() => {
  return (
    <html lang="en">
      <head>
        <Link href="/app/globals.css" rel="stylesheet" />
      </head>
      <body class="bg-white">
        {/* omitted... */}
      </body>
    </html>
  );
});

// vite.config.ts
import { defineConfig } from "vite";

export default defineConfig(({ mode }) => {
  if (mode === "client") {
    return {
      build: {
        rollupOptions: {
          input: ["/app/globals.css"],
          output: {
            assetFileNames: "static/assets/[name].[hash].[ext]",
          },
        },
      },
      /* omitted... */
    };
  } else {
    return {
      /* omitted... */
    };
  }
});

```

yusukebe: Hi @mika-f 

This is a super cool feature! I'd like to merge it, but I've left one comment. Check it!
yusukebe: Hey @ryuapp @bruceharrison1984 !

What do you think of this feature? Will it resolve #90, though we still need to add a path to the CSS file in `vite.config.ts`?
ryuapp: Great work. I like this feature because this simplifies a renderer file.

> Will it resolve https://github.com/honojs/honox/issues/90, though we still need to add a path to the CSS file in vite.config.ts?

Partially solve it.
The reason I'm asking for #90 is because I'm looking for **a simple way** to import CSS.
I would be even happier if this feature could be used with zero configuration.
yusukebe: @ryuapp 

Thank you for the comment.

> I would be even happier if this feature could be used with zero configuration.

Yeah, I also think so. I'm not sure how we can implement it. But for example, if we can write the following, it will be great:

```ts
import tailwind from '@hono/vite-tailwind-css'

export default defineConfig(({ mode }) => {
  return {
    plugins: [honox(), pages(), tailwind()]
  }
})
```
yusukebe: @mika-f 

Looks good! I'll merge this and release the new version which includes this change later. Thank you for your contribution!

Patch:
diff --git a/mocks/app-link/routes/_renderer.tsx b/mocks/app-link/routes/_renderer.tsx
new file mode 100644
index 0000000..4a7467d
--- /dev/null
+++ b/mocks/app-link/routes/_renderer.tsx
@@ -0,0 +1,23 @@
+import { jsxRenderer } from 'hono/jsx-renderer'
+import { Link } from '../../../src/server'
+
+export default jsxRenderer(
+  ({ children }) => {
+    return (
+      <html>
+        <head>
+          <Link
+            href='/app/globals.css'
+            rel='stylesheet'
+            prod={true}
+            manifest={{ 'app/globals.css': { file: 'static/globals-abc.css' } }}
+          />
+        </head>
+        <body>{children}</body>
+      </html>
+    )
+  },
+  {
+    docType: false,
+  }
+)
diff --git a/mocks/app-link/routes/classic/index.tsx b/mocks/app-link/routes/classic/index.tsx
new file mode 100644
index 0000000..6d5b2d9
--- /dev/null
+++ b/mocks/app-link/routes/classic/index.tsx
@@ -0,0 +1,13 @@
+import { Hono } from 'hono'
+
+const app = new Hono()
+
+app.get('/', (c) => {
+  return c.render(
+    <main>
+      <div />
+    </main>
+  )
+})
+
+export default app
diff --git a/mocks/app-link/routes/index.tsx b/mocks/app-link/routes/index.tsx
new file mode 100644
index 0000000..6c4a568
--- /dev/null
+++ b/mocks/app-link/routes/index.tsx
@@ -0,0 +1,7 @@
+export default function Hello() {
+  return (
+    <main>
+      <div />
+    </main>
+  )
+}
diff --git a/mocks/app/globals.css b/mocks/app/globals.css
new file mode 100644
index 0000000..e69de29
diff --git a/src/server/components/index.ts b/src/server/components/index.ts
index d154eb8..b2f936b 100644
--- a/src/server/components/index.ts
+++ b/src/server/components/index.ts
@@ -1,2 +1,3 @@
 export { HasIslands } from './has-islands.js'
 export { Script } from './script.js'
+export { Link } from './link.js'
diff --git a/src/server/components/link.tsx b/src/server/components/link.tsx
new file mode 100644
index 0000000..927b8ef
--- /dev/null
+++ b/src/server/components/link.tsx
@@ -0,0 +1,38 @@
+import type { FC } from 'hono/jsx'
+import type { Manifest } from 'vite'
+
+type Options = { manifest?: Manifest; prod?: boolean } & JSX.IntrinsicElements['link']
+
+export const Link: FC<Options> = async (options) => {
+  let { href, prod, manifest, ...rest } = options
+  if (href) {
+    if (prod ?? import.meta.env.PROD) {
+      if (!manifest) {
+        const MANIFEST = import.meta.glob<{ default: Manifest }>('/dist/.vite/manifest.json', {
+          eager: true,
+        })
+        for (const [, manifestFile] of Object.entries(MANIFEST)) {
+          if (manifestFile['default']) {
+            manifest = manifestFile['default']
+            break
+          }
+        }
+      }
+      if (manifest) {
+        const assetInManifest = manifest[href.replace(/^\//, '')]
+        if (assetInManifest) {
+          if (href.startsWith('/')) {
+            return <link href={`/${assetInManifest.file}`} {...rest}></link>
+          }
+
+          return <link href={assetInManifest.file} {...rest}></link>
+        }
+      }
+      return <></>
+    } else {
+      return <link href={href} {...rest}></link>
+    }
+  }
+
+  return <link {...rest} />
+}


Test patch:
diff --git a/test-integration/apps.test.ts b/test-integration/apps.test.ts
index 5ea3df3..485e473 100644
--- a/test-integration/apps.test.ts
+++ b/test-integration/apps.test.ts
@@ -583,6 +583,32 @@ describe('<Script /> component', () => {
   })
 })
 
+describe('<Link /> component', () => {
+  const ROUTES = import.meta.glob('../mocks/app-link/routes/**/index.tsx', {
+    eager: true,
+  })
+
+  describe('default (rel=stylesheet, absolute path)', () => {
+    const RENDERER = import.meta.glob('../mocks/app-link/routes/**/_renderer.tsx', {
+      eager: true,
+    })
+
+    const app = createApp({
+      root: '../mocks/app-link/routes',
+      ROUTES: ROUTES as any,
+      RENDERER: RENDERER as any,
+    })
+
+    it('Should convert the link path correctly', async () => {
+      const res = await app.request('/')
+      expect(res.status).toBe(200)
+      expect(await res.text()).toBe(
+        '<html><head><link href="/static/globals-abc.css" rel="stylesheet"></link></head><body><main><div></div></main></body></html>'
+      )
+    })
+  })
+})
+
 describe('<HasIslands /> Component with path aliases', () => {
   const ROUES = import.meta.glob('../mocks/app-alias/routes/**/[a-z[-][a-z-_[]*.(tsx|ts)', {
     eager: true,