Skip to content

Commit

Permalink
refactor!(jsx): Tidyup the types to be exported (#2151)
Browse files Browse the repository at this point in the history
* refactor(jsx): export types from src/jsx/types.ts

* refactor(jsx): migrate to new type definition

* chore: denoify

* fix(jsx): use `export *` for now until denoify bug is fixed
  • Loading branch information
usualoma authored Feb 5, 2024
1 parent 37842fb commit 82dab25
Show file tree
Hide file tree
Showing 36 changed files with 788 additions and 747 deletions.
331 changes: 331 additions & 0 deletions deno_dist/jsx/base.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,331 @@
import { raw } from '../helper/html/index.ts'
import { escapeToBuffer, stringBufferToString } from '../utils/html.ts'
import type { StringBuffer, HtmlEscaped, HtmlEscapedString } from '../utils/html.ts'
import type { Context } from './context.ts'
import { globalContexts } from './context.ts'
import type { IntrinsicElements as IntrinsicElementsDefined } from './intrinsic-elements.ts'
import { normalizeIntrinsicElementProps } from './utils.ts'

// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type Props = Record<string, any>
export type FC<T = Props> = (props: T) => HtmlEscapedString | Promise<HtmlEscapedString>

declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace JSX {
type Element = HtmlEscapedString | Promise<HtmlEscapedString>
interface ElementChildrenAttribute {
children: Child
}
interface IntrinsicElements extends IntrinsicElementsDefined {
[tagName: string]: Props
}
}
}

const emptyTags = [
'area',
'base',
'br',
'col',
'embed',
'hr',
'img',
'input',
'keygen',
'link',
'meta',
'param',
'source',
'track',
'wbr',
]
const booleanAttributes = [
'allowfullscreen',
'async',
'autofocus',
'autoplay',
'checked',
'controls',
'default',
'defer',
'disabled',
'formnovalidate',
'hidden',
'inert',
'ismap',
'itemscope',
'loop',
'multiple',
'muted',
'nomodule',
'novalidate',
'open',
'playsinline',
'readonly',
'required',
'reversed',
'selected',
]

const childrenToStringToBuffer = (children: Child[], buffer: StringBuffer): void => {
for (let i = 0, len = children.length; i < len; i++) {
const child = children[i]
if (typeof child === 'string') {
escapeToBuffer(child, buffer)
} else if (typeof child === 'boolean' || child === null || child === undefined) {
continue
} else if (child instanceof JSXNode) {
child.toStringToBuffer(buffer)
} else if (
typeof child === 'number' ||
(child as unknown as { isEscaped: boolean }).isEscaped
) {
;(buffer[0] as string) += child
} else if (child instanceof Promise) {
buffer.unshift('', child)
} else {
// `child` type is `Child[]`, so stringify recursively
childrenToStringToBuffer(child, buffer)
}
}
}

