فهرست منبع

adds so-so working own reactivity

Matheus Giovani 2 سال پیش
والد
کامیت
bf4945d599

+ 0 - 2
packages/compiler/src/core/Plugin.ts

@@ -3,7 +3,6 @@ import type { PugPlugin, PugToken, PugAST, PugNode, PugNodes, PugNodeAttribute,
 import { Hook } from "./plugin/Hook";
 
 import { ConditionalHook } from "./plugin/hooks/ConditionalHook";
-import { PropertyHook } from "./plugin/hooks/PropertyHook";
 import { PupperToAlpineHook } from "./plugin/hooks/PupperToAlpineHook";
 import { ImportHook } from "./plugin/hooks/ImportHook";
 import { CompilerNode } from "../model/core/nodes/CompilerNode";
@@ -66,7 +65,6 @@ export { PugToken, PugAST, PugNode, PugNodeAttribute, PugNodes, CompilerNode as
 export default class Plugin implements PugPlugin {
     public static Hooks: THookArray = [
         ConditionalHook,
-        PropertyHook,
         PupperToAlpineHook,
         ImportHook,
         StyleAndScriptHook,

+ 187 - 97
packages/compiler/src/core/compiler/HTMLToVirtualDOM.ts

@@ -10,6 +10,8 @@ export class PugToVirtualDOM {
     protected identation: string;
     protected identLevel: number = 0;
 
+    protected content: string;
+
     constructor(
         protected compiler: PupperCompiler
     ) {
@@ -17,136 +19,224 @@ export class PugToVirtualDOM {
     }
 
     /**
-     * Idents a string with the current identation level.
+     * Writes a raw string to the contents.
+     * @param string The string to be written.
+     */
+    protected write(string: string) {
+        this.content += string;
+    }
+
+    /**
+     * Writes a string and appends a new line to the contents.
+     * @param string The string to be written.
+     */
+    protected writeLn(string: string = "") {
+        this.content += string + "\n";
+    }
+
+    /**
+     * Writes a string applying identation before it.
+     * @param string The string to be written.
+     * @returns 
+     */
+    protected applyIdent(string: string = "") {
+        this.content += this.identation.repeat(this.identLevel) + string;
+    }
+
+    /**
+     * Increases the identation level and writes a string after it.
      * @param string The string to be idented.
      * @returns 
      */
     protected ident(string: string = "") {
-        return this.identation.repeat(this.identLevel) + string;
+        this.identLevel++;
+        return this.applyIdent(string);
     }
 
     /**
-     * Virtualizes a single node.
-     * @param node The node to be virtualized.
+     * Decreases the identation level and writes a string after it.
+     * @param string The string to be idented.
      * @returns 
      */
-    public virtualizeNode(node: Partial<PugNodes>) {
-        let content = this.ident("h(");
-
-        switch(node.type) {
-            case "Tag":
-                content += `"${node.name}"`;
-
-                // If the node has attributes
-                if (node.attrs.length) {
-                    content += `, {\n`;
-                    this.identLevel++;
-
-                        content += this.ident(`attrs: {\n`);
-                        this.identLevel++;
-
-                            content += node.attrs
-                            .reduce((arr: Pug.Nodes.TagNode["attrs"], attr) => {
-                                const existing = arr.find((at) => at.name === attr.name);
-
-                                if (typeof attr.val === "string") {
-                                    if (attr.val.match(/^['"]/)) {
-                                        attr.val = attr.val.substring(1, attr.val.length - 1);
-                                    }
-                                }
-
-                                if (existing) {
-                                    existing.val += " " + attr.val;
-                                } else {
-                                    arr.push(attr);
-                                }
-
-                                return arr;
-                            }, [])
-                            .map((attr) => {
-                                return this.ident(
-                                    `"${attr.name}": ${typeof attr.val === "string" ? '"' + attr.val.trim() + '"' : attr.val}`
-                                )
-                            })
-                            .join(",\n")
-
-                        this.identLevel--;
-                        content += "\n";
-                        content += this.ident(`}\n`);
-
-                    this.identLevel--;
-                    content += this.ident(`}`);
-                }
-
-                if (node.block?.nodes?.length) {
-                    this.identLevel++;
+    protected outdent(string: string = "") {
+        this.identLevel--;
+        return this.applyIdent(string);
+    }
 
-                    content += `, [\n`;
-                        content += node.block.nodes.map((node) => {
-                            return this.virtualizeNode(node);
-                        })
-                        .filter((res) => !!res)
-                        .join(",\n");
+    /**
+     * Retrieves a string applying identation before it.
+     * @param string The string to be written.
+     * @returns 
+     */
+    protected getIdentation(string: string = "") {
+        return this.identation.repeat(this.identLevel) + string;
+    }
 
-                        this.identLevel--;
+    protected rollbackIdent(times = 1) {
+        this.content = this.content.substring(0, this.content.length - (this.ident.length * times));
+    }
 
-                    content += "\n" + this.ident(`]`);
-                }
-            break;
+    /**
+     * Converts a string into a virtual DOM string.
+     * @param ast The AST to be virtualized.
+     * @returns
+     */
+    public virtualize(ast: PugAST) {
+        this.identLevel = 0;
+        this.content = "";
 
-            case "Text":
-                // If it's empty
-                if (node.val.trim().length === 0) {
-                    // Ignore it
-                    return null;
+        if (ast.nodes.length > 1) {
+            this.virtualizeNode({
+                type: "Tag",
+                name: "div",
+                block: {
+                    type: "Block",
+                    nodes: ast.nodes
                 }
+            } as any);
+        } else {
+            this.virtualizeNode(ast.nodes[0]);
+        }
 
-                return this.ident("\"" + node.val.replace(/"/g, '\\"').trim() + "\"");
+        return this.content;
+    }
 
-            case "Code":
-                if (node.val.trim().length === 0) {
-                    // Ignore it
-                    return null;
-                }
+    /**
+     * Virtualizes a single node.
+     * @param node The node to be virtualized.
+     * @returns 
+     */
+    public virtualizeNode(node: PugNodes) {
+        switch(node.type) {
+            case "Tag":
+                return this.virtualizeTag(node);
 
-                if (node.val.match(/^["']/)) {
-                    node.val = node.val.substring(1, node.val.length - 1);
-                }
+            case "Text":
+                return this.virtualizeText(node);
 
-                return `h("span", { attrs: { "x-${node.mustEscape ? "text": "html"}": "${node.val.replace(/"/g, '\"')}" } })`;
+            case "Code":
+                return this.virtualizeCode(node);
 
             // Ignore comments
             case "Comment":
-                return null;
+                return false;
 
             default:
-                this.compiler.debugger.log("unknown node type", node);
+                this.compiler.debugger.log("unhandled node type", node);
         }
 
-        return content + ")";
+        return false;
     }
 
     /**
-     * Converts a string into a virtual DOM string.
-     * @param ast The AST to be virtualized.
-     * @returns
+     * Virtualizes a text node.
+     * @param node The node to be virtualized.
+     * @returns 
      */
-    public virtualize(ast: PugAST) {
-        let final = "";
+    protected virtualizeText(node: Pug.Nodes.TextNode) {
+        // If it's empty
+        if (node.val.trim().length === 0) {
+            // Ignore it
+            return false;
+        }
 
-        if (ast.nodes.length > 1) {
-            final = this.virtualizeNode({
-                type: "Tag",
-                name: "div",
-                block: {
-                    type: "Block",
-                    nodes: ast.nodes
+        this.write("\"" + node.val.replace(/"/g, '\\"').trim() + "\"");
+    }
+
+    /**
+     * Virtualizes a code node.
+     * @param node The node to be virtualized.
+     * @returns 
+     */
+    protected virtualizeCode(node: Pug.Nodes.CodeNode) {
+        if (node.val.trim().length === 0) {
+            // Ignore it
+            return false;
+        }
+
+        this.writeLn(`h("span", {`);
+            this.ident(`attrs: {\n`);
+                this.ident(`"x-${node.mustEscape ? "text": "html"}": "${node.val.replace(/"/g, '\\"')}"\n`);
+            this.outdent(`}\n`);
+        this.outdent(`})`);
+    }
+
+    /**
+     * Virtualizes a tag node.
+     * @param node The tag node to be virtualized.
+     * @returns 
+     */
+    protected virtualizeTag(node: Pug.Nodes.TagNode) {
+        this.write(`h("${node.name}"`);
+
+        // If the node has attributes
+        if (node.attrs.length) {
+            this.writeLn(", {");
+            this.ident();
+
+                this.virtualizeTagAttributes(node.attrs);
+
+            this.outdent();
+            this.write("}");
+        }
+
+        if (node.block?.nodes?.length) {
+            this.writeLn(`, [`);
+
+            node.block.nodes.forEach((node, index, arr) => {
+                this.ident();
+                const result = this.virtualizeNode(node);
+
+                if (result === false) {
+                    // Revert identation
+                    this.rollbackIdent();
+                } else {
+                    this.write(index < arr.length - 1 ? ",\n" : "");
                 }
+
+                this.identLevel--;
             });
-        } else {
-            final = this.virtualizeNode(ast.nodes[0]);
+
+            // End with a new line
+            this.writeLn();
+            this.applyIdent(`]`);
         }
 
-        return final;
+        this.write(")");
+    }
+
+    protected virtualizeTagAttributes(attrs: Pug.Nodes.TagNode["attrs"]) {
+        this.writeLn(`attrs: {`);
+        this.ident();
+
+            this.write(
+                attrs
+                .reduce((arr: Pug.Nodes.TagNode["attrs"], attr) => {
+                    const existing = arr.find((at) => at.name === attr.name);
+
+                    if (typeof attr.val === "string") {
+                        if (attr.val.match(/^['"]/)) {
+                            attr.val = attr.val.substring(1, attr.val.length - 1);
+                        }
+                    }
+
+                    if (existing) {
+                        existing.val += " " + attr.val;
+                    } else {
+                        arr.push(attr);
+                    }
+
+                    return arr;
+                }, [])
+                .map((attr) => {
+                    return (
+                        `"${attr.name}": ${typeof attr.val === "string" ? '"' + attr.val.trim() + '"' : attr.val}`
+                    )
+                })
+                .join(",\n" + this.getIdentation())
+            );
+
+        this.outdent("\n");
+        this.applyIdent(`}\n`);
     }
 }

+ 0 - 61
packages/compiler/src/core/plugin/hooks/PropertyHook.ts

@@ -1,61 +0,0 @@
-import { CompilerNode } from "../../../model/core/nodes/CompilerNode";
-import { PugToken } from "../../Plugin";
-import { Hook } from "../Hook";
-
-export class PropertyHook extends Hook {
-    /**
-     * The regex to test if an expression is a valid reactive item
-     */
-    public REGEX = /\{(?<tag>\{|-) ?(?<exp>(?:[\w+]|\.)+) ?(\}|-)\}/;
-
-    public testExpression(exp: string) {
-        return this.REGEX.test(exp);
-    }
-
-    public lex(tokens: PugToken[]) {
-        let insideAttribute = false;
-
-        return tokens.map((token) => {
-            if (token.type === "start-attributes") {
-                insideAttribute = true;
-            } else
-            if (token.type === "end-attributes") {
-                insideAttribute = false;
-            }
-            
-            // We want only attribute and code tokens
-            if (token.type !== "attribute" && token.type !== "code") {
-                return token;
-            }
-
-            // Check if it's a reactive item
-            if (token.mustEscape && this.REGEX.test(token.val)) {
-                // Extract the token value
-                const result = token.val.match(this.REGEX).groups;
-                const value = result.exp.replace(/\"/g, "\\\"");
-
-                const fn = result.tag === "{" ? "escape" : "literal";
-
-                // If it's an attribute
-                if (token.type === "attribute") {
-                    // Replace with our shorthand escape
-                    token.name = ":" + token.name;
-                    token.val = `"${value}"`;
-                    token.mustEscape = false;
-                } else {
-                    const textOrHtml = fn === "escape" ? "text" : "html";
-
-                    token.type = "code";
-                    token.val = /*html*/`"<span x-${textOrHtml}=\\"${value}\\"></span>"`;
-                    token.mustEscape = false;
-                }
-
-                if (insideAttribute) {
-                    token.type = "attribute";
-                }
-            }
-
-            return token;
-        });
-    }
-};

+ 2 - 4
packages/renderer/package.json

@@ -11,20 +11,18 @@
     "watch:ts": "tsc -watch"
   },
   "dependencies": {
-    "alpinejs": "^3.10.2",
     "morphdom": "^2.6.1",
     "pug": "^3.0.2",
     "snabbdom": "^3.5.0"
   },
   "types": "./types/",
   "devDependencies": {
-    "@types/alpinejs": "^3.7.0",
     "@types/node": "^16.7.6",
     "debug": "^4.3.4",
-    "npm-run-all": "^4.1.5",
     "tsc": "^2.0.3",
     "typescript": "^4.4.2",
     "webpack": "^5.51.1",
-    "webpack-cli": "^4.8.0"
+    "webpack-cli": "^4.8.0",
+    "yarn-run-all": "^3.1.1"
   }
 }

+ 12 - 46
packages/renderer/src/core/Component.ts

@@ -1,7 +1,6 @@
-import Alpine from "alpinejs";
 import { VNode } from "snabbdom";
-import { DOMParser } from "./DomParser";
-import { VDomRenderer } from "./VDomRenderer";
+import { reactive } from "../model/Reactivity";
+import { Renderer } from "./vdom/Renderer";
 
 /**
  * Represents a slot.
@@ -51,31 +50,26 @@ export interface IComponent<
     /**
      * Called when the component is mounted
      */
-    created?: (this: PupperComponent) => any,
+    created?: (this: Component) => any,
 
     /**
      * Called when the component is mounted.
      */
-    mounted?: (this: PupperComponent) => any;
+    mounted?: (this: Component) => any;
 }
 
-export class PupperComponent {
+export class Component {
     public static create<
         TMethods extends Record<string, CallableFunction>,
         TData extends Record<string, any>
     >(component: IComponent<TData, TMethods>) {
-        return new PupperComponent(component) as (PupperComponent & TMethods);
+        return new Component(component) as (Component & TMethods);
     }
 
-    /**
-     * A unique identifier for this component.
-     */
-    protected $identifier: string;
-
     /**
      * The state related to this component.
      */
-    public $state: Record<string, any> = {};
+    public $state = reactive({});
 
     /**
      * Any slots references.
@@ -102,7 +96,7 @@ export class PupperComponent {
     /**
      * The virtual DOM renderer instance.
      */
-    protected renderer = new VDomRenderer(this);
+    protected renderer = new Renderer(this);
 
     constructor(
         /**
@@ -213,7 +207,7 @@ export class PupperComponent {
      * Renders the template function into a div tag.
      */
     public async render() {
-        let renderContainer: HTMLElement;
+        let renderContainer: HTMLDivElement;
 
         if (this.firstRender) {
             this.firstRender = false;
@@ -273,43 +267,15 @@ export class PupperComponent {
      * @returns 
      */
     public async mount(target: HTMLElement | Slot) {
-        this.$identifier = "p_" + String((Math.random() + 1).toString(36).substring(2));
-
         const rendered = await this.render();
-        
-        rendered.setAttribute("x-data", this.$identifier);
-
-        // Initialize the data
-        Alpine.data(this.$identifier, () => {
-            return {
-                ...this.$state,
-                init() {
-                    if (this.component?.mounted) {
-                        this.component.mounted.call(this);
-                    }
-                }
-            };
-        });
 
         // If it's targeting a slot
         if (!(target instanceof HTMLElement)) {
-            const replaced = document.createElement("div");
-            replaced.setAttribute("p-slot", "1");
-
-            target.container.replaceWith(replaced);
-            this.parser = new DOMParser(replaced);
-            
+            target.container.replaceWith(...rendered.childNodes);
         } else {
-            // Append it to the virtual DOM
-            this.parser = new DOMParser(target);
+            target.append(...rendered.childNodes);
         }
 
-        const mounted = await this.parser.appendChild(rendered);
-
-        // Save a reference to the internal Alpine data proxy
-        // @ts-ignore
-        this.$state = mounted._x_dataStack[0];
-
-        return mounted;
+        return rendered;
     }
 }

+ 0 - 129
packages/renderer/src/core/DomParser.ts

@@ -1,129 +0,0 @@
-import Alpine from "alpinejs";
-import morphdom from "morphdom";
-
-const AlpineNames = ["x-data", "x-teleport", "x-text", "x-html"];
-
-/**
- * DOM parser is everything a virtual DOM wants to be.
- * 
- * It parses the Alpine reactivity inside a template tag
- * and then removes everything related to Alpine like
- * the attributes starting with "@" and ":", and also
- * remove the Alpine-related tags like "x-data" or "x-html".
- * 
- * @todo would be useful in the future to use a real virtual DOM
- * by the pug-code-gen thing.
- */
-export class DOMParser {
-    /**
-     * The virtual dom where Alpine will work on.
-     */
-    public template = document.createElement("template");
-
-    protected templateObserver: MutationObserver;
-    observer: MutationObserver;
-
-    constructor(
-        /**
-         * The container where the application is hosted.
-         * If none is given, will target the document body.
-         */
-        protected container: HTMLElement = document.body
-    ) {
-        this.observer = new MutationObserver(this.observeMutations.bind(this));
-        this.observer.observe(this.container, {
-            childList: true,
-            subtree: true
-        });
-    }
-
-    protected observeMutations(mutations: MutationRecord[]) {
-        const queue: HTMLElement[] = [];
-
-        for(let mutation of mutations) {
-            if (!(mutation.target instanceof HTMLElement)) {
-                continue;
-            }
-
-            if (queue.includes(mutation.target)) {
-                continue;
-            }
-
-            queue.push(mutation.target);
-        }
-
-        queue.forEach((node) => {
-            if (!(node instanceof HTMLElement)) {
-                return;
-            }
-
-            this.filterChildrenAlpineAttributes(node);
-        });
-    }
-
-    /**
-     * Initializes a virtual dom node like Alpine does in start().
-     * @param node The node to be initialized.
-     */
-    protected initNode(node: HTMLElement) {
-        //Alpine.initTree(node);
-    }
-
-    /**
-     * Appends a child to the virtual DOM.
-     * @param child The child to be appended.
-     * @returns 
-     */
-    public async appendChild(child: HTMLElement) {
-        // Append it to the virtual DOM
-        child = this.template.content.appendChild(child);
-
-        this.initNode(child);
-
-        // Wait for the next tick
-        await this.nextTick();
-
-        this.flush();
-
-        return child;
-    }
-
-    /**
-     * Waits for the next parser tick.
-     */
-    public nextTick() {
-        return new Promise<void>((resolve) => Alpine.nextTick(resolve));
-    }
-
-    /**
-     * Flushes the updates to the DOM container.
-     */
-    protected flush() {
-        morphdom(this.container, this.template.content, {
-            childrenOnly: true
-        });
-        
-        this.filterAlpineAttributes(this.container);
-    }
-
-    protected filterAlpineAttributes(el: Element) {
-        Array.from(el.attributes).forEach(({ name }) => {
-            if (
-                AlpineNames.includes(name) ||
-                name.startsWith("x-bind") ||
-                name.startsWith("x-on")
-            ) {
-                el.removeAttribute(name);
-            }
-        });
-    }
-
-    protected filterChildrenAlpineAttributes(node: Element) {
-        node.querySelectorAll("template, slot").forEach((node) => node.remove());
-        
-        return Array.from(node.querySelectorAll("*"))
-            .forEach((el) => 
-                this.filterAlpineAttributes(el)
-            );
-    }
-}

+ 0 - 386
packages/renderer/src/core/VDomRenderer.ts

@@ -1,386 +0,0 @@
-import { PupperComponent } from "./Component";
-import {
-    h,
-    propsModule,
-    attributesModule,
-    styleModule,
-    eventListenersModule,
-    init,
-    VNode
-} from "snabbdom";
-
-import { ISafeAsyncFunction, SafeAsyncFunction } from "./evaluator/SafeAsyncFunction";
-import { IsNumeric, IsObject } from "../util/ObjectUtils";
-
-const debug = require("debug")("pupper:vdom");
-
-/**
- * Most of the evaluation functions were taken from alpine.js
- * Thanks, alpine.js!
- */
-export class VDomRenderer {
-    patch: ReturnType<typeof init>;
-
-    protected evaluatorMemo: Record<string, ISafeAsyncFunction> = {};
-
-    /**
-     * The stack of states that formulates the context for rendering elements.
-     */
-    protected stateStack: Record<string, any>[] = [];
-
-    constructor(
-        protected component: PupperComponent
-    ) {
-        this.patch = init([
-            propsModule,
-            attributesModule,
-            styleModule,
-            eventListenersModule
-        ]);
-
-        this.stateStack.push(component.$state);
-    }
-
-    /**
-     * Evaluates an expression string into a function.
-     * @see https://github.com/alpinejs/alpine/blob/71ee8361207628b1faa14e97533373e9ebee468a/packages/alpinejs/src/evaluator.js#L61
-     * @param expression The expression to be evaluated.
-     * @returns 
-     */
-    protected evaluateString(expression: string) {
-        // If this expression has already been evaluated
-        if (this.evaluatorMemo[expression]) {
-            return this.evaluatorMemo[expression];
-        }
-    
-        // Some expressions that are useful in Alpine are not valid as the right side of an expression.
-        // Here we'll detect if the expression isn't valid for an assignement and wrap it in a self-
-        // calling function so that we don't throw an error AND a "return" statement can b e used.
-        let rightSideSafeExpression = 0
-            // Support expressions starting with "if" statements like: "if (...) doSomething()"
-            || /^[\n\s]*if.*\(.*\)/.test(expression)
-            // Support expressions starting with "let/const" like: "let foo = 'bar'"
-            || /^(let|const)\s/.test(expression)
-                ? `(() => { ${expression} })()`
-                : expression
-
-        let func: ISafeAsyncFunction;
-
-        try {
-            func = SafeAsyncFunction(rightSideSafeExpression);
-        } catch (err) {
-            console.warn("pupper.js warning: invalid expression", rightSideSafeExpression);
-            throw err;
-        }
-
-        this.evaluatorMemo[expression] = func;
-
-        return func;
-    }
-
-    protected generateState() {
-        return this.stateStack.reduce((carrier, curr) => {
-            for(let key in curr) {
-                carrier[key] = curr[key];
-            }
-
-            return carrier;
-        }, {});
-    }
-
-    /**
-     * Evaluates an expression.
-     * @param expression The expression to be evaluated.
-     * @returns 
-     */
-    protected async evaluate<TExpressionResult = any>(expression: string | number | boolean | CallableFunction) {
-        const state = this.generateState();
-
-        if (typeof expression === "function") {
-            return await expression(this.component.$state);
-        }
-
-        const func = this.evaluateString(String(expression));
-        func.result = undefined;
-        func.finished = false;
-
-        try {
-            return await func(func, state) as TExpressionResult;
-        } catch(e) {
-            console.warn("pupper.js warning: failed to evaluate " + expression);
-            console.warn(e);
-        }
-
-        return undefined;
-    }
-
-    protected async maybeEvaluate(expression: string | number | boolean | CallableFunction) {
-        try {
-            return await this.evaluate(expression);
-        } catch(e) {
-
-        }
-
-        return expression;
-    }
-
-    /**
-     * Parses a "for" expression
-     * @note This was taken from VueJS 2.* core. Thanks Vue!
-     * @param expression The expression to be parsed.
-     * @returns 
-     */
-    protected parseForExpression(expression: string | number | boolean | CallableFunction) {
-        let forIteratorRE = /,([^,\}\]]*)(?:,([^,\}\]]*))?$/
-        let stripParensRE = /^\s*\(|\)\s*$/g
-        let forAliasRE = /([\s\S]*?)\s+(?:in|of)\s+([\s\S]*)/
-        let inMatch = String(expression).match(forAliasRE)
-
-        if (!inMatch) return
-
-        let res: {
-            items?: string;
-            index?: string;
-            item?: string;
-            collection?: string;
-        } = {};
-
-        res.items = inMatch[2].trim()
-        let item = inMatch[1].replace(stripParensRE, "").trim();
-        let iteratorMatch = item.match(forIteratorRE)
-
-        if (iteratorMatch) {
-            res.item = item.replace(forIteratorRE, "").trim();
-            res.index = iteratorMatch[1].trim()
-
-            if (iteratorMatch[2]) {
-                res.collection = iteratorMatch[2].trim();
-            }
-        } else {
-            res.item = item;
-        }
-
-        return res;
-    }
-
-    /**
-     * Evaluates a conditional expression.
-     * @param node The node to be evaluated.
-     * @param exp The if expression to be evaluated.
-     * @returns 
-     */
-    protected async evaluateIf(node: VNode, exp: string | number | boolean | CallableFunction) {
-        const evaluated = await this.evaluate<boolean>(exp);
-        
-        debug("evaluated if \"%s\" as %s", exp, evaluated);
-
-        return evaluated ? await this.parseNode(node.children[0]) : null;
-    }
-
-    /**
-     * Evaluates a loop expression.
-     * @param node The node to be evaluated.
-     * @param exp The loop expression to be evaluated.
-     * @returns 
-     */
-    protected async evaluateLoop(node: VNode, exp: string | number | boolean | CallableFunction) {
-        const parsed = this.parseForExpression(exp);
-        let items: any[] = await this.evaluate(parsed.items);
-
-        debug("evaluated for \"%s\" as %O", exp, parsed);
-
-        // Support number literals, eg.: x-for="i in 100"
-        if (IsNumeric(items)) {
-            items = Array.from(Array(items).keys(), (i) => i + 1);
-        } else
-        // If it's an object
-        if (IsObject(items)) {
-            // Retrieve the entries from it
-            items = Object.entries(items);
-        } else
-        // If nothing is found, default to an empty array.
-        if (items === undefined) {
-            items = [];
-        }
-
-        // The final node that will receive the children nodes.
-        let finalNode: VNode = {
-            children: [],
-            sel: "div",
-            data: {},
-            elm: node.elm,
-            key: node.key,
-            text: null
-        };
-
-        // Iterate over all evaluated items
-        for(let item in items) {
-            // Push the current item to the state stack
-            this.stateStack.push({
-                [parsed.item]: items[item],
-                [parsed.index]: item,
-                [parsed.collection]: items
-            });
-
-            // Create the children from it
-            const cloned = typeof node.children[0] === "string" ? node.children[0] : JSON.parse(JSON.stringify(node.children[0]));
-            const parsedNode = await this.parseNode(cloned);
-
-            finalNode.children.push(parsedNode);
-
-            // Remove the current item from the state stack
-            this.stateStack.pop();
-        }
-
-        return finalNode;
-    }
-
-    protected async parseNode(node: VNode | string): Promise<VNode | string | null> {
-        // If it's null
-        if (!node) {
-            // Ignore it
-            return null;
-        }
-
-        // Ignore if it's a string
-        if (typeof node === "string") {
-            return node;
-        }
-
-        debug("evaluating %s %O", node.sel || "text", node);
-
-        // If it's a template tag
-        if (node.sel === "template") {
-            // If it's an "if"
-            if ("x-if" in node.data.attrs) {
-                // Evaluate and return the result of it
-                return await this.evaluateIf(node, node.data.attrs["x-if"]);
-            } else
-            // If it's a "for"
-            if ("x-for" in node.data.attrs) {
-                return await this.evaluateLoop(node, node.data.attrs["x-for"]);
-            } else {
-                console.warn("pupper.js has found an unknown template node", node);
-            }
-
-            // Prevent from going further
-            return node;
-        }
-
-        if (node.data !== undefined) {
-            if ("attrs" in node.data) {
-                // If has a "x-text" attribute
-                if ("x-text" in node.data.attrs) {
-                    node.children = node.children || [];
-
-                    // Append the text to the children
-                    node.children.push({
-                        children: undefined,
-                        sel: undefined,
-                        data: undefined,
-                        elm: undefined,
-                        key: undefined,
-                        text: await this.maybeEvaluate(node.data.attrs["x-text"])
-                    });
-
-                    delete node.data.attrs["x-text"];
-                } else
-                // If has a "x-html" attribute
-                if ("x-html" in node.data.attrs) {
-                    node.children = node.children || [];
-
-                    // Append the HTML to the children
-                    node.children.push({
-                        children: undefined,
-                        sel: undefined,
-                        data: undefined,
-                        elm: undefined,
-                        key: undefined,
-                        text: await this.evaluate(node.data.attrs["x-html"])
-                    });
-
-                    delete node.data.attrs["x-html"];
-                }
-
-                // Find events
-                const events = Object.keys(node.data.attrs).filter((prop) => prop.startsWith("x-on:"));
-
-                // Iterate over all events
-                for(let prop of events) {
-                    const event = prop.replace("x-on:", "");
-
-                    // Bind the event to it
-                    node.data.on = node.data.on || {};
-                    const lastStack = [...this.stateStack];
-
-                    const fn = node.data.attrs[prop];
-
-                    node.data.on[event] = async ($event) => {
-                        debug("handled %s event", event);
-
-                        const savedStack = this.stateStack;
-
-                        this.stateStack = lastStack;
-                        this.stateStack.push({ $event });
-
-                        await this.maybeEvaluate(fn);
-
-                        this.stateStack = savedStack;
-                    };
-
-                    debug("binding event \"%s\" to \"%s\"", event, node.data.attrs[prop]);
-
-                    // Remove the prop from the node
-                    delete node.data.attrs[prop];
-                }
-
-                // Find property bindings
-                const bindings = Object.keys(node.data.attrs).filter((prop) => prop.startsWith("x-bind:"));
-
-                // Iterate over all bindings
-                for(let prop of bindings) {
-                    const bindingProp = prop.replace("x-bind:", "");
-
-                    // Bind the property to it
-                    node.data.attrs[bindingProp] = await this.maybeEvaluate(
-                        node.data.attrs[prop]
-                    );
-
-                    debug("binding prop \"%s\" to \"%s\"", bindingProp, node.data.attrs[bindingProp]);
-
-                    // Remove the prop from the node
-                    delete node.data.attrs[prop];
-                }
-            }
-        }
-
-        // Parse children if needed
-        if (node.children) {
-            for(let i = 0; i < node.children.length; i++) {
-                node.children[i] = await this.parseNode(node.children[i]);
-            }
-
-            // Filtren null ones
-            node.children = node.children.filter((node) => !!node);
-        }
-
-        return node;
-    }
-
-    /**
-     * Renders the virtual dom for the first time.
-     * @returns 
-     */
-    public async renderFirst() {
-        debug("first render");
-
-        const vdom = this.component.$component.render({ h }) as VNode;
-
-        await this.parseNode(vdom);
-
-        const template = document.createElement("div");
-        this.patch(template, vdom);
-
-        return template;
-    }
-}

+ 0 - 28
packages/renderer/src/core/evaluator/SafeAsyncFunction.ts

@@ -1,28 +0,0 @@
-/**
- * A constructor for async functions.
- */
-export const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor as AsyncGeneratorFunction;
-
-/**
- * Used to represent a safe async evaluator.
- */
-export interface ISafeAsyncFunction extends AsyncGeneratorFunction {
-    result: any;
-    finished: boolean;
-}
-
-/**
- * Generates a safe async function to be executed.
- * @param rightSideSafeExpression The safe right side expression.
- * @returns 
- */
-export function SafeAsyncFunction(rightSideSafeExpression: string) {
-    return new AsyncFunction(["__self", "scope"], /*js*/`
-        with (scope) {
-            __self.result = ${rightSideSafeExpression}
-        };
-        
-        __self.finished = true;
-        return __self.result;
-    `) as any as ISafeAsyncFunction;
-}

+ 377 - 0
packages/renderer/src/core/vdom/Node.ts

@@ -0,0 +1,377 @@
+import { dir } from "console";
+import { VNode } from "snabbdom";
+import { cloneNode } from "../../model/VirtualDom";
+import { Renderer } from "./Renderer";
+
+const debug = require("debug")("pupper:vdom:node");
+
+export class Node<TVNode extends Partial<VNode> | string = any> {
+    public children: Node[] = [];
+
+    public properties: Record<string, string | boolean | number>;
+    public attributes: Record<string, string | boolean | number>;
+    public eventListeners: Record<string, CallableFunction[]>;
+
+    public tag: string;
+
+    public ignore: boolean = false;
+    public dirty: boolean = true;
+    public invisible: boolean = false;
+
+    constructor(
+        protected node: TVNode,
+        public parent: Node = null,
+        public renderer: Renderer
+    ) {
+        if (typeof node !== "string") {
+            // Initialize the properties
+            this.tag = node.sel || "text";
+
+            if (node.data) {
+                if ("attrs" in node.data) {
+                    this.attributes = Object.assign({}, node.data.attrs);
+                } else {
+                    this.attributes = {};
+                }
+
+                if ("props" in node.data) {
+                    this.properties = Object.assign({}, node.data.props);
+                } else {
+                    this.properties = {};
+                }
+
+                if ("on" in node.data) {
+                    this.eventListeners = Object.assign({}, node.data.on as any);
+                } else {
+                    this.eventListeners = {};
+                }
+            } else {
+                this.attributes = {};
+                this.properties = {};
+                this.eventListeners = {};
+            }
+
+            this.children = node.children ? node.children.map((child) => new Node(child, this, renderer)) : [];
+        }
+    }
+
+    /**
+     * Determines if this node is invisible (will be skipped).
+     * @param invisible If it's invisible or not.
+     */
+    public setInvisible(invisible = true) {
+        this.invisible = invisible;
+    }
+
+    /**
+     * Determines if this node is dirty (needs to be reparsed) or not.
+     * @param dirty If it's dirty or not.
+     */
+    public setDirty(dirty: boolean = true) {
+        this.dirty = dirty;
+        this.renderer.enqueueRender();
+    }
+
+    /**
+     * Determines if this node is being ignored by the directives.
+     * @param ignored If this node needs to be ignored.
+     */
+    public setIgnored(ignored: boolean = true) {
+        this.ignore = ignored;
+    }
+
+    /**
+     * Checks if the node is being ignored.
+     * @returns 
+     */
+    public isBeingIgnored() {
+        return this.ignore || !this.dirty;
+    }
+
+    /**
+     * Retrieves an object containing all attributes and properties.
+     * @returns 
+     */
+    public getAttributesAndProps() {
+        return {
+            ...this.attributes,
+            ...this.properties
+        };
+    }
+
+    /**
+     * Retrieves an attribute by the key.
+     * @param key The attribute key.
+     * @returns 
+     */
+    public getAttribute(key: string) {
+        return this.attributes[key];
+    }
+
+    /**
+     * Checks if an attribute exists.
+     * @param key The attribute key.
+     * @returns 
+     */
+    public hasAttribute(key: string) {
+        return key in this.attributes;
+    }
+
+    /**
+     * Sets an attribute value.
+     * @param key The attribute key.
+     * @param value The attribute value.
+     * @returns 
+     */
+    public setAttribute(key: string, value: string | boolean | number) {
+        this.attributes[key] = value;
+        return this;
+    }
+
+    /**
+     * Removes an attribute by the key.
+     * @param key The attribute key.
+     */
+    public removeAttribute(key: string) {
+        delete this.attributes[key];
+    }
+
+    /**
+     * Retrieves a property by the key.
+     * @param key The property key.
+     * @returns 
+     */
+    public getProperty(key: string) {
+        return this.properties[key];
+    }
+
+    /**
+     * Checks if a property exists.
+     * @param key The property key.
+     * @returns 
+     */
+    public hasProperty(key: string) {
+        return key in this.properties;
+    }
+
+    /**
+     * Removes a property by the key.
+     * @param key The property key.
+     */
+    public removeProperty(key: string) {
+        delete this.properties[key];
+    }
+
+    /**
+     * Sets a property value.
+     * @param key The property key.
+     * @param value The property value.
+     * @returns 
+     */
+    public setProperty(key: string, value: string | boolean | number) {
+        this.attributes[key] = value;
+        return this;
+    }
+
+    /**
+     * Replaces this node with a new one.
+     * @param nodes The nodes to replace the current one.
+     * @returns 
+     */
+    public replaceWith(...nodes: (Node | VNode)[]) {
+        if (!this.parent) {
+            return false;
+        }
+
+        this.parent.children.splice(
+            this.getIndex(),
+            1,
+            ...nodes.map((node) => !(node instanceof Node) ? new Node(node, this.parent, this.renderer) : node)
+        );
+
+        return nodes;
+    }
+
+    /**
+     * Replaces the current node with a comment.
+     * @returns 
+     */
+    public replaceWithComment() {
+        const comment = new Node({
+            sel: "!"
+        }, this.parent, this.renderer);
+
+        this.replaceWith(comment);
+
+        return comment;
+    }
+
+    /**
+     * Adds an event listener to this node.
+     * @param event The event name to be added.
+     * @param listener The event callback.
+     */
+    public addEventListener(event: keyof DocumentEventMap | string, listener: CallableFunction) {
+        this.eventListeners[event] = this.eventListeners[event] || [];
+        this.eventListeners[event].push(listener);
+    }
+
+    /**
+     * Removes a callback from an event listener.
+     * @param event The event name.
+     * @param listener The callback to be removed.
+     */
+    public removeEventListener(event: keyof DocumentEventMap | string, listener: CallableFunction) {
+        this.eventListeners[event].splice(
+            this.eventListeners[event].indexOf(listener),
+            1
+        );
+    }
+
+    /**
+     * Returns the index of this node in the parent node children.
+     * @returns 
+     */
+    public getIndex(): number {
+        return this.parent.children.indexOf(this);
+    }
+
+    /**
+     * Checks if the node exists.
+     * @returns 
+     */
+    public exists() {
+        return this.parent === null || this.getIndex() > -1;
+    }
+
+    /**
+     * Sets the node parent node.
+     * @param parent The new node parent.
+     * @returns 
+     */
+    public setParent(parent: Node) {
+        this.parent = parent;
+        return this;
+    }
+
+    /**
+     * Insert a list of nodes before the current node.
+     * @param nodes The list of nodes to be inserted.
+     */
+    public insertBefore(...nodes: Node[]) {
+        this.parent.children.splice(
+            this.getIndex() - 1,
+            0,
+            ...nodes
+        );
+    }
+
+    /**
+     * Insert a list of nodes after the current node.
+     * @param nodes The list of nodes to be inserted.
+     */
+    public insertAfter(...nodes: Node[]) {
+        this.parent.children.splice(
+            this.getIndex() + 1,
+            0,
+            ...nodes
+        );
+    }
+
+    /**
+     * Appends a node to the children nodes.
+     * @param node The node to be appended.
+     */
+    public appendChild(node: Node) {
+        this.children.push(node);
+    }
+
+    /**
+     * Appends a list of node to the children nodes.
+     * @param nodes The nodes to be appended.
+     */
+    public append(...nodes: Node[]) {
+        this.children.push(...nodes);
+    }
+
+    /**
+     * Deletes the current node from the parent node.
+     */
+    public delete() {
+        this.parent.children.splice(
+            this.getIndex(),
+            1
+        );
+    }
+
+    /**
+     * Clones the current node into a new one.
+     * @returns 
+     */
+    public clone() {
+        return new Node(cloneNode(this.node as VNode), this.parent, this.renderer);
+    }
+
+    /**
+     * Patches the DOM for this element.
+     */
+    public updateDOM(callback?: CallableFunction) {
+        if (typeof this.node === "string") {
+            return;
+        }
+
+        if (this.renderer.rendered) {
+            this.renderer.update();
+
+            if (typeof callback === "function") {
+                this.renderer.nextTick(callback);
+            }
+        }
+    }
+
+    /**
+     * Retrieves the root node.
+     * @returns 
+     */
+    public getRoot() {
+        let node: Node = this;
+
+        while(node.parent !== null) {
+            node = node.parent;
+        }
+        
+        return node;
+    }
+
+    /**
+     * Converts this node into a virtual node.
+     * @returns 
+     */
+    public toVirtualNode(): TVNode | VNode {
+        if (typeof this.node === "string") {
+            return {
+                sel: undefined,
+                data: undefined,
+                elm: undefined,
+                children: undefined,
+                key: undefined,
+                text: this.node
+            };
+        }
+
+        this.node = {
+            sel: this.tag === "text" ? undefined : this.tag,
+            data: {
+                props: this.properties,
+                attrs: this.attributes,
+                on: this.eventListeners as any
+            },
+            elm: this.node.elm,
+            children: this.children.map((child) => child.toVirtualNode()),
+            key: this.node.key,
+            text: this.node.text
+        } as TVNode;
+
+        return this.node;
+    }
+}

+ 215 - 0
packages/renderer/src/core/vdom/Renderer.ts

@@ -0,0 +1,215 @@
+import { Component } from "../Component";
+import {
+    h,
+    propsModule,
+    attributesModule,
+    styleModule,
+    eventListenersModule,
+    init,
+    VNode
+} from "snabbdom";
+
+import Pupper from "../..";
+
+import { walk } from "../../model/NodeWalker";
+import { Node } from "./Node";
+
+const debug = require("debug")("pupper:vdom");
+
+/**
+ * Most of the evaluation functions were taken from alpine.js
+ * Thanks, alpine.js!
+ */
+export class Renderer {
+    private patch: ReturnType<typeof init>;
+
+    /**
+     * The stack of states that formulates the context for rendering elements.
+     */
+    protected stateStack: Record<string, any>[] = [];
+
+    /**
+     * The container that will receive the renderer contents.
+     */
+    protected container: HTMLDivElement;
+
+    /**
+     * The current VDOM node.
+     */
+    protected currentDOM: VNode;
+
+    /**
+     * The rendering queue.
+     */
+    private queue: {
+        callback: CallableFunction,
+        listeners: CallableFunction[]
+    }[] = [];
+    
+    /**
+     * Determines if the renderer queue is currently running.
+     */
+    private inQueue: boolean;
+
+    /**
+     * Determines if has a pending render.
+     */
+    private isRenderEnqueued: boolean;
+
+    constructor(
+        protected component: Component
+    ) {
+        this.patch = init([
+            propsModule,
+            attributesModule,
+            styleModule,
+            eventListenersModule
+        ]);
+
+        this.stateStack.push(
+            // Globals
+            Pupper.$global,
+
+            // Component state
+            component.$state,
+        );
+    }
+
+    /**
+     * Starts the queue if not executing it already.
+     */
+    private maybeStartQueue() {
+        if (!this.inQueue) {
+            this.processQueue();
+        }
+    }
+
+    /**
+     * Processes the renderer queue.
+     */
+    private async processQueue() {
+        this.inQueue = this.queue.length > 0;
+
+        // If doesn't have more items to process.
+        if (!this.inQueue) {
+            // Go out of the current queue.
+            return;
+        }
+
+        // Retrieve the first queue job.
+        const { callback, listeners } = this.queue.shift();
+
+        // Do the job.
+        await callback();
+
+        // If has any listeners
+        if (listeners && listeners.length) {
+            for(let listener of listeners) {
+                await listener();
+            }
+        }
+
+        // Wait for a new job.
+        window.requestAnimationFrame(this.processQueue.bind(this));
+    }
+
+    /**
+     * Generates a state from the state stack.
+     * @returns 
+     */
+    protected generateScope() {
+        return this.stateStack.reduce((carrier, curr) => {
+            for(let key in curr) {
+                carrier[key] = curr[key];
+            }
+
+            return carrier;
+        }, {});
+    }
+
+    public rendered = false;
+
+    /**
+     * Renders the virtual dom for the first time.
+     * @returns 
+     */
+    public async renderFirst() {
+        const tick = this.nextTick(async () => {
+            debug("first render");
+
+            const vdom = this.component.$component.render({ h }) as VNode;
+            const node = new Node(vdom, null, this);
+
+            const result = await walk(node, this.generateScope());
+
+            this.currentDOM = result.toVirtualNode() as VNode;
+
+            this.container = document.createElement("div");
+            this.patch(this.container, this.currentDOM);
+
+            this.rendered = true;
+        });
+
+        await this.waitForTick(tick);
+
+        return this.container;
+    }
+
+    /**
+     * Enqueues a function to be executed in the next queue tick.
+     * @param callback The callback to be executed.
+     */
+    public nextTick(callback: CallableFunction) {
+        const tick = this.queue.push({
+            callback,
+            listeners: []
+        });
+
+        setTimeout(() => this.maybeStartQueue());
+
+        return tick;
+    }
+
+    /**
+     * Waits for the given tick or the last added tick to be executed.
+     * @returns 
+     */
+    public waitForTick(tick: number = null) {
+        return new Promise((resolve) => {
+            this.queue[tick !== null ? (tick - 1) : this.queue.length - 1].listeners.push(resolve);
+        });
+    }
+
+    /**
+     * Updates the renderer contents.
+     */
+    public update() {
+        if (!this.rendered) {
+            return;
+        }
+
+        this.isRenderEnqueued = true;
+
+        return this.nextTick(async () => {
+            const vdom = this.component.$component.render({ h }) as VNode;
+            const node = new Node(vdom, null, this);
+
+            const result = await walk(node, this.generateScope());
+
+            const newDOM = result.toVirtualNode() as VNode;
+
+            this.patch(this.currentDOM, newDOM);
+
+            this.currentDOM = newDOM;
+        });
+    }
+
+    /**
+     * Enqueues a render update if the not enqueued yet.
+     */
+    public enqueueRender() {
+        if (!this.isRenderEnqueued) {
+            this.nextTick(() => this.update());
+        }
+    }
+}

+ 35 - 0
packages/renderer/src/core/vdom/directives/Bind.ts

@@ -0,0 +1,35 @@
+import { directive, mapAttributes, startingWith as replaceWith } from "../../../model/Directive";
+import { evaluateLater } from "../../../model/Evaluator";
+import { effect } from "../../../model/Reactivity";
+
+const debug = require("debug")("pupper:vdom:on");
+
+mapAttributes(replaceWith("@", "x-on:"));
+mapAttributes(replaceWith(":", "x-bind:"));
+
+/**
+ * @directive x-bind
+ * @description Adds an event handler to the node.
+ */
+directive("bind", async (node, { value, expression, scope }) => {
+    const evaluate = expression ? evaluateLater(expression) : () => {};
+
+    await effect(async () => {
+        try {
+            const evaluated = await evaluate(scope);
+
+            // Bind the evaluated value to it
+            node.setAttribute(value, evaluated);
+        
+            debug("binding prop \"%s\" to \"%s\"", value, evaluated);
+        
+            // Remove the original attribute from the node
+            node.removeAttribute("x-bind:" + value);
+            
+            node.setDirty();
+        } catch(e) {
+            console.warn("[pupperjs] failed to bind property:");
+            console.error(e);
+        }
+    });
+});

+ 41 - 0
packages/renderer/src/core/vdom/directives/Conditional.ts

@@ -0,0 +1,41 @@
+import { directive } from "../../../model/Directive";
+import { evaluateLater } from "../../../model/Evaluator";
+import { walk } from "../../../model/NodeWalker";
+import { effect } from "../../../model/Reactivity";
+
+const debug = require("debug")("pupper:vdom:directives:conditional");
+
+/**
+ * @directive x-if
+ * @description Conditionally renders a tag's children nodes if the condition is met.
+ */
+directive("if", async (node, { expression, scope }) => {
+    const evaluate = evaluateLater(expression);
+
+    // Save and remove the children
+    const children = node.children;
+    const comment = node.replaceWithComment();
+
+    await effect(async () => {
+        if (comment.isBeingIgnored()) {
+            return;
+        }
+
+        try {
+            const value = await evaluate(scope);
+
+            debug("%s evaluated to %O", expression, value);
+
+            if (value) {
+                comment.insertBefore(
+                    ...await walk(children, scope)
+                );
+            }
+
+            comment.parent.setDirty();
+        } catch(e) {
+            console.warn("[pupperjs] failed to evaluate conditional:");
+            console.error(e);
+        }
+    });
+});

+ 25 - 0
packages/renderer/src/core/vdom/directives/EventHandler.ts

@@ -0,0 +1,25 @@
+import { directive } from "../../../model/Directive";
+import { evaluateLater } from "../../../model/Evaluator";
+
+const debug = require("debug")("pupper:vdom:on");
+
+/**
+ * @directive x-on
+ * @description Adds an event handler to the node.
+ */
+directive("on", async (node, { value, expression, scope }) => {
+    try {
+        const evaluate = expression ? evaluateLater(expression) : () => {};
+
+        node.addEventListener(value, async ($event: any) => {
+            debug("handled %s event", value);
+            evaluate(scope);
+        });
+
+        // Remove the prop from the node
+        node.removeAttribute("x-on:" + value);
+    } catch(e) {
+        console.warn("[pupperjs] failed to evaluate event handler:");
+        console.error(e);
+    }
+});

+ 29 - 0
packages/renderer/src/core/vdom/directives/HTML.ts

@@ -0,0 +1,29 @@
+import { directive } from "../../../model/Directive";
+import { evaluateLater } from "../../../model/Evaluator";
+import { effect } from "../../../model/Reactivity";
+import { Node } from "../Node";
+
+/**
+ * @directive x-html
+ * @description Sets an element inner HTML.
+ */
+directive("html", async (node, { expression, scope }) => {
+    const evaluate = evaluateLater(expression);
+
+    await effect(async () => {
+        try {
+            const html = await evaluate(scope) as string;
+
+            node.appendChild(
+                new Node(html, node.parent, node.renderer)
+            );
+
+            node.removeAttribute("x-html");
+
+            node.setDirty();
+        } catch(e) {
+            console.warn("[pupperjs] failed to set inner HTML:");
+            console.error(e);
+        }
+    });
+});

+ 121 - 0
packages/renderer/src/core/vdom/directives/Loop.ts

@@ -0,0 +1,121 @@
+import { directive } from "../../../model/Directive";
+import { evaluateLater } from "../../../model/Evaluator";
+import { walk } from "../../../model/NodeWalker";
+import { effect } from "../../../model/Reactivity";
+import { IsNumeric, IsObject } from "../../../util/ObjectUtils";
+import { Node } from "../Node";
+
+/**
+ * @directive x-for
+ * @description Recursively renders a node's children nodes.
+ */
+directive("for", async (node, { expression, scope }) => {
+    const loopData = parseForExpression(expression);
+    const evaluate = evaluateLater(loopData.items);
+
+    // Save and remove the children
+    const children = node.children;
+    node = node.replaceWithComment();
+    node.setIgnored(true);
+
+    let clonedChildren: Node[] = [];
+
+    await effect(async () => {        
+        let loopScope;
+
+        try {
+            let items = await evaluate(scope);
+
+            // Support number literals, eg.: x-for="i in 100"
+            if (IsNumeric(items)) {
+                items = Array.from(Array(items).keys(), (i) => i + 1);
+            } else
+            // If it's an object
+            if (IsObject(items)) {
+                // Retrieve the entries from it
+                items = Object.entries(items);
+            } else
+            // If nothing is found, default to an empty array.
+            if (items === undefined) {
+                items = [];
+            }
+
+            // Delete the existing children
+            //node.parent.children = node.parent.children.filter((child) => !clonedChildren.includes(child));
+
+            // Iterate over all evaluated items
+            for(let item in items) {
+                loopScope = { ...scope };
+
+                console.log(items[item]);
+                
+                // Push the current item to the state stack
+                if ("item" in loopData) {
+                    loopScope[loopData.item] = items[item];
+                }
+
+                if ("index" in loopData) {
+                    loopScope[loopData.index] = item;
+                }
+
+                if ("collection" in loopData) {
+                    loopScope[loopData.collection] = items;
+                }
+
+                for(let child of children) {
+                    child = child.clone().setParent(node.parent);
+                    node.insertBefore(child);
+
+                    await walk(child, loopScope);
+                }                
+            }
+        } catch(e) {
+            console.warn("[pupperjs] The following information can be useful for debugging:");
+            console.warn("last scope:", loopScope);
+            console.error(e);
+        }
+
+        node.parent.setDirty();
+    });
+});
+
+/**
+ * Parses a "for" expression
+ * @note This was taken from VueJS 2.* core. Thanks Vue!
+ * @param expression The expression to be parsed.
+ * @returns 
+ */
+function parseForExpression(expression: string | number | boolean | CallableFunction) {
+    let forIteratorRE = /,([^,\}\]]*)(?:,([^,\}\]]*))?$/;
+    let stripParensRE = /^\s*\(|\)\s*$/g;
+    let forAliasRE = /([\s\S]*?)\s+(?:in|of)\s+([\s\S]*)/;
+    let inMatch = String(expression).match(forAliasRE);
+
+    if (!inMatch) {
+        return;
+    }
+
+    let res: {
+        items?: string;
+        index?: string;
+        item?: string;
+        collection?: string;
+    } = {};
+
+    res.items = inMatch[2].trim();
+    let item = inMatch[1].replace(stripParensRE, "").trim();
+    let iteratorMatch = item.match(forIteratorRE);
+
+    if (iteratorMatch) {
+        res.item = item.replace(forIteratorRE, "").trim();
+        res.index = iteratorMatch[1].trim();
+
+        if (iteratorMatch[2]) {
+            res.collection = iteratorMatch[2].trim();
+        }
+    } else {
+        res.item = item;
+    }
+
+    return res;
+}

+ 38 - 0
packages/renderer/src/core/vdom/directives/Text.ts

@@ -0,0 +1,38 @@
+import { directive } from "../../../model/Directive";
+import { maybeEvaluateLater } from "../../../model/Evaluator";
+import { effect } from "../../../model/Reactivity";
+import { Node } from "../Node";
+
+/**
+ * @directive x-text
+ * @description Sets an element inner text.
+ */
+directive("text", async (node, { expression, scope }) => {
+    const evaluate = maybeEvaluateLater(expression);
+
+    await effect(async () => {
+        try {
+            const text = await evaluate(scope) as string;
+
+            if (!text) {
+                console.warn(`pupper.js evaluated x-text expression "${expression}" as`, undefined);
+                return;
+            }
+
+            if (node.tag === "text") {
+                node.replaceWith(new Node(text, node.parent, node.renderer));
+            } else {
+                node.appendChild(
+                    new Node(text, node, node.renderer)
+                );
+
+                node.removeAttribute("x-text");
+            }
+
+            node.setDirty();
+        } catch(e) {
+            console.warn("[pupperjs] failed to set inner text:");
+            console.error(e);
+        }
+    });
+});

+ 29 - 0
packages/renderer/src/core/vdom/evaluator/SafeAsyncFunction.ts

@@ -0,0 +1,29 @@
+/**
+ * A constructor for async functions.
+ */
+export const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor;
+
+export interface ISafeAsyncProps {
+    result: any;
+    finished: boolean;
+}
+
+export type TAsyncFunction<TResultType = any> = ((scope: Record<string, any>) => Promise<TResultType>);
+
+/**
+ * Used to represent a safe async evaluator.
+ */
+export type ISafeAsyncFunction<TResultType = any> = ISafeAsyncProps & TAsyncFunction<TResultType>;
+
+/**
+ * Generates a safe async function to be executed.
+ * @param rightSideSafeExpression The safe right side expression.
+ * @returns 
+ */
+export function SafeAsyncFunction<TResultType = any>(rightSideSafeExpression: string): ISafeAsyncFunction<TResultType> {
+    return new AsyncFunction(["scope"], /*js*/`
+        with (scope) {
+            return ${rightSideSafeExpression}
+        };
+    `);
+}

+ 21 - 8
packages/renderer/src/index.ts

@@ -1,6 +1,14 @@
-import Alpine from "alpinejs";
+import { Component as Component } from "./core/Component";
 
-import { PupperComponent as Component } from "./core/Component";
+/**
+ * Import all directives
+ */
+import "./core/vdom/directives/Conditional";
+import "./core/vdom/directives/Loop";
+import "./core/vdom/directives/Bind";
+import "./core/vdom/directives/EventHandler";
+import "./core/vdom/directives/Text";
+import "./core/vdom/directives/HTML";
 
 export default class Pupper {
     /**
@@ -8,7 +16,15 @@ export default class Pupper {
      */
     public static Component = Component;
 
-    public static defineComponent = Component.create;
+    /**
+     * An alias to Component.create
+     */
+    public static defineComponent: typeof Component["create"] = Component.create;
+
+    /**
+     * A handler for all saved store states.
+     */
+    public static $store: Record<string, any> = {};
 
     /**
      * Sets a state in the global store.
@@ -17,16 +33,13 @@ export default class Pupper {
      * @returns 
      */
     public static store(name: string, value?: any) {
-        return Alpine.store(name, value);
+        return value !== undefined ? this.$store[name] : this.$store[name] = value;
     };
     
     /**
      * The Pupper global state.
      */
-    public static $global = Alpine.store("__GLOBAL__") as Record<string, any>;
+    public static $global = this.store("__GLOBAL__");
 };
 
-// Sets the global magic
-Alpine.magic("global", () => Pupper.$global);
-
 module.exports = Pupper;

+ 188 - 0
packages/renderer/src/model/Directive.ts

@@ -0,0 +1,188 @@
+import { VNode } from "snabbdom";
+import { Node } from "../core/vdom/Node";
+import { Renderer } from "../core/vdom/Renderer";
+
+export type TScope = Record<string, string | boolean | number>;
+
+export type TAttributeVal = string | number | boolean;
+
+export interface IDirectiveVNode extends VNode {
+    _x_ignore: boolean;
+    _x_ignoreSelf: boolean;
+}
+
+type TDirectives = typeof directiveOrder[number];
+
+interface IProp {
+    name: string;
+    value: TAttributeVal;
+}
+
+interface IDirective {
+    type: TDirectives | null;
+    value: TAttributeVal | null;
+    modifiers: string[];
+    expression: string;
+}
+
+let isDeferringHandlers = false;
+
+const directiveHandler: {
+    [index in TDirectives]?: TDirectiveCallback
+} = {};
+
+const directiveHandlerStacks = new Map<string | Symbol, TDirectiveCallback[]>();
+const currentHandlerStackKey = Symbol();
+
+/**
+ * The regex to detect and evaluate pupper.js related attributes
+ */
+const pupperAttrRegex = /^x-([^:^.]+)\b/;
+
+type TDirectiveCallback = (
+    node: Node,
+    data: {
+        renderer: Renderer;
+        scope: TScope;
+        expression?: string;
+        value?: string;
+    }
+) => any;
+
+export function directive(attribute: TDirectives, callback: TDirectiveCallback) {
+    directiveHandler[attribute] = callback;
+}
+
+/**
+ * Retrieves an array of directives to be evaluated for the given node.
+ * @param node The node to be evaluated.
+ * @returns 
+ */
+export function directives(node: Node, scope: TScope) {
+    let transformedAttributeMap: Record<string, string> = {};
+
+    const attributes = node.getAttributesAndProps();
+
+    return Object.keys(attributes)
+        .map((attr) => {
+            return {
+                name: attr,
+                value: attributes[attr]
+            };
+        })
+        .map(
+            toTransformedAttributes((newName, oldName) => 
+                transformedAttributeMap[newName] = oldName
+            )
+        )
+        // Filter non-pupper attributes
+        .filter((attr) => attr.name.match(pupperAttrRegex))
+        .map(
+            toParsedDirectives(transformedAttributeMap, null)
+        )
+        .sort(byPriority)
+        .map((dir) => {
+            return getDirectiveHandler(node, dir, scope);
+        });
+}
+
+export function getDirectiveHandler(node: Node, directive: IDirective, scope: TScope) {
+    let noop = async () => {};
+    let handler = directiveHandler[directive.type] || noop;
+
+    const props = {
+        ...directive,
+        scope
+    };
+
+    // If wants to ignore this node
+    if (node.isBeingIgnored()) {
+        return noop;
+    }
+
+    if (isDeferringHandlers) {
+        directiveHandlerStacks.get(currentHandlerStackKey).push(handler);
+        return noop;
+    }
+
+    // Bind the handler to itself with the properties
+    handler = handler.bind(handler, node, props);
+
+    return async() => await handler(node, props as any);
+}
+
+const attributeTransformers: CallableFunction[] = [];
+
+function toTransformedAttributes(callback: (newName: string, name: string) => any): ((props: IProp) => IProp) {
+    return ({ name, value }) => {
+        let { name: newName, value: newValue } = attributeTransformers.reduce((carry, transform) => {
+            return transform(carry);
+        }, { name, value });
+
+        if (newName !== name) {
+            callback && callback(newName, name);
+        }
+
+        return {
+            name: newName,
+            value: newValue
+        };
+    }
+}
+
+function toParsedDirectives(
+    transformedAttributeMap: Record<string, TAttributeVal>,
+    originalAttributeOverride: Record<string, TAttributeVal>
+): ((props: IProp) => IDirective) {
+    return ({ name, value }) => {
+        const typeMatch = name.match(pupperAttrRegex)
+        const valueMatch = name.match(/:([a-zA-Z0-9\-:]+)/)
+        const modifiers: string[] = name.match(/\.[^.\]]+(?=[^\]]*$)/g) || []
+        const original = originalAttributeOverride || transformedAttributeMap[name] || name
+
+        return {
+            type: typeMatch ? typeMatch[1] as any : null,
+            value: valueMatch ? valueMatch[1] as any : null,
+            modifiers: modifiers.map(i => i.replace(".", "")),
+            expression: String(value),
+            original,
+        };
+    }
+}
+
+export function mapAttributes(callback: CallableFunction) {
+    attributeTransformers.push(callback);
+}
+
+export function startingWith(subject: string, replacement: string): (prop: IProp) => IProp {
+    return ({ name, value }) => {
+        if (name.startsWith(subject)) {
+            name = name.replace(subject, replacement);
+        }
+
+        return { name, value };
+    };
+}
+
+const DEFAULT = "DEFAULT"
+
+const directiveOrder = [
+    "ref",
+    "id",
+    "bind",
+    "if",
+    "for",
+    "transition",
+    "show",
+    "on",
+    "text",
+    "html",
+    DEFAULT
+] as const;
+
+function byPriority(a: IDirective, b: IDirective) {
+    let typeA = !directiveOrder.includes(a.type) ? DEFAULT : a.type
+    let typeB = !directiveOrder.includes(b.type) ? DEFAULT : b.type
+
+    return directiveOrder.indexOf(typeA) - directiveOrder.indexOf(typeB)
+}

+ 112 - 0
packages/renderer/src/model/Evaluator.ts

@@ -0,0 +1,112 @@
+import { ISafeAsyncFunction, SafeAsyncFunction } from "../core/vdom/evaluator/SafeAsyncFunction";
+
+/**
+ * A handler containing all already-evaluated functions.
+ */
+const evaluatorMemo: Record<string, ISafeAsyncFunction> = {};
+
+/**
+ * Evaluates an expression string into a function.
+ * @param expression The expression to be evaluated.
+ * @returns 
+ */
+export function evaluateString<TExpressionResult = any>(expression: string) {
+    // If this expression has already been evaluated
+    if (evaluatorMemo[expression]) {
+        return evaluatorMemo[expression];
+    }
+
+    // Some expressions that are useful in Alpine are not valid as the right side of an expression.
+    // Here we'll detect if the expression isn't valid for an assignement and wrap it in a self-
+    // calling function so that we don't throw an error AND a "return" statement can b e used.
+    let rightSideSafeExpression = 0
+        // Support expressions starting with "if" statements like: "if (...) doSomething()"
+        || /^[\n\s]*if.*\(.*\)/.test(expression)
+        // Support expressions starting with "let/const" like: "let foo = 'bar'"
+        || /^(let|const)\s/.test(expression)
+            ? `(() => { ${expression} })()`
+            : expression
+
+    let func: ISafeAsyncFunction<TExpressionResult>;
+
+    try {
+        func = SafeAsyncFunction(rightSideSafeExpression);
+    } catch (err) {
+        console.warn("pupper.js warning: invalid expression \"" + rightSideSafeExpression + "\"\n", err);
+        return undefined;
+    }
+
+    evaluatorMemo[expression] = func;
+
+    return func;
+}
+
+/**
+ * Evaluates an expression.
+ * @param expression The expression to be evaluated.
+ * @returns 
+ */
+export function evaluateLater<TExpressionResult = any>(expression: string | number | boolean | CallableFunction) {
+    if (typeof expression === "function") {
+        return expression() as ISafeAsyncFunction<TExpressionResult>;
+    }
+
+    const func = evaluateString<TExpressionResult>(String(expression));
+
+    if (func === undefined) {
+        return undefined;
+    }
+
+    return func;
+}
+
+/**
+ * Evaluates an expression if it's not a plain string.
+ * @param expression The expression to be evaluated.
+ * @returns 
+ */
+ export function maybeEvaluateLater<TExpressionResult = any>(expression: string | number | boolean | CallableFunction) {
+    if (typeof expression === "function") {
+        return expression() as ISafeAsyncFunction<TExpressionResult>;
+    }
+
+    const func = evaluateString<TExpressionResult>(String(expression));
+
+    if (func === undefined) {
+        return (): any => undefined;
+    }
+
+    if (typeof func === "string") {
+        return () => func;
+    }
+
+    return func;
+}
+
+/**
+ * Evaluates an expression and executes it immediately.
+ * @param expression The expression to be evaluated.
+ * @param scope The scope to be passed to the evaluator.
+ * @returns 
+ */
+export async function evaluate<TExpressionResult = any>(expression: string | number | boolean | CallableFunction, scope: any) {
+    const evaluated = evaluateLater(expression);
+
+    if (evaluated === undefined) {
+        return undefined;
+    }
+
+    try {
+        return await evaluated(scope) as TExpressionResult;
+    } catch(e) {
+        console.warn("pupper.js warning: failed to evaluate " + expression, "\n", e);
+    }
+}
+
+export function runIfIsFunction(value: CallableFunction | any) {
+    if (typeof value === 'function') {
+        return value();
+    } else {
+        return value;
+    }
+}

+ 86 - 0
packages/renderer/src/model/NodeWalker.ts

@@ -0,0 +1,86 @@
+import { Node } from "../core/vdom/Node";
+import { directives } from "./Directive";
+
+export async function walk<TNode extends Node | Node[]>(nodes: TNode, scope: any = null): Promise<TNode> {
+    if (!Array.isArray(nodes)) {
+        return await node(nodes as Node, scope) as TNode;
+    }
+
+    let count = nodes.length;
+    let i = 0;
+
+    while(i < count) {
+        const result = await node(nodes[i], scope);
+
+        if (result === undefined) {
+            nodes.splice(i++, 1);
+            count = nodes.length;
+            continue;
+        }
+
+        if (Array.isArray(result)) {
+            nodes.splice(i++, 1, ...result);
+            count = nodes.length;
+            continue;
+        }
+
+        if (!result.exists() || result.isBeingIgnored()) {
+            i++;
+            continue;
+        }
+
+        nodes[i] = result;
+
+        i++;
+    }
+
+    return nodes;
+}
+
+async function node(node: Node | undefined, scope: any) {
+    //console.group(node.tag, node.getAttributesAndProps());
+
+    // If it's an invalid node
+    if (!node) {
+        //console.groupEnd();
+        // Ignore it
+        return undefined;
+    }
+
+    // Ignore if it's a string
+    if (typeof node === "string") {
+        //console.groupEnd();
+        return node;
+    }
+
+    // Ignore if it's being ignored
+    if (node.isBeingIgnored()) {
+        //console.groupEnd();
+        return node;
+    }
+
+    for(let handle of directives(node, scope)) {
+        await handle();
+    }
+
+    // If it's invisible
+    if (node.invisible) {
+        //console.groupEnd();
+        return undefined;
+    }
+
+    // If the node was removed, stop parsing
+    if (!node.exists()) {
+        //console.groupEnd();
+        return node;
+    }
+
+    // Parse children if needed
+    if (node.children?.length > 0) {
+        node.children = await walk(node.children, scope);
+    }
+
+    //console.groupEnd();
+
+    return node;
+}

+ 60 - 0
packages/renderer/src/model/Reactivity.ts

@@ -0,0 +1,60 @@
+type TEffect = () => any | Promise<any>;
+type TReactiveObj = Record<string | number | symbol, any>;
+
+const effects = new Map<TReactiveObj, TEffect | any>();
+let currentEffect: TEffect = null;
+
+export async function effect(effect: TEffect) {
+    currentEffect = effect;
+
+    // Calling the effect immediately will make it
+    // be detected and registered at the effects handler.
+    await effect();
+
+    currentEffect = null;
+}
+
+export function reactive(obj: TReactiveObj) {
+    return new Proxy(obj, {
+        get(target, property) {
+            if (currentEffect === null) {
+                return target[property];
+            }
+
+            if (!effects.has(target)) {
+                effects.set(target, {} as any);
+            }
+
+            const targetEffects = effects.get(target);
+
+            if (!targetEffects[property]) {
+                targetEffects[property] = [];
+            }
+
+            targetEffects[property].push(currentEffect);
+
+            return target[property];
+        },
+
+        set(target, property, value) {
+            // Only objects can be reactive
+            if (typeof value === "object") {
+                target[property] = reactive(value);
+            }
+
+            if (effects.has(target)) {
+                const targetEffects = effects.get(target);
+
+                if (Array.isArray(targetEffects[property])) {
+                    (async () => {
+                        for(let effect of targetEffects[property]) {
+                            await effect();
+                        }
+                    })();
+                }
+            }
+
+            return true;
+        }
+    })
+}

+ 35 - 0
packages/renderer/src/model/VirtualDom.ts

@@ -0,0 +1,35 @@
+import { VNode } from "snabbdom";
+
+/**
+ * Clones a list of nodes.
+ * @param nodes The list to be cloned.
+ * @returns 
+ */
+export function cloneNodes(nodes: (VNode | string)[]) {
+    const cloned: (VNode | string)[] = [];
+
+    for(let node of nodes) {
+        cloned.push(cloneNode(node));
+    }
+
+    return cloned;
+}
+
+export function cloneNode(node: VNode | string): VNode | string {
+    if (typeof node === "string") {
+        return node;
+    }
+
+    return {
+        children: node.children ? cloneNodes(node.children) : undefined,
+        data: node.data ? {
+            attrs: node.data.attrs ? JSON.parse(JSON.stringify(node.data.attrs)) : undefined,
+            props: node.data.props ? JSON.parse(JSON.stringify(node.data.props)) : undefined,
+            on: node.data.on ? JSON.parse(JSON.stringify(node.data.on)) : undefined
+        } : undefined,
+        elm: undefined,
+        key: node.key || undefined,
+        sel: node.sel || undefined,
+        text: node.text || undefined
+    };
+}

+ 2 - 2
packages/renderer/src/types/pupper.d.ts

@@ -1,8 +1,8 @@
-import { PupperComponent, IComponent } from "../core/Component";
+import { Component, IComponent } from "../core/Component";
 
 /**
  * Used to represent whats is a pupper module
  */
 declare module "*.pupper" {
-    export default function(data: IComponent): PupperComponent;
+    export default function(data: IComponent): Component;
 }