Signed-off-by: Avior <git@avior.me>
This commit is contained in:
2025-06-30 22:41:18 +02:00
commit 86b37af716
16 changed files with 1526 additions and 0 deletions

161
src/compiler/codeshift.ts Normal file
View File

@ -0,0 +1,161 @@
import { ArrayExpression, Identifier, JSCodeshift, Literal, ObjectExpression, Property, Transform } from "jscodeshift"
import pathUtils from 'path/posix'
interface ObjectField {
type: 'Object'
items: Record<string, Field>
item: ObjectExpression
}
interface EndField {
type: 'Literal'
item: Literal
}
interface ArrayField {
type: 'Array'
items: Array<Field>
item: ArrayExpression
}
type Field = ObjectField | EndField | ArrayField
type Possible = ObjectExpression | ArrayExpression | Literal
function processItem(value: Possible): Field {
if (value.type === 'ObjectExpression') {
return simplify(value)
} else if (value.type === 'ArrayExpression') {
const field: Field = {
type: 'Array',
items: [],
item: value
}
value.elements.forEach((it) => {
field.items.push(processItem(it as Possible))
})
return field
} else {
return {
type: 'Literal',
item: value
}
}
}
function simplify(base: ObjectExpression): ObjectField {
const list: ObjectField['items'] = {}
base.properties.forEach((it) => {
const item = it as Property
const key = (item.key as Identifier).name
list[key] = processItem(item.value as Possible)
})
return {
type: 'Object',
items: list,
item: base
}
}
function exists(path: ObjectExpression | ArrayExpression, key: string | number) {
if (path.type === 'ObjectExpression') {
path.properties.forEach((p) => {
const prop = p as Property
if ((prop.key as Identifier).name === (key + '')) {
return true
}
})
return false
} else {
}
}
function set(j: JSCodeshift, path: ObjectExpression | ArrayExpression, value: Possible, key: string | number, options?: { override?: boolean }) {
let exists = false
if (path.type === 'ObjectExpression') {
path.properties.forEach((p) => {
const prop = p as Property
if ((prop.key as Identifier).name === (key + '')) {
exists = true
if (!options?.override) {
console.warn('Property already exist, add the option override to change it')
return
}
prop.value = value
}
})
if (exists) { return }
if (key.toString().includes('-')) {
key = `'${key.toString()}'`
}
path.properties.push(j.property('init', j.identifier(key + ''), value))
} else {
}
}
function remove(path: ObjectExpression | ArrayExpression, key: string | number) {
if (path.type === 'ObjectExpression') {
const index = path.properties.findIndex((p) => ((p as Property).key as Identifier).name === (key + ''))
if (index === -1) {
return
}
path.properties.splice(index)
} else {
}
}
function rename(parent: ObjectExpression, oldKey: string, newKey: string) {
parent.properties.forEach((p) => {
if (p.key.name === oldKey) {
p.key.name = newKey
}
})
}
/**
* Start editing here !
*/
module.exports = (file, api): Transformer => {
const j = api.jscodeshift
const root = j(file.source)
return root
.find(j.ObjectExpression)
.forEach((path, index) => {
if (index !== 0) return
const filename = pathUtils.basename(file.path, '.ts')
let simplified = simplify(path.node)
rename(simplified.item, 'abbrevation', 'abbreviations')
// set(j, simplified.item, j.objectExpression([j.property('init', j.identifier('fr'), j.literal(abbr))]), 'abbrevation')
// set(j, simplified.item, j.literal('a'), 's.official')
// Example remove field
// remove(name.item as ObjectExpression, 'fr')
// Example Set/Add regulationMArk to cards
// set(j, name.items.fr, j.literal('D'), 'regulationMark')
// console.log(filename)
const ids = [
5, 6, 8, 11, 12, 13, 14, 17, 22, 23, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 37, 41, 43, 44, 45, 46, 49, 51, 54, 56, 57, 58, 59, 60, 64, 65, 70, 73, 75, 76, 78, 80, 82, 91, 92, 116, 117, 119, 128, 129
]
const id = parseInt(filename)
const isHolo = ids.includes(id) || id >= 131
const isNormal = !isHolo
if (isHolo) {
set(j, simplified.items.variants.item as ObjectExpression, j.literal(true), 'holo')
set(j, simplified.items.variants.item as ObjectExpression, j.literal(false), 'normal')
} else {
remove(simplified.item, 'variants')
}
})
.toSource({ useTabs: true, lineTerminator: '\n' }).replace(/ /g, ' ')
}
module.exports.parser = 'ts'

