Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: enable placing islands anywhere #176

Merged
merged 6 commits into from
May 11, 2024
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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|ts)'),
...import.meta.glob('/app/**/_[a-zA-Z0-9-]+.island.(tsx|ts)'),
...import.meta.glob('/app/**/$[a-zA-Z0-9-]+.(tsx|ts)'),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It has nothing to do with this PR change, In matchIslandComponentId(), only .tsx matches (.ts does not), so I think only .tsx` should be used here as well.

https://github.com/honojs/honox/pull/176/files#diff-4b7b8acce4bb320abe3a28d1bb85d7ad346253af647225df7e62d19891192a27R25

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@usualoma

You are right! It should be only .tsx and it's not a problem if it does not support .ts.

}

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