Skip to content

Commit

Permalink
feat: Enable to export named component by adding component-export att…
Browse files Browse the repository at this point in the history
…ribute (#163)

* feat: Enable to export named component by adding component-export attribute

* refactor: tweaks condition for valid component name

* test: add test case for `isComponentName()`

* test: update test data.

* test: add test for component name transformation

* refactor: remove redundant lowercase letter check

* test: oops, fix test case description
  • Loading branch information
usualoma authored May 4, 2024
1 parent 481e900 commit 9445d71
Show file tree
Hide file tree
Showing 9 changed files with 228 additions and 37 deletions.
23 changes: 23 additions & 0 deletions mocks/app/islands/NamedCounter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type { PropsWithChildren } from 'hono/jsx'
import { useState } from 'hono/jsx'
import Badge from './Badge'

export function NamedCounter({
children,
initial = 0,
id = '',
}: PropsWithChildren<{
initial?: number
id?: string
}>) {
const [count, setCount] = useState(initial)
const increment = () => setCount(count + 1)
return (
<div id={id}>
<Badge name='Counter' />
<p>Count: {count}</p>
<button onClick={increment}>Increment</button>
{children}
</div>
)
}
2 changes: 2 additions & 0 deletions mocks/app/routes/interaction/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Counter from '../../islands/Counter'
import { NamedCounter } from '../../islands/NamedCounter'

export default function Interaction() {
return (
Expand All @@ -7,6 +8,7 @@ export default function Interaction() {
<Counter initial={10}>
<Counter initial={15} />
</Counter>
<NamedCounter initial={30} id='named' />
<Counter initial={20} slot={<Counter id='slot' initial={25} />}>
<Counter initial={30} />
</Counter>
Expand Down
12 changes: 9 additions & 3 deletions src/client/client.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { render } from 'hono/jsx/dom'
import { jsx as jsxFn } from 'hono/jsx/dom/jsx-runtime'
import { COMPONENT_NAME, DATA_HONO_TEMPLATE, DATA_SERIALIZED_PROPS } from '../constants.js'
import {
COMPONENT_NAME,
COMPONENT_EXPORT,
DATA_HONO_TEMPLATE,
DATA_SERIALIZED_PROPS,
} from '../constants.js'
import type {
CreateElement,
CreateChildren,
Expand All @@ -10,7 +15,7 @@ import type {
} from '../types.js'

// eslint-disable-next-line @typescript-eslint/no-explicit-any
type FileCallback = () => Promise<{ default: Promise<any> }>
type FileCallback = () => Promise<Record<string, Promise<any>>>

export type ClientOptions = {
hydrate?: Hydrate
Expand Down Expand Up @@ -44,10 +49,11 @@ export const createClient = async (options?: ClientOptions) => {
if (elements) {
const elementPromises = Array.from(elements).map(async (element) => {
element.setAttribute('data-hono-hydrated', 'true') // mark as hydrated
const exportName = element.getAttribute(COMPONENT_EXPORT) || 'default'

const fileCallback = FILES[filePath] as FileCallback
const file = await fileCallback()
const Component = await file.default
const Component = await file[exportName]

const serializedProps = element.attributes.getNamedItem(DATA_SERIALIZED_PROPS)?.value
const props = JSON.parse(serializedProps ?? '{}') as Record<string, unknown>
Expand Down
1 change: 1 addition & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export const COMPONENT_NAME = 'component-name'
export const COMPONENT_EXPORT = 'component-export'
export const DATA_SERIALIZED_PROPS = 'data-serialized-props'
export const DATA_HONO_TEMPLATE = 'data-hono-template'
export const IMPORTING_ISLANDS_ID = '__importing_islands' as const
15 changes: 13 additions & 2 deletions src/vite/components/honox-island.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { createContext, useContext, isValidElement } from 'hono/jsx'
import { COMPONENT_NAME, DATA_SERIALIZED_PROPS, DATA_HONO_TEMPLATE } from '../../constants'
import {
COMPONENT_NAME,
COMPONENT_EXPORT,
DATA_SERIALIZED_PROPS,
DATA_HONO_TEMPLATE,
} from '../../constants'

const inIsland = Symbol()
const inChildren = Symbol()
Expand All @@ -15,10 +20,12 @@ const isElementPropValue = (value: unknown): boolean =>

export const HonoXIsland = ({
componentName,
componentExport,
Component,
props,
}: {
componentName: string
componentExport: string
Component: Function
// eslint-disable-next-line @typescript-eslint/no-explicit-any
props: any
Expand All @@ -40,7 +47,11 @@ export const HonoXIsland = ({
return islandState[inChildren] || !islandState[inIsland] ? (
// top-level or slot content
<honox-island
{...{ [COMPONENT_NAME]: componentName, [DATA_SERIALIZED_PROPS]: JSON.stringify(restProps) }}
{...{
[COMPONENT_NAME]: componentName,
[COMPONENT_EXPORT]: componentExport || undefined,
[DATA_SERIALIZED_PROPS]: JSON.stringify(restProps),
}}
>
<IslandContext.Provider value={{ ...islandState, [inIsland]: true }}>
<Component {...props} />
Expand Down
100 changes: 100 additions & 0 deletions src/vite/island-components.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,75 @@ const WrappedBadge = function (props) {
export default WrappedBadge;`
)
})

it('Should add component-wrapper and component-name attribute for named export', () => {
const code = `function Badge() {
return <h1>Hello</h1>
}
export { Badge }
`
const result = transformJsxTags(code, 'Badge.tsx')
expect(result).toBe(
`import { HonoXIsland } from "honox/vite/components";
function Badge() {
return <h1>Hello</h1>;
}
const WrappedBadge = function (props) {
return import.meta.env.SSR ? <HonoXIsland componentName="Badge.tsx" Component={Badge} props={props} componentExport="Badge" /> : <Badge {...props}></Badge>;
};
export { WrappedBadge as Badge };`
)
})

it('Should add component-wrapper and component-name attribute for named function', () => {
const code = `export function Badge() {
return <h1>Hello</h1>
}
`
const result = transformJsxTags(code, 'Badge.tsx')
expect(result).toBe(
`import { HonoXIsland } from "honox/vite/components";
function Badge() {
return <h1>Hello</h1>;
}
const WrappedBadge = function (props) {
return import.meta.env.SSR ? <HonoXIsland componentName="Badge.tsx" Component={Badge} props={props} componentExport="Badge" /> : <Badge {...props}></Badge>;
};
export { WrappedBadge as Badge };`
)
})

it('Should add component-wrapper and component-name attribute for variable', () => {
const code = `export const Badge = () => {
return <h1>Hello</h1>
}
`
const result = transformJsxTags(code, 'Badge.tsx')
expect(result).toBe(
`import { HonoXIsland } from "honox/vite/components";
const Badge = () => {
return <h1>Hello</h1>;
};
const WrappedBadge = function (props) {
return import.meta.env.SSR ? <HonoXIsland componentName="Badge.tsx" Component={Badge} props={props} componentExport="Badge" /> : <Badge {...props}></Badge>;
};
export { WrappedBadge as Badge };`
)
})

it('Should not transform constant', () => {
const code = `export const MAX = 10
const MIN = 0
export { MIN }
`
const result = transformJsxTags(code, 'Badge.tsx')
expect(result).toBe(
`export const MAX = 10;
const MIN = 0;
export { MIN };`
)
})

it('Should not transform if it is blank', () => {
const code = transformJsxTags('', 'Badge.tsx')
expect(code).toBe('')
Expand Down Expand Up @@ -114,6 +183,37 @@ const WrappedExportViaVariable = function (props) {
export { utilityFn, WrappedExportViaVariable as default };`
)
})

describe('component name or not', () => {
const codeTemplate = `export function %s() {
return <h1>Hello</h1>;
}`
test.each([
'Badge', // simple name
'BadgeComponent', // camel case
'BadgeComponent0', // end with number
'BadgeComponentA', // end with capital letter
'B1Badge', // "B1" prefix
])('Should transform %s as component name', (name) => {
const code = codeTemplate.replace('%s', name)
const result = transformJsxTags(code, `${name}.tsx`)
expect(result).not.toBe(code)
})

test.each([
'utilityFn', // lower camel case
'utility_fn', // snake case
'Utility_Fn', // capital snake case
'MAX', // all capital (constant)
'MAX_LENGTH', // all capital with underscore (constant)
'M', // single capital (constant)
'M1', // single capital with number (constant)
])('Should not transform %s as component name', (name) => {
const code = codeTemplate.replace('%s', name)
const result = transformJsxTags(code, `${name}.tsx`)
expect(result).toBe(code)
})
})
})

describe('options', () => {
Expand Down
102 changes: 75 additions & 27 deletions src/vite/island-components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,27 +29,46 @@ import {
memberExpression,
importDeclaration,
importSpecifier,
exportNamedDeclaration,
exportSpecifier,
} from '@babel/types'
import { parse as parseJsonc } from 'jsonc-parser'
// eslint-disable-next-line node/no-extraneous-import
import type { Plugin } from 'vite'

function addSSRCheck(funcName: string, componentName: string) {
/**
* 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)
}

function addSSRCheck(funcName: string, componentName: string, componentExport?: string) {
const isSSR = memberExpression(
memberExpression(identifier('import'), identifier('meta')),
identifier('env.SSR')
)

const props = [
jsxAttribute(jsxIdentifier('componentName'), stringLiteral(componentName)),
jsxAttribute(jsxIdentifier('Component'), jsxExpressionContainer(identifier(funcName))),
jsxAttribute(jsxIdentifier('props'), jsxExpressionContainer(identifier('props'))),
]
if (componentExport && componentExport !== 'default') {
props.push(jsxAttribute(jsxIdentifier('componentExport'), stringLiteral(componentExport)))
}

const ssrElement = jsxElement(
jsxOpeningElement(
jsxIdentifier('HonoXIsland'),
[
jsxAttribute(jsxIdentifier('componentName'), stringLiteral(componentName)),
jsxAttribute(jsxIdentifier('Component'), jsxExpressionContainer(identifier(funcName))),
jsxAttribute(jsxIdentifier('props'), jsxExpressionContainer(identifier('props'))),
],
true
),
jsxOpeningElement(jsxIdentifier('HonoXIsland'), props, true),
null,
[]
)
Expand All @@ -65,20 +84,47 @@ function addSSRCheck(funcName: string, componentName: string) {
}

export const transformJsxTags = (contents: string, componentName: string) => {
if (!contents) {
return ''
}

const ast = parse(contents, {
sourceType: 'module',
plugins: ['typescript', 'jsx'],
})

if (ast) {
let wrappedFunctionId
let isTransformed = false

traverse(ast, {
ExportNamedDeclaration(path) {
if (path.node.declaration?.type === 'FunctionDeclaration') {
// transform `export function NamedFunction() {}`
const name = path.node.declaration.id?.name
if (name && isComponentName(name)) {
path.insertBefore(path.node.declaration)
path.replaceWith(
exportNamedDeclaration(null, [exportSpecifier(identifier(name), identifier(name))])
)
}
return
}

if (path.node.declaration?.type === 'VariableDeclaration') {
// transform `export const NamedFunction = () => {}`
const kind = path.node.declaration.kind
for (const declaration of path.node.declaration.declarations) {
if (declaration.id.type === 'Identifier') {
const name = declaration.id.name
if (!isComponentName(name)) {
continue
}
path.insertBefore(variableDeclaration(kind, [declaration]))
path.insertBefore(
exportNamedDeclaration(null, [exportSpecifier(identifier(name), identifier(name))])
)
path.remove()
}
}
return
}

for (const specifier of path.node.specifiers) {
if (specifier.type !== 'ExportSpecifier') {
continue
Expand All @@ -87,11 +133,12 @@ export const transformJsxTags = (contents: string, componentName: string) => {
specifier.exported.type === 'StringLiteral'
? specifier.exported.value
: specifier.exported.name
if (exportAs !== 'default') {
if (exportAs !== 'default' && !isComponentName(exportAs)) {
continue
}
isTransformed = true

const wrappedFunction = addSSRCheck(specifier.local.name, componentName)
const wrappedFunction = addSSRCheck(specifier.local.name, componentName, exportAs)
const wrappedFunctionId = identifier('Wrapped' + specifier.local.name)
path.insertBefore(
variableDeclaration('const', [variableDeclarator(wrappedFunctionId, wrappedFunction)])
Expand All @@ -108,6 +155,8 @@ export const transformJsxTags = (contents: string, componentName: string) => {
declarationType === 'ArrowFunctionExpression' ||
declarationType === 'Identifier'
) {
isTransformed = true

const functionName =
(declarationType === 'Identifier'
? path.node.declaration.name
Expand Down Expand Up @@ -141,24 +190,23 @@ export const transformJsxTags = (contents: string, componentName: string) => {
}

const wrappedFunction = addSSRCheck(originalFunctionId.name, componentName)
wrappedFunctionId = identifier('Wrapped' + functionName)
const wrappedFunctionId = identifier('Wrapped' + functionName)
path.replaceWith(
variableDeclaration('const', [variableDeclarator(wrappedFunctionId, wrappedFunction)])
)
ast.program.body.push(exportDefaultDeclaration(wrappedFunctionId))
}
},
})

if (wrappedFunctionId) {
ast.program.body.push(exportDefaultDeclaration(wrappedFunctionId))
}

ast.program.body.unshift(
importDeclaration(
[importSpecifier(identifier('HonoXIsland'), identifier('HonoXIsland'))],
stringLiteral('honox/vite/components')
if (isTransformed) {
ast.program.body.unshift(
importDeclaration(
[importSpecifier(identifier('HonoXIsland'), identifier('HonoXIsland'))],
stringLiteral('honox/vite/components')
)
)
)
}

const { code } = generate(ast)
return code
Expand Down
Loading

0 comments on commit 9445d71

Please sign in to comment.