Skip to content

Commit

Permalink
Squashed commit of the following:
Browse files Browse the repository at this point in the history
commit ae891c61951729ed714f2c25a355505edae8d17c
Author: kanno <[email protected]>
Date:   Mon Feb 17 11:49:35 2025 +0800

    feat: simple hydrate

commit 919ce3b
Author: kanno <[email protected]>
Date:   Mon Feb 17 08:05:42 2025 +0800

    chore: stash

commit 567dbfa
Author: kanno <[email protected]>
Date:   Mon Feb 17 00:23:05 2025 +0800

    fix: docs missing icon
  • Loading branch information
nonzzz committed Feb 17, 2025
1 parent 4b10158 commit 05c3363
Show file tree
Hide file tree
Showing 4 changed files with 226 additions and 102 deletions.
146 changes: 131 additions & 15 deletions scripts/h.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,31 @@
/* eslint-disable no-use-before-define */
// preact is fine, but i won't need it for the project.
// Note: This is a minimal implementation that only do jsx to html string conversion.
interface Context {
clientCallbacks: Array<{ fn: () => void }>
id: symbol
}

let currentContext: Context | null = null
const contexts = new Map<symbol, Context>()

function createContext(): Context {
return {
clientCallbacks: [],
id: Symbol('context')
}
}

function withContext<T>(fn: () => T): T {
const prevContext = currentContext
currentContext = createContext()
try {
return fn()
} finally {
contexts.delete(currentContext.id)
currentContext = prevContext
}
}

export type HTMLTag = keyof HTMLElementTagNameMap

Expand All @@ -16,9 +42,10 @@ export type DeepOptionalProps<T> = {
export type InferElement<T extends HTMLTag> = HTMLElementTagNameMap[T]

export interface VNode<P = Any> {
type: HTMLTag | Component<P>
type: HTMLTag | Component<P> | 'svg'
props: ProprsWithChildren<P>
children: Child[]
__id__?: string
}

export type JSXElement<E extends HTMLTag | Component> = E extends HTMLTag ? VNode<DeepOptionalProps<InferElement<E>>>
Expand All @@ -43,16 +70,30 @@ export function h<T extends HTMLTag | Component>(
export const Fragment = Symbol('Fragment') as unknown as Component<Any>
export type FragmentType = typeof Fragment

function normalizeKey(key: string): string {
function normalizeKey(key: string, isSvg: boolean): string {
if (isSvg) {
const svgSpecialCases: Record<string, string> = {
className: 'class',
htmlFor: 'for',
viewBox: 'viewBox',
fillRule: 'fill-rule',
clipRule: 'clip-rule',
strokeWidth: 'stroke-width',
strokeLinecap: 'stroke-linecap',
strokeLinejoin: 'stroke-linejoin',
strokeDasharray: 'stroke-dasharray',
strokeDashoffset: 'stroke-dashoffset'
}
return svgSpecialCases[key] || key
}
const specialCases: Record<string, string> = {
className: 'class',
htmlFor: 'for'
}

return specialCases[key] || key.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`)
}

function renderProps(props: ProprsWithChildren<Record<string, Any>>): string {
function renderProps(props: ProprsWithChildren<Record<string, Any>>, isSvg: boolean): string {
if (!props) { return '' }
return Object.entries(props)
.filter(([key]) => key !== 'children')
Expand All @@ -61,23 +102,52 @@ function renderProps(props: ProprsWithChildren<Record<string, Any>>): string {
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
const style = Object.entries(value)
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
.map(([k, v]) => `${normalizeKey(k)}:${v}`)
.map(([k, v]) => `${normalizeKey(k, isSvg)}:${v}`)
.join(';')
return `style="${style}"`
}
if (typeof value === 'boolean' && value) {
return normalizeKey(key)
return normalizeKey(key, isSvg)
}
if (typeof value === 'string' || typeof value === 'number') {
return `${normalizeKey(key)}="${value}"`
return `${normalizeKey(key, isSvg)}="${value}"`
}
return ''
})
.filter(Boolean)
.join(' ')
}

export function renderToString(node: Child): string {
const SVG_NAMESPACE = 'http://www.w3.org/2000/svg'
const SVG_TAGS = new Set([
'svg',
'path',
'rect',
'circle',
'line',
'g',
'defs',
'pattern',
'mask',
'use',
'polyline',
'polygon',
'text',
'tspan'
])

export function renderToString(node: VNode) {
return withContext(() => {
const context = currentContext!
const { vnode } = processVNode(node)
return {
html: processNodeToStr(vnode),
onClientMethods: context.clientCallbacks
}
})
}

export function processNodeToStr(node: Child): string {
if (node == null || typeof node === 'boolean') {
return ''
}
Expand All @@ -86,18 +156,22 @@ export function renderToString(node: Child): string {
return String(node)
}

const { type, props, children } = node as VNode<unknown>
const { type, props, children, __id__ } = node as VNode<unknown>

const refAttr = __id__ ? `data-ref="${__id__}"` : ''

if (type === Fragment) {
return children.map(renderToString).join('')
return children.map(processNodeToStr).join('')
}

if (typeof type === 'function') {
return renderToString(type(props))
return processNodeToStr(type(props))
}

const propsString = renderProps(props)
const childrenString = children.map(renderToString).join('')
const isSvg = typeof type === 'string' && SVG_TAGS.has(type)

const propsString = renderProps(props, isSvg)
const childrenString = children.map(processNodeToStr).join('')

// Self-closing tags
const voidElements = new Set([
Expand All @@ -116,9 +190,51 @@ export function renderToString(node: Child): string {
'track',
'wbr'
])
if (isSvg && type === 'svg') {
return `<svg xmlns="${SVG_NAMESPACE}"${propsString ? ' ' + propsString : ''}${refAttr}>${childrenString}</svg>`
}

if (voidElements.has(type)) {
return `<${type}${propsString ? ' ' + propsString : ''}/>`
return `<${type}${propsString ? ' ' + propsString : ''} ${refAttr}/>`
}

return `<${type}${propsString ? ' ' + propsString : ''}>${childrenString}</${type}>`
return `<${type}${propsString ? ' ' + propsString : ''} ${refAttr}>${childrenString}</${type}>`
}

export function processVNode(rootNode: VNode) {
const context = currentContext!

function processNode(node: VNode<unknown>): VNode<unknown> {
return withContext(() => {
if (typeof node.type === 'function') {
const result = node.type(node.props)
const processed = processNode(result)
context.clientCallbacks.push(...currentContext!.clientCallbacks)
return processed
}

const processedNode = { ...node }

processedNode.children = node.children.map((child) => {
if (child && typeof child === 'object' && 'type' in child) {
return processNode(child as VNode)
}
return child
})

return processedNode
})
}

const processedVNode = processNode(rootNode)
return { vnode: processedVNode }
}

export function onClient(callback: () => void) {
if (!currentContext) {
throw new Error('onClient must be called within a component')
}
currentContext.clientCallbacks.push({
fn: callback
})
}
Loading

0 comments on commit 05c3363

Please sign in to comment.