From 05c33635edca6cfc5b25754fc92005299ccae571 Mon Sep 17 00:00:00 2001 From: kanno <812137533@qq.com> Date: Mon, 17 Feb 2025 11:49:54 +0800 Subject: [PATCH] Squashed commit of the following: commit ae891c61951729ed714f2c25a355505edae8d17c Author: kanno <812137533@qq.com> Date: Mon Feb 17 11:49:35 2025 +0800 feat: simple hydrate commit 919ce3b0d4f48646a1a23f3ac133146782e996f3 Author: kanno <812137533@qq.com> Date: Mon Feb 17 08:05:42 2025 +0800 chore: stash commit 567dbfab099220a05c46db813b3eb90c72068030 Author: kanno <812137533@qq.com> Date: Mon Feb 17 00:23:05 2025 +0800 fix: docs missing icon --- scripts/h.ts | 146 ++++++++++++++++++++++++++++++++++++++++----- scripts/render.tsx | 130 +++++++++++++++++++++++++++++----------- scripts/serve.ts | 1 + scripts/theme.ts | 51 ---------------- 4 files changed, 226 insertions(+), 102 deletions(-) delete mode 100644 scripts/theme.ts diff --git a/scripts/h.ts b/scripts/h.ts index 87e87de..534dc33 100644 --- a/scripts/h.ts +++ b/scripts/h.ts @@ -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() + +function createContext(): Context { + return { + clientCallbacks: [], + id: Symbol('context') + } +} + +function withContext(fn: () => T): T { + const prevContext = currentContext + currentContext = createContext() + try { + return fn() + } finally { + contexts.delete(currentContext.id) + currentContext = prevContext + } +} export type HTMLTag = keyof HTMLElementTagNameMap @@ -16,9 +42,10 @@ export type DeepOptionalProps = { export type InferElement = HTMLElementTagNameMap[T] export interface VNode

{ - type: HTMLTag | Component

+ type: HTMLTag | Component

| 'svg' props: ProprsWithChildren

children: Child[] + __id__?: string } export type JSXElement = E extends HTMLTag ? VNode>> @@ -43,16 +70,30 @@ export function h( export const Fragment = Symbol('Fragment') as unknown as Component export type FragmentType = typeof Fragment -function normalizeKey(key: string): string { +function normalizeKey(key: string, isSvg: boolean): string { + if (isSvg) { + const svgSpecialCases: Record = { + 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 = { className: 'class', htmlFor: 'for' } - return specialCases[key] || key.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`) } -function renderProps(props: ProprsWithChildren>): string { +function renderProps(props: ProprsWithChildren>, isSvg: boolean): string { if (!props) { return '' } return Object.entries(props) .filter(([key]) => key !== 'children') @@ -61,15 +102,15 @@ function renderProps(props: ProprsWithChildren>): 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 '' }) @@ -77,7 +118,36 @@ function renderProps(props: ProprsWithChildren>): string { .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 '' } @@ -86,18 +156,22 @@ export function renderToString(node: Child): string { return String(node) } - const { type, props, children } = node as VNode + const { type, props, children, __id__ } = node as VNode + + 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([ @@ -116,9 +190,51 @@ export function renderToString(node: Child): string { 'track', 'wbr' ]) + if (isSvg && type === 'svg') { + return `${childrenString}` + } + if (voidElements.has(type)) { - return `<${type}${propsString ? ' ' + propsString : ''}/>` + return `<${type}${propsString ? ' ' + propsString : ''} ${refAttr}/>` } - return `<${type}${propsString ? ' ' + propsString : ''}>${childrenString}` + return `<${type}${propsString ? ' ' + propsString : ''} ${refAttr}>${childrenString}` +} + +export function processVNode(rootNode: VNode) { + const context = currentContext! + + function processNode(node: VNode): VNode { + 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 + }) } diff --git a/scripts/render.tsx b/scripts/render.tsx index c930622..d18f6d9 100644 --- a/scripts/render.tsx +++ b/scripts/render.tsx @@ -6,8 +6,7 @@ import yaml from 'js-yaml' import markdownit from 'markdown-it' import path from 'path' import type { Component } from './h' -import { Fragment, h, renderToString } from './h' -import type { Theme } from './theme' +import { Fragment, h, onClient, renderToString } from './h' /// const md = markdownit({ html: true }) @@ -24,17 +23,19 @@ interface HeadProps { title: string } +export type Theme = 'light' | 'dark' + const Icons = { Moon: () => ( ), @@ -43,14 +44,14 @@ const Icons = { ), @@ -59,17 +60,17 @@ const Icons = { ), @@ -107,7 +108,6 @@ const data = yaml.load(fs.readFileSync(path.join(Dirs.docs, 'index.yaml'), 'utf8 const pages = Object.entries(data) const commonCSS = minifyCSS(fs.readFileSync(path.join(Dirs.script, 'style.css'), 'utf8')) -const coomonScript = minifyJS(fs.readFileSync(path.join(Dirs.script, 'theme.ts'), 'utf8')) function createTag(tag: T, value: DocTagValue): FormattedDocTag { return { tag, value } @@ -135,10 +135,6 @@ function minifyCSS(css: string) { return esbuild.transformSync(css, { target, loader: 'css', minify: true }).code } -function minifyJS(js: string) { - return esbuild.transformSync(js, { target, loader: 'ts', minify: true }).code -} - function buildAndMinifyJS(entry: string) { const r = esbuild.buildSync({ bundle: true, @@ -237,6 +233,16 @@ const assert = { } } +declare global { + interface Window { + useTheme: () => { + preferredDark: boolean, + updateTheme: (theme: Theme) => void, + toggleTheme: () => void + } + } +} + function Menu() { const structure: HeadingStruct[] = [] @@ -259,6 +265,12 @@ function Menu() { structure.push(root) } + onClient(() => { + const { toggleTheme } = window.useTheme() + const btn = document.querySelector('#theme-toggle')! + btn.addEventListener('click', toggleTheme) + }) + return (