Skip to content

Commit

Permalink
feat: enable placing islands anywhere (#176)
Browse files Browse the repository at this point in the history
* feat: enable placing islands anywhere

* add e2e test

* fixed path resolving

* improve resolving paths

* add some tests

* import only `.tsx` at the client
  • Loading branch information
yusukebe authored May 11, 2024
1 parent f9471c1 commit 5287f7d
Show file tree
Hide file tree
Showing 12 changed files with 216 additions and 124 deletions.
19 changes: 19 additions & 0 deletions mocks/app/components/$counter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { PropsWithChildren } from 'hono/jsx'
import { useState } from 'hono/jsx'

export default function Counter({
children,
initial = 0,
}: PropsWithChildren<{
initial?: number
}>) {
const [count, setCount] = useState(initial)
const increment = () => setCount(count + 1)
return (
<div>
<p>Count: {count}</p>
<button onClick={increment}>Increment</button>
{children}
</div>
)
}
9 changes: 9 additions & 0 deletions mocks/app/routes/interaction/anywhere.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import Counter from '../../components/$counter'

export default function Interaction() {
return (
<>
<Counter initial={5} />
</>
)
}
11 changes: 0 additions & 11 deletions mocks/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,6 @@ import mdx from '@mdx-js/rollup'
import { defineConfig } from 'vite'
import honox from '../src/vite'

const root = './'

export default defineConfig({
resolve: {
alias: {
Expand All @@ -14,15 +12,6 @@ export default defineConfig({
plugins: [
honox({
entry: './app/server.ts',
islandComponents: {
isIsland: (id) => {
const resolvedPath = path.resolve(root).replace(/\\/g, '\\\\')
const regexp = new RegExp(
`${resolvedPath}[\\\\/]app[^\\\\/]*[\\\\/]islands[\\\\/].+\.tsx?$|${resolvedPath}[\\\\/]app[^\\\\/]*[\\\\/]routes[\\\\/].+\.island\.tsx?$`
)
return regexp.test(path.resolve(id))
},
},
}),
mdx({
jsxImportSource: 'hono/jsx',
Expand Down
14 changes: 8 additions & 6 deletions src/client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,20 +29,22 @@ export type ClientOptions = {
*/
triggerHydration?: TriggerHydration
ISLAND_FILES?: Record<string, () => Promise<unknown>>
/**
* @deprecated
*/
island_root?: string
}

export const createClient = async (options?: ClientOptions) => {
const FILES = options?.ISLAND_FILES ?? {
...import.meta.glob('/app/islands/**/[a-zA-Z0-9[-]+.(tsx|ts)'),
...import.meta.glob('/app/routes/**/_[a-zA-Z0-9[-]+.island.(tsx|ts)'),
...import.meta.glob('/app/islands/**/[a-zA-Z0-9-]+.tsx'),
...import.meta.glob('/app/**/_[a-zA-Z0-9-]+.island.tsx'),
...import.meta.glob('/app/**/$[a-zA-Z0-9-]+.tsx'),
}

const root = options?.island_root ?? '/app'

const hydrateComponent: HydrateComponent = async (document) => {
const filePromises = Object.keys(FILES).map(async (filePath) => {
const componentName = filePath.replace(root, '')
const componentName = filePath
const elements = document.querySelectorAll(
`[${COMPONENT_NAME}="${componentName}"]:not([data-hono-hydrated])`
)
Expand Down Expand Up @@ -73,7 +75,7 @@ export const createClient = async (options?: ClientOptions) => {
const { buildCreateChildrenFn } = await import('./runtime')
createChildren = buildCreateChildrenFn(
createElement as CreateElement,
async (name: string) => (await (FILES[`${root}${name}`] as FileCallback)()).default
async (name: string) => (await (FILES[`${name}`] as FileCallback)()).default
)
}
props[propKey] = await createChildren(
Expand Down
52 changes: 41 additions & 11 deletions src/vite/inject-importing-islands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,38 @@ import { parse } from '@babel/parser'
import precinct from 'precinct'
import { normalizePath, type Plugin } from 'vite'
import { IMPORTING_ISLANDS_ID } from '../constants.js'
import { matchIslandComponentId } from './utils/path.js'

// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const generate = (_generate.default as typeof _generate) ?? _generate

export async function injectImportingIslands(): Promise<Plugin> {
const isIslandRegex = new RegExp(/(\/islands\/|\_[a-zA-Z0-9[-]+\.island\.[tj]sx$)/)
const fileRegex = new RegExp(/(routes|_renderer|_error|_404)\/.*\.[tj]sx$/)
type InjectImportingIslandsOptions = {
appDir?: string
islandDir?: string
}

type ResolvedId = {
id: string
}

export async function injectImportingIslands(
options?: InjectImportingIslandsOptions
): Promise<Plugin> {
let appPath = ''
const islandDir = options?.islandDir ?? '/app/islands'
let root = ''
const cache: Record<string, string> = {}

const walkDependencyTree: (
baseFile: string,
dependencyFile?: string
) => Promise<string[]> = async (baseFile: string, dependencyFile?: string) => {
resolve: (path: string, importer?: string) => Promise<ResolvedId | null>,
dependencyFile?: ResolvedId | string
) => Promise<string[]> = async (baseFile: string, resolve, dependencyFile?) => {
const depPath = dependencyFile
? path.join(path.dirname(baseFile), dependencyFile) + '.tsx' //TODO: This only includes tsx files, how to also include JSX?
? typeof dependencyFile === 'string'
? path.join(path.dirname(baseFile), dependencyFile) + '.tsx'
: dependencyFile['id']
: baseFile
const deps = [depPath]

Expand All @@ -35,7 +51,10 @@ export async function injectImportingIslands(): Promise<Plugin> {
}) as string[]

const childDeps = await Promise.all(
currentFileDeps.map(async (x) => await walkDependencyTree(depPath, x))
currentFileDeps.map(async (file) => {
const resolvedId = await resolve(file, baseFile)
return await walkDependencyTree(depPath, resolve, resolvedId ?? file)
})
)
deps.push(...childDeps.flat())
return deps
Expand All @@ -47,14 +66,25 @@ export async function injectImportingIslands(): Promise<Plugin> {

return {
name: 'inject-importing-islands',
configResolved: async (config) => {
appPath = path.join(config.root, options?.appDir ?? '/app')
root = config.root
},
async transform(sourceCode, id) {
if (!fileRegex.test(id)) {
if (!path.resolve(id).startsWith(appPath)) {
return
}

const hasIslandsImport = (await walkDependencyTree(id))
.flat()
.some((x) => isIslandRegex.test(normalizePath(x)))
const hasIslandsImport = (
await Promise.all(
(await walkDependencyTree(id, async (id: string) => await this.resolve(id)))
.flat()
.map(async (x) => {
const rootPath = '/' + path.relative(root, normalizePath(x)).replace(/\\/g, '/')
return matchIslandComponentId(rootPath, islandDir)
})
)
).some((matched) => matched)

if (!hasIslandsImport) {
return
Expand Down
39 changes: 1 addition & 38 deletions src/vite/island-components.test.ts
Original file line number Diff line number Diff line change
@@ -1,44 +1,7 @@
import fs from 'fs'
import os from 'os'
import path from 'path'
import { matchIslandComponentId, transformJsxTags, islandComponents } from './island-components'

describe('matchIslandComponentId', () => {
describe('match', () => {
const paths = [
'/islands/counter.tsx',
'/islands/directory/counter.tsx',
'/routes/$counter.tsx',
'/routes/directory/$counter.tsx',
'/routes/_counter.island.tsx',
'/routes/directory/_counter.island.tsx',
]

paths.forEach((path) => {
it(`Should match ${path}`, () => {
const match = matchIslandComponentId(path)
expect(match).not.toBeNull()
expect(match![0]).toBe(path)
})
})
})

describe('not match', () => {
const paths = [
'/routes/directory/component.tsx',
'/routes/directory/foo$component.tsx',
'/routes/directory/foo_component.island.tsx',
'/routes/directory/component.island.tsx',
]

paths.forEach((path) => {
it(`Should not match ${path}`, () => {
const match = matchIslandComponentId(path)
expect(match).toBeNull()
})
})
})
})
import { transformJsxTags, islandComponents } from './island-components.js'

describe('transformJsxTags', () => {
it('Should add component-wrapper and component-name attribute', () => {
Expand Down
45 changes: 8 additions & 37 deletions src/vite/island-components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,34 +35,7 @@ import {
import { parse as parseJsonc } from 'jsonc-parser'
// eslint-disable-next-line node/no-extraneous-import
import type { Plugin } from 'vite'

/**
* Check if the name is a valid component name
*
* @param name - The name to check
* @returns true if the name is a valid component name
* @example
* isComponentName('Badge') // true
* isComponentName('BadgeComponent') // true
* isComponentName('badge') // false
* isComponentName('MIN') // false
* isComponentName('Badge_Component') // false
*/
function isComponentName(name: string) {
return /^[A-Z][A-Z0-9]*[a-z][A-Za-z0-9]*$/.test(name)
}

/**
* Matches when id is the filename of Island component
*
* @param id - The id to match
* @returns The result object if id is matched or null
*/
export function matchIslandComponentId(id: string) {
return id.match(
/\/islands\/.+?\.tsx$|\/routes\/(?:.*\/)?(?:\_[a-zA-Z0-9-]+\.island\.tsx$|\$[a-zA-Z0-9-]+\.tsx$)/
)
}
import { matchIslandComponentId, isComponentName } from './utils/path.js'

function addSSRCheck(funcName: string, componentName: string, componentExport?: string) {
const isSSR = memberExpression(
Expand Down Expand Up @@ -227,13 +200,18 @@ export const transformJsxTags = (contents: string, componentName: string) => {

type IsIsland = (id: string) => boolean
export type IslandComponentsOptions = {
/**
* @deprecated
*/
isIsland?: IsIsland
islandDir?: string
reactApiImportSource?: string
}

export function islandComponents(options?: IslandComponentsOptions): Plugin {
let root = ''
let reactApiImportSource = options?.reactApiImportSource
const islandDir = options?.islandDir ?? '/app/islands'
return {
name: 'transform-island-components',
configResolved: async (config) => {
Expand Down Expand Up @@ -267,15 +245,8 @@ export function islandComponents(options?: IslandComponentsOptions): Plugin {
}
}

const defaultIsIsland: IsIsland = (id) => {
const islandDirectoryPath = path.join(root, 'app')
return path.resolve(id).startsWith(islandDirectoryPath)
}
const matchIslandPath = options?.isIsland ?? defaultIsIsland
if (!matchIslandPath(id)) {
return
}
const match = matchIslandComponentId(id)
const rootPath = '/' + path.relative(root, id).replace(/\\/g, '/')
const match = matchIslandComponentId(rootPath, islandDir)
if (match) {
const componentName = match[0]
const contents = await fs.readFile(id, 'utf-8')
Expand Down
54 changes: 54 additions & 0 deletions src/vite/utils/path.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { matchIslandComponentId } from './path'

describe('matchIslandComponentId', () => {
describe('match', () => {
const paths = [
'/islands/counter.tsx',
'/islands/directory/counter.tsx',
'/routes/$counter.tsx',
'/routes/directory/$counter.tsx',
'/routes/_counter.island.tsx',
'/routes/directory/_counter.island.tsx',
'/$counter.tsx',
'/directory/$counter.tsx',
'/_counter.island.tsx',
'/directory/_counter.island.tsx',
]

paths.forEach((path) => {
it(`Should match ${path}`, () => {
const match = matchIslandComponentId(path)
expect(match).not.toBeNull()
expect(match![0]).toBe(path)
})
})
})

describe('not match', () => {
const paths = [
'/routes/directory/component.tsx',
'/routes/directory/foo$component.tsx',
'/routes/directory/foo_component.island.tsx',
'/routes/directory/component.island.tsx',
'/directory/islands/component.tsx',
]

paths.forEach((path) => {
it(`Should not match ${path}`, () => {
const match = matchIslandComponentId(path)
expect(match).toBeNull()
})
})
})

describe('not match - with `islandDir`', () => {
const paths = ['/islands/component.tsx']

paths.forEach((path) => {
it(`Should not match ${path}`, () => {
const match = matchIslandComponentId(path, '/directory/islands')
expect(match).toBeNull()
})
})
})
})
28 changes: 28 additions & 0 deletions src/vite/utils/path.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/**
* Check if the name is a valid component name
*
* @param name - The name to check
* @returns true if the name is a valid component name
* @example
* isComponentName('Badge') // true
* isComponentName('BadgeComponent') // true
* isComponentName('badge') // false
* isComponentName('MIN') // false
* isComponentName('Badge_Component') // false
*/
export function isComponentName(name: string) {
return /^[A-Z][A-Z0-9]*[a-z][A-Za-z0-9]*$/.test(name)
}

/**
* Matches when id is the filename of Island component
*
* @param id - The id to match
* @returns The result object if id is matched or null
*/
export function matchIslandComponentId(id: string, islandDir: string = '/islands') {
const regExp = new RegExp(
`^${islandDir}\/.+?\.tsx$|.*\/(?:\_[a-zA-Z0-9-]+\.island\.tsx$|\\\$[a-zA-Z0-9-]+\.tsx$)`
)
return id.match(regExp)
}
Loading

0 comments on commit 5287f7d

Please sign in to comment.