├── .eslintrc.cjs ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .prettierrc ├── .vscode └── settings.json ├── README.md ├── package.json ├── src ├── client │ ├── client.ts │ └── index.ts ├── constants.ts ├── default │ ├── index.ts │ └── server.ts ├── index.ts ├── misc.ts ├── server │ ├── head.ts │ ├── index.ts │ └── server.ts ├── types.ts ├── utils │ ├── file.ts │ └── html.ts └── vite │ ├── index.ts │ ├── island-components.ts │ └── minify-es.ts ├── test ├── api │ ├── app │ │ ├── components │ │ │ └── Badge.tsx │ │ └── routes │ │ │ ├── about │ │ │ └── [name].ts │ │ │ ├── index.ts │ │ │ └── middleware │ │ │ └── index.ts │ ├── integration.test.ts │ └── vitest.config.ts ├── hono-jsx │ ├── app │ │ ├── components │ │ │ └── Badge.tsx │ │ └── routes │ │ │ ├── _404.tsx │ │ │ ├── _error.tsx │ │ │ ├── _layout.tsx │ │ │ ├── about │ │ │ ├── [name].tsx │ │ │ ├── [name] │ │ │ │ ├── __layout.tsx │ │ │ │ └── address.tsx │ │ │ └── _layout.tsx │ │ │ ├── api.tsx │ │ │ ├── index.tsx │ │ │ ├── page.tsx │ │ │ ├── post.mdx │ │ │ └── throw_error.tsx │ ├── integration.test.ts │ └── vitest.config.ts └── unit │ ├── server │ └── head.test.ts │ ├── utils │ ├── file.test.ts │ └── html.test.ts │ └── vite │ └── island-components.test.ts ├── tsconfig.build.json ├── tsconfig.json ├── tsup.config.ts ├── vitest.config.ts └── yarn.lock /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['@hono/eslint-config'], 3 | } 4 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | on: 3 | push: 4 | branches: [main, next] 5 | pull_request: 6 | branches: ['*'] 7 | 8 | jobs: 9 | ci: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: actions/setup-node@v3 14 | with: 15 | node-version: 20.x 16 | - run: yarn install --frozen-lockfile 17 | - run: yarn build 18 | - run: yarn test 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | package-lock.json 4 | yarn.lock 5 | pnpm-lock.yaml 6 | *.tgz 7 | sandbox 8 | test-results 9 | playwright-report 10 | 11 | # yarn 12 | .yarn/* 13 | !.yarn/patches 14 | !.yarn/plugins 15 | !.yarn/releases 16 | !.yarn/sdks 17 | !.yarn/versions 18 | yarn-error.log -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "trailingComma": "es5", 4 | "tabWidth": 2, 5 | "semi": false, 6 | "singleQuote": true, 7 | "jsxSingleQuote": true, 8 | "endOfLine": "lf" 9 | } 10 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "deno.enable": false, 3 | "eslint.validate": [ 4 | "javascript", 5 | "javascriptreact", 6 | "typescript", 7 | "typescriptreact" 8 | ], 9 | "editor.codeActionsOnSave": { 10 | "source.fixAll.eslint": "explicit" 11 | } 12 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Sonik 2 | 3 | > [!IMPORTANT] 4 | > Sonik becomes HonoX; development of Sonik is discontinued and it is reborn as HonoX. 5 | > Everyone, please use HonoX: 6 | 7 | --- 8 | 9 | Sonik is a simple and fast -_supersonic_- meta framework for creating web APIs and websites with Server-Side Rendering. 10 | It stands on the shoulders of giants; built on [Hono](https://hono.dev), [Vite](https://vitejs.dev), and JSX-based UI libraries. 11 | 12 | **Note:** _Sonik is currently in a "alpha stage". There will be breaking changes without any announcement. Don't use it in production. However, feel free to try it in your hobby project and give us your feedback!_ 13 | 14 | ## Features 15 | 16 | - **File-based routing** - Now, you can create a large app by separating concerns. 17 | - **Fast SSR** - Supports only Server-Side Rendering. Rendering is ultra-fast thanks to Hono. 18 | - **No JavaScript** - By default, there's no need for JavaScript. Nothing loads. 19 | - **Island hydration** - If you want interaction, create an island. JavaScript is hydrated only for that island. 20 | - **UI presets** - Any JSX-based UI library works with Sonik. Presets for hono/jsx, Preact, React are available. 21 | - **Easy API creation** - You can create APIs using Hono's syntax. 22 | - **Middleware** - It works just like Hono, so you can use many of Hono's middleware. 23 | - **Edge optimized** - The bundle size is minimized, making it easy to deploy to edge platforms like Cloudflare Workers. 24 | 25 | ## Quick Start 26 | 27 | ### Getting with the starter templates 28 | 29 | Give it a try: 30 | 31 | ```txt 32 | npm create sonik@latest 33 | 34 | // Or 35 | 36 | yarn create sonik 37 | 38 | // Or 39 | 40 | pnpm create sonik@latest 41 | ``` 42 | 43 | ### Usage 44 | 45 | _By default, it can be deployed to Cloudflare Pages._ 46 | 47 | npm: 48 | 49 | ```txt 50 | npm install 51 | npm run dev 52 | npm run build 53 | npm run deploy 54 | ``` 55 | 56 | yarn: 57 | 58 | ```txt 59 | yarn install 60 | yarn dev 61 | yarn build 62 | yarn deploy 63 | ``` 64 | 65 | ## Project Structure 66 | 67 | Below is a typical project structure for a Sonik application with Islands. 68 | 69 | ```txt 70 | . 71 | ├── app 72 | │   ├── client.ts // client entry file 73 | │   ├── islands 74 | │   │   └── counter.tsx // island component 75 | │   ├── routes 76 | │   │   ├── _404.tsx // not found page 77 | │   │   ├── _error.tsx // error page 78 | │   │   ├── _layout.tsx // layout template 79 | │   │   ├── about 80 | │   │   │   └── [name].tsx // matches `/about/:name` 81 | │   │   └── index.tsx // matches `/` 82 | │   ├── server.ts // server entry file 83 | │   └── style.css 84 | ├── package.json 85 | ├── public 86 | │   └── favicon.ico 87 | ├── tsconfig.json 88 | └── vite.config.ts 89 | ``` 90 | 91 | ## Building Your Application 92 | 93 | ### Server Entry File 94 | 95 | A server entry file is required. The file is should be placed at `src/server.ts`. 96 | This file is first called by the Vite during the development or build phase. 97 | 98 | In the entry file, simply initialize your app using the `createApp()` function. `app` will be an instance of Hono, so you can utilize Hono's middleware and the `app.showRoutes()` feature. 99 | 100 | ```ts 101 | // app/server.ts 102 | import { createApp } from 'sonik' 103 | 104 | const app = createApp() 105 | 106 | app.showRoutes() 107 | 108 | export default app 109 | ``` 110 | 111 | ### Presets 112 | 113 | You can construct pages with the JSX syntax using your favorite UI framework. Presets for hono/jsx, Preact, React are available. 114 | 115 | If you prefer to use the Preact presets, simply import from `@sonikjs/preact`: 116 | 117 | ```ts 118 | import { createApp } from '@sonikjs/preact' 119 | ``` 120 | 121 | The following presets are available: 122 | 123 | - `sonik` - hono/jsx 124 | - `@sonikjs/preact` - Preact 125 | - `@sonikjs/react` - React 126 | 127 | ### Pages 128 | 129 | There are two syntaxes for creating a page. 130 | 131 | #### `c.render()` 132 | 133 | Before introducing the two syntaxes, let you know about `c.render()`. 134 | 135 | You can use `c.render()` to return a HTML content with applying the layout is applied. 136 | The `Renderer` definition is the following: 137 | 138 | ```ts 139 | declare module 'hono' { 140 | interface ContextRenderer { 141 | (content: Node, head?: Partial>): 142 | | Response 143 | | Promise 144 | } 145 | } 146 | ``` 147 | 148 | #### `AppRoute` Component 149 | 150 | Export the `AppRoute` typed object with `defineRoute()` as the `route`. 151 | The `app` is an instance of `Hono`. 152 | 153 | ```ts 154 | // app/index.tsx 155 | import { defineRoute } from 'sonik' 156 | 157 | export const route = defineRoute((app) => { 158 | app.get((c) => { 159 | const res = c.render(

Hello

, { 160 | title: 'This is a title', 161 | meta: [{ name: 'description', content: 'This is a description' }], 162 | }) 163 | return res 164 | }) 165 | }) 166 | ``` 167 | 168 | #### Function component 169 | 170 | Just return JSX function as the `default`: 171 | 172 | ```ts 173 | // app/index.tsx 174 | export default function Home() { 175 | return

Hello!

176 | } 177 | ``` 178 | 179 | Or you can use the `Context` instance: 180 | 181 | ```ts 182 | import type { Context } from 'sonik' 183 | 184 | // app/index.tsx 185 | export default function Home(c: Context) { 186 | return c.render(

Hello!

