Repository: honojs/honox (ts)

Issue:
Support "base" config of Vite or alternatives
### What is the feature you are proposing?

When client code is present, it is not possible to deploy a site built with HonoX to GitHub Pages.

When deploying a site built with Vite to GitHub Pages, it is [instructed](https://ja.vite.dev/guide/static-deploy#github-pages) to change the `base` setting.
Behavior for embedded deployments is also [provided](https://ja.vite.dev/guide/build.html#%E7%9B%B8%E5%AF%BE%E7%9A%84%E3%81%AA-base).

FYI: My workaround and considerations:
- vite.config.js cannot be read from runtime, so I could not directly tell the Script component the value of base. And the Script component uses `"/"`, so it is not configurable.
- I have implemented my own Script component equivalent to get around this, but there may be a better way to do it.
    - The `base` value is shared by environment variables.

Pull request description:
feat: Support subpath deployment
fixes #243 partially

> The problem occurs because the .js file references in each HTML are written as root-relative paths. If the deployment destination is a subpath (e.g., GitHub Pages), the subpath segment must be included when referencing root-relative paths.
>
> When deploying to https://example.com/path/to/some/dir/, the following mismatch arises, causing the .js file to fail to load:
>
> | Reference in the output HTML |Actual file path = Correct reference |
> |----|----|
> |`/static/client-${hash}.js`|`/path/to/some/dir/static/client-${hash}.js`|

This PR enables deployment to subpaths such as GitHub Pages. See https://vite.dev/config/shared-options#base https://vite.dev/guide/env-and-mode#env-variables

Patch:
diff --git a/src/server/components/link.tsx b/src/server/components/link.tsx
index 9c549af..8295f47 100644
--- a/src/server/components/link.tsx
+++ b/src/server/components/link.tsx
@@ -1,6 +1,7 @@
 import type { FC } from 'hono/jsx'
 import type { JSX } from 'hono/jsx/jsx-runtime'
 import type { Manifest } from 'vite'
+import { ensureTrailngSlash } from '../utils/path'
 
 type Options = { manifest?: Manifest; prod?: boolean } & JSX.IntrinsicElements['link']
 
@@ -23,7 +24,12 @@ export const Link: FC<Options> = (options) => {
         const assetInManifest = manifest[href.replace(/^\//, '')]
         if (assetInManifest) {
           if (href.startsWith('/')) {
-            return <link href={`/${assetInManifest.file}`} {...rest}></link>
+            return (
+              <link
+                href={`${ensureTrailngSlash(import.meta.env.BASE_URL)}${assetInManifest.file}`}
+                {...rest}
+              ></link>
+            )
           }
 
           return <link href={assetInManifest.file} {...rest}></link>
diff --git a/src/server/components/script.tsx b/src/server/components/script.tsx
index 062071c..c38730f 100644
--- a/src/server/components/script.tsx
+++ b/src/server/components/script.tsx
@@ -1,4 +1,5 @@
 import type { Manifest } from 'vite'
+import { ensureTrailngSlash } from '../utils/path.js'
 import { HasIslands } from './has-islands.js'
 
 type Options = {
@@ -33,7 +34,7 @@ export const Script = (options: Options): any => {
             <script
               type='module'
               async={!!options.async}
-              src={`/${scriptInManifest.file}`}
+              src={`${ensureTrailngSlash(import.meta.env.BASE_URL)}${scriptInManifest.file}`}
               nonce={options.nonce}
             ></script>
           </HasIslands>
diff --git a/src/server/utils/path.ts b/src/server/utils/path.ts
new file mode 100644
index 0000000..f4ce168
--- /dev/null
+++ b/src/server/utils/path.ts
@@ -0,0 +1,3 @@
+export const ensureTrailngSlash = (path: string) => {
+  return path.endsWith('/') ? path : path + '/'
+}


Test patch:
diff --git a/src/server/utils/path.test.ts b/src/server/utils/path.test.ts
new file mode 100644
index 0000000..170d90d
--- /dev/null
+++ b/src/server/utils/path.test.ts
@@ -0,0 +1,12 @@
+import { ensureTrailngSlash } from './path'
+
+describe('ensureTrailngSlash', () => {
+  it('Should ensure trailing slash', () => {
+    expect(ensureTrailngSlash('./')).toBe('./')
+    expect(ensureTrailngSlash('/')).toBe('/')
+    expect(ensureTrailngSlash('/subdir')).toBe('/subdir/')
+    expect(ensureTrailngSlash('/subdir/')).toBe('/subdir/')
+    expect(ensureTrailngSlash('https://example.com')).toBe('https://example.com/')
+    expect(ensureTrailngSlash('https://example.com/subdir')).toBe('https://example.com/subdir/')
+  })
+})
diff --git a/test-integration/apps.test.ts b/test-integration/apps.test.ts
index 5ad05ae..570ce83 100644
--- a/test-integration/apps.test.ts
+++ b/test-integration/apps.test.ts
@@ -489,6 +489,69 @@ describe('<Script /> component', () => {
         '<html><head><script type="module" src="/static/client-abc.js"></script></head><body><main><honox-island component-name="/mocks/app-script/islands/Component.tsx" data-serialized-props="{}"><p>Component</p></honox-island></main></body></html>'
       )
     })
+
+    describe('with base path - root relative', () => {
+      const originalBaseURL = import.meta.env.BASE_URL
+
+      beforeAll(() => {
+        // this means `base: "/base/path/"` in vite.config.ts
+        import.meta.env.BASE_URL = '/base/path/'
+      })
+
+      afterAll(() => {
+        import.meta.env.BASE_URL = originalBaseURL
+      })
+
+      it('Should convert the script path correctly', async () => {
+        const res = await app.request('/')
+        expect(res.status).toBe(200)
+        expect(await res.text()).toBe(
+          '<html><head><script type="module" src="/base/path/static/client-abc.js"></script></head><body><main><honox-island component-name="/mocks/app-script/islands/Component.tsx" data-serialized-props="{}"><p>Component</p></honox-island></main></body></html>'
+        )
+      })
+    })
+
+    describe('with base path - root relative, without trailing slash', () => {
+      const originalBaseURL = import.meta.env.BASE_URL
+
+      beforeAll(() => {
+        // this means `base: "/base/path"` in vite.config.ts
+        import.meta.env.BASE_URL = '/base/path'
+      })
+
+      afterAll(() => {
+        import.meta.env.BASE_URL = originalBaseURL
+      })
+
+      it('Should convert the script path correctly', async () => {
+        const res = await app.request('/')
+        expect(res.status).toBe(200)
+        expect(await res.text()).toBe(
+          '<html><head><script type="module" src="/base/path/static/client-abc.js"></script></head><body><main><honox-island component-name="/mocks/app-script/islands/Component.tsx" data-serialized-props="{}"><p>Component</p></honox-island></main></body></html>'
+        )
+      })
+    })
+
+    describe('with base path - absolute url', () => {
+      const originalBaseURL = import.meta.env.BASE_URL
+
+      beforeAll(() => {
+        // this means `base: "https://example.com/base/path/"` in vite.config.ts
+        import.meta.env.BASE_URL = 'https://example.com/base/path/'
+      })
+
+      afterAll(() => {
+        import.meta.env.BASE_URL = originalBaseURL
+      })
+
+      it('Should convert the script path correctly', async () => {
+        const res = await app.request('/')
+        expect(res.status).toBe(200)
+        expect(await res.text()).toBe(
+          '<html><head><script type="module" src="https://example.com/base/path/static/client-abc.js"></script></head><body><main><honox-island component-name="/mocks/app-script/islands/Component.tsx" data-serialized-props="{}"><p>Component</p></honox-island></main></body></html>'
+        )
+      })
+    })
   })
 
   describe('With async', () => {
@@ -555,6 +618,69 @@ describe('<Link /> component', () => {
         '<html><head><link href="/static/globals-abc.css" rel="stylesheet"></link></head><body><main><div></div></main></body></html>'
       )
     })
+
+    describe('with base path - root relative', () => {
+      const originalBaseURL = import.meta.env.BASE_URL
+
+      beforeAll(() => {
+        // this means `base: "/base/path/"` in vite.config.ts
+        import.meta.env.BASE_URL = '/base/path/'
+      })
+
+      afterAll(() => {
+        import.meta.env.BASE_URL = originalBaseURL
+      })
+
+      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="/base/path/static/globals-abc.css" rel="stylesheet"></link></head><body><main><div></div></main></body></html>'
+        )
+      })
+    })
+
+    describe('with base path - root relative, without trailing slash', () => {
+      const originalBaseURL = import.meta.env.BASE_URL
+
+      beforeAll(() => {
+        // this means `base: "/base/path"` in vite.config.ts
+        import.meta.env.BASE_URL = '/base/path'
+      })
+
+      afterAll(() => {
+        import.meta.env.BASE_URL = originalBaseURL
+      })
+
+      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="/base/path/static/globals-abc.css" rel="stylesheet"></link></head><body><main><div></div></main></body></html>'
+        )
+      })
+    })
+
+    describe('with base path - absolute url', () => {
+      const originalBaseURL = import.meta.env.BASE_URL
+
+      beforeAll(() => {
+        // this means `base: "https://example.com/base/path/"` in vite.config.ts
+        import.meta.env.BASE_URL = 'https://example.com/base/path/'
+      })
+
+      afterAll(() => {
+        import.meta.env.BASE_URL = originalBaseURL
+      })
+
+      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="https://example.com/base/path/static/globals-abc.css" rel="stylesheet"></link></head><body><main><div></div></main></body></html>'
+        )
+      })
+    })
   })
 })