type LocalContexts = [Context<unknown>, unknown][]
export type Child = string | Promise<string> | number | JSXNode | Child[]
export class JSXNode implements HtmlEscaped {
tag: string | Function
props: Props
key?: string
children: Child[]
isEscaped: true = true as const
localContexts?: LocalContexts
constructor(tag: string | Function, props: Props, children: Child[]) {
this.tag = tag
this.props = props
this.children = children
}

toString(): string | Promise<string> {
const buffer: StringBuffer = ['']
this.localContexts?.forEach(([context, value]) => {
context.values.push(value)
})
try {
this.toStringToBuffer(buffer)
} finally {
this.localContexts?.forEach(([context]) => {
context.values.pop()
})
}
return buffer.length === 1 ? buffer[0] : stringBufferToString(buffer)
}

toStringToBuffer(buffer: StringBuffer): void {
const tag = this.tag as string
const props = this.props
let { children } = this

buffer[0] += `<${tag}`

const propsKeys = Object.keys(props || {})

for (let i = 0, len = propsKeys.length; i < len; i++) {
const key = propsKeys[i]
const v = props[key]
// object to style strings
if (key === 'style' && typeof v === 'object') {
const styles = Object.keys(v)
.map((k) => {
const property = k.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`)
return `${property}:${v[k]}`
})
.join(';')
buffer[0] += ` style="${styles}"`
} else if (typeof v === 'string') {
buffer[0] += ` ${key}="`
escapeToBuffer(v, buffer)
buffer[0] += '"'
} else if (v === null || v === undefined) {
// Do nothing
} else if (typeof v === 'number' || (v as HtmlEscaped).isEscaped) {
buffer[0] += ` ${key}="${v}"`
} else if (typeof v === 'boolean' && booleanAttributes.includes(key)) {
if (v) {
buffer[0] += ` ${key}=""`
}
} else if (key === 'dangerouslySetInnerHTML') {
if (children.length > 0) {
throw 'Can only set one of `children` or `props.dangerouslySetInnerHTML`.'
}

children = [raw(v.__html)]
} else if (v instanceof Promise) {
buffer[0] += ` ${key}="`
buffer.unshift('"', v)
} else {
buffer[0] += ` ${key}="`
escapeToBuffer(v.toString(), buffer)
buffer[0] += '"'
}
}

if (emptyTags.includes(tag as string)) {
buffer[0] += '/>'
return
}

buffer[0] += '>'

childrenToStringToBuffer(children, buffer)

buffer[0] += `</${tag}>`
}
}

class JSXFunctionNode extends JSXNode {
toStringToBuffer(buffer: StringBuffer): void {
const { children } = this

const res = (this.tag as Function).call(null, {
...this.props,
children: children.length <= 1 ? children[0] : children,
})

if (res instanceof Promise) {
if (globalContexts.length === 0) {
buffer.unshift('', res)
} else {
// save current contexts for resuming
const currentContexts: LocalContexts = globalContexts.map((c) => [c, c.values.at(-1)])
buffer.unshift(
'',
res.then((childRes) => {
if (childRes instanceof JSXNode) {
childRes.localContexts = currentContexts
}
return childRes
})
)
}
} else if (res instanceof JSXNode) {
res.toStringToBuffer(buffer)
} else if (typeof res === 'number' || (res as HtmlEscaped).isEscaped) {
buffer[0] += res
} else {
escapeToBuffer(res, buffer)
}
}
}

export class JSXFragmentNode extends JSXNode {
toStringToBuffer(buffer: StringBuffer): void {
childrenToStringToBuffer(this.children, buffer)
}
}

export const jsx = (
tag: string | Function,
props: Props,
...children: (string | HtmlEscapedString)[]
): JSXNode => {
let key
if (props) {
key = props?.key
delete props['key']
}
const node = jsxFn(tag, props, children)
node.key = key
return node
}

export const jsxFn = (
tag: string | Function,
props: Props,
children: (string | HtmlEscapedString)[]
): JSXNode => {
if (typeof tag === 'function') {
return new JSXFunctionNode(tag, props, children)
} else {
normalizeIntrinsicElementProps(props)
return new JSXNode(tag, props, children)
}
}

const shallowEqual = (a: Props, b: Props): boolean => {
if (a === b) {
return true
}

const aKeys = Object.keys(a).sort()
const bKeys = Object.keys(b).sort()
if (aKeys.length !== bKeys.length) {
return false
}

for (let i = 0, len = aKeys.length; i < len; i++) {
if (
aKeys[i] === 'children' &&
bKeys[i] === 'children' &&
!a.children?.length &&
!b.children?.length
) {
continue
} else if (a[aKeys[i]] !== b[aKeys[i]]) {
return false
}
}

return true
}

export const memo = <T>(
component: FC<T>,
propsAreEqual: (prevProps: Readonly<T>, nextProps: Readonly<T>) => boolean = shallowEqual
): FC<T> => {
let computed: HtmlEscapedString | Promise<HtmlEscapedString> | undefined = undefined
let prevProps: T | undefined = undefined
return ((props: T & { children?: Child }): HtmlEscapedString | Promise<HtmlEscapedString> => {
if (prevProps && !propsAreEqual(prevProps, props)) {
computed = undefined
}
prevProps = props
return (computed ||= component(props))
}) as FC<T>
}

export const Fragment = ({
children,
}: {
key?: string
children?: Child | HtmlEscapedString
}): HtmlEscapedString => {
return new JSXFragmentNode(
'',
{},
Array.isArray(children) ? children : children ? [children] : []
) as never
}

export const isValidElement = (element: unknown): element is JSXNode => {
return !!(
element &&
typeof element === 'object' &&
'tag' in element &&
'props' in element &&
'children' in element
)
}

export const cloneElement = <T extends JSXNode | JSX.Element>(
element: T,
props: Partial<Props>,
...children: Child[]
): T => {
return jsxFn(
(element as JSXNode).tag,
{ ...(element as JSXNode).props, ...props },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
children.length ? children : ((element as JSXNode).children as any) || []
) as T
}
14 changes: 8 additions & 6 deletions deno_dist/jsx/components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { HtmlEscapedCallbackPhase, resolveCallback } from '../utils/html.ts'
import { DOM_RENDERER } from './constants.ts'
import { ErrorBoundary as ErrorBoundaryDomRenderer } from './dom/components.ts'
import type { HasRenderToDom } from './dom/render.ts'
import type { FC, Child } from './index.ts'
import type { FC, PropsWithChildren, Child } from './index.ts'

let errorBoundaryCounter = 0

Expand All @@ -29,11 +29,13 @@ export type FallbackRender = (error: Error) => Child
* `ErrorBoundary` is an experimental feature.
* The API might be changed.
*/
export const ErrorBoundary: FC<{
fallback?: Child
fallbackRender?: FallbackRender
onError?: ErrorHandler
}> = async ({ children, fallback, fallbackRender, onError }) => {
export const ErrorBoundary: FC<
PropsWithChildren<{
fallback?: Child
fallbackRender?: FallbackRender
onError?: ErrorHandler
}>
> = async ({ children, fallback, fallbackRender, onError }) => {
if (!children) {
return raw('')
}
Expand Down
6 changes: 3 additions & 3 deletions deno_dist/jsx/context.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { raw } from '../helper/html/index.ts'
import type { HtmlEscapedString } from '../utils/html.ts'
import { JSXFragmentNode } from './base.ts'
import { DOM_RENDERER } from './constants.ts'
import { createContextProviderFunction } from './dom/context.ts'
import { JSXFragmentNode } from './index.ts'
import type { FC } from './index.ts'
import type { FC, PropsWithChildren } from './index.ts'

export interface Context<T> {
values: T[]
Provider: FC<{ value: T }>
Provider: FC<PropsWithChildren<{ value: T }>>
}

export const globalContexts: Context<unknown>[] = []
Expand Down
Loading

0 comments on commit 82dab25

Please sign in to comment.