From 566e0783047cf33ef0684c411868e511f12195dd Mon Sep 17 00:00:00 2001 From: Shigma Date: Tue, 14 May 2024 22:03:11 +0800 Subject: [PATCH] feat(element): add universal jsx runtime --- packages/element/package.json | 52 +++ packages/element/src/index.ts | 499 +++++++++++++++++++++++++ packages/element/tests/segment.spec.ts | 107 ++++++ packages/element/tsconfig.json | 10 + 4 files changed, 668 insertions(+) create mode 100644 packages/element/package.json create mode 100644 packages/element/src/index.ts create mode 100644 packages/element/tests/segment.spec.ts create mode 100644 packages/element/tsconfig.json diff --git a/packages/element/package.json b/packages/element/package.json new file mode 100644 index 0000000..b01d127 --- /dev/null +++ b/packages/element/package.json @@ -0,0 +1,52 @@ +{ + "name": "@cordisjs/element", + "description": "Element Manipulation", + "version": "0.1.0", + "type": "module", + "exports": { + ".": { + "types": "./lib/index.d.ts", + "import": "./lib/index.js", + "require": "./lib/index.cjs" + }, + "./jsx-runtime": { + "types": "./lib/index.d.ts", + "import": "./lib/index.js", + "require": "./lib/index.cjs" + }, + "./jsx-dev-runtime": { + "types": "./lib/index.d.ts", + "import": "./lib/index.js", + "require": "./lib/index.cjs" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib" + ], + "author": "Shigma ", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/cordiverse/webui.git", + "directory": "packages/element" + }, + "bugs": { + "url": "https://github.com/cordiverse/webui/issues" + }, + "keywords": [ + "cordis", + "element", + "segment", + "component", + "message", + "utilities" + ], + "devDependencies": { + "chai-shape": "^1.0.0" + }, + "dependencies": { + "cosmokit": "^1.6.2" + } +} diff --git a/packages/element/src/index.ts b/packages/element/src/index.ts new file mode 100644 index 0000000..43c2252 --- /dev/null +++ b/packages/element/src/index.ts @@ -0,0 +1,499 @@ +import { Awaitable, camelize, defineProperty, Dict, hyphenate, isNullable, makeArray } from 'cosmokit' + +declare global { + namespace JSX { + interface IntrinsicElements { + [key: string]: any + message: { + id?: string + forward?: boolean + children?: any[] + } + quote: { + id?: string + name?: string + avatar?: string + children?: any[] + } + at: { + id?: string + name?: string + avatar?: string + role?: string + type?: string + } + sharp: { + id?: string + name?: string + avatar?: string + } + img: ResourceElement + audio: ResourceElement + video: ResourceElement + file: ResourceElement + } + + interface ResourceElement { + [key: string]: any + src?: string + title?: string + width?: string | number + height?: string | number + duration?: string | number + poster?: string + } + } +} + +const kElement = Symbol.for('satori.element') + +interface Element { + [kElement]: true + type: string + attrs: Dict + /** @deprecated use `attrs` instead */ + data: Dict + children: Element[] + source?: string + toString(strip?: boolean): string +} + +interface ElementConstructor extends Element { } + +class ElementConstructor { + get data() { + return this.attrs + } + + getTagName() { + if (this.type === 'component') { + return this.attrs.is?.name ?? 'component' + } else { + return this.type + } + } + + toAttrString() { + return Object.entries(this.attrs).map(([key, value]) => { + if (isNullable(value)) return '' + key = hyphenate(key) + if (value === true) return ` ${key}` + if (value === false) return ` no-${key}` + return ` ${key}="${Element.escape('' + value, true)}"` + }).join('') + } + + toString(strip = false) { + if (this.type === 'text' && 'content' in this.attrs) { + return strip ? this.attrs.content : Element.escape(this.attrs.content) + } + const inner = this.children.map(child => child.toString(strip)).join('') + if (strip) return inner + const attrs = this.toAttrString() + const tag = this.getTagName() + if (!this.children.length) return `<${tag}${attrs}/>` + return `<${tag}${attrs}>${inner}` + } +} + +defineProperty(ElementConstructor, 'name', 'Element') +defineProperty(ElementConstructor.prototype, kElement, true) + +type RenderFunction = Element.Render + +function Element(type: string | RenderFunction, ...children: (Element.Fragment | undefined)[]): Element +function Element(type: string | RenderFunction, attrs: Dict, ...children: (Element.Fragment | undefined)[]): Element +function Element(type: string | RenderFunction, ...args: any[]) { + const el = Object.create(ElementConstructor.prototype) + const attrs: Dict = {}, children: Element[] = [] + if (args[0] && typeof args[0] === 'object' && !Element.isElement(args[0]) && !Array.isArray(args[0])) { + const props = args.shift() + for (const [key, value] of Object.entries(props)) { + if (isNullable(value)) continue + // https://github.com/reactjs/rfcs/pull/107 + if (key === 'children') { + args.push(...makeArray(value)) + } else { + attrs[camelize(key)] = value + } + } + } + for (const child of args) { + children.push(...Element.toElementArray(child)) + } + if (typeof type === 'function') { + attrs.is = type + type = 'component' + } + return Object.assign(el, { type, attrs, children }) +} + +// eslint-disable-next-line no-new-func +const evaluate = new Function('expr', 'context', ` + try { + with (context) { + return eval(expr) + } + } catch {} +`) as ((expr: string, context: object) => string) + +namespace Element { + export const jsx = Element + export const jsxs = Element + export const jsxDEV = Element + export const Fragment = 'template' + + export type Fragment = string | Element | (string | Element)[] + export type Visit = (element: Element, session?: S) => T + export type Render = (attrs: Dict, children: Element[], session: S) => T + export type SyncTransformer = boolean | Fragment | Render + export type Transformer = boolean | Fragment | Render, S> + + type SyncVisitor = Dict> | Visit + type Visitor = Dict> | Visit, S> + + export function isElement(source: any): source is Element { + return source && typeof source === 'object' && source[kElement] + } + + export function toElement(content: string | Element) { + if (typeof content === 'string' || typeof content === 'number' || typeof content === 'boolean') { + content = '' + content + if (content) return Element('text', { content }) + } else if (isElement(content)) { + return content + } else if (!isNullable(content)) { + throw new TypeError(`Invalid content: ${content}`) + } + } + + export function toElementArray(content: Element.Fragment) { + if (Array.isArray(content)) { + return content.map(toElement).filter((x): x is Element => !!x) + } else { + return [toElement(content)].filter((x): x is Element => !!x) + } + } + + export function normalize(source: Fragment, context?: any) { + return typeof source === 'string' ? parse(source, context) : toElementArray(source) + } + + export function escape(source: string, inline = false) { + const result + = (source ?? '').replace(/&/g, '&') + .replace(//g, '>') + return inline + ? result.replace(/"/g, '"') + : result + } + + export function unescape(source: string) { + return source + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/&#(\d+);/g, (_, code) => code === '38' ? _ : String.fromCharCode(+code)) + .replace(/&#x([0-9a-f]+);/gi, (_, code) => code === '26' ? _ : String.fromCharCode(parseInt(code, 16))) + .replace(/&(amp|#38|#x26);/g, '&') + } + + export interface FindOptions { + type?: string + caret?: boolean + } + + /** @deprecated use `Element.select()` instead */ + export function from(source: string, options: FindOptions = {}): Element | undefined { + const elements = parse(source) + if (options.caret) { + if (options.type && elements[0]?.type !== options.type) return + return elements[0] + } + return select(elements, options.type || '*')[0] + } + + type Combinator = ' ' | '>' | '+' | '~' + + export interface Selector { + type: string + combinator: Combinator + } + + const combRegExp = / *([ >+~]) */g + + export function parseSelector(input: string): Selector[][] { + return input.split(',').map((query) => { + const selectors: Selector[] = [] + query = query.trim() + let combCap: RegExpExecArray | null, combinator: Combinator = ' ' + while ((combCap = combRegExp.exec(query))) { + selectors.push({ type: query.slice(0, combCap.index), combinator }) + combinator = combCap[1] as Combinator + query = query.slice(combCap.index + combCap[0].length) + } + selectors.push({ type: query, combinator }) + return selectors + }) + } + + export function select(source: string | Element[], query: string | Selector[][]): Element[] { + if (!source || !query) return [] + if (typeof source === 'string') source = parse(source) + if (typeof query === 'string') query = parseSelector(query) + if (!query.length) return [] + let adjacent: Selector[][] = [] + const results: Element[] = [] + for (const [index, element] of source.entries()) { + const inner: Selector[][] = [] + const local = [...query, ...adjacent] + adjacent = [] + let matched = false + for (const group of local) { + const { type, combinator } = group[0] + if (type === element.type || type === '*') { + if (group.length === 1) { + matched = true + } else if ([' ', '>'].includes(group[1].combinator)) { + inner.push(group.slice(1)) + } else if (group[1].combinator === '+') { + adjacent.push(group.slice(1)) + } else { + query.push(group.slice(1)) + } + } + if (combinator === ' ') { + inner.push(group) + } + } + if (matched) results.push(source[index]) + results.push(...select(element.children, inner)) + } + return results + } + + export function interpolate(expr: string, context: any) { + expr = expr.trim() + if (!/^[\w.]+$/.test(expr)) { + return evaluate(expr, context) ?? '' + } + let value = context + for (const part of expr.split('.')) { + value = value[part] + if (isNullable(value)) return '' + } + return value ?? '' + } + + const tagRegExp1 = /(?)|(?<(\/?)([^!\s>/]*)([^>]*?)\s*(\/?)>)/ + const tagRegExp2 = /(?)|(?<(\/?)([^!\s>/]*)([^>]*?)\s*(\/?)>)|(?\{(?[@:/#][^\s}]*)?[\s\S]*?\})/ + const attrRegExp1 = /([^\s=]+)(?:="(?[^"]*)"|='(?[^']*)')?/g + const attrRegExp2 = /([^\s=]+)(?:="(?[^"]*)"|='(?[^']*)'|=\{(?[^}]+)\})?/g + + const enum Position { + OPEN, + CLOSE, + EMPTY, + CONTINUE, + } + + interface Token { + type: 'angle' | 'curly' + name: string + position: Position + source: string + extra: string + children?: Dict<(string | Token)[]> + } + + export function parse(source: string, context?: any) { + const tokens: (string | Token)[] = [] + function pushText(content: string) { + if (content) tokens.push(content) + } + + const tagRegExp = context ? tagRegExp2 : tagRegExp1 + let tagCap: RegExpExecArray | null + let trimStart = true + while ((tagCap = tagRegExp.exec(source))) { + const trimEnd = !tagCap.groups!.curly + parseContent(source.slice(0, tagCap.index), trimStart, trimEnd) + trimStart = trimEnd + source = source.slice(tagCap.index + tagCap[0].length) + const [_, , , close, type, extra, empty] = tagCap + if (tagCap.groups!.comment) continue + if (tagCap.groups!.curly) { + let name = '', position = Position.EMPTY + if (tagCap.groups!.derivative) { + name = tagCap.groups!.derivative.slice(1) + position = { + '@': Position.EMPTY, + '#': Position.OPEN, + '/': Position.CLOSE, + ':': Position.CONTINUE, + }[tagCap.groups!.derivative[0]]! + } + tokens.push({ + type: 'curly', + name, + position, + source: tagCap.groups!.curly, + extra: tagCap.groups!.curly.slice(1 + (tagCap.groups!.derivative ?? '').length, -1), + }) + continue + } + tokens.push({ + type: 'angle', + source: _, + name: type || Fragment, + position: close ? Position.CLOSE : empty ? Position.EMPTY : Position.OPEN, + extra, + }) + } + + parseContent(source, trimStart, true) + function parseContent(source: string, trimStart: boolean, trimEnd: boolean) { + source = unescape(source) + if (trimStart) source = source.replace(/^\s*\n\s*/, '') + if (trimEnd) source = source.replace(/\s*\n\s*$/, '') + pushText(source) + } + + return parseTokens(foldTokens(tokens), context) + } + + function foldTokens(tokens: (string | Token)[]) { + const stack: [Required, string][] = [[{ + type: 'angle', + name: Fragment, + position: Position.OPEN, + source: '', + extra: '', + children: { default: [] }, + }, 'default']] + + function pushToken(...tokens: (string | Token)[]) { + const [token, slot] = stack[0] + token.children[slot].push(...tokens) + } + + for (const token of tokens) { + if (typeof token === 'string') { + pushToken(token) + continue + } + const { name, position } = token + if (position === Position.CLOSE) { + if (stack[0][0].name === name) { + stack.shift() + } + } else if (position === Position.CONTINUE) { + stack[0][0].children[name] = [] + stack[0][1] = name + } else if (position === Position.OPEN) { + pushToken(token) + token.children = { default: [] } + stack.unshift([token as Required, 'default']) + } else { + pushToken(token) + } + } + + return stack[stack.length - 1][0].children.default + } + + function parseTokens(tokens: (string | Token)[], context?: any) { + const result: Element[] = [] + for (const token of tokens) { + if (typeof token === 'string') { + result.push(Element('text', { content: token })) + } else if (token.type === 'angle') { + const attrs = {} + const attrRegExp = context ? attrRegExp2 : attrRegExp1 + let attrCap: RegExpExecArray | null + while ((attrCap = attrRegExp.exec(token.extra))) { + const [, key, v1, v2 = v1, v3] = attrCap + if (v3) { + attrs[key] = interpolate(v3, context) + } else if (!isNullable(v2)) { + attrs[key] = unescape(v2) + } else if (key.startsWith('no-')) { + attrs[key.slice(3)] = false + } else { + attrs[key] = true + } + } + result.push(Element(token.name, attrs, token.children && parseTokens(token.children.default, context))) + } else if (!token.name) { + result.push(...toElementArray(interpolate(token.extra, context))) + } else if (token.name === 'if') { + if (evaluate(token.extra, context)) { + result.push(...parseTokens(token.children!.default, context)) + } else { + result.push(...parseTokens(token.children!.else || [], context)) + } + } else if (token.name === 'each') { + const [expr, ident] = token.extra.split(/\s+as\s+/) + const items = interpolate(expr, context) + if (!items || !items[Symbol.iterator]) continue + for (const item of items) { + result.push(...parseTokens(token.children!.default, { ...context, [ident]: item })) + } + } + } + return result + } + + function visit(element: Element, rules: Visitor, session?: S) { + const { type, attrs, children } = element + if (typeof rules === 'function') { + return rules(element, session) + } else { + let result: any = rules[typeof type === 'string' ? type : ''] ?? rules.default ?? true + if (typeof result === 'function') { + result = result(attrs, children, session) + } + return result + } + } + + export function transform(source: string, rules: SyncVisitor, session?: S): string + export function transform(source: Element[], rules: SyncVisitor, session?: S): Element[] + export function transform(source: string | Element[], rules: SyncVisitor, session?: S) { + const elements = typeof source === 'string' ? parse(source) : source + const output: Element[] = [] + elements.forEach((element) => { + const { type, attrs, children } = element + const result = visit(element, rules, session) + if (result === true) { + output.push(Element(type, attrs, transform(children, rules, session))) + } else if (result !== false) { + output.push(...toElementArray(result)) + } + }) + return typeof source === 'string' ? output.join('') : output + } + + export async function transformAsync(source: string, rules: Visitor, session?: S): Promise + export async function transformAsync(source: Element[], rules: Visitor, session?: S): Promise + export async function transformAsync(source: string | Element[], rules: Visitor, session?: S) { + const elements = typeof source === 'string' ? parse(source) : source + const children = (await Promise.all(elements.map(async (element) => { + const { type, attrs, children } = element + const result = await visit(element, rules, session) + if (result === true) { + return [Element(type, attrs, await transformAsync(children, rules, session))] + } else if (result !== false) { + return toElementArray(result) + } else { + return [] + } + }))).flat(1) + return typeof source === 'string' ? children.join('') : children + } +} + +export default Element + +export { Element, Element as h, Element as jsx, Element as jsxs, Element as jsxDEV } diff --git a/packages/element/tests/segment.spec.ts b/packages/element/tests/segment.spec.ts new file mode 100644 index 0000000..49ab469 --- /dev/null +++ b/packages/element/tests/segment.spec.ts @@ -0,0 +1,107 @@ +import Element from '../src' +import { describe, test } from 'node:test' +import { expect, use } from 'chai' +import shape from 'chai-shape' + +use(shape) + +describe('Element API', () => { + test('Element.escape()', () => { + expect(Element.escape('')).to.equal('<foo>') + expect(Element.escape('"')).to.equal('&quot;') + }) + + test('Element.unescape()', () => { + expect(Element.unescape('<foo>')).to.equal('') + expect(Element.unescape('&quot;')).to.equal('"') + }) + + describe('Element.parse()', () => { + test('basic support', () => { + expect(Element.parse('')) + .to.deep.equal([Element('img', { src: 'https://test.com/?foo=1&bar=2' })]) + expect(Element.parse(`text`)) + .to.deep.equal([Element('tag', { foo: "'", bar: '"' }, 'text')]) + expect(Element.parse('text')) + .to.deep.equal([Element('tag', { foo: false, barQux: true }, 'text')]) + }) + + test('mismatched tags', () => { + expect(Element.parse('123').join('')).to.equal('123') + expect(Element.parse('14').join('')).to.equal('14') + expect(Element.parse('14').join('')).to.equal('14') + }) + + test('whitespace', () => { + expect(Element.parse(`<> + 1 + + 2 + `).join('')).to.equal('') + }) + }) + + describe('Interpolation', () => { + test('interpolate', () => { + expect(Element.parse('1{foo}1', { foo: 233, bar: 666 })) + .to.deep.equal([Element('tag', { bar: 666 }, '1', '233', '1')]) + expect(Element.parse('>{">"}', {})) + .to.deep.equal([Element('tag', '>', '>')]) + expect(Element.parse('{0}{1+1}', [233, 666])) + .to.deep.equal([Element('tag', '233', '2')]) + }) + + test('control flow', () => { + expect(Element.parse('{#if foo >= 0}{foo}{:else}

negative

{/if}', { foo: 233 })) + .to.deep.equal([Element.text('233')]) + expect(Element.parse('{#if foo >= 0}{foo}{:else}

negative

{/if}', { foo: -233 })) + .to.deep.equal([Element('p', 'negative')]) + }) + + test('#each', () => { + expect(Element.parse('{#each arr as i}{i ** 2}{/each}', { arr: [1, 2, 3] })) + .to.deep.equal([Element.text('1'), Element.text('4'), Element.text('9')]) + }) + }) + + describe('Element.toString()', () => { + test('basic support', () => { + expect(Element('img', { src: 'https://test.com/?foo=1&bar=2' }).toString()) + .to.equal('') + expect(Element('tag', { foo: false, barQux: true }, 'text').toString()) + .to.equal('text') + expect(Element('template', Element.parse('<bar>')).toString(true)).to.equal('') + }) + + test('validate children', () => { + expect(() => Element('tag', {}, {} as any)).to.throw() + expect(Element('tag', ['123', null, Element('span', '456')]).toString()) + .to.equal('123456') + expect(Element('tag', { children: '789' }).toString()) + .to.equal('789') + }) + }) + + describe('Selectors', () => { + const selectIds = (source: string, query: string) => Element.select(source, query).map(el => el.attrs.id) + + test('type selector', () => { + expect(selectIds('', 'a')).to.deep.equal(['1', '2']) + expect(selectIds('', 'b')).to.deep.equal(['2']) + expect(selectIds('', 'c')).to.deep.equal([]) + }) + + test('descendant', () => { + expect(selectIds('', 'b>c')).to.deep.equal(['3']) + expect(selectIds('', 'a c')).to.deep.equal(['3']) + expect(selectIds('', 'a>c')).to.deep.equal([]) + expect(selectIds('', 'b c')).to.deep.equal([]) + expect(selectIds('', 'a>c')).to.deep.equal(['3']) + }) + + test('sibling', () => { + expect(selectIds('', 'a+b')).to.deep.equal(['3']) + expect(selectIds('', 'a~b')).to.deep.equal(['3', '4']) + }) + }) +}) diff --git a/packages/element/tsconfig.json b/packages/element/tsconfig.json new file mode 100644 index 0000000..eecffdf --- /dev/null +++ b/packages/element/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib", + }, + "include": [ + "src" + ] +} \ No newline at end of file