, { 187 | title: 'My title', 188 | }) 189 | } 190 | ``` 191 | 192 | #### Use both syntaxes 193 | 194 | You can put both syntaxes in one file: 195 | 196 | ```ts 197 | export const route = defineRoute((app) => { 198 | app.post((c) => { 199 | return c.text('Created!', 201) 200 | }) 201 | }) 202 | 203 | export default function Books(c: Context) { 204 | return c.render( 205 |
206 | 207 | 208 |
209 | ) 210 | } 211 | ``` 212 | 213 | ### Creating API 214 | 215 | You can write the API endpoints in the same syntax as Hono. 216 | 217 | ```ts 218 | // app/routes/about/index.ts 219 | import { Hono } from 'hono' 220 | 221 | const app = new Hono() 222 | 223 | // matches `/about/:name` 224 | app.get('/:name', (c) => { 225 | const name = c.req.param('name') 226 | return c.json({ 227 | 'your name is': name, 228 | }) 229 | }) 230 | 231 | export default app 232 | ``` 233 | 234 | ### Reserved Files 235 | 236 | Files named in the following manner have designated roles: 237 | 238 | - `_404.tsx` - Not found page 239 | - `_error.tsx` - Error page 240 | - `_layout.tsx` - Layout template 241 | - `__layout.tsx` - Template for nested layouts 242 | 243 | ### Client 244 | 245 | To write client-side scripts that include JavaScript or stylesheets managed by Vite, craft a file and import `sonik/client` as seen in `app/client.ts`: 246 | 247 | ```ts 248 | import { createClient } from '@sonikjs/preact/client' 249 | 250 | createClient() 251 | ``` 252 | 253 | Also presets are avialbles for client entry file: 254 | 255 | - `@sonikjs/preact/client` - Preact 256 | - `@sonikjs/react/client` - React 257 | 258 | And then, import it in `app/routes/_layout.tsx`: 259 | 260 | ```tsx 261 | import type { LayoutHandler } from '@sonikjs/preact' 262 | 263 | const handler: LayoutHandler = ({ children, head }) => { 264 | return ( 265 | 266 | 267 | 268 | {import.meta.env.PROD ? ( 269 | <> 270 | 271 | 272 | 273 | ) : ( 274 | <> 275 | 276 | 277 | 278 | )} 279 | {head.createTags()} 280 | 281 | 282 |
{children}
283 | 284 | 285 | ) 286 | } 287 | 288 | export default handler 289 | ``` 290 | 291 | `import.meta.env.PROD` is useful flag for separate tags wehere it is on dev server or production. 292 | You should use `/app/client.ts` in development and use the file built in the production. 293 | 294 | ### Using Middleware 295 | 296 | Given that a Sonik instance is fundamentally a Hono instance, you can utilize all of Hono's middleware. If you wish to apply it before the Sonik app processes a request, create a `base` variable and pass it as a constructor option for `createApp()`: 297 | 298 | ```ts 299 | const base = new Hono() 300 | base.use('*', poweredBy()) 301 | 302 | const app = createApp({ 303 | app: base, 304 | }) 305 | ``` 306 | 307 | ### Using Tailwind CSS 308 | 309 | Given that Sonik is Vite-centric, if you wish to utilize Tailwind CSS, simply adhere to the official instructions. 310 | 311 | Prepare `tailwind.config.js` and `postcss.config.js`: 312 | 313 | ```js 314 | // tailwind.config.js 315 | /** @type {import('tailwindcss').Config} */ 316 | export default { 317 | content: ['./app/**/*.tsx'], 318 | theme: { 319 | extend: {}, 320 | }, 321 | plugins: [], 322 | } 323 | ``` 324 | 325 | ```js 326 | export default { 327 | plugins: { 328 | tailwindcss: {}, 329 | autoprefixer: {}, 330 | }, 331 | } 332 | ``` 333 | 334 | Write `app/style.css`: 335 | 336 | ```css 337 | @tailwind base; 338 | @tailwind components; 339 | @tailwind utilities; 340 | ``` 341 | 342 | Finally, import it in client entry file: 343 | 344 | ```ts 345 | //app/client.ts 346 | import { createClient } from '@sonikjs/preact/client' 347 | import './style.css' 348 | 349 | createClient() 350 | ``` 351 | 352 | ### Using MDX 353 | 354 | Integrate MDX using `@mdx-js/rollup` by configuring it in `vite.config.ts`: 355 | 356 | ```ts 357 | import devServer from '@hono/vite-dev-server' 358 | import mdx from '@mdx-js/rollup' 359 | import { defineConfig } from 'vite' 360 | import sonik from 'sonik/vite' 361 | 362 | export default defineConfig({ 363 | plugins: [ 364 | devServer({ 365 | entry: './app/server.ts', 366 | }), 367 | sonik(), 368 | { 369 | ...mdx({ 370 | jsxImportSource: 'preact', 371 | }), 372 | }, 373 | ], 374 | }) 375 | ``` 376 | 377 | ### SSR Streaming 378 | 379 | Sonik supports SSR Streaming, which, as of now, is exclusively available for React with `Suspense`. 380 | 381 | To enable is, set the `streaming` as `true` and pass the `renderToReadableString()` method in the `createApp()`: 382 | 383 | ```ts 384 | import { renderToReadableStream } from 'react-dom/server' 385 | 386 | const app = createApp({ 387 | streaming: true, 388 | renderToReadableStream: renderToReadableStream, 389 | }) 390 | ``` 391 | 392 | ## Deployment 393 | 394 | Since a Sonik instance is essentially a Hono instance, it can be deployed on any platform that Hono supports. 395 | 396 | The following adapters for deploying to the platforms are available in the Sonik package. 397 | 398 | ### Cloudflare Pages 399 | 400 | Setup the `vite.config.ts`: 401 | 402 | ```ts 403 | // vite.config.ts 404 | import { defineConfig } from 'vite' 405 | import sonik from 'sonik/vite' 406 | import pages from 'sonik/cloudflare-pages' 407 | 408 | export default defineConfig({ 409 | plugins: [sonik(), pages()], 410 | }) 411 | ``` 412 | 413 | Build command (including a client): 414 | 415 | ```txt 416 | vite build && vite build --mode client 417 | ``` 418 | 419 | Deploy with the following commands after build. Ensure you have [Wrangler](https://developers.cloudflare.com/workers/wrangler/) installed: 420 | 421 | ```txt 422 | wrangler pages deploy ./dist 423 | ``` 424 | 425 | ### Vercel 426 | 427 | ```ts 428 | // vite.config.ts 429 | import { defineConfig } from 'vite' 430 | import sonik from 'sonik/vite' 431 | import vercel from 'sonik/vercel' 432 | 433 | export default defineConfig({ 434 | plugins: [sonik(), vercel()], 435 | }) 436 | ``` 437 | 438 | Build command (including a client): 439 | 440 | ```txt 441 | vite build && vite build --mode client 442 | ``` 443 | 444 | Ensure you have [Vercel CLI](https://vercel.com/docs/cli) installed. 445 | 446 | ```txt 447 | vercel --prebuilt 448 | ``` 449 | 450 | ### Cloudflare Workers 451 | 452 | _The Cloudflare Workers adapter supports the "server" only and does not support the "client"._ 453 | 454 | ```ts 455 | // vite.config.ts 456 | import { defineConfig } from 'vite' 457 | import sonik from 'sonik/vite' 458 | import workers from 'sonik/cloudflare-workers' 459 | 460 | export default defineConfig({ 461 | plugins: [sonik(), workers()], 462 | }) 463 | ``` 464 | 465 | Build command: 466 | 467 | ```txt 468 | vite build 469 | ``` 470 | 471 | Deploy command: 472 | 473 | ```txt 474 | wrangler deploy --compatibility-date 2023-08-01 --minify ./dist/index.js --name my-app 475 | ``` 476 | 477 | ## Examples 478 | 479 | - [Sonik Blog](https://github.com/yusukebe/sonik-blog) 480 | - [ChatGPT Streaming](https://github.com/yusukebe/chatgpt-streaming) 481 | 482 | ## Related projects 483 | 484 | - [Hono](https://hono.dev) 485 | - [Vite](https://vitejs.dev/) 486 | 487 | ## Authors 488 | 489 | - Yusuke Wada 490 | 491 | ## License 492 | 493 | MIT 494 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sonik", 3 | "description": "The meta-framework for Edges", 4 | "version": "0.1.1", 5 | "types": "dist/index.d.ts", 6 | "module": "dist/index.js", 7 | "type": "module", 8 | "scripts": { 9 | "test": "yarn test:unit && yarn test:integration", 10 | "test:unit": "vitest --run test/unit", 11 | "test:integration": "run-s 'test:integration:*'", 12 | "test:integration:hono-jsx": "vitest run -c ./test/hono-jsx/vitest.config.ts ./test/hono-jsx/integration.test.ts", 13 | "test:integration:api": "vitest run -c ./test/api/vitest.config.ts ./test/api/integration.test.ts", 14 | "build": "rimraf dist && tsup && publint", 15 | "watch": "tsup --watch", 16 | "lint": "eslint src/**.ts", 17 | "lint:fix": "eslint src/**.ts --fix", 18 | "prerelease": "yarn test && yarn build", 19 | "release": "np" 20 | }, 21 | "files": [ 22 | "dist" 23 | ], 24 | "exports": { 25 | ".": { 26 | "types": "./dist/index.d.ts", 27 | "import": "./dist/index.js" 28 | }, 29 | "./types": { 30 | "types": "./dist/types.d.ts", 31 | "import": "./dist/types.js" 32 | }, 33 | "./misc": { 34 | "types": "./dist/misc.d.ts", 35 | "import": "./dist/misc.js" 36 | }, 37 | "./server": { 38 | "types": "./dist/server/index.d.ts", 39 | "import": "./dist/server/index.js" 40 | }, 41 | "./client": { 42 | "types": "./dist/client/index.d.ts", 43 | "import": "./dist/client/index.js" 44 | }, 45 | "./utils/*": { 46 | "types": "./dist/utils/*.d.ts", 47 | "import": "./dist/utils/*.js" 48 | }, 49 | "./vite": { 50 | "types": "./dist/vite/index.d.ts", 51 | "import": "./dist/vite/index.js" 52 | }, 53 | "./default": { 54 | "types": "./dist/default/index.d.ts", 55 | "import": "./dist/default/index.js" 56 | } 57 | }, 58 | "typesVersions": { 59 | "*": { 60 | "types": [ 61 | "./dist/types" 62 | ], 63 | "server": [ 64 | "./dist/server" 65 | ], 66 | "misc": [ 67 | "./dist/misc" 68 | ], 69 | "client": [ 70 | "./dist/client" 71 | ], 72 | "utils/*": [ 73 | "./dist/utils/*" 74 | ], 75 | "vite": [ 76 | "./dist/vite" 77 | ], 78 | "default": [ 79 | "./dist/default" 80 | ] 81 | } 82 | }, 83 | "author": "Yusuke Wada (https://github.com/yusukebe)", 84 | "license": "MIT", 85 | "repository": { 86 | "type": "git", 87 | "url": "https://github.com/sonikjs/sonik.git" 88 | }, 89 | "publishConfig": { 90 | "registry": "https://registry.npmjs.org", 91 | "access": "public" 92 | }, 93 | "homepage": "https://github.com/sonikjs/sonik", 94 | "dependencies": { 95 | "@babel/generator": "^7.22.9", 96 | "@babel/parser": "^7.22.7", 97 | "@babel/traverse": "^7.22.8", 98 | "@babel/types": "^7.22.5", 99 | "@hono/vite-dev-server": "^0.0.10" 100 | }, 101 | "peerDependencies": { 102 | "hono": "3.x" 103 | }, 104 | "devDependencies": { 105 | "@hono/eslint-config": "^0.0.3", 106 | "@mdx-js/rollup": "^2.3.0", 107 | "@playwright/test": "^1.37.0", 108 | "@types/babel__core": "^7.20.1", 109 | "@types/babel__generator": "^7.6.4", 110 | "@types/babel__traverse": "^7.20.1", 111 | "@types/glob": "^8.1.0", 112 | "esbuild": "^0.19.2", 113 | "eslint": "^8.55.0", 114 | "glob": "^10.3.10", 115 | "hono": "^3.11.7", 116 | "jsdom": "^22.1.0", 117 | "np": "^7.7.0", 118 | "npm-run-all": "^4.1.5", 119 | "publint": "^0.1.12", 120 | "rimraf": "^5.0.1", 121 | "tsup": "^7.2.0", 122 | "typescript": "^5.3.3", 123 | "vite": "^5.0.8", 124 | "vitest": "^1.0.4" 125 | }, 126 | "engines": { 127 | "node": ">=18.14.1" 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /src/client/client.ts: -------------------------------------------------------------------------------- 1 | import { COMPONENT_NAME, DATA_SERIALIZED_PROPS } from '../constants.js' 2 | import type { CreateElement, Hydrate } from '../types.js' 3 | 4 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 5 | type FileCallback = () => Promise<{ default: Promise }> 6 | 7 | export type ClientOptions = { 8 | hydrate: Hydrate 9 | createElement: CreateElement 10 | ISLAND_FILES?: Record Promise> 11 | island_root?: string 12 | } 13 | 14 | export const createClient = async (options: ClientOptions) => { 15 | const FILES = options.ISLAND_FILES ?? import.meta.glob('/app/islands/**/[a-zA-Z0-9[-]+.(tsx|ts)') 16 | const root = options.island_root ?? '/app/islands/' 17 | 18 | const hydrateComponent = async () => { 19 | const filePromises = Object.keys(FILES).map(async (filePath) => { 20 | const componentName = filePath.replace(root, '') 21 | const elements = document.querySelectorAll(`[${COMPONENT_NAME}="${componentName}"]`) 22 | if (elements) { 23 | const elementPromises = Array.from(elements).map(async (element) => { 24 | const fileCallback = FILES[filePath] as FileCallback 25 | const file = await fileCallback() 26 | const Component = await file.default 27 | 28 | const serializedProps = element.attributes.getNamedItem(DATA_SERIALIZED_PROPS)?.value 29 | const props = JSON.parse(serializedProps ?? '{}') as Record 30 | 31 | const hydrate = options.hydrate 32 | const createElement = options.createElement 33 | 34 | const newElem = createElement(Component, props) 35 | hydrate(newElem, element) 36 | }) 37 | await Promise.all(elementPromises) 38 | } 39 | }) 40 | 41 | await Promise.all(filePromises) 42 | } 43 | 44 | await hydrateComponent() 45 | } 46 | -------------------------------------------------------------------------------- /src/client/index.ts: -------------------------------------------------------------------------------- 1 | export { createClient } from './client.js' 2 | export type { ClientOptions } from './client.js' 3 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const COMPONENT_NAME = 'component-name' 2 | export const DATA_SERIALIZED_PROPS = 'data-serialized-props' 3 | -------------------------------------------------------------------------------- /src/default/index.ts: -------------------------------------------------------------------------------- 1 | export * from './server.js' 2 | import type { Head } from '../types.js' 3 | import type { Node } from './server.js' 4 | 5 | declare module 'hono' { 6 | interface ContextRenderer { 7 | (content: Node, head?: Partial>): 8 | | Response 9 | | Promise 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/default/server.ts: -------------------------------------------------------------------------------- 1 | import type { Env } from 'hono' 2 | import { jsx, Fragment } from 'hono/jsx' 3 | import { createApp as baseCreateApp } from '../server/server.js' 4 | import type { ServerOptions } from '../server/server.js' 5 | import type * as types from '../types.js' 6 | 7 | export type Node = JSX.Element 8 | 9 | export const createApp = ( 10 | options?: Omit< 11 | ServerOptions, 12 | 'renderToString' | 'renderToReadableStream' | 'createElement' | 'fragment' 13 | > 14 | ) => { 15 | return baseCreateApp({ 16 | renderToString: (node: Node) => node.toString(), 17 | createElement: jsx, 18 | fragment: Fragment, 19 | ...options, 20 | }) 21 | } 22 | 23 | export type NotFoundHandler = types.NotFoundHandler 24 | export type ErrorHandler = types.ErrorHandler 25 | export type LayoutHandler = types.LayoutHandler 26 | export type FC = types.FC 27 | export type FH = types.FH 28 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export type { Context, Env } from 'hono' 2 | export * from './misc.js' 3 | export * from './types.js' 4 | -------------------------------------------------------------------------------- /src/misc.ts: -------------------------------------------------------------------------------- 1 | import type { Env } from 'hono' 2 | import type { AppRoute } from './types' 3 | 4 | export const defineRoute = (appRoute: AppRoute) => { 5 | return { 6 | APP: appRoute, 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/server/head.ts: -------------------------------------------------------------------------------- 1 | import type { Node, CreateElement, FragmentType } from '../types.js' 2 | 3 | type HeadData = { 4 | title?: string 5 | meta?: Record[] 6 | link?: Record[] 7 | } 8 | 9 | export class Head { 10 | title?: string 11 | meta: Record[] = [] 12 | link: Record[] = [] 13 | #createElement: CreateElement 14 | #fragment: FragmentType 15 | 16 | constructor({ 17 | createElement, 18 | fragment, 19 | }: { 20 | createElement: CreateElement 21 | fragment: FragmentType 22 | }) { 23 | this.#createElement = createElement 24 | this.#fragment = fragment 25 | } 26 | 27 | set(data: HeadData) { 28 | this.title = data.title 29 | this.meta = data.meta ?? [] 30 | this.link = data.link ?? [] 31 | } 32 | 33 | createTags(): N { 34 | return this.#createElement( 35 | this.#fragment, 36 | {}, 37 | this.title ? this.#createElement('title', {}, this.title) : null, 38 | this.meta ? this.meta.map((attr) => this.#createElement('meta', attr)) : null, 39 | this.link ? this.link.map((attr) => this.#createElement('link', attr)) : null 40 | ) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/server/index.ts: -------------------------------------------------------------------------------- 1 | export * from './server.js' 2 | export * from './head.js' 3 | -------------------------------------------------------------------------------- /src/server/server.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import type { Env, Context } from 'hono' 3 | import { Hono } from 'hono' 4 | import type { 5 | ErrorHandler, 6 | FH, 7 | Node, 8 | LayoutHandler, 9 | NotFoundHandler, 10 | RenderToString, 11 | RenderToReadableStream, 12 | CreateElement, 13 | FragmentType, 14 | AppRoute, 15 | } from '../types.js' 16 | import { filePathToPath, groupByDirectory, listByDirectory } from '../utils/file.js' 17 | import { Head } from './head.js' 18 | 19 | const NOTFOUND_FILENAME = '_404.tsx' 20 | const ERROR_FILENAME = '_error.tsx' 21 | 22 | export type ServerOptions = { 23 | PRESERVED?: Record 24 | LAYOUTS?: Record 25 | NESTED_LAYOUTS?: Record 26 | ROUTES?: Record 27 | root?: string 28 | renderToString: RenderToString 29 | renderToReadableStream?: RenderToReadableStream 30 | createElement: CreateElement 31 | fragment: FragmentType 32 | createHead?: () => Head 33 | streaming?: boolean 34 | setDefaultRenderer?: boolean 35 | app?: Hono 36 | } 37 | 38 | type RouteFile = { default: FH & Hono; route: { APP: AppRoute } } 39 | type LayoutFile = { default: LayoutHandler } 40 | type PreservedFile = { default: ErrorHandler } 41 | 42 | type ToWebOptions = { 43 | head: Head 44 | layouts?: string[] 45 | nestedLayouts?: string[] 46 | filename: string 47 | } 48 | 49 | const addDocType = (html: string) => { 50 | return `${html}` 51 | } 52 | 53 | const createResponse = async ( 54 | c: Context, 55 | res: string | ReadableStream | Promise 56 | ) => { 57 | if (typeof res === 'string') { 58 | return c.html(res) 59 | } else { 60 | return c.body(await res, undefined, { 61 | 'Transfer-Encoding': 'chunked', 62 | 'X-Content-Type-Options': 'nosniff', 63 | 'Content-Type': 'text/html; charset=utf-8', 64 | }) 65 | } 66 | } 67 | 68 | export const createApp = (options: ServerOptions): Hono => { 69 | const PRESERVED = 70 | options.PRESERVED ?? 71 | import.meta.glob('/app/routes/**/(_error|_404).(ts|tsx)', { 72 | eager: true, 73 | }) 74 | 75 | const preservedMap = groupByDirectory(PRESERVED) 76 | 77 | const LAYOUTS = 78 | options.LAYOUTS ?? 79 | import.meta.glob('/app/routes/**/_layout.tsx', { 80 | eager: true, 81 | }) 82 | 83 | const NESTED_LAYOUTS = 84 | options.NESTED_LAYOUTS ?? 85 | import.meta.glob('/app/routes/**/__layout.tsx', { 86 | eager: true, 87 | }) 88 | 89 | const layoutList = listByDirectory(LAYOUTS) 90 | const nestedLayoutList = listByDirectory(NESTED_LAYOUTS) 91 | 92 | const ROUTES = 93 | options.ROUTES ?? 94 | import.meta.glob('/app/routes/**/[a-z0-9[-][a-z0-9[_-]*.(ts|tsx|mdx)', { 95 | eager: true, 96 | }) 97 | 98 | const routesMap = groupByDirectory(ROUTES) 99 | 100 | const root = options.root ?? '/app/routes' 101 | 102 | const render = 103 | options.streaming && options.renderToReadableStream 104 | ? options.renderToReadableStream 105 | : options.renderToString 106 | 107 | const renderContent = async ( 108 | c: Context, 109 | innerContent: string | Promise | Node | Promise, 110 | { layouts, head, filename, nestedLayouts }: ToWebOptions 111 | ) => { 112 | if (nestedLayouts && nestedLayouts.length) { 113 | nestedLayouts = nestedLayouts.sort((a, b) => { 114 | return b.split('/').length - a.split('/').length 115 | }) 116 | for (const path of nestedLayouts) { 117 | const layout = NESTED_LAYOUTS[path] 118 | if (layout) { 119 | try { 120 | innerContent = await layout.default({ children: innerContent, head, filename, c }) 121 | } catch (e) { 122 | console.trace(e) 123 | } 124 | } 125 | } 126 | } 127 | 128 | let defaultLayout: LayoutFile | undefined 129 | if (layouts && layouts.length) { 130 | layouts = layouts.sort((a, b) => { 131 | return b.split('/').length - a.split('/').length 132 | }) 133 | defaultLayout = LAYOUTS[layouts[0]] 134 | } 135 | 136 | defaultLayout ??= LAYOUTS[root + '/_layout.tsx'] 137 | 138 | if (defaultLayout) { 139 | try { 140 | innerContent = await defaultLayout.default({ children: innerContent, head, filename, c }) 141 | } catch (e) { 142 | console.trace(e) 143 | } 144 | } 145 | 146 | const content = await render(innerContent) 147 | 148 | if (typeof content === 'string') { 149 | if (defaultLayout || layouts?.length) { 150 | return addDocType(content) 151 | } 152 | return content 153 | } 154 | return content 155 | } 156 | 157 | const app = options.app ?? new Hono() 158 | 159 | if (options.setDefaultRenderer === true) { 160 | app.use('*', async (c, next) => { 161 | c.setRenderer(async (node) => { 162 | return createResponse(c, await render(node)) 163 | }) 164 | await next() 165 | }) 166 | } 167 | 168 | for (const [dir, content] of Object.entries(routesMap)) { 169 | const subApp = new Hono() 170 | 171 | let layouts = layoutList[dir] 172 | let nestedLayouts = nestedLayoutList[dir] 173 | 174 | const dirPaths = dir.split('/') 175 | 176 | if (!layouts) { 177 | const getLayoutPaths = (paths: string[]) => { 178 | layouts = layoutList[paths.join('/')] 179 | if (!layouts) { 180 | paths.pop() 181 | if (paths.length) { 182 | getLayoutPaths(paths) 183 | } 184 | } 185 | } 186 | getLayoutPaths(dirPaths) 187 | } 188 | 189 | if (!nestedLayouts) { 190 | const getLayoutPaths = (paths: string[]) => { 191 | nestedLayouts = nestedLayoutList[paths.join('/')] 192 | if (!nestedLayouts) { 193 | paths.pop() 194 | if (paths.length) { 195 | getLayoutPaths(paths) 196 | } 197 | } 198 | } 199 | getLayoutPaths(dirPaths) 200 | } 201 | 202 | const regExp = new RegExp(`^${root}`) 203 | let rootPath = dir.replace(regExp, '') 204 | rootPath = filePathToPath(rootPath) 205 | 206 | for (const [filename, route] of Object.entries(content)) { 207 | const routeDefault = route.default 208 | 209 | const path = filePathToPath(filename) 210 | 211 | // Instance of Hono 212 | if (routeDefault && 'fetch' in routeDefault) { 213 | subApp.route(path, routeDefault) 214 | continue 215 | } 216 | 217 | // Create an instance of Head 218 | let head: Head 219 | if (options.createHead) { 220 | head = options.createHead() 221 | } else { 222 | head = new Head({ 223 | createElement: options.createElement, 224 | fragment: options.fragment, 225 | }) 226 | } 227 | 228 | // Options for the renderContent() 229 | const renderOptions = { 230 | layouts, 231 | nestedLayouts, 232 | head, 233 | filename, 234 | } 235 | 236 | // Set a renderer 237 | if (layouts && layouts.length) { 238 | subApp.use('*', async (c, next) => { 239 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 240 | // @ts-ignore 241 | c.setRenderer(async (node, headProps) => { 242 | if (headProps) head.set(headProps) 243 | const content = await renderContent(c, node, renderOptions) 244 | return createResponse(c, content) 245 | }) 246 | await next() 247 | }) 248 | } 249 | 250 | // Function Handler 251 | if (routeDefault && typeof routeDefault === 'function') { 252 | subApp.get(path, async (c) => { 253 | const innerContent = await (routeDefault as FH)(c, { head }) 254 | if (innerContent instanceof Response) return innerContent 255 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 256 | // @ts-ignore 257 | return c.render(innerContent, head) 258 | }) 259 | } 260 | 261 | // export const route {} satisfies { APP: AppRoute } 262 | const appRoute = route.route 263 | if (!appRoute) continue 264 | 265 | appRoute['APP'](subApp.use(path)) 266 | 267 | for (const [preservedDir, content] of Object.entries(preservedMap)) { 268 | if (dir !== root && dir === preservedDir) { 269 | const notFound = content[NOTFOUND_FILENAME] 270 | if (notFound) { 271 | const notFoundHandler = notFound.default as NotFoundHandler 272 | 273 | subApp.get('*', async (c) => { 274 | const content = await renderContent(c, notFoundHandler(c, { head }), renderOptions) 275 | c.status(404) 276 | return createResponse(c, content) 277 | }) 278 | } 279 | const error = content[ERROR_FILENAME] 280 | if (error) { 281 | const errorHandler = error.default as ErrorHandler 282 | subApp.onError((error, c) => { 283 | c.status(500) 284 | return (async () => { 285 | const content = await renderContent( 286 | c, 287 | errorHandler(c, { error, head }), 288 | renderOptions 289 | ) 290 | c.status(500) 291 | return createResponse(c, content) 292 | })() 293 | }) 294 | } 295 | } 296 | } 297 | } 298 | app.route(rootPath, subApp) 299 | } 300 | 301 | let head: Head 302 | if (options.createHead) { 303 | head = options.createHead() 304 | } else { 305 | head = new Head({ 306 | createElement: options.createElement, 307 | fragment: options.fragment, 308 | }) 309 | } 310 | 311 | if (preservedMap[root]) { 312 | const defaultNotFound = preservedMap[root][NOTFOUND_FILENAME] 313 | if (defaultNotFound) { 314 | const notFoundHandler = defaultNotFound.default as unknown as NotFoundHandler 315 | app.notFound(async (c) => { 316 | const content = await renderContent(c, notFoundHandler(c, { head }), { 317 | head, 318 | filename: NOTFOUND_FILENAME, 319 | }) 320 | c.status(404) 321 | return createResponse(c, content) 322 | }) 323 | } 324 | 325 | const defaultError = preservedMap[root][ERROR_FILENAME] 326 | if (defaultError) { 327 | const errorHandler = defaultError.default as unknown as ErrorHandler 328 | app.onError((error, c) => { 329 | return (async () => { 330 | const content = await renderContent(c, errorHandler(c, { error, head }), { 331 | head, 332 | filename: ERROR_FILENAME, 333 | }) 334 | c.status(500) 335 | return createResponse(c, content) 336 | })() 337 | }) 338 | } 339 | } 340 | 341 | return app as unknown as Hono 342 | } 343 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | export type { Head } 3 | export type { Hono, Context } from 'hono' 4 | import type { Context, Env, Hono, Next } from 'hono' 5 | import type { Head } from './server/head.js' 6 | 7 | /** Internal */ 8 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 9 | export type Node = any 10 | export type HandlerResponse = 11 | | N 12 | | Promise 13 | | Response 14 | | Promise 15 | | Promise 16 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 17 | export type AppHandler = ( 18 | app: Hono, 19 | props: { 20 | head: Head 21 | render: (node: N, status?: number) => Response | Promise 22 | } 23 | ) => void 24 | export type ReservedHandler = Handler | ErrorHandler | LayoutHandler 25 | export type Methods = 'GET' | 'POST' | 'PUT' | 'DELETE' 26 | 27 | /** JSX */ 28 | export type CreateElement = (type: any, props: any, ...children: any[]) => Node 29 | export type FragmentType = any 30 | export type RenderToString = (node: N) => string | Promise 31 | export type RenderToReadableStream = (node: N, options?: any) => Promise 32 | export type Hydrate = (children: Node, parent: Element) => void 33 | 34 | type Handler = ( 35 | c: Context, 36 | props: { 37 | head: Head 38 | next: Next 39 | } 40 | ) => HandlerResponse 41 | 42 | /** Preserved */ 43 | export type NotFoundHandler = ( 44 | c: Context, 45 | props: { head: Head } 46 | ) => HandlerResponse 47 | 48 | export type ErrorHandler = ( 49 | c: Context, 50 | props: { error: Error; head: Head } 51 | ) => HandlerResponse 52 | 53 | export type LayoutHandler = (props: { 54 | children: N | string 55 | head: Head 56 | filename: string 57 | c: LayoutContext 58 | }) => N | string | Promise 59 | 60 | /** Function Handler */ 61 | export type FH = (c: Context, props: { head: Head }) => N 62 | 63 | /** Function Component */ 64 | export type FC = (props: Props & { children: N }) => N 65 | 66 | /** Route */ 67 | export type Route = { 68 | APP: AppHandler 69 | } 70 | 71 | export type AppRoute = (app: Hono) => void 72 | 73 | export type LayoutContext = Omit 74 | -------------------------------------------------------------------------------- /src/utils/file.ts: -------------------------------------------------------------------------------- 1 | export const filePathToPath = (filePath: string) => { 2 | filePath = filePath 3 | .replace(/\.tsx?$/g, '') 4 | .replace(/\.mdx$/g, '') 5 | .replace(/^\/?index/, '/') // `/index` 6 | .replace(/\/index/, '') // `/about/index` 7 | .replace(/\[\.{3}.+\]/, '*') 8 | .replace(/\[(.+)\]/, ':$1') 9 | return /^\//.test(filePath) ? filePath : '/' + filePath 10 | } 11 | 12 | /* 13 | /app/routes/_error.tsx 14 | /app/routes/_404.tsx 15 | => { 16 | '/app/routes': { 17 | '/app/routes/_error.tsx': file, 18 | '/app/routes/_404.tsx': file 19 | } 20 | ... 21 | } 22 | */ 23 | export const groupByDirectory = (files: Record) => { 24 | const organizedFiles = {} as Record> 25 | 26 | for (const [path, content] of Object.entries(files)) { 27 | const pathParts = path.split('/') 28 | const fileName = pathParts.pop() 29 | const directory = pathParts.join('/') 30 | 31 | if (!organizedFiles[directory]) { 32 | organizedFiles[directory] = {} 33 | } 34 | 35 | if (fileName) { 36 | organizedFiles[directory][fileName] = content 37 | } 38 | } 39 | 40 | // Sort the files in each directory 41 | for (const [directory, files] of Object.entries(organizedFiles)) { 42 | const sortedEntries = Object.entries(files).sort(([keyA], [keyB]) => { 43 | if (keyA[0] === '[' && keyB[0] !== '[') { 44 | return 1 45 | } 46 | if (keyA[0] !== '[' && keyB[0] === '[') { 47 | return -1 48 | } 49 | return keyA.localeCompare(keyB) 50 | }) 51 | 52 | organizedFiles[directory] = Object.fromEntries(sortedEntries) 53 | } 54 | 55 | return organizedFiles 56 | } 57 | 58 | /* 59 | /app/routes/_layout.tsx 60 | /app/routes/blog/_layout.tsx 61 | => { 62 | '/app/routes': ['/app/routes/_layout.tsx'] 63 | '/app/routes/blog': ['/app/routes/blog/_layout.tsx', '/app/routes/_layout.tsx'] 64 | } 65 | */ 66 | export const listByDirectory = (files: Record) => { 67 | const organizedFiles = {} as Record 68 | 69 | for (const path of Object.keys(files)) { 70 | const pathParts = path.split('/') 71 | pathParts.pop() // extract file 72 | const directory = pathParts.join('/') 73 | 74 | if (!organizedFiles[directory]) { 75 | organizedFiles[directory] = [] 76 | } 77 | if (!organizedFiles[directory].includes(path)) { 78 | organizedFiles[directory].push(path) 79 | } 80 | } 81 | 82 | const directories = Object.keys(organizedFiles).sort((a, b) => a.length - b.length) 83 | for (const dir of directories) { 84 | for (const subDir of directories) { 85 | if (subDir.startsWith(dir) && subDir !== dir) { 86 | const uniqueFiles = new Set([...organizedFiles[dir], ...organizedFiles[subDir]]) 87 | organizedFiles[subDir] = [...uniqueFiles] 88 | } 89 | } 90 | } 91 | 92 | return organizedFiles 93 | } 94 | -------------------------------------------------------------------------------- /src/utils/html.ts: -------------------------------------------------------------------------------- 1 | const escapeRe = /[&<>'"]/ 2 | 3 | export const escape = (str: string): string => { 4 | const match = str.search(escapeRe) 5 | if (match === -1) { 6 | return str 7 | } 8 | 9 | let res = '' 10 | 11 | let escape 12 | let index 13 | let lastIndex = 0 14 | 15 | for (index = match; index < str.length; index++) { 16 | switch (str.charCodeAt(index)) { 17 | case 34: // " 18 | escape = '"' 19 | break 20 | case 39: // ' 21 | escape = ''' 22 | break 23 | case 38: // & 24 | escape = '&' 25 | break 26 | case 60: // < 27 | escape = '<' 28 | break 29 | case 62: // > 30 | escape = '>' 31 | break 32 | default: 33 | continue 34 | } 35 | 36 | res += str.substring(lastIndex, index) + escape 37 | lastIndex = index + 1 38 | } 39 | 40 | res += str.substring(lastIndex, index) 41 | return res 42 | } 43 | 44 | export function createTagString(name: string, records: Record[]): string { 45 | const result: string[] = [] 46 | if (records.length > 0) { 47 | for (const record of records) { 48 | let str: string = `<${name} ` 49 | for (const [k, v] of Object.entries(record)) { 50 | str += `${escape(k)}="${escape(v)}" ` 51 | } 52 | str += '/>' 53 | result.push(str) 54 | } 55 | } 56 | return result.join('') 57 | } 58 | -------------------------------------------------------------------------------- /src/vite/index.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line node/no-extraneous-import 2 | import path from 'path' 3 | import devServer, { defaultOptions } from '@hono/vite-dev-server' 4 | import type { DevServerOptions } from '@hono/vite-dev-server' 5 | import type { PluginOption } from 'vite' 6 | import { islandComponents } from './island-components.js' 7 | import { minifyEs } from './minify-es.js' 8 | 9 | type SonikOptions = { 10 | minify?: boolean 11 | islands?: boolean 12 | entry?: string 13 | devServer?: DevServerOptions 14 | } 15 | 16 | function sonik(options?: SonikOptions): PluginOption[] { 17 | const plugins: PluginOption[] = [] 18 | 19 | const defaultEntryPath = path.join(process.cwd(), './app/server.ts') 20 | 21 | plugins.push( 22 | devServer({ 23 | entry: options?.entry ?? defaultEntryPath, 24 | exclude: [...defaultOptions.exclude, '^/app/.+', '^/favicon.ico', '^/static/.+'], 25 | ...options?.devServer, 26 | }) 27 | ) 28 | 29 | if (options?.minify === true) { 30 | plugins.push(minifyEs()) 31 | } 32 | if (options?.islands !== false) { 33 | plugins.push(islandComponents()) 34 | } 35 | 36 | return plugins 37 | } 38 | 39 | export const devServerDefaultOptions = defaultOptions 40 | 41 | export default sonik 42 | -------------------------------------------------------------------------------- /src/vite/island-components.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs/promises' 2 | import _generate from '@babel/generator' 3 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 4 | // @ts-ignore 5 | const generate = (_generate.default as typeof _generate) ?? _generate 6 | import { parse } from '@babel/parser' 7 | import _traverse from '@babel/traverse' 8 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 9 | // @ts-ignore 10 | const traverse = (_traverse.default as typeof _traverse) ?? _traverse 11 | import { 12 | identifier, 13 | jsxAttribute, 14 | jsxClosingElement, 15 | jsxElement, 16 | jsxIdentifier, 17 | jsxOpeningElement, 18 | stringLiteral, 19 | callExpression, 20 | variableDeclarator, 21 | variableDeclaration, 22 | functionExpression, 23 | blockStatement, 24 | returnStatement, 25 | jsxSpreadAttribute, 26 | jsxExpressionContainer, 27 | exportDefaultDeclaration, 28 | conditionalExpression, 29 | memberExpression, 30 | } from '@babel/types' 31 | // eslint-disable-next-line node/no-extraneous-import 32 | import type { Plugin } from 'vite' 33 | import { COMPONENT_NAME, DATA_SERIALIZED_PROPS } from '../constants.js' 34 | 35 | function addSSRCheck(funcName: string, componentName: string) { 36 | const isSSR = memberExpression( 37 | memberExpression(identifier('import'), identifier('meta')), 38 | identifier('env.SSR') 39 | ) 40 | 41 | const serializedProps = callExpression(identifier('JSON.stringify'), [identifier('props')]) 42 | 43 | const ssrElement = jsxElement( 44 | jsxOpeningElement( 45 | jsxIdentifier('div'), 46 | [ 47 | jsxAttribute(jsxIdentifier(COMPONENT_NAME), stringLiteral(componentName)), 48 | jsxAttribute(jsxIdentifier(DATA_SERIALIZED_PROPS), jsxExpressionContainer(serializedProps)), 49 | ], 50 | false 51 | ), 52 | jsxClosingElement(jsxIdentifier('div')), 53 | [ 54 | jsxElement( 55 | jsxOpeningElement( 56 | jsxIdentifier(funcName), 57 | [jsxSpreadAttribute(identifier('props'))], 58 | false 59 | ), 60 | jsxClosingElement(jsxIdentifier(funcName)), 61 | [] 62 | ), 63 | ] 64 | ) 65 | 66 | const clientElement = jsxElement( 67 | jsxOpeningElement(jsxIdentifier(funcName), [jsxSpreadAttribute(identifier('props'))], false), 68 | jsxClosingElement(jsxIdentifier(funcName)), 69 | [] 70 | ) 71 | 72 | const returnStmt = returnStatement(conditionalExpression(isSSR, ssrElement, clientElement)) 73 | return functionExpression(null, [identifier('props')], blockStatement([returnStmt])) 74 | } 75 | 76 | export const transformJsxTags = (contents: string, componentName: string) => { 77 | const ast = parse(contents, { 78 | sourceType: 'module', 79 | plugins: ['typescript', 'jsx'], 80 | }) 81 | 82 | if (ast) { 83 | traverse(ast, { 84 | ExportDefaultDeclaration(path) { 85 | if (path.node.declaration.type === 'FunctionDeclaration') { 86 | const functionId = path.node.declaration.id 87 | if (!functionId) return 88 | const originalFunctionId = identifier(functionId.name + 'Original') 89 | 90 | path.insertBefore( 91 | variableDeclaration('const', [ 92 | variableDeclarator( 93 | originalFunctionId, 94 | functionExpression(null, path.node.declaration.params, path.node.declaration.body) 95 | ), 96 | ]) 97 | ) 98 | 99 | const wrappedFunction = addSSRCheck(originalFunctionId.name, componentName) 100 | const wrappedFunctionId = identifier('Wrapped' + functionId.name) 101 | path.replaceWith( 102 | variableDeclaration('const', [variableDeclarator(wrappedFunctionId, wrappedFunction)]) 103 | ) 104 | path.insertAfter(exportDefaultDeclaration(wrappedFunctionId)) 105 | } 106 | }, 107 | }) 108 | 109 | const { code } = generate(ast) 110 | return code 111 | } 112 | } 113 | 114 | export function islandComponents(): Plugin { 115 | return { 116 | name: 'transform-island-components', 117 | async load(id) { 118 | const match = id.match(/\/islands\/(.+?\.tsx)$/) 119 | if (match) { 120 | const componentName = match[1] 121 | const contents = await fs.readFile(id, 'utf-8') 122 | const code = transformJsxTags(contents, componentName) 123 | if (code) { 124 | return { 125 | code, 126 | map: null, 127 | } 128 | } 129 | } 130 | }, 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /src/vite/minify-es.ts: -------------------------------------------------------------------------------- 1 | import { transform } from 'esbuild' 2 | import type { Plugin } from 'vite' 3 | 4 | export function minifyEs(): Plugin { 5 | return { 6 | name: 'minify-es', 7 | renderChunk: { 8 | order: 'post', 9 | async handler(code, _, outputOptions) { 10 | if (outputOptions.format === 'es') { 11 | return await transform(code, { minify: true }) 12 | } 13 | return code 14 | }, 15 | }, 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /test/api/app/components/Badge.tsx: -------------------------------------------------------------------------------- 1 | export default function Badge({ name }: { name: string }) { 2 | return My name is {name} 3 | } 4 | -------------------------------------------------------------------------------- /test/api/app/routes/about/[name].ts: -------------------------------------------------------------------------------- 1 | import { Hono } from 'hono' 2 | 3 | const app = new Hono() 4 | 5 | app.get('/', (c) => { 6 | const name = c.req.param<'/:name'>('name') 7 | return c.json({ 8 | path: `/about/${name}`, 9 | }) 10 | }) 11 | 12 | app.get('/address', (c) => { 13 | const name = c.req.param<'/:name'>('name') 14 | return c.json({ 15 | path: `/about/${name}/address`, 16 | }) 17 | }) 18 | 19 | export default app 20 | -------------------------------------------------------------------------------- /test/api/app/routes/index.ts: -------------------------------------------------------------------------------- 1 | import { Hono } from 'hono' 2 | 3 | const app = new Hono() 4 | 5 | app.get('/', (c) => 6 | c.json({ 7 | path: '/', 8 | }) 9 | ) 10 | 11 | app.get('/foo', (c) => 12 | c.json({ 13 | path: '/foo', 14 | }) 15 | ) 16 | 17 | export default app 18 | -------------------------------------------------------------------------------- /test/api/app/routes/middleware/index.ts: -------------------------------------------------------------------------------- 1 | import { Hono } from 'hono' 2 | import { poweredBy } from 'hono/powered-by' 3 | 4 | const app = new Hono() 5 | 6 | app.use('*', poweredBy()) 7 | 8 | app.get('/', (c) => 9 | c.json({ 10 | path: '/middleware', 11 | }) 12 | ) 13 | 14 | app.get('/foo', (c) => 15 | c.json({ 16 | path: '/middleware/foo', 17 | }) 18 | ) 19 | 20 | export default app 21 | -------------------------------------------------------------------------------- /test/api/integration.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest' 2 | import { createApp } from '../../src/default' 3 | 4 | describe('Basic', () => { 5 | const ROUTES = import.meta.glob('./app/routes/**/[a-z[-][a-z[_-]*.ts', { 6 | eager: true, 7 | }) 8 | 9 | const app = createApp({ 10 | root: './app/routes', 11 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 12 | ROUTES: ROUTES as any, 13 | }) 14 | 15 | it('Should have correct routes', () => { 16 | const routes = [ 17 | { 18 | path: '/about/:name', 19 | method: 'GET', 20 | handler: expect.anything(), 21 | }, 22 | { 23 | path: '/about/:name/address', 24 | method: 'GET', 25 | handler: expect.anything(), 26 | }, 27 | { path: '/', method: 'GET', handler: expect.anything() }, 28 | { path: '/foo', method: 'GET', handler: expect.anything() }, 29 | { 30 | path: '/middleware/*', 31 | method: 'ALL', 32 | handler: expect.anything(), 33 | }, 34 | { 35 | path: '/middleware', 36 | method: 'GET', 37 | handler: expect.anything(), 38 | }, 39 | { 40 | path: '/middleware/foo', 41 | method: 'GET', 42 | handler: expect.anything(), 43 | }, 44 | ] 45 | expect(app.routes).toEqual(routes) 46 | }) 47 | 48 | it('Should return 200 response - /', async () => { 49 | const res = await app.request('/') 50 | expect(res.status).toBe(200) 51 | expect(await res.json()).toEqual({ 52 | path: '/', 53 | }) 54 | }) 55 | 56 | it('Should return 200 response - /foo', async () => { 57 | const res = await app.request('/foo') 58 | expect(res.status).toBe(200) 59 | expect(await res.json()).toEqual({ 60 | path: '/foo', 61 | }) 62 | }) 63 | 64 | it('Should return 404 response - /bar', async () => { 65 | const res = await app.request('/bar') 66 | expect(res.status).toBe(404) 67 | }) 68 | 69 | it('Should return 200 response /about/me', async () => { 70 | const res = await app.request('/about/me') 71 | expect(res.status).toBe(200) 72 | expect(await res.json()).toEqual({ 73 | path: '/about/me', 74 | }) 75 | }) 76 | 77 | it('Should return 200 response /about/me/address', async () => { 78 | const res = await app.request('/about/me/address') 79 | expect(res.status).toBe(200) 80 | expect(await res.json()).toEqual({ 81 | path: '/about/me/address', 82 | }) 83 | }) 84 | 85 | it('Should return 200 with header values /middleware', async () => { 86 | const res = await app.request('/middleware') 87 | expect(res.status).toBe(200) 88 | expect(res.headers.get('x-powered-by')).toBe('Hono') 89 | expect(await res.json()).toEqual({ 90 | path: '/middleware', 91 | }) 92 | }) 93 | 94 | it('Should return 200 with header values /middleware/foo', async () => { 95 | const res = await app.request('/middleware/foo') 96 | expect(res.status).toBe(200) 97 | expect(res.headers.get('x-powered-by')).toBe('Hono') 98 | expect(await res.json()).toEqual({ 99 | path: '/middleware/foo', 100 | }) 101 | }) 102 | }) 103 | -------------------------------------------------------------------------------- /test/api/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import { islandComponents } from '../../src/vite/island-components' 3 | 4 | export default defineConfig({ 5 | plugins: [islandComponents()], 6 | }) 7 | -------------------------------------------------------------------------------- /test/hono-jsx/app/components/Badge.tsx: -------------------------------------------------------------------------------- 1 | export default function Badge({ name }: { name: string }) { 2 | return My name is {name} 3 | } 4 | -------------------------------------------------------------------------------- /test/hono-jsx/app/routes/_404.tsx: -------------------------------------------------------------------------------- 1 | import type { NotFoundHandler } from '../../../../src' 2 | 3 | const handler: NotFoundHandler = () => { 4 | return