220
src/compiler/index.ts Normal file
View File

@ -0,0 +1,220 @@
import ts from 'typescript'
import fs from 'fs'
const input = 'src/components/button/index.ts' // Input file path
const output = 'src/components/button/Button.transformed.ts' // Output file path for transformed code
interface SimpleHTMLElement {
name: string
attrs?: Record<string, string | any>
childs?: Array<SimpleHTMLElement | string>
}
// Read input source code
const src = fs.readFileSync(input, 'utf-8')
// Create a TypeScript source file from input source code string
const sourceFile = ts.createSourceFile(input, src, ts.ScriptTarget.Latest, true)
function parseName(name: string, values: Array<ts.Expression>): string | ts.Expression {
const match = /^\u0000(\d+)\u0000$/.exec(name)
if (match) {
const idx = Number(match[1])
return values[idx]
}
return name
}
function parseAttrs(attrString: string, values: Array<ts.Expression>): Record<string, string | ts.Expression> {
const attrs: Record<string, string | ts.Expression> = {}
console.log(attrString) // \u00001\u0000
const parts = attrString.match(/(?:[^.*=]+=(?:"[^"]*"|'[^']*'|[^.*"']+))/g) ?? []
for (const part of parts) {
if (!part) {
continue
}
const [key, raw] = part.trim().split('=') as [string, string]
if (!raw) {
continue
}
const val = raw.replace(/^['"]?|['"]?$/g, '')
const match = /^\u0000(\d+)\u0000$/.exec(val)
if (match) {
const idx = Number(match[1])
attrs[key] = values[idx]
} else {
attrs[key] = val
}
}
return attrs
}
/**
* Naively parse a TemplateLiteral assumed to contain a simple html tagged template string.
* Extracts:
* - tagName (e.g. 'button')
* - attrs as key-value pairs (only "class" attribute parsed here)
* - children: a mix of static strings and interpolated expressions
* @param template TemplateLiteral node from the AST (expects TemplateExpression)
* @returns parsed SimpleHTMLElement parts or null if parsing fails
*/
function extractHtmlData(template: ts.TemplateLiteral): {
tagName: string | ts.Expression
attrs: Record<string, string | ts.Expression>
children: (ts.Expression | ts.StringLiteral)[]
} | null {
// We only handle TemplateExpression (with interpolations) here
if (!ts.isTemplateExpression(template)) return null
// Collect all template literal strings and expressions separately
const fullStrings: string[] = [template.head.text]
const exprs: ts.Expression[] = []
// Each template span has an expression and a literal text after it
let idx = 0
for (const span of template.templateSpans) {
exprs.push(span.expression) // interpolation `${...}`
fullStrings.push(`\u0000${idx++}\u0000`) // literal after interpolation
fullStrings.push(span.literal.text) // literal after interpolation
}
console.log('aaa', exprs, fullStrings.join('').replaceAll(/\s*\n\s*/g, '').trim())
// Join all literal parts to parse the opening tag naively with regex
const fullHtml = fullStrings.join('').replaceAll(/\s*\n\s*/g, '').trim()
const selfClose = /^<([a-zA-Z0-9-\u0000]+)([^>]*)\/>$/.exec(fullHtml)
if (selfClose) {
const [, tagName, rawAttrs] = selfClose
const attrs = parseAttrs(rawAttrs, exprs)
return {
tagName: parseName(tagName, exprs),
attrs: Object.keys(attrs).length ? attrs : {},
children: []
}
}
const fullMatch = /^<([a-zA-Z0-9-\u0000]+)([^>]*)>([\s\S]*)<\/.*>$/.exec(fullHtml)
if (!fullMatch) {
throw new Error(`Invalid HTML: ${fullHtml}`)
}
const [, tagName, rawAttrs, inner] = fullMatch
const attrs = parseAttrs(rawAttrs, exprs)
const children: (ts.Expression | ts.StringLiteral)[] = []
const startsWithShit = inner.startsWith('\u0000')
inner.split(/\u0000/).forEach((part, i) => {
if (i % 2 === (startsWithShit ? 1 : 0) && exprs[Number.parseInt(part)]) {
children.push(exprs[Number.parseInt(part)])
} else if (part) {
children.push(ts.factory.createStringLiteral(part))
}
})
// // Build children array alternating strings and expressions from template parts
// const children: (ts.Expression | ts.StringLiteral)[] = []
// for (let i = 0; i < fullStrings.length; i++) {
// console.log(fullStrings[i], exprs[i])
// // Add string chunk if not empty
// if (inner[i]) {
// children.push(ts.factory.createStringLiteral(fullStrings[i]))
// }
// // Add expression if exists (one less than strings length)
// if (exprs[i]) {
// children.push(exprs[i])
// }
// }
return {
tagName: parseName(tagName, exprs),
attrs,
children,
}
}
/**
* TypeScript Transformer Factory
* Transforms `html` tagged template literals into SimpleHTMLElement object literals.
* Only handles simple cases with static tags and attributes and interpolated children.
*/
const transformer: ts.TransformerFactory<ts.SourceFile> = context => {
return rootNode => {
// Recursive AST visitor function
function visit(node: ts.Node): ts.Node {
// Check if node is a tagged template expression with tag name 'html'
if (
ts.isTaggedTemplateExpression(node) &&
node.tag.getText() === 'html' &&
ts.isTemplateExpression(node.template)
) {
// Extract html data from template literal
const parsed = extractHtmlData(node.template)
// console.log(parsed)
if (!parsed) return node // fallback: no transform if parsing failed
// Create AST properties for the SimpleHTMLElement object literal
// console.log(parsed.tagName)
const props: ts.ObjectLiteralElementLike[] = [
// name property: tag name as string literal
ts.factory.createPropertyAssignment(
'name',
typeof parsed.tagName === 'string' ? ts.factory.createStringLiteral(parsed.tagName) : parsed.tagName
),
]
// Add attrs property if any attributes found
if (Object.keys(parsed.attrs).length > 0) {
props.push(
ts.factory.createPropertyAssignment(
'attrs',
ts.factory.createObjectLiteralExpression(
// Create key-value pairs for each attribute
Object.entries(parsed.attrs).map(([k, v]) =>
ts.factory.createPropertyAssignment(k, typeof v === 'string' ? ts.factory.createStringLiteral(v) : v)
),
true // multiline formatting
)
)
)
}
// Add childs property if any children (strings or expressions)
if (parsed.children.length > 0) {
props.push(
ts.factory.createPropertyAssignment(
'childs',
ts.factory.createArrayLiteralExpression(parsed.children, true)
)
)
}
// Return the object literal AST node that replaces the `html` tagged template call
return ts.factory.createObjectLiteralExpression(props, true)
}
// Recursively visit children nodes
return ts.visitEachChild(node, visit, context)
}
// Start AST traversal from root
return ts.visitNode(rootNode, visit) as ts.SourceFile
}
}
// Run the transform on the source file with our custom transformer
const result = ts.transform(sourceFile, [transformer])
// Prepare printer to output transformed AST back to TypeScript code string
const printer = ts.createPrinter()
// Get transformed source code text
const outputText = printer.printFile(result.transformed[0])
// Write the transformed source code to output file
fs.writeFileSync(output, outputText)
console.log(`✅ Wrote transformed file to: ${output}`)

View File

@ -0,0 +1,5 @@
---
import Item from '.'
import AstroSSR from '../utils/AstroSSR.astro'
---
<AstroSSR component={Item} props={Astro.props} slots={Astro.slots} />

View File

@ -0,0 +1,22 @@
import { Component, WebElement, type SimpleHTMLElement, html, cls, attribute } from '..'
@Component('bad-ge')
export default class Badge extends WebElement {
@attribute()
public badge?: WebElement
@attribute()
public class?: string
public override async render(): Promise<SimpleHTMLElement> {
return html`
<div class=${cls(['px-4 py-1 text-center rounded-full relative flex', this.class])}>
<div class="overflow-ellipsis w-full overflow-clip">
${this.badge}
<slot/>
</div>
</div>
`
}
}

View File

@ -0,0 +1,5 @@
---
import Btn from '.'
import AstroSSR from '../utils/AstroSSR.astro'
---
<AstroSSR component={Btn} props={Astro.props} slots={Astro.slots} />

View File

@ -0,0 +1,74 @@
import { attribute, Component, WebElement, cls, html } from '..'
@Component('butt-on')
export default class Button extends WebElement {
@attribute('boolean')
public block?: boolean
@attribute()
public iconLeft?: any
@attribute()
public iconRight?: any
@attribute('boolean')
public outline?: boolean
@attribute('boolean')
public outlineR?: boolean
@attribute('boolean')
public outlineG?: boolean
@attribute('boolean')
public ghost?: boolean
@attribute('boolean')
public disabled?: boolean | undefined
@attribute()
public name?: string
@attribute()
public value?: string
@attribute()
public tag?: string
@attribute()
public enctype?: string
@attribute()
public class?: string
@attribute()
public href?: string
public override async render() {
const classes = [
'button',
'no-link-style',
'focus:ring',
{ 'w-full': this.block },
{ outline: this.outline },
{ outlineR: this.outlineR && !this.disabled },
{ outlineG: this.outlineG && !this.disabled },
{ ghost: this.ghost },
{ disabled: this.disabled },
this.class,
]
const tag = this.tag ?? this.href ? 'a' : 'button'
return html`
<${tag} ${{ ...this.getProps() }} class="${cls(classes)}">
${this.iconLeft}
<slot />
${this.iconRight}
</${tag}>
`
}
private onClick = () => {
if (this.disabled) { return }
console.log('Button clicked')
}
}

7
src/components/index.ts Normal file
View File

@ -0,0 +1,7 @@
import WebElement from './utils/web-element'
export {
WebElement
}
export * from './utils/decorators'
export * from './utils/utils'

11
src/components/list.ts Normal file
View File

@ -0,0 +1,11 @@
import Button from './button'
import Badge from './badge'
const components = [
Button,
Badge
]
export function loadComponents() {
return components
}

View File

@ -0,0 +1,32 @@
---
import { objectRemap, objectSize } from '@dzeio/object-util'
import { html, type SimpleHTMLElement, type WebElement } from '..'
export interface Props {
component: new () => WebElement
props?: Record<string, any>
slots?: typeof Astro.slots
}
const omp = new Astro.props.component()
const Tag = omp.getConfig().tag
// parse Astro slots
const slots : Record<string, string | SimpleHTMLElement> = {}
for (const slot of Object.keys(Astro.slots)) {
slots[slot] = html(await Astro.slots.render(slot))
}
if (Astro.props.slots) {
for (const slot of Object.keys(Astro.props.slots)) {
slots[slot] = html(await Astro.props.slots.render(slot))
}
}
// render the component server-side
omp.setProps(Astro.props.props)
omp.setSlots(slots)
const rendered = await omp
.renderString()
---
<Tag set:html={rendered.output} data-hydrate={rendered.needHydration ? true : undefined} {...omp.getProps()} data-slots={objectSize(slots) > 0 ? JSON.stringify(slots) : undefined} />

View File

@ -0,0 +1,121 @@
import { WebElement } from '..'
interface ComponentConfig {
tag: `${string}-${string}`
attrs?: Record<string, AttributeOptions>
shadow?: boolean
}
/**
* Decorator to define a custom element.
* @param tagOrConfig The tag name of the custom element. it MUST respect the formatting else the browser will not recognize it.
*/
export function Component(tagOrConfig: ComponentConfig['tag'] | Omit<ComponentConfig, 'attrs'>) {
const conf = typeof tagOrConfig === 'string' ? { tag: tagOrConfig } : tagOrConfig
return function(constructor: typeof WebElement) {
const child = class extends constructor {
// public static override readonly tag = tag
public constructor() {
super()
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
const proto = ((this as any).__proto__ as typeof WebElement)
Object.assign(proto.__config, conf)
proto.__config.tag = conf.tag
const config = proto.__config
for (const attr of Object.keys(config.attrs ?? {})) {
const pouet = config.attrs![attr]
// console.log(attr, pouet)
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete proto[attr as keyof typeof WebElement]
lateAttribute(this, attr, pouet)
}
}
}
if (typeof customElements === 'undefined') {
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return child as any
}
if (!customElements.get(conf.tag)) {
customElements.define(conf.tag, child)
} else {
throw new Error(`Custom element ${conf.tag} is already defined`)
}
}
}
export interface AttributeOptions {
type?: 'string' | 'number' | 'boolean'
onChange?: (newValue: any, oldValue: any) => void
}
export function lateAttribute(el: WebElement, name: string, options: AttributeOptions = {}) {
if (!(el instanceof WebElement)) {
return
}
const privateKey = `__${name}`
Object.defineProperty(el, name, {
get(this: WebElement) {
return this[privateKey as keyof WebElement]
},
set(this: WebElement, newVal) {
const oldVal = this[privateKey as keyof WebElement]
const changed = newVal !== oldVal
if (!changed) {
return
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
(this as any)[privateKey] = newVal
options.onChange?.(newVal, oldVal)
if (newVal == null || newVal === false) {
this.removeAttribute(name)
} else {
this.setAttribute(name, String(newVal))
}
// invalide current rendering
this.invalidate()
// trigger new render
void this.triggerRender()
},
configurable: true,
enumerable: true,
})
}
type AttributeFunction = (proto: any, name: string) => void
/**
* Decorator to define an attribute on a WebElement.
*/
export function attribute(): AttributeFunction
/**
* Decorator to define an attribute on a WebElement.
* @param type The type of the attribute.
*/
// eslint-disable-next-line @typescript-eslint/unified-signatures
export function attribute(type: AttributeOptions['type']): AttributeFunction
/**
* Decorator to define an attribute on a WebElement.
* @param options The options for the attribute.
*/
// eslint-disable-next-line @typescript-eslint/unified-signatures
export function attribute(options: AttributeOptions): AttributeFunction
export function attribute(typeOrOptions?: AttributeOptions['type'] | AttributeOptions): AttributeFunction {
return function(proto: typeof WebElement, name: string) {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
proto.__config ??= {} as unknown as typeof proto['__config']
proto.__config.attrs ??= {}
proto.__config.attrs[name] = typeof typeOrOptions === 'string' ? { type: typeOrOptions } : typeOrOptions ?? {}
}
}

View File

@ -0,0 +1,291 @@
/* eslint-disable max-depth */
/* eslint-disable complexity */
import { objectMap } from '@dzeio/object-util'
import { WebElement } from '..'
export function HTMLtoString(tag: SimpleHTMLElement): string {
// compile attributes (note: the attributes values are encoded to not break json)
const attrs = objectMap(tag.attrs ?? {}, (value, key) => ` ${key}="${value.replaceAll('"', '&#34;')}"`).join('')
// console.log('a', tag)
// console.log('a', tag.childs)
let childs = ''
if (tag.childs) {
console.log('na', tag, tag.childs)
childs = tag.childs.map((it) => typeof it === 'string' ? it : HTMLtoString(it)).join('')
}
return `<${tag.name}${attrs}>${childs}</${tag.name}>`
}
export function HTMLToElement(tag: SimpleHTMLElement): HTMLElement {
// create root element
const el = document.createElement(tag.name)
// apply attributes
if (tag.attrs) {
for (const [k, v] of Object.entries(tag.attrs)) {
el.setAttribute(k, v)
}
}
// apply event handlers
if (tag.events) {
for (const [evt, handler] of Object.entries(tag.events)) {
el.addEventListener(evt, handler)
}
}
// apply childs
if (tag.childs) {
for (const child of tag.childs) {
if (typeof child === 'string') {
el.appendChild(document.createTextNode(child))
} else {
el.appendChild(HTMLToElement(child))
}
}
}
return el
}
export interface SimpleHTMLElement {
name: string
attrs?: Record<string, string>
events?: Record<string, EventListener>
childs?: Array<SimpleHTMLElement | string>
}
export function hasEvents(el: SimpleHTMLElement): boolean {
if (Object.keys(el.events ?? {}).length > 0) {
return true
}
if (el.childs) {
for (const child of el.childs) {
if (typeof child === 'string') {
continue
}
if (hasEvents(child)) {
return true
}
}
}
return false
}
function parseAttrs(attrString: string, values: Array<unknown>): { attrs: Record<string, string>, events: Record<string, EventListener> } {
const attrs: Record<string, string> = {}
const events: Record<string, EventListener> = {}
const parts = attrString.match(/(?:[^.*=]+=(?:"[^"]*"|'[^']*'|[^.*"']+))/g) ?? []
for (const part of parts) {
if (!part) {
continue
}
const [key, raw] = part.trim().split('=') as [string, string]
if (!raw) {
continue
}
const val = raw.replace(/^['"]?|['"]?$/g, '')
const match = /^\u0000(\d+)\u0000$/.exec(val)
if (match) {
const idx = Number(match[1])
const value = values[idx]
if (key.startsWith('on') && typeof value === 'function') {
events[key.slice(2)] = value as EventListener
} else if (typeof value === 'string') {
attrs[key] = value
}
} else {
attrs[key] = val
}
}
return { attrs, events }
}
// TODO: rework
function parseElement(fragment: string, values: Array<unknown> = []): SimpleHTMLElement | string {
fragment = fragment.trim()
if (!fragment.startsWith('<')) { return fragment }
// self-closing tag
const selfClose = /^<([a-zA-Z0-9_-]+)([^>]*)\/>$/.exec(fragment)
if (selfClose) {
const [, tagName, rawAttrs] = selfClose
const { attrs, events } = parseAttrs(rawAttrs, values)
return {
name: tagName,
attrs: Object.keys(attrs).length ? attrs : undefined,
events: Object.keys(events).length ? events : undefined,
}
}
// regular tag
const fullMatch = /^<([a-zA-Z0-9_-]+)([^>]*)>([\s\S]*)<\/\1>$/.exec(fragment)
if (!fullMatch) { return fragment }
const [, tagName, rawAttrs, inner] = fullMatch
const { attrs, events } = parseAttrs(rawAttrs, values)
const childs: Array<SimpleHTMLElement | string> = []
let rest = inner.trim()
while (rest) {
// interpolation placeholder
const interp = /^\u0000(\d+)\u0000/.exec(rest)
if (interp) {
const idx = Number(interp[1])
const val = values[idx]
if (val instanceof WebElement) {
// childs.push(val.render())
} else if (typeof val === 'object' && val !== null && 'name' in val) { childs.push(val as SimpleHTMLElement) }
else { childs.push(String(val)) }
rest = rest.slice(interp[0].length).trim()
continue
}
if (rest.startsWith('<')) {
const tagMatch = /^<([a-zA-Z0-9_-]+)/.exec(rest)
if (!tagMatch) { break }
const childTag = tagMatch[1]
let depth = 0
let i = 0
for (; i < rest.length; i++) {
if (rest.startsWith(`<${childTag}`, i)) { depth++ }
else if (rest.startsWith(`</${childTag}>`, i)) {
depth--
if (depth === 0) {
i += (`</${childTag}>`).length
break
}
}
}
const chunk = rest.slice(0, i)
childs.push(parseElement(chunk, values) as SimpleHTMLElement)
rest = rest.slice(i).trim()
} else {
const nextIndices = [rest.indexOf('<'), rest.indexOf('\u0000')].filter((i) => i >= 0)
const idx = nextIndices.length ? Math.min(...nextIndices) : -1
const text = idx >= 0 ? rest.slice(0, idx) : rest
childs.push(text.trim())
rest = idx >= 0 ? rest.slice(idx).trim() : ''
}
}
return {
name: tagName,
attrs: Object.keys(attrs).length ? attrs : undefined,
events: Object.keys(events).length ? events : undefined,
childs: childs.length ? childs : undefined,
}
}
/**
* Parses a tagged template literal into a SimpleHTMLElement.
*/
export function html(str: string): SimpleHTMLElement
export function html(strings: TemplateStringsArray, ...values: Array<string | number | undefined | boolean | object | null | WebElement>): SimpleHTMLElement
export function html(strings: TemplateStringsArray | string, ...values: Array<unknown>): SimpleHTMLElement {
// input as a raw string, limited parsing.
if (typeof strings === 'string') {
return parseElement(strings.replaceAll('&#34;', '"')) as SimpleHTMLElement
}
// the new built string
let full = ''
// indexes of values that are already parsed into the `full` string
for (let idx = 0; idx < strings.length; idx++) {
full += strings[idx]!
if (idx < values.length) {
const value = values[idx]
switch (typeof value) {
case 'undefined': {
break
}
// if the value is a string, add it to the full string and mark it for removal
case 'string': {
full += values[idx] as string
break
}
case 'object': {
if (value === null) {
break
}
if (value instanceof WebElement) {
} else {
const attrs = Object.entries(value as Record<string, unknown>)
.filter(([, value]) => typeof value !== 'undefined' && value !== null)
.map(([key, value]) => `${key}="${(value as string).replace('"', '&#34;')}"`)
.join(' ')
full += attrs
break
}
}
default: {
// add a placeholder for the value to be parsed later
full += `\u0000${idx}\u0000`
}
}
}
}
// parse & return :D
return parseElement(full.replaceAll('&#34;', '"'), values) as SimpleHTMLElement
}
type ClassList = Array<string | Record<string, any>>
/**
* Simple helper function to create a string with class names.
* @param items - Array of class names or objects with class names as keys and boolean values as values.
* @returns A string with the class names separated by spaces.
*/
export function cls(items: string | ClassList): string
/**
* Simple helper function to create a string with class names.
* @param items - Array of class names or objects with class names as keys and boolean values as values.
* @returns A string with the class names separated by spaces.
*/
export function cls(...items: ClassList | Array<ClassList>): string
/**
* Simple helper function to create a string with class names.
* @param items - Array of class names or objects with class names as keys and boolean values as values.
* @returns A string with the class names separated by spaces.
*/
export function cls(...items: Array<string | undefined | null | Record<string, any>>): string {
if (items.length === 1 && typeof items[0] === 'string') {
return items[0]
} else if (items.length === 1 && Array.isArray(items[0])) {
items = items[0] as ClassList
}
return items.map((item) => {
if (typeof item === 'undefined' || item === null) {
return null
}
if (typeof item === 'string') {
return item
}
return Object.keys(item).filter((key) => item[key]).join(' ')
}).filter((it) => !!it).join(' ')
}
export function attrs(string: Record<string, any>): string {
return ''
}
export function getContext(): 'browser' | 'node' {
if (typeof document !== 'undefined') {
return 'browser'
}
return 'node'
}
export function assert(bool: any, message?: string): asserts bool {
if (!bool) {
throw new Error(message ?? 'Assertion failed')
}
}

View File

@ -0,0 +1,287 @@
/* eslint-disable max-classes-per-file */
import { objectLoop } from '@dzeio/object-util'
import { assert, type AttributeOptions, html, type SimpleHTMLElement, HTMLToElement, HTMLtoString, getContext, hasEvents } from '..'
// Polyfill HTMLElement on server to allow SSR rendering
let localHTMLElement: typeof HTMLElement
if (typeof HTMLElement !== 'undefined') {
localHTMLElement = HTMLElement
} else {
// @ts-expect-error polyfill for SSR
localHTMLElement = class HTMLElement {
public attachShadow(_options: ShadowRootInit): ShadowRoot {
return undefined as unknown as ShadowRoot
}
public setAttribute(_name: string, _value: string) {
// super.setAttribute(name, value)
}
}
}
/**
* Wrapper around HTMLElement that provides a simple way to create custom elements.
*/
export default class WebElement extends localHTMLElement {
public static readonly tag: string = 'web-element'
public static __config: {
tag: string
attrs?: Record<string, AttributeOptions>
} = {
tag: 'web-element'
}
/**
* childs of the current element that will be rendered inside `<slot />` elements
*/
public childs: Record<string, SimpleHTMLElement | string> = {}
/**
* Indicates whether the element has been mounted in the DOM.
*/
private mounted = false
/**
* Indicates whether the element needs to be rendered.
*/
private needRender = true
/**
* The last rendered element for caching purposes.
*/
private lastRender: SimpleHTMLElement | undefined
public constructor() {
super()
// attach to shadow root
// this.attachShadow({ mode: 'open' })
// this.connectedCallback()
}
public static async ref(props?: any): Promise<SimpleHTMLElement> {
const conf = this.__config
const self = new this()
.setProps(props)
return html`<${conf.tag}>${(await self.renderString()).output}</${conf.tag}>`
}
public getConfig(): typeof WebElement['__config'] {
return this.__proto__.__config
}
/**
* Function run when the element is connected to the DOM.
*/
public connectedCallback() {
// setup slots
if (this.dataset.slots) {
this.setSlots(JSON.parse(this.dataset.slots) as Record<string, SimpleHTMLElement>)
this.removeAttribute('data-slots')
}
// move the element inside of the shadow root
// this.shadowRoot!.innerHTML = this.innerHTML
// this.innerHTML = ''
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
const attrSettings = ((this as any).__proto__ as typeof WebElement).__config.attrs
for (const key of Object.keys(attrSettings)) {
if (this.hasAttribute(key)) {
this.setProps({[key]: this.getAttribute(key)})
}
}
// setup attributes observer
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
const attr = mutation.attributeName
if (mutation.type === 'attributes' && attr) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
const attrSettings = ((this as any).__proto__ as typeof WebElement).__config.attrs?.[attr]
if (!attrSettings) {
return
}
const type = attrSettings.type ?? 'string'
switch (type) {
case 'boolean': {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
(this as any)[attr] = this.hasAttribute(attr) ? this.getAttribute(attr) !== 'false' : false
break
}
case 'number': {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
(this as any)[attr] = this.hasAttribute(attr) ? parseInt(this.getAttribute(attr) ?? '0', 10) : undefined
break
}
default: {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
(this as any)[attr] = this.getAttribute(attr)
}
}
}
})
})
observer.observe(this, {
attributes: true
})
// trigger frontend hydration
if (this.dataset.hydrate === 'true') {
this.removeAttribute('data-hydrate')
void this.triggerRender()
}
}
/**
* Called when the component is mounted in the DOM
*/
public didMount() { /** child to implement */}
/**
* Called when the component was updated from an attribute change
*/
public didUpdate() { /** child to implement */}
/**
* Render the component, this MUST be stateless and return the result of the HTML `html` helper
*
* note: if a parent changes, this render function won't be run again
*/
public async render(): Promise<SimpleHTMLElement> {
return html`<div />`
}
/**
* Invalidate the component, this will trigger a re-render on the next call to `triggerRender`
*/
public invalidate() {
this.needRender = true
}
public setSlots(name: string, value: SimpleHTMLElement | string | null): this
public setSlots(slots: Record<string, SimpleHTMLElement | string | null>): this
public setSlots(slots: Record<string, SimpleHTMLElement | string | null> | string, element?: SimpleHTMLElement | string | null) {
if (typeof slots === 'string') {
slots = { [slots]: element as SimpleHTMLElement | string | null }
}
objectLoop(slots, (value, key) => {
if (value === null) {
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete this.childs[key]
} else {
this.childs[key] = value
}
})
return this
}
public getProps(): Record<string, any> {
const props: Record<string, any> = {}
for (const key of Object.keys(this.getConfig().attrs ?? {})) {
props[key] = this[key]
}
return props
}
public setProps(props?: Partial<Record<string, any>>) {
if (!props) {return this}
objectLoop(props, (value, key) => {
this[key] = value
})
return this
}
/**
* Render the component to a string.
*
* warning: events are excluded from the output.
*/
public async renderString(): Promise<{output: string, needHydration: boolean}> {
const res = await this.renderOrCache()
// SSR: manually replace <slot> tags
let out = HTMLtoString(res)
for (const [name, el] of Object.entries(this.childs)) {
let regex: RegExp
if (name === 'default') {
regex = /<slot(\s+name=["']?default["']?)?\s*\/?>.*?<\/slot>|<slot(\s+name=["']?default["']?)?\s*\/?>/i
} else {
regex = new RegExp(
`<slot\\s+name=["']?${name}["']?\\s*\\/?>.*?<\\/slot>|<slot\\s+name=["']?${name}["']?\\s*\\/?>`,
'i'
)
}
out = out.replace(regex, typeof el === 'string' ? el : HTMLtoString(el))
}
return { output: out, needHydration: hasEvents(res) }
}
/**
* Render the component to an HTMLElement for the browser.
*
* @throws {Error} if run outside the browser
*/
public async renderHTML(): Promise<HTMLElement> {
assert(getContext() === 'browser', 'renderHTML can only be run inside the browser')
const rendered = await this.renderOrCache()
const root = HTMLToElement(rendered)
for (const [name, el] of Object.entries(this.childs)) {
let slot: HTMLSlotElement | undefined | null
if (name === 'default') {
slot = root.querySelector('slot')
} else {
slot = root.querySelector<HTMLSlotElement>(`slot[name="${name}"]`)
}
if (slot) {
slot.replaceWith(typeof el === 'string' ? el : HTMLToElement(el))
}
}
return root
}
/**
* Tell the component to render itself.
*
* *works only in browser*
*/
public async triggerRender() {
// ignore automatics renders inside if we are not in the server
if (getContext() !== 'browser' /* || !this.shadowRoot*/) {
return
}
const rendered = await this.renderHTML()
this.innerHTML = ''
this.appendChild(rendered)
if (!this.mounted) {
this.mounted = true
this.didMount()
} else {
this.didUpdate()
}
}
private async renderOrCache(): Promise<SimpleHTMLElement> {
if (this.needRender || !this.lastRender) {
this.lastRender = await this.render()
}
return this.lastRender
}
}