diff options
Diffstat (limited to '')
| -rw-r--r-- | src/eval.ts | 113 | ||||
| -rw-r--r-- | src/index.ts | 157 | ||||
| -rw-r--r-- | src/parser.ts | 78 | ||||
| -rw-r--r-- | src/utils/adt.ts | 32 | ||||
| -rw-r--r-- | src/utils/parser-comb.ts | 120 | ||||
| -rw-r--r-- | src/utils/result.ts | 18 |
6 files changed, 518 insertions, 0 deletions
diff --git a/src/eval.ts b/src/eval.ts new file mode 100644 index 0000000..5e99282 --- /dev/null +++ b/src/eval.ts @@ -0,0 +1,113 @@ +import { CSSUnit, Expr } from './parser' +import { match, matchString } from './utils/adt' + +export type EvalActions = { + addClass(id: string, classes: string): Promise<void> + removeClass(id: string, classes: string): Promise<void> + delay(num: number): Promise<void> + jsEval(js: string): Promise<any> + loadCssx(id: string, url: string): Promise<string> + getVariable(name: string): Promise<string | undefined> + updateVariable(id: string, varName: string, value: string): Promise<void> + setAttribute(name: string, value: string): Promise<void> + withEvent(fn: (e: any) => void): Promise<void> + getFormData(): Promise<FormData | undefined> + sendRequest(_: { + method: string + url: string + data: FormData | undefined + }): Promise<void> + // calculate ?? +} + +type EvalValue = string | undefined | void + +export const evalExpr = async ( + expr: Expr, + actions: EvalActions, +): Promise<EvalValue> => + match<Promise<EvalValue>, Expr>(expr, { + Call: async ({ name, args }) => getFunctions(name, args, actions), + LiteralString: async s => s, + LiteralNumber: async ({ value, unit }) => + matchString<number, CSSUnit>(unit, { + s: () => value * 1000, + _: () => value, + }).toString(), + Identifier: async s => s, + VarIdentifier: async s => s, + _: async _ => undefined, + }) + +const getFunctions = (name: string, args: Expr[], actions: EvalActions) => + matchString<Promise<EvalValue>>(name, { + 'add-class': async () => { + const id = await evalExpr(args[0], actions) + const classes = await evalExpr(args[1], actions) + if (id && classes) { + await actions.addClass(id, classes) + } + }, + 'remove-class': async () => { + const id = await evalExpr(args[0], actions) + const classes = await evalExpr(args[1], actions) + if (id && classes) { + await actions.removeClass(id, classes) + } + }, + + delay: async () => { + const num = await evalExpr(args[0], actions) + console.log(num) + num && (await actions.delay(parseInt(num, 10))) + }, + 'js-eval': async () => { + const js = await evalExpr(args[0], actions) + js && (await actions.jsEval(js)) + }, + + 'load-cssx': async () => { + const id = await evalExpr(args[0], actions) + const url = await evalExpr(args[1], actions) + if (id && url) { + await actions.loadCssx(id, url) + } + }, + + var: async () => { + const varName = await evalExpr(args[0], actions) + const defaultValue = await evalExpr(args[1], actions) + return varName && (actions.getVariable(varName) ?? defaultValue) + }, + update: async () => { + const id = await evalExpr(args[0], actions) + const varName = await evalExpr(args[1], actions) + const value = await evalExpr(args[2], actions) + if (id && varName && value) { + actions.updateVariable(id, varName, value) + } + }, + + 'set-attr': async () => { + const name = await evalExpr(args[0], actions) + const value = await evalExpr(args[1], actions) + if (name && value) { + actions.setAttribute(name, value) + } + }, + 'prevent-default': async () => actions.withEvent(e => e.preventDefault()), + + request: async () => { + const url = await evalExpr(args[0], actions) + const method = args[1] + ? (await evalExpr(args[1], actions)) ?? 'post' + : 'post' + + if (url) { + const data = await actions.getFormData() + await actions.sendRequest({ method, url, data }) + } + }, + + _: () => Promise.reject(new Error('not supposed to be here')), + }) diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..2d70c3f --- /dev/null +++ b/src/index.ts @@ -0,0 +1,157 @@ +import { EvalActions, evalExpr } from './eval' +import { parse } from './parser' + +const UNSET_PROPERTY_VALUE = '<unset>' +const EVENT_HANDLERS = { + click: '--cssx-on-click', + load: '--cssx-on-load', + mount: '--cssx-on-mount', + submit: '--cssx-on-submit', +} + +const PROPERTIES = [ + '--cssx-children', + '--cssx-text', + '--cssx-disgustingly-set-innerhtml', +] + +const injectStyles = () => { + const STYLE_TAG_CLASS = 'cssx-style-root' + if (document.querySelector(`.${STYLE_TAG_CLASS}`)) return + + const $style = document.createElement('style') + $style.className = STYLE_TAG_CLASS + + const properties = [...PROPERTIES, ...Object.values(EVENT_HANDLERS)] + + $style.textContent = `.cssx-layer { + ${properties.map(p => `${p}: ${UNSET_PROPERTY_VALUE};`).join(' ')} + }` + + document.body.appendChild($style) +} + +const getPropertyValue = ($element: Element, prop: string) => { + const value = `${getComputedStyle($element).getPropertyValue(prop)}`.trim() + return !value || value === UNSET_PROPERTY_VALUE ? '' : value +} + +const getChildrenIds = ($element: Element) => { + const value = getPropertyValue($element, '--cssx-children') + return value.split(/(\s*,\s*)|\s+/g).filter(Boolean) +} + +const getEvalActions = ($element: Element, event: any): EvalActions => ({ + addClass: async (id, cls) => document.getElementById(id)?.classList.add(cls), + removeClass: async (id, cls) => + document.getElementById(id)?.classList.remove(cls), + delay: delay => new Promise(res => setTimeout(res, delay)), + jsEval: async js => (0, eval)(js), + loadCssx: async (id, url) => + new Promise((resolve, reject) => { + const $link = Object.assign(document.createElement('link'), { + href: url, + rel: 'stylesheet', + }) + $link.onload = () => { + const $el = document.getElementById(id) + // NOTE: Maybe create and append to body if no root? + if ($el) { + manageElement($el) + resolve(id) + } else { + console.error(`[CSSX] Unable to find root for ${id}`) + reject(`[CSSX] Unable to find root for ${id}`) + } + } + document.body.appendChild($link) + }), + getVariable: async varName => getPropertyValue($element, varName), + updateVariable: async (targetId, varName, value) => { + const $el = document.getElementById(targetId) + if ($el) { + $el.style.setProperty(varName, JSON.stringify(value)) + } + }, + setAttribute: async (name, value) => { + $element.setAttribute(name, value) + }, + withEvent: async fn => fn(event), + getFormData: async () => + $element.nodeName === 'FORM' + ? new FormData($element as HTMLFormElement) + : undefined, + sendRequest: async ({ url, method, data }) => { + await fetch(url, { method, body: data }) + // TODO: Handle response? + }, +}) + +const handleEvents = async ( + $element: Element, + isNewElement: boolean = false, +) => { + for (const [eventType, property] of Object.entries(EVENT_HANDLERS)) { + const handlerExpr = getPropertyValue($element, property) + + if (handlerExpr) { + const eventHandler = async (event: any) => { + console.log(`Triggered event: ${eventType}`) + const exprs = parse(handlerExpr) + for (const expr of exprs) { + await evalExpr(expr, getEvalActions($element, event)) + } + } + + if (eventType === 'mount') { + if (isNewElement) setTimeout(eventHandler) + } else { + ;($element as any)[`on${eventType}`] = eventHandler + } + } + } +} + +const manageElement = async ( + $element: Element, + isNewElement: boolean = false, +) => { + await handleEvents($element, isNewElement) + + const text = getPropertyValue($element, '--cssx-text') + if (text) $element.textContent = text + + const html = getPropertyValue($element, '--cssx-disgustingly-set-innerhtml') + if (html) $element.innerHTML = html.replace(/(^'|")|('|"$)/g, '') + + const childrenIds = getChildrenIds($element) + if (childrenIds.length > 0) { + const LAYER_CLASS = 'cssx-layer' + const $childrenRoot = + $element.querySelector(`:scope > .${LAYER_CLASS}`) ?? + Object.assign(document.createElement('div'), { className: LAYER_CLASS }) + $element.appendChild($childrenRoot) + + for (const childId of childrenIds) { + const selector = childId.split('#') + const [tag, id] = selector.length >= 2 ? selector : ['div', ...selector] + let $child = $childrenRoot.querySelector(`:scope > #${id}`) + const isNewElement = !$child + if (!$child) { + $child = Object.assign(document.createElement(tag || 'div'), { id }) + } + $childrenRoot.appendChild($child) + await manageElement($child, isNewElement) + } + } +} + +interface Options { + root?: HTMLElement +} +const render = async ({ root = document.body }: Options = {}) => { + injectStyles() + await manageElement(root) +} + +render()
\ No newline at end of file diff --git a/src/parser.ts b/src/parser.ts new file mode 100644 index 0000000..3279f57 --- /dev/null +++ b/src/parser.ts @@ -0,0 +1,78 @@ +import { Enum, constructors, match } from './utils/adt' +import * as P from './utils/parser-comb' + +export type CSSUnit = '' | 's' | 'ms' + +export type Expr = Enum<{ + Call: { name: string; args: Expr[] } + Identifier: string + VarIdentifier: string + LiteralString: string + LiteralNumber: { value: number; unit: CSSUnit } +}> + +export const Expr = constructors<Expr>() + +const whitespace = P.regex(/^\s*/) +const consumeWhitespace = <A>(p: P.Parser<A>): P.Parser<A> => + P.between(whitespace, p, whitespace) +const comma = consumeWhitespace(P.string(',')) +const parens = <A>(p: P.Parser<A>): P.Parser<A> => + P.between(P.string('('), p, P.string(')')) +const identifierParser = P.regex(/^[a-z][a-z0-9_-]*/i) +const varIdentifierParser = P.regex(/^--[a-z][a-z0-9-]*/i) +const singleQuote = P.string("'") +const doubleQuote = P.string('"') + +const identifierExprParser = P.map(identifierParser, Expr.Identifier) +const varIdentifierExprParser = P.map(varIdentifierParser, Expr.VarIdentifier) + +const callExprParser = (input: string) => + P.map( + P.zip2( + consumeWhitespace(identifierParser), + parens(consumeWhitespace(P.sepBy(exprParser, comma))), + ), + ([name, args]) => Expr.Call({ name, args }), + )(input) + +const stringLiteralParser: P.Parser<Expr> = P.map( + P.or([ + P.between(singleQuote, P.regex(/^[^']*/), singleQuote), + P.between(doubleQuote, P.regex(/^[^"]*/), doubleQuote), + ]), + Expr.LiteralString, +) + +const numberParser = P.regex(/^[-+]?((\d*\.\d+)|\d+)/) + +const numberExprParser: P.Parser<Expr> = P.map( + P.zip2(numberParser, P.optional(P.regex(/^(s|ms)/i))), + ([value, unit]) => + Expr.LiteralNumber({ value: Number(value), unit: (unit ?? '') as CSSUnit }), +) + +const exprParser: P.Parser<Expr> = P.or([ + stringLiteralParser, + varIdentifierExprParser, + numberExprParser, + callExprParser, + identifierExprParser, +]) + +const multiExprParser = P.many1(exprParser) + +export const parse = (input: string): Array<Expr> => { + const res = multiExprParser(input.trim()) + return match(res, { + Ok: ({ value, input }) => { + if (input) { + throw new Error(`Input not consumed completely here brosky: "${input}"`) + } + return value + }, + Err: ({ error, input }) => { + throw new Error(`${error}.\n Left input: ${input.slice(0, 20)}...`) + }, + }) +} diff --git a/src/utils/adt.ts b/src/utils/adt.ts new file mode 100644 index 0000000..45d9e50 --- /dev/null +++ b/src/utils/adt.ts @@ -0,0 +1,32 @@ +type TagValue<T, N> = T extends Tag<N, infer V> ? V : never + +export const match = <R, T extends Tag<string, any>>( + tag: T, + pattern: { + [key in T['tag'] | '_']?: (v: TagValue<T, key>) => R + }, +): R => ((pattern as any)[tag.tag] || (pattern._ as any))(tag.value) + +export const matchString = <R, T extends string = string>( + key: T, + pattern: { + [key in T | '_']?: (key: key) => R + }, +): R => ((pattern as any)[key] || (pattern._ as any))(key) + +type Tag<N, V> = { tag: N; value: V } +export type Enum<T> = { [N in keyof T]: Tag<N, T[N]> }[keyof T] + +export const constructors = <T extends Tag<string, any>>(): { + [N in T['tag']]: TagValue<T, N> extends null | never + ? (value?: null | never) => T + : (value: TagValue<T, N>) => T +} => + new Proxy( + {}, + { + get(_, k) { + return (value: any) => ({ tag: k, value }) + }, + }, + ) as any diff --git a/src/utils/parser-comb.ts b/src/utils/parser-comb.ts new file mode 100644 index 0000000..3f171b8 --- /dev/null +++ b/src/utils/parser-comb.ts @@ -0,0 +1,120 @@ +import { match } from './adt' +import { Result, mapResult, chainResult } from './result' + +export type ParseResult<T> = Result< + { value: T; input: string }, + { error: string; input: string } +> + +export type Parser<T> = (input: string) => ParseResult<T> + +export const regex = + (re: RegExp): Parser<string> => + input => { + if (input.length === 0) return Result.Err({ error: 'fuckedinput', input }) + const res = input.match(re) + if (!res) return Result.Err({ error: 'fucked', input }) + return Result.Ok({ value: res[0], input: input.replace(re, '') }) + } + +export const string = + (str: string): Parser<string> => + input => { + if (input.length === 0) return Result.Err({ error: 'fuckedinput', input }) + if (!input.startsWith(str)) + return Result.Err({ error: 'fuckedstring', input }) + return Result.Ok({ value: str, input: input.slice(str.length) }) + } + +export const or = + <T>([parser, ...rest]: Array<Parser<T>>): Parser<T> => + input => { + if (rest.length === 0) return parser(input) + const result = parser(input) + return match(result, { + Ok: () => result, + Err: _ => or(rest)(input), + }) + } + +export const mapParseResult = + <T, R>( + parser: Parser<T>, + fn: (_: { value: T; input: string }) => { value: R; input: string }, + ): Parser<R> => + input => + mapResult(parser(input), fn) + +export const map = <T, R>(parser: Parser<T>, fn: (_: T) => R): Parser<R> => + mapParseResult(parser, ({ value, ...rest }) => ({ + ...rest, + value: fn(value), + })) + +export const zip2 = + <A, B>(parserA: Parser<A>, parserB: Parser<B>): Parser<readonly [A, B]> => + input => { + // TODO: refactor please. shit code + const resa: Result< + { value: A; input: string }, + { error: string; input: string } + > = parserA(input) + return chainResult(resa, ({ value: a, input: inputB }) => { + const res: Result< + { value: readonly [A, B]; input: string }, + { error: string; input: string } + > = map(parserB, b => [a, b] as const)(inputB) + return res + }) + } + +export const prefixed = <A>( + parserPrefix: Parser<any>, + parser: Parser<A>, +): Parser<A> => map(zip2(parserPrefix, parser), ([_, a]) => a) + +export const suffixed = <A>( + parser: Parser<A>, + parserSuffix: Parser<any>, +): Parser<A> => map(zip2(parser, parserSuffix), ([a, _]) => a) + +export const between = <A>( + prefix: Parser<any>, + parser: Parser<A>, + suffix: Parser<any>, +): Parser<A> => suffixed(prefixed(prefix, parser), suffix) + +export const many0 = + <A>(parser: Parser<A>): Parser<Array<A>> => + originalInput => + match(parser(originalInput), { + Ok: ({ value, input }) => map(many0(parser), ls => [value, ...ls])(input), + Err: ({ input }) => Result.Ok({ value: [], input }), + }) + +export const many1 = + <A>(parser: Parser<A>): Parser<Array<A>> => + originalInput => + match(parser(originalInput), { + Ok: ({ value, input }) => map(many0(parser), ls => [value, ...ls])(input), + Err: err => Result.Err(err), + }) + +export const sepBy = + <A>(parser: Parser<A>, sepP: Parser<any>): Parser<Array<A>> => + originalInput => + match(parser(originalInput), { + Ok: ({ value, input }) => + map(many0(prefixed(sepP, parser)), ls => [value, ...ls])(input), + Err: _ => Result.Ok({ value: [], input: originalInput }), + }) + +export const optional = + <A>(parser: Parser<A>): Parser<undefined | A> => + input => { + const result = parser(input) + return match(result, { + Ok: _ => result, + Err: _ => Result.Ok({ value: undefined, input }), + }) + } diff --git a/src/utils/result.ts b/src/utils/result.ts new file mode 100644 index 0000000..39a7fe6 --- /dev/null +++ b/src/utils/result.ts @@ -0,0 +1,18 @@ +import { Enum, constructors, match } from './adt' + +export type Result<V, E> = Enum<{ Ok: V; Err: E }> +export const Result = constructors<Result<any, any>>() + +export const mapResult = <A, B, E>( + res: Result<A, E>, + fn: (_: A) => B, +): Result<B, E> => chainResult(res, a => Result.Ok(fn(a))) + +export const chainResult = <A, B, E>( + res: Result<A, E>, + fn: (_: A) => Result<B, E>, +): Result<B, E> => + match(res, { + Ok: a => fn(a), + Err: e => Result.Err(e), + }) |