Not Found

5 | } 6 | 7 | export default handler 8 | -------------------------------------------------------------------------------- /test/hono-jsx/app/routes/_error.tsx: -------------------------------------------------------------------------------- 1 | import type { ErrorHandler } from '../../../../src' 2 | 3 | const handler: ErrorHandler = (_, { error }) => { 4 | return

Custom Error Message: {error.message}

5 | } 6 | 7 | export default handler 8 | -------------------------------------------------------------------------------- /test/hono-jsx/app/routes/_layout.tsx: -------------------------------------------------------------------------------- 1 | import type { LayoutHandler } from '../../../../src' 2 | 3 | const handler: LayoutHandler = ({ children, head }) => { 4 | return ( 5 | 6 | {head.createTags()} 7 | {children} 8 | 9 | ) 10 | } 11 | 12 | export default handler 13 | -------------------------------------------------------------------------------- /test/hono-jsx/app/routes/about/[name].tsx: -------------------------------------------------------------------------------- 1 | import type { Context } from '../../../../../src' 2 | import { defineRoute } from '../../../../../src' 3 | import Badge from '../../components/Badge' 4 | 5 | export const route = defineRoute((app) => { 6 | app.post((c) => { 7 | return c.text('Created!', 201) 8 | }) 9 | }) 10 | 11 | export default function AboutName(c: Context) { 12 | const { name } = c.req.param() 13 | return c.render( 14 | <> 15 |

It's {name}

16 | 17 | , 18 | { 19 | title: name, 20 | } 21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /test/hono-jsx/app/routes/about/[name]/__layout.tsx: -------------------------------------------------------------------------------- 1 | import type { LayoutHandler } from '../../../../../../src' 2 | 3 | const handler: LayoutHandler = ({ children }) => { 4 | return
{children}
5 | } 6 | 7 | export default handler 8 | -------------------------------------------------------------------------------- /test/hono-jsx/app/routes/about/[name]/address.tsx: -------------------------------------------------------------------------------- 1 | import type { Context } from '../../../../../../src' 2 | 3 | export default function Address(c: Context) { 4 | const { name } = c.req.param() 5 | return c.render({name}'s address, { 6 | title: `${name}'s address`, 7 | }) 8 | } 9 | -------------------------------------------------------------------------------- /test/hono-jsx/app/routes/about/_layout.tsx: -------------------------------------------------------------------------------- 1 | import type { LayoutHandler } from '../../../../../src' 2 | 3 | const handler: LayoutHandler = ({ children, head }) => { 4 | return ( 5 | 6 | 7 | {head.createTags()} 8 | 9 | 10 |

About

11 | {children} 12 | 13 | 14 | ) 15 | } 16 | 17 | export default handler 18 | -------------------------------------------------------------------------------- /test/hono-jsx/app/routes/api.tsx: -------------------------------------------------------------------------------- 1 | import { defineRoute } from '../../../../src' 2 | 3 | export const route = defineRoute((app) => { 4 | app.get((c) => { 5 | c.header('X-Custom', 'Hello') 6 | return c.json({ 7 | foo: 'bar', 8 | }) 9 | }) 10 | app.post((c) => { 11 | return c.json( 12 | { 13 | message: 'created', 14 | ok: true, 15 | }, 16 | 201 17 | ) 18 | }) 19 | }) 20 | -------------------------------------------------------------------------------- /test/hono-jsx/app/routes/index.tsx: -------------------------------------------------------------------------------- 1 | import { defineRoute } from '../../../../src' 2 | 3 | export const route = defineRoute((app) => { 4 | app.get((c) => { 5 | return c.render(

Hello

, { 6 | title: 'This is a title', 7 | meta: [{ name: 'description', content: 'This is a description' }], 8 | }) 9 | }) 10 | }) 11 | -------------------------------------------------------------------------------- /test/hono-jsx/app/routes/page.tsx: -------------------------------------------------------------------------------- 1 | export default function Page() { 2 | return

Function Handler!

3 | } 4 | -------------------------------------------------------------------------------- /test/hono-jsx/app/routes/post.mdx: -------------------------------------------------------------------------------- 1 | ## Hello MDX! -------------------------------------------------------------------------------- /test/hono-jsx/app/routes/throw_error.tsx: -------------------------------------------------------------------------------- 1 | import { defineRoute } from '../../../../src' 2 | 3 | export const route = defineRoute((app) => { 4 | app.get(() => { 5 | throw new Error('Foo') 6 | }) 7 | }) 8 | -------------------------------------------------------------------------------- /test/hono-jsx/integration.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import { describe, expect, it, vi } from 'vitest' 3 | import { createApp } from '../../src/default' 4 | 5 | describe('Basic', () => { 6 | const ROUTES = import.meta.glob('./app/routes/**/[a-z[-][a-z[_-]*.(tsx|ts)', { 7 | eager: true, 8 | }) 9 | 10 | const app = createApp({ 11 | root: './app/routes', 12 | ROUTES: ROUTES as any, 13 | }) 14 | 15 | it('Should have correct routes', () => { 16 | const routes = [ 17 | { 18 | path: '/about/:name', 19 | method: 'GET', 20 | handler: expect.anything(), 21 | }, 22 | { 23 | path: '/about/:name', 24 | method: 'POST', 25 | handler: expect.anything(), 26 | }, 27 | { 28 | path: '/about/:name/address', 29 | method: 'GET', 30 | handler: expect.anything(), 31 | }, 32 | { path: '/api', method: 'GET', handler: expect.anything() }, 33 | { path: '/api', method: 'POST', handler: expect.anything() }, 34 | { path: '/', method: 'GET', handler: expect.anything() }, 35 | { path: '/page', method: 'GET', handler: expect.anything() }, 36 | { 37 | path: '/throw_error', 38 | method: 'GET', 39 | handler: expect.anything(), 40 | }, 41 | ] 42 | expect(app.routes).toEqual(routes) 43 | }) 44 | 45 | it('Should return 200 response - /', async () => { 46 | const res = await app.request('/') 47 | expect(res.status).toBe(200) 48 | expect(await res.text()).toBe('

Hello

') 49 | }) 50 | 51 | it('Should return 404 response - /foo', async () => { 52 | const res = await app.request('/foo') 53 | expect(res.status).toBe(404) 54 | }) 55 | 56 | it('Should return 200 response /about/me', async () => { 57 | const res = await app.request('/about/me') 58 | expect(res.status).toBe(200) 59 | // hono/jsx escape a single quote to ' 60 | expect(await res.text()).toBe('

It's me

My name is me') 61 | }) 62 | 63 | it('Should return 200 response POST /about/me', async () => { 64 | const res = await app.request('/about/me', { 65 | method: 'POST', 66 | }) 67 | expect(res.status).toBe(201) 68 | }) 69 | 70 | it('Should return 200 response /page', async () => { 71 | const res = await app.request('/page') 72 | expect(res.status).toBe(200) 73 | expect(await res.text()).toBe('

Function Handler!

') 74 | }) 75 | 76 | it('Should return 500 response /throw_error', async () => { 77 | global.console.trace = vi.fn() 78 | const res = await app.request('/throw_error') 79 | expect(res.status).toBe(500) 80 | expect(await res.text()).toBe('Internal Server Error') 81 | }) 82 | }) 83 | 84 | describe('With preserved', () => { 85 | const ROUTES = import.meta.glob('./app/routes/**/[a-z[-][a-z-_[]*.(tsx|ts)', { 86 | eager: true, 87 | }) 88 | 89 | const PRESERVED = import.meta.glob('./app/routes/(_error|_404).tsx', { 90 | eager: true, 91 | }) 92 | 93 | const LAYOUTS = import.meta.glob('./app/routes/**/_layout.tsx', { 94 | eager: true, 95 | }) 96 | 97 | const NESTED_LAYOUTS = import.meta.glob('./app/routes/**/__layout.tsx', { 98 | eager: true, 99 | }) 100 | 101 | const app = createApp({ 102 | root: './app/routes', 103 | ROUTES: ROUTES as any, 104 | PRESERVED: PRESERVED as any, 105 | LAYOUTS: LAYOUTS as any, 106 | NESTED_LAYOUTS: NESTED_LAYOUTS as any, 107 | }) 108 | 109 | it('Should return 200 response - /', async () => { 110 | const res = await app.request('/') 111 | expect(res.status).toBe(200) 112 | expect(await res.text()).toBe( 113 | 'This is a title

Hello

' 114 | ) 115 | }) 116 | 117 | it('Should return 404 response - /foo', async () => { 118 | const res = await app.request('/foo') 119 | expect(res.status).toBe(404) 120 | expect(await res.text()).toBe( 121 | '

Not Found

' 122 | ) 123 | }) 124 | 125 | it('Should return 200 response /about/me', async () => { 126 | const res = await app.request('/about/me') 127 | expect(res.status).toBe(200) 128 | // hono/jsx escape a single quote to ' 129 | expect(await res.text()).toBe( 130 | 'me

About

It's me

My name is me' 131 | ) 132 | }) 133 | 134 | it('Should return 200 response /about/me/address', async () => { 135 | const res = await app.request('/about/me/address') 136 | expect(res.status).toBe(200) 137 | // hono/jsx escape a single quote to ' 138 | expect(await res.text()).toBe( 139 | 'me's address

About

me's address
' 140 | ) 141 | }) 142 | 143 | it('Should return 500 response /throw_error', async () => { 144 | const res = await app.request('/throw_error') 145 | expect(res.status).toBe(500) 146 | expect(await res.text()).toBe( 147 | '

Custom Error Message: Foo

' 148 | ) 149 | }) 150 | }) 151 | 152 | describe('API', () => { 153 | const ROUES = import.meta.glob('./app/routes//**/[a-z[-][a-z-_[]*.(tsx|ts)', { 154 | eager: true, 155 | }) 156 | 157 | const app = createApp({ 158 | root: './app/routes', 159 | ROUTES: ROUES as any, 160 | }) 161 | 162 | it('Should return 200 response - /api', async () => { 163 | const res = await app.request('/api') 164 | expect(res.status).toBe(200) 165 | expect(res.headers.get('X-Custom')).toBe('Hello') 166 | expect(await res.json()).toEqual({ foo: 'bar' }) 167 | }) 168 | 169 | it('Should return 200 response - POST /api', async () => { 170 | const res = await app.request('/api', { 171 | method: 'POST', 172 | }) 173 | expect(res.status).toBe(201) 174 | expect(await res.json()).toEqual({ 175 | ok: true, 176 | message: 'created', 177 | }) 178 | }) 179 | }) 180 | 181 | describe('MDX', () => { 182 | const ROUES = import.meta.glob('./app/routes/**/[a-z[-][a-z-_[]*.(tsx|mdx)', { 183 | eager: true, 184 | }) 185 | 186 | const LAYOUTS = import.meta.glob('./app/routes/_layout.tsx', { 187 | eager: true, 188 | }) 189 | 190 | const app = createApp({ 191 | root: './app/routes', 192 | ROUTES: ROUES as any, 193 | LAYOUTS: LAYOUTS as any, 194 | }) 195 | 196 | it('Should return 200 response with MDX', async () => { 197 | const res = await app.request('/post') 198 | expect(res.status).toBe(200) 199 | expect(await res.text()).toBe( 200 | '

Hello MDX!

' 201 | ) 202 | }) 203 | }) 204 | -------------------------------------------------------------------------------- /test/hono-jsx/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import mdx from '@mdx-js/rollup' 2 | import { defineConfig } from 'vite' 3 | import { islandComponents } from '../../src/vite/island-components' 4 | 5 | export default defineConfig({ 6 | plugins: [ 7 | islandComponents(), 8 | { 9 | ...mdx({ 10 | jsxImportSource: 'hono/jsx', 11 | }), 12 | }, 13 | ], 14 | }) 15 | -------------------------------------------------------------------------------- /test/unit/server/head.test.ts: -------------------------------------------------------------------------------- 1 | import { jsx, Fragment } from 'hono/jsx' 2 | import { describe, it, expect, beforeEach } from 'vitest' 3 | import { Head } from '../../../src/server/head' 4 | 5 | describe('Head', () => { 6 | let head: Head 7 | 8 | beforeEach(async () => { 9 | head = new Head({ 10 | createElement: jsx, 11 | fragment: Fragment, 12 | }) 13 | }) 14 | 15 | it('Should create a head tag', () => { 16 | head.title = 'Sonik Blog' 17 | const tags = head.createTags() 18 | expect(tags.toString()).toBe('Sonik Blog') 19 | }) 20 | 21 | it('Should create meta tags', () => { 22 | head.meta = [ 23 | { 24 | name: 'description', 25 | content: 'Sonik is cool', 26 | }, 27 | { 28 | name: 'keywords', 29 | content: 'Framework', 30 | }, 31 | ] 32 | const tags = head.createTags() 33 | expect(tags.toString()).toBe( 34 | '' 35 | ) 36 | }) 37 | 38 | it('Should create link tags', () => { 39 | head.link = [ 40 | { 41 | href: 'main.css', 42 | rel: 'stylesheet', 43 | }, 44 | { 45 | href: 'favicon.ico', 46 | rel: 'icon', 47 | }, 48 | ] 49 | const tags = head.createTags() 50 | expect(tags.toString()).toBe( 51 | '' 52 | ) 53 | }) 54 | 55 | it('Should create head & meta & link tags', () => { 56 | head.set({ 57 | title: 'Sonik Blog', 58 | meta: [ 59 | { 60 | name: 'description', 61 | content: 'Sonik is cool', 62 | }, 63 | { 64 | name: 'keywords', 65 | content: 'Framework', 66 | }, 67 | ], 68 | link: [ 69 | { 70 | href: 'main.css', 71 | rel: 'stylesheet', 72 | }, 73 | { 74 | href: 'favicon.ico', 75 | rel: 'icon', 76 | }, 77 | ], 78 | }) 79 | const tags = head.createTags() 80 | expect(tags.toString()).toBe( 81 | 'Sonik Blog' 82 | ) 83 | }) 84 | }) 85 | -------------------------------------------------------------------------------- /test/unit/utils/file.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest' 2 | import { filePathToPath, groupByDirectory, listByDirectory } from '../../../src/utils/file.js' 3 | 4 | describe('filePathToPath', () => { 5 | it('Should return a correct path', () => { 6 | expect(filePathToPath('index.tsx')).toBe('/') 7 | expect(filePathToPath('about.tsx')).toBe('/about') 8 | expect(filePathToPath('about/index.tsx')).toBe('/about') 9 | expect(filePathToPath('about/me')).toBe('/about/me') 10 | expect(filePathToPath('about/me/index.tsx')).toBe('/about/me') 11 | expect(filePathToPath('about/me/address.tsx')).toBe('/about/me/address') 12 | 13 | expect(filePathToPath('/index.tsx')).toBe('/') 14 | expect(filePathToPath('/about.tsx')).toBe('/about') 15 | expect(filePathToPath('/about/index.tsx')).toBe('/about') 16 | expect(filePathToPath('/about/me')).toBe('/about/me') 17 | expect(filePathToPath('/about/me/index.tsx')).toBe('/about/me') 18 | expect(filePathToPath('/about/me/address.tsx')).toBe('/about/me/address') 19 | 20 | expect(filePathToPath('/about/[name].tsx')).toBe('/about/:name') 21 | expect(filePathToPath('/about/[...foo].tsx')).toBe('/about/*') 22 | expect(filePathToPath('/about/[name]/address.tsx')).toBe('/about/:name/address') 23 | }) 24 | }) 25 | 26 | describe('groupByDirectory', () => { 27 | const files = { 28 | '/app/routes/index.tsx': 'file1', 29 | '/app/routes/about.tsx': 'file2', 30 | '/app/routes/blog/index.tsx': 'file3', 31 | '/app/routes/blog/about.tsx': 'file4', 32 | '/app/routes/blog/posts/index.tsx': 'file5', 33 | '/app/routes/blog/posts/comments.tsx': 'file6', 34 | } 35 | 36 | it('Should group by directories', () => { 37 | expect(groupByDirectory(files)).toEqual({ 38 | '/app/routes': { 39 | 'index.tsx': 'file1', 40 | 'about.tsx': 'file2', 41 | }, 42 | '/app/routes/blog': { 43 | 'index.tsx': 'file3', 44 | 'about.tsx': 'file4', 45 | }, 46 | '/app/routes/blog/posts': { 47 | 'index.tsx': 'file5', 48 | 'comments.tsx': 'file6', 49 | }, 50 | }) 51 | }) 52 | }) 53 | 54 | describe('listByDirectory', () => { 55 | it('Should list files by their directory', () => { 56 | const files = { 57 | '/app/routes/blog/posts/_layout.tsx': 'foo3', 58 | '/app/routes/_layout.tsx': 'foo', 59 | '/app/routes/blog/_layout.tsx': 'foo2', 60 | } 61 | 62 | const result = listByDirectory(files) 63 | 64 | expect(result).toEqual({ 65 | '/app/routes': ['/app/routes/_layout.tsx'], 66 | '/app/routes/blog': ['/app/routes/_layout.tsx', '/app/routes/blog/_layout.tsx'], 67 | '/app/routes/blog/posts': [ 68 | '/app/routes/_layout.tsx', 69 | '/app/routes/blog/_layout.tsx', 70 | '/app/routes/blog/posts/_layout.tsx', 71 | ], 72 | }) 73 | }) 74 | }) 75 | -------------------------------------------------------------------------------- /test/unit/utils/html.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest' 2 | import { createTagString, escape } from '../../../src/utils/html.js' 3 | 4 | describe('escape', () => { 5 | it('Should escape the strings', () => { 6 | expect(escape('"\'&<>')).toBe('"'&<>') 7 | expect(escape('')).toBe('') 8 | }) 9 | }) 10 | 11 | describe('createTagString', () => { 12 | it('Should create tag strings from records', () => { 13 | const records = [ 14 | { 15 | name: 'description', 16 | content: 'Sonik is cool', 17 | }, 18 | { 19 | name: 'keywords', 20 | content: 'Framework', 21 | }, 22 | { 23 | name: 'specialChars', 24 | content: '"\'&<>', 25 | }, 26 | ] 27 | expect(createTagString('meta', records)).toBe( 28 | '' 29 | ) 30 | }) 31 | }) 32 | -------------------------------------------------------------------------------- /test/unit/vite/island-components.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, describe, it } from 'vitest' 2 | import { transformJsxTags } from '../../../src/vite/island-components.js' 3 | 4 | describe('transformJsxTags', () => { 5 | it('Should add component-wrapper and component-name attribute', () => { 6 | const code = `export default function Badge() { 7 | return

Hello

8 | }` 9 | const result = transformJsxTags(code, 'Badge.tsx') 10 | expect(result).toBe( 11 | `const BadgeOriginal = function () { 12 | return

Hello

; 13 | }; 14 | const WrappedBadge = function (props) { 15 | return import.meta.env.SSR ?
: ; 16 | }; 17 | export default WrappedBadge;` 18 | ) 19 | }) 20 | it('Should not transform if it is blank', () => { 21 | const code = transformJsxTags('', 'Badge.tsx') 22 | expect(code).toBe('') 23 | }) 24 | }) 25 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": "./src/", 5 | }, 6 | "include": [ 7 | "src/**/*.ts", 8 | "src/**/*.tsx", 9 | ], 10 | "exclude": [ 11 | "src/**/*.test.ts", 12 | "src/**/*.mock.ts" 13 | ] 14 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "module": "ES2022", 5 | "moduleResolution": "Bundler", 6 | "esModuleInterop": true, 7 | "strict": true, 8 | "declaration": true, 9 | "types": [ 10 | "node", 11 | "vite/client" 12 | ], 13 | "jsx": "react-jsx", 14 | "jsxImportSource": "hono/jsx", 15 | }, 16 | "include": [ 17 | "src", 18 | "test" 19 | ], 20 | } -------------------------------------------------------------------------------- /tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { glob } from 'glob' 2 | import { defineConfig } from 'tsup' 3 | 4 | const entryPoints = glob.sync('./src/**/*.+(ts|tsx|json)', { 5 | ignore: ['./src/**/*.test.+(ts|tsx)'], 6 | }) 7 | 8 | export default defineConfig({ 9 | entry: entryPoints, 10 | dts: true, 11 | tsconfig: './tsconfig.build.json', 12 | splitting: false, 13 | minify: false, 14 | format: ['esm'], 15 | bundle: false, 16 | platform: 'node', 17 | publicDir: './src/static', 18 | }) 19 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import mdx from '@mdx-js/rollup' 2 | import { defineConfig } from 'vitest/config' 3 | 4 | export default defineConfig({ 5 | test: { 6 | exclude: ['node_modules', 'dist', '.git', '.cache', 'test-presets', 'sandbox'], 7 | }, 8 | plugins: [ 9 | { 10 | ...mdx({ 11 | jsxImportSource: 'hono/jsx', 12 | }), 13 | }, 14 | ], 15 | }) 16 | --------------------------------------------------------------------------------