Browse Source

added foreach & general reactoring

Matheus Giovani 3 years ago
parent
commit
101c4b57fd

+ 1 - 1
modules/webpack/index.js

@@ -4,7 +4,7 @@
  * @license AGPL-3.0
  */
 
-const pupper = require("../../types");
+const pupper = require("../..");
 
 /**
  * @param {string} source The source filename

+ 2 - 0
package.json

@@ -20,7 +20,9 @@
   "devDependencies": {
     "@types/node": "^16.7.6",
     "@types/pug": "^2.0.5",
+    "debug": "^4.3.2",
     "js-beautify": "^1.14.0",
+    "source-map-loader": "^3.0.0",
     "tsc": "^2.0.3",
     "typescript": "^4.4.2",
     "webpack": "^5.51.1",

+ 4 - 2
src/core/Compiler.ts

@@ -48,7 +48,8 @@ export default class PupperCompiler {
                     // @ts-ignore
                     plugins: [{
                         lex: new Lexer(),
-                        preParse: parser.preParse.bind(this)
+                        preParse: parser.preParse.bind(this),
+                        postParse: parser.postParse.bind(this)
                     }],
                     ...options.pug || {}
                 })
@@ -76,7 +77,8 @@ export default class PupperCompiler {
                 // @ts-ignore
                 plugins: [{
                     lex: new Lexer(),
-                    preParse: parser.preParse.bind(this)
+                    preParse: parser.preParse.bind(this),
+                    preLoad: parser.postParse.bind(this)
                 }],
                 ...options.pug || {}
             });

+ 230 - 127
src/core/Renderer.ts

@@ -1,80 +1,66 @@
 import type pug from "pug";
 import deepGetSet from "deep-get-set";
-import observableSlim, { ObservableChange } from "observable-slim";
+import observableSlim from "observable-slim";
+import Reactor from "./renderer/Reactor";
+import { Reactive } from "./renderer/Reactive";
 
-export namespace Renderer {
-    export type ReactiveData = Record<string, any>;
-    export type ReactiveTarget = "text" | "html" | "attribute";
-    export type ReactiveCommand = "escape" | "literal";
+const debug = require("debug")("pupperjs:renderer");
+
+export interface NodeOptions {
+    /**
+     * Any prefix to be passed to the dot notation
+     */
+    pathPrefix?: string,
+
+    /**
+     * Any additional context indexes to search for values
+     */
+    context?: Record<string, any>
+}
+
+export enum NodePreparationResult {
+    SKIP_CHILD,
+    SUCCESS,
+    FAILED
 }
 
 export default class PupperRenderer {
-    private static SYNTAX_REGEX = /(?: +)?\@pupperjs\:(?<command>.+)\((?<property>(?:[\w+]|\.| )+?)\)(?: +)?/;
+    private static SYNTAX_REGEX = /(?: +)?\@p\:(?<command>.+)\((?<property>.+?)\)(?: +)?/;
 
+    /**
+     * The pug compiled template function
+     */
     private template: pug.compileTemplate;
-    public data: ProxyHandler<Renderer.ReactiveData> = {};
-    private dom: HTMLDivElement;
 
     /**
-     * A list of reactive properties with their respective elements
+     * The reactive data
      */
-    private reactive: Record<string, {
-        element: (HTMLElement | Element | Node),
-        target: Renderer.ReactiveTarget,
-        command: Renderer.ReactiveCommand,
-        key?: string
-    }[]> = {};
-
-    constructor(template: pug.compileTemplate, data?: Renderer.ReactiveData) {
-        this.template = template;
+    public data: ProxyHandler<Reactive.ReactiveData> = {};
 
-        if (data) {
-            this.setData(data);
-        }
-    }
+    /**
+     * The DOM element that will receive all children
+     */
+    private dom: HTMLElement;
 
     /**
-     * When a data property has changed
-     * @param changes The observed changes
+     * The reactor for this renderer
      */
-    onPropertyChange(changes: ObservableChange[]) {
-        changes.forEach((change) => {
-            // Check if has any handler registered for the given property
-            if (this.reactive[change.currentPath] !== undefined) {
-                // Trigger all of them
-                this.reactive[change.currentPath].forEach((reactive) => {
-                    console.log(reactive);
-
-                    switch(reactive.target) {
-                        // If it's targeting the text content
-                        case "text":
-                            reactive.element.textContent = change.newValue;
-                        break;
-
-                        // If it's targeting the HTML content
-                        case "html":
-                            (reactive.element as HTMLElement).innerHTML = change.newValue;
-                        break;
-
-                        // If it's targeting an attribute
-                        case "attribute":
-                            (reactive.element as HTMLElement).setAttribute(reactive.key, change.newValue);
-                        break;
-                    }
-                });
-            }
-        });
-    }
+    public reactor: Reactor;
 
-    private addReactive(element: HTMLElement | Element | Node, property: string, command: Renderer.ReactiveCommand, target: Renderer.ReactiveTarget, attribute?: string) {
-        this.reactive[property] = this.reactive[property] || [];
+    /**
+     * Creates a new renderer instance
+     * @param template The pug compiled template function
+     * @param data The data that will be used for reactivity
+     */
+    constructor(template: pug.compileTemplate, data?: Reactive.ReactiveData) {
+        this.template = template;
+
+        // Create the reactor
+        this.reactor = new Reactor(this);
 
-        this.reactive[property].push({
-            element,
-            target,
-            command,
-            key: attribute
-        });
+        if (data) {
+            this.setData(data);
+        }
     }
 
     /**
@@ -91,23 +77,50 @@ export default class PupperRenderer {
              * Pupper helpers
              */
             pupper: class PupperHelper {
+                /**
+                 * 
+                 * @param key The path to the data to be retrieved
+                 * @param context Any additional contexts
+                 * @returns 
+                 */
+                static getValue(key: string, context?: Record<string, any>) {
+                    let value;
+
+                    // First, try from the context
+                    if (context !== undefined) {
+                        value = deepGetSet(context, key);
+                    }
+
+                    // Then try from the data itself
+                    if (value === undefined) {
+                        value = deepGetSet(self.getData(), key);
+                    }
+
+                    debug("retrieving value %s: %O", key, value);
+
+                    return value;
+                }
+
                 /**
                  * Retrieves an escaped value to be displayed
                  * @param key The path to the data to be escaped
                  * @returns 
                  */
-                static escape(key: string): string {
-                    const text = document.createTextNode(deepGetSet(self.getData(), key));
+                static escape(key: string, context?: Record<string, any>): string {
+                    const text = document.createTextNode(
+                        this.getValue(key, context)
+                    );
+
                     return text.textContent;
                 }
 
                 /**
                  * Retrieves a literal value to be displayed
-                 * @param key The path to the data to be escaped
+                 * @param key The path to the data to be retrieved
                  * @returns 
                  */
-                static literal<T>(key: string): T {
-                    return deepGetSet(self.getData(), key);
+                static literal<T>(key: string, context?: Record<string, any>): T {
+                    return this.getValue(key, context);
                 }
             }
         };
@@ -117,7 +130,7 @@ export default class PupperRenderer {
      * Retrieves the underlying pug template function
      * @returns 
      */
-    getTemplateFn() {
+    public getTemplateFn() {
         return this.template;
     }
 
@@ -125,15 +138,17 @@ export default class PupperRenderer {
      * Retrieves the template data
      * @returns 
      */
-    getData() {
+    public getData() {
         return this.data;
     }
 
     /**
-     * Replaces all the object data
+     * Replaces all the object data with new proxied data
      * @param data The new template data
+     * @returns The proxied data object
      */
-    setData(data: object) {
+    public setData<T extends object>(data: T): ProxyHandler<T> {
+        // Prepare the proxy
         const proxy = {
             get(target: Record<any, any>, key: string): any {
                 if (key == "isProxy") {
@@ -158,17 +173,19 @@ export default class PupperRenderer {
 
                 return target[key];
             },
-            set: this.onPropertyChange.bind(this)
+            set: this.reactor.onPropertyChange.bind(this.reactor)
         };
 
-        this.data = observableSlim.create(data, true, this.onPropertyChange.bind(this));
+        this.data = observableSlim.create(data, true, this.reactor.onPropertyChange.bind(this.reactor));
+
+        return this.data;
     }
 
     /**
-     * Retrieves the template "locals" variable context
+     * Retrieves the context that will be passed to the template "locals" variable
      * @returns 
      */
-    getTemplateContext() {
+    public getTemplateContext() {
         return {
             ...this.getHelpers(),
             ...this.getData()
@@ -179,7 +196,7 @@ export default class PupperRenderer {
      * Renders the template into a string
      * @returns 
      */
-    renderToString() {
+    public renderToString() {
         return this.template(this.getTemplateContext());
     }
 
@@ -187,102 +204,197 @@ export default class PupperRenderer {
      * Renders the template into a string
      * @returns 
      */
-    render() {
+    public render() {
+        // Convert into the final tag so we can parse it
+        const target = document.createElement("div");
+        target.classList.add("pupper");
+        
+        this.dom = this.renderTo(this.dom);
+
+        return this.dom;
+    }
+
+    /**
+     * Renders the template into a target element
+     * @param target The target element
+     * @returns 
+     */
+    public renderTo(target: string | HTMLElement = document.body) {
         // Render the template
         const rendered = this.renderToString();
 
-        // Convert into the final tag so we can parse it
-        this.dom = document.createElement("div");
-        this.dom.classList.add("pupper");
-        this.dom.innerHTML = rendered;
+        // Create a template tag and set the contents of the template to it
+        const template = document.createElement("template");
+        template.innerHTML = rendered;
+
+        // Prepare the nodes
+        this.prepareNodes(template.content.childNodes);
 
-        // Fix all children
-        this.prepareNodes(this.dom.childNodes);
+        // Append it to the DOM itself
+        const targetEl: HTMLElement = target instanceof HTMLElement ? target : document.querySelector(target);
+        targetEl.appendChild(template.content);
+
+        this.dom = targetEl;
 
         return this.dom;
     }
 
     /**
-     * Parses a single @pupperjs syntax
-     * @param action The action / syntax to be parsed
+     * Parses a single pupper syntax
+     * @param command The command / syntax to be parsed
+     * @param nodeOptions The node options that will be used for parsing
      * @returns 
      */
-    private parseAction(action: string) {
+    private parseCommand(command: string, nodeOptions: NodeOptions = {}) {
+        command = command.trim();
+
         // Parse it
-        const parsed = action.match(PupperRenderer.SYNTAX_REGEX);
+        const parsed = command.match(PupperRenderer.SYNTAX_REGEX);
         
         if (parsed === null) {
-            throw new Error("Failed to parse action \"" + action + "\"");
+            throw new Error("Failed to parse command \"" + command + "\"");
         }
 
-        const command: Renderer.ReactiveCommand = (parsed.groups.command as Renderer.ReactiveCommand);
+        const fn: Reactive.ReactiveCommand = (parsed.groups.command as Reactive.ReactiveCommand);
         const property = parsed.groups.property;
 
-        let content = property;
+        let value = property;
 
-        switch(command) {
+        switch(fn) {
             // If it's an escape call
             case "escape":
-                content = this.getHelpers().pupper.escape(property);
+                value = this.getEscapedValue(property, nodeOptions.context);
             break;
 
             // If it's a literal call
             case "literal":
-                content = this.getHelpers().pupper.literal(property);
+                value = this.getLiteralValue(property, nodeOptions.context);
             break;
         }
 
         return {
-            content,
-            command,
+            value,
+            command: fn,
             property
         };
     }
 
+    /**
+     * Retrieves an HTML-escaped text value
+     * @param property The property to be retrieved
+     * @returns 
+     */
+    public getEscapedValue(property: string, context?: Record<string, any>) {
+        return this.getHelpers().pupper.escape(property, context);
+    }
+
+    /**
+     * Retrieves a literal value (as-is, without any treatment)
+     * @param property The property to be retrieved
+     * @returns 
+     */
+    public getLiteralValue<T>(property: string, context?: Record<string, any>): T {
+        return this.getHelpers().pupper.literal<T>(property, context);
+    }
+
     /**
      * Prepares nodes to be reactive
      * @param nodes The node list to be prepared
+     * @param context The proxy context to the nodes; defaults to `this.data`
+     * @returns
      */
-    private prepareNodes(nodes: NodeListOf<ChildNode>) {
-        // Iterate over all children nodes
-        Array.prototype.forEach.call(nodes, (element: HTMLElement) => {
-            // If has children, fix the children too
-            if (element.hasChildNodes()) {
-                this.prepareNodes(element.childNodes);
+    public prepareNodes(nodes: NodeListOf<ChildNode>, options?: NodeOptions) {
+        for(let element of [...nodes]) {
+            const result = this.prepareNode(element as HTMLElement, options);
+
+            // Check if failed
+            if (result === NodePreparationResult.FAILED) {
+                // Stop parsing
+                return false;
             }
 
-            this.prepareNode(element);
-        });
+            // Check if doesn't want to skip the child items
+            if (result !== NodePreparationResult.SKIP_CHILD) {
+                // If has children, fix the children too
+                if (element.hasChildNodes()) {
+                    this.prepareNodes(element.childNodes, options);
+                }
+            }
+        }
+
+        return true;
     }
 
     /**
      * Prepares a single HTML element
      * @param element The element to be prepared
+     * @param context The proxy context to the node; defaults to `this.data`
+     * @returns The preparation result
      */
-    private prepareNode(element: HTMLElement | Element) {
+    private prepareNode(element: HTMLElement | Element, options: NodeOptions = {}): NodePreparationResult {
+        // Copy the default options
+        options = {
+            pathPrefix: "",
+            ...options
+        };
+
         // Check if it's a comment
         if (element instanceof Comment) {
             const comment = (element as Comment);
-            
+
             // Check if it's not a pupper comment
-            if (comment.textContent.indexOf("@pupperjs:") === -1) {
+            if (comment.textContent.indexOf("@p:") === -1) {
                 return;
             }
 
             // Parse it
-            const parsed = this.parseAction(comment.textContent);
-            const text = document.createTextNode(parsed.content || "");
+            const parsed = this.parseCommand(comment.textContent, options);
+            const text = document.createTextNode(parsed.value || "");
 
             // Replace with a text node
             element.replaceWith(text);
 
             // Set it as reactive
-            this.addReactive(text, parsed.property, parsed.command, "text");
-        } else {
+            this.reactor.addReactivity(text, options.pathPrefix + parsed.property, "text", {
+                command: parsed.command,
+                initialValue: parsed.value,
+                nodeOptions: options
+            });
+        } else
+        // Check if it's a foreach
+        if (element.tagName === "P:FOREACH") {
+            // Retrieve the foreach attributes
+            const array = element.getAttribute("array");
+            const variable = element.getAttribute("var");
+            const type = element.getAttribute("type");
+            const html = element.innerHTML;
+
+            const comment = document.createComment(" ");
+            element.replaceWith(comment);
+
+            /**
+             * @todo move this to a sub class to manage this better.
+             * For. God's. Sake.
+             */
+
+            // Add reactivity for it
+            this.reactor.addReactivity(comment, options.pathPrefix + array, "foreach", {
+                var: variable,
+                type: type,
+                innerHTML: html,
+                initialValue: this.getLiteralValue(array),
+                nodeOptions: options
+            });
+
+            // Skip children preparation
+            return NodePreparationResult.SKIP_CHILD;
+        } else
+        // Check if it's an HTML element
+        if (element instanceof HTMLElement) {
             // Iterate over all the attributes
             element.getAttributeNames().forEach((attr) => {
                 // Check if it doesn't start with our identifier
-                if (element.getAttribute(attr).indexOf("@pupperjs:") === -1) {
+                if (element.getAttribute(attr).indexOf("@p:") === -1) {
                     return;
                 }
 
@@ -290,31 +402,22 @@ export default class PupperRenderer {
                 const value = element.getAttribute(attr);
 
                 // Parse it
-                const parsed = this.parseAction(value);
+                const parsed = this.parseCommand(value, options);
 
                 if (!!parsed) {
                     element.removeAttribute(attr);
                 }
 
-                // Replace it
-                element.setAttribute(attr, parsed.content);
-
                 // Set it as reactive
-                this.addReactive(element, parsed.property, parsed.command, "attribute", attr);
+                this.reactor.addReactivity(element, options.pathPrefix + parsed.property, "attribute", {
+                    key: attr,
+                    command: parsed.command,
+                    initialValue: parsed.value,
+                    nodeOptions: options
+                });
             });
         }
-    }    
 
-    /**
-     * Renders the template to an element
-     * @param element The element that will receive the children elements
-     * @returns 
-     */
-    renderTo(element: string | HTMLElement | Element = document.body) {
-        if (typeof element === "string") {
-            element = document.querySelector(element);
-        }
-
-        return element.append(this.render());
-    }
+        return NodePreparationResult.SUCCESS;
+    }    
 }

+ 8 - 6
src/core/compiler/Lexer.ts

@@ -1,10 +1,13 @@
 import type PugLexer from "pug-lexer";
+import type Token from "./lexer/Token";
+import ForEach from "./lexer/tokens/ForEach";
+import Property from "./lexer/tokens/Property";
 
 export default class Lexer {
-    /**
-     * The regex to test if an expression is a valid reactive item
-     */
-    public static REACTIVE_REGEX = /\{(?<tag>\{|-) ?(?<exp>(?:[\w+]|\.)+) ?(\}|-)\}/;
+    public static LexerRegexes: typeof Token[] = [
+        Property,
+        ForEach
+    ];
 
     /**
      * Checks if a given expression is valid
@@ -13,7 +16,6 @@ export default class Lexer {
      * @returns 
      */
     public isExpression(lexer: PugLexer.Lexer, exp: string) {
-        const result = Lexer.REACTIVE_REGEX.test(exp);
-        return result;
+        return Lexer.LexerRegexes.some((token) => token.testExpression(exp));
     }
 }

+ 41 - 34
src/core/compiler/Parser.ts

@@ -1,5 +1,32 @@
 import Lexer from "./Lexer";
 
+import { LexTokenType } from "pug-lexer";
+
+export interface PugToken {
+    type: LexTokenType,
+    loc?: Record<string, any>,
+    val?: string,
+    name?: string,
+    mustEscape?: boolean
+}
+
+export interface PugBlock {
+    type: "Block",
+    nodes: PugNode[]
+}
+
+export interface PugNode extends Record<string, any> {
+    type: string,
+    start: number,
+    end: number,
+    block?: PugBlock,
+    attributes?: {
+        name: string,
+        value: string,
+        mustEscape: boolean
+    }[]
+}
+
 export default class Parser {
     /**
      * Called before starts parsing
@@ -7,41 +34,21 @@ export default class Parser {
      * @param exp The expression to be checked against
      * @returns 
      */
-    public preParse(tokens: {
-        type: string,
-        loc: Record<string, any>,
-        val?: string,
-        name?: string,
-        mustEscape?: boolean
-    }[]) {
-        tokens = tokens.map((token, index) => {
-            // 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 && Lexer.REACTIVE_REGEX.test(token.val)) {
-                // Extract the token value
-                const result = token.val.match(Lexer.REACTIVE_REGEX).groups;
-                const value = result.exp.replace(/\"/g, "\\\"");
-
-                const fn = result.tag === "{" ? "escape" : "literal";
-
-                if (token.type === "attribute") {
-                    // Replace with our escape
-                    token.val = `"@pupperjs:${fn}(${value})"`;
-                    token.mustEscape = false;
-                } else {
-                    // Replace it with a comment tag
-                    token.val = `"<!-- @pupperjs:${fn}(${value}) -->"`;
-                    token.mustEscape = false;
-                }
-            }
-
-            return token;
-        });
+    public preParse(tokens: PugToken[]) {
+        Lexer.LexerRegexes.forEach((token) => {
+            const t = new token();
+            tokens = t.lex(tokens);
+        });       
 
         return tokens;
     }
+
+    public postParse(block: PugBlock) {
+        Lexer.LexerRegexes.forEach((token) => {
+            const t = new token();
+            block.nodes = t.parse(block.nodes);
+        }); 
+
+        return block;
+    }
 }

+ 32 - 0
src/core/compiler/lexer/Token.ts

@@ -0,0 +1,32 @@
+import { PugNode, PugToken } from "../Parser";
+
+export default class Token {
+    public static readonly REGEX: RegExp;
+
+    /**
+     * Tests if the token matches with the given expression
+     * @param exp The expression to be tested
+     * @returns 
+     */
+    public static testExpression(exp: string): boolean {
+        return false;
+    }
+
+    /**
+     * Lexes the given token and return new ones
+     * @param tokens The tokens to be parsed
+     * @returns Parsed tokens
+     */
+    public lex(tokens: PugToken[]) {
+        return tokens;
+    }
+
+    /**
+     * Parses the given token and return new ones
+     * @param nodes The nodes to be parsed
+     * @returns Parsed nodes
+     */
+    public parse(nodes: PugNode[]) {
+        return nodes;
+    }
+}

+ 57 - 0
src/core/compiler/lexer/tokens/ForEach.ts

@@ -0,0 +1,57 @@
+import { PugNode } from "../../Parser";
+import Token from "../Token";
+
+export default class ForEach extends Token {
+    private static readonly FOREACH_CONDITION = /for(each)? (?<variable>.+?) (?<type>in|of) (?<arr>.+)$/;
+
+    public parse(nodes: PugNode[]) {
+        for(let index = 0; index < nodes.length; index++) {
+            const node = nodes[index];
+
+            // Check if it's a foreach
+            if (node.type === "Tag" && node.name === "foreach" && node.block?.nodes[0].type === "Text") {
+                // Parse the parts
+                const condition: RegExpMatchArray = ("foreach " + node.block?.nodes[0].val).match(ForEach.FOREACH_CONDITION);
+
+                // Check if it's an invalid foreach condition
+                if (!condition) {
+                    throw new TypeError("Invalid foreach condition. It needs to have a variable (array) and a condition.");
+                }
+
+                const { variable, type, arr } = condition.groups;
+
+                // Set the tag name
+                node.name = "p:foreach";
+
+                // Remove the text from it
+                node.block.nodes.splice(0, 1);
+
+                // Setup the attributes for the foreach
+                node.attrs = [
+                    {
+                        name: "var",
+                        val: `"${variable}"`,
+                        mustEscape: false
+                    },
+                    {
+                        name: "type",
+                        val: `"${type}"`,
+                        mustEscape: false
+                    },
+                    {
+                        name: "array",
+                        val: `"${arr}"`,
+                        mustEscape: false
+                    }
+                ];
+            }
+
+            // Parses the block
+            if (node.block) {
+                node.block.nodes = this.parse(node.block.nodes);
+            }
+        }
+
+        return nodes;
+    }
+};

+ 48 - 0
src/core/compiler/lexer/tokens/Property.ts

@@ -0,0 +1,48 @@
+import { PugToken } from "../../Parser";
+import Token from "../Token";
+
+export default class Property extends Token {
+    /**
+     * The regex to test if an expression is a valid reactive item
+     */
+    public static REGEX = /\{(?<tag>\{|-) ?(?<exp>(?:[\w+]|\.)+) ?(\}|-)\}/;
+
+    /**
+     * Tests if the token matches with the given expression
+     * @param exp The expression to be tested
+     * @returns 
+     */
+    public static testExpression(exp: string) {
+        return this.REGEX.test(exp);
+    }
+
+    public lex(tokens: PugToken[]) {
+        return tokens.map((token, index) => {
+            // 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 && Property.REGEX.test(token.val)) {
+                // Extract the token value
+                const result = token.val.match(Property.REGEX).groups;
+                const value = result.exp.replace(/\"/g, "\\\"");
+
+                const fn = result.tag === "{" ? "escape" : "literal";
+
+                if (token.type === "attribute") {
+                    // Replace with our escape
+                    token.val = `"@p:${fn}(${value})"`;
+                    token.mustEscape = false;
+                } else {
+                    // Replace it with a comment tag
+                    token.val = `"<!-- @p:${fn}(${value}) -->"`;
+                    token.mustEscape = false;
+                }
+            }
+
+            return token;
+        });
+    }
+};

+ 68 - 0
src/core/renderer/Reactive.ts

@@ -0,0 +1,68 @@
+import type { Renderer } from "../../pupper";
+import type { NodeOptions } from "../Renderer";
+import type Reactor from "./Reactor";
+
+export namespace Reactive {
+    export type ReactiveData = Record<string, any>;
+    export type ReactiveTarget = "text" | "html" | "attribute" | "foreach";
+    export type ReactiveCommand = "escape" | "literal" | null;
+
+    export type Context = (ProxyHandler<Reactive.ReactiveData> | Record<any, any>);
+
+    export interface ReactiveNodeOptions extends Record<string, any> {
+        /**
+         * The reactive command for this item
+         */
+        command?: Reactive.ReactiveCommand,
+
+        /**
+         * The initial item value
+         */
+        initialValue?: any,
+        
+        /**
+         * Any node options to be passed to the reactive node
+         */
+        nodeOptions?: NodeOptions
+    }
+
+    export class AbstractReactor {
+        public static readonly Type: string;
+
+        protected readonly reactor: Reactor;
+
+        constructor(
+            public readonly renderer: Renderer,
+            public readonly path: string,
+            public readonly element: HTMLElement | Element | Node,
+            public readonly options: Reactive.ReactiveNodeOptions,
+        ) {
+            this.reactor = renderer.reactor;
+        }
+
+        /**
+         * Retrieves the reactor path
+         * @returns 
+         */
+        public getPath(): string | RegExp {
+            return this.path;
+        }
+
+        /**
+         * Tests if the path can be handled by the reactor
+         * @param path The path to be tested
+         */
+        public test(path: string): boolean {
+            return this.path === path;
+        }
+
+        /**
+         * Handles a new reacted value
+         * @param path The path to be handled
+         * @param newValue The value to be handled
+         */
+        public handle(path: string, newValue?: any): any {
+            throw new Error("Abstract class not implemented.");
+        }
+    }
+}

+ 125 - 0
src/core/renderer/Reactor.ts

@@ -0,0 +1,125 @@
+import type Renderer from "../Renderer";
+import { ObservableChange } from "observable-slim";
+import { Reactive } from "./Reactive";
+
+import ForEachReactor from "./reactors/ForEach";
+import HTMLReactor from "./reactors/HTML";
+import HTMLAttributeReactor from "./reactors/HTMLAttribute";
+import TextReactor from "./reactors/Text";
+
+const debug = require("debug")("pupperjs:renderer:reactor");
+
+export default class Reactor {
+    // @ts-ignore
+    private static readonly Reactors: Record<string, typeof Reactive.AbstractReactor> = {
+        foreach: ForEachReactor,
+        html: HTMLReactor,
+        attribute: HTMLAttributeReactor,
+        text: TextReactor
+    };
+
+    /**
+     * The renderer related to this reactor
+     */
+    private renderer: Renderer;
+
+    /**
+     * A list of reactive properties with their respective elements
+     */
+    private reactive: Reactive.AbstractReactor[] = [];
+
+    constructor(renderer: Renderer) {
+        this.renderer = renderer;
+    }
+
+    /**
+     * When a data property has changed
+     * @param changes The observed changes
+     */
+    public onPropertyChange(changes: ObservableChange[]) {
+        changes.forEach((change) => this.triggerChangeFor(change.currentPath, change.newValue));
+    }
+
+    /**
+     * Checks if has reactivity for the given path
+     * @param path The dot notation path to be checked
+     * @returns 
+     */
+    public hasReactivityFor(path: string) {
+        return this.reactive.some((reactive) => reactive.path === path);
+    }
+
+    /**
+     * Retrieves all registered reactors
+     * @returns 
+     */
+    public getReactors() {
+        return this.reactive;
+    }
+
+    /**
+     * Retrieves all registered reactors as an object that
+     * the object index is the reactor path
+     * @returns 
+     */
+    public getPathIndexedReactors() {
+        const reactors: Record<string, Reactive.AbstractReactor[]> = {};
+
+        this.reactive.forEach((reactor) => {
+            reactors[reactor.path] = reactors[reactor.path] || [];
+            reactors[reactor.path].push(reactor);
+        });
+
+        return reactors;
+    }
+
+    /**
+     * Triggers reactivity changes for an object path
+     * @param path The object dot notation path
+     * @param newValue The new value that the object received
+     */
+    public triggerChangeFor(path: string, newValue?: any) {
+        if (newValue === undefined) {
+            return false;
+        }
+
+        this.reactive.some((reactive) => {
+            if (reactive.test(path)) {
+                debug("%s handled by %s", path, reactive.constructor.name);
+
+                reactive.handle(path, newValue);
+
+                return true;
+            }
+
+            return false;
+        });
+    }
+
+    /**
+     * Turns an element into a reactive element that will listen for object changes.
+     * @param element The element that will be reactive
+     * @param property The element property that will changed
+     * @param command The reactivity command, optional
+     * @param target The reactivity target
+     * @param options Any options to be passed to the reactivity renderer
+     */
+    public addReactivity(element: HTMLElement | Element | Node, property: string, target?: Reactive.ReactiveTarget, options: Reactive.ReactiveNodeOptions = {}) {
+        const reactor = Reactor.Reactors[target];
+
+        const instance = new reactor(
+            this.renderer,
+            property,
+            element,
+            options
+        );
+
+        // Add it to the default property
+        this.reactive.push(instance);
+
+        // Check if has an initial value
+        if (options.initialValue !== undefined) {
+            this.triggerChangeFor(property, options.initialValue);
+        }
+    }
+}

+ 101 - 0
src/core/renderer/reactors/ForEach.ts

@@ -0,0 +1,101 @@
+import { Reactive } from "../Reactive";
+
+const debug = require("debug")("pupperjs:renderer:reactors:foreach");
+
+export default class ForEachReactor extends Reactive.AbstractReactor {
+    public static readonly Type: "foreach";
+
+    private regex: RegExp = null;
+
+    private reacted: Record<string | number, Node> = {};
+
+    public getPath() {
+        if (this.regex === null) {
+            this.regex = new RegExp(`^(?<literal>${this.path.replace(/\./g, "\\.")})\.(?<index>.+?)\.(?<value>\.+)$`);
+        }
+
+        return this.regex;
+    }
+
+    public test(path: string): boolean {
+        return !path.includes("." + this.options.var + ".") && (this.path === path || this.getPath().exec(path) !== null);
+    }
+
+    private handleAll(path: string, newValue: any) {
+        let target: any[] | object = newValue;
+        let indexes: number[] | string[];
+
+        debug("new for...%s loop for \"%s\" (%O)", this.options.type, path, target);
+
+        // Check if it's a for...in
+        if (this.options.type === "in") {
+            // Check if the target is an array
+            if (Array.isArray(target)) {
+                console.warn("Tried to iterate using for...in in a non-object variable", path);
+                return false;
+            }
+
+            indexes = Object.keys(target);
+        } else {
+            indexes = Array.from(
+                Array((target as any[]).length).keys()
+            );
+        }
+
+        // Iterate over all changed array targets
+        for(let index of indexes) {
+            // @ts-ignore
+            this.addSingle(index, target[index]);
+        }
+    }
+
+    private addSingle(index: number|string, value: any) {
+        const content = document.createElement("template");
+        content.innerHTML = this.options.innerHTML;
+
+        // Prepare the context
+        const context = {
+            [this.options.var]: value
+        };
+
+        // Prepare the nodes
+        this.renderer.prepareNodes(content.content.childNodes, {
+            pathPrefix: this.path + "." + index + ".",
+            context
+        });
+
+        debug("\tparsed children %s", this.path + "." + index);
+
+        this.reacted[index] = content.content;
+
+        // Append it to the parent element
+        (this.element as Comment).parentElement.appendChild(content.content);
+    }
+
+    private handleSingle(path: string, newValue: any) {
+        const { groups } = path.match(this.regex);
+
+        // Check if it's array.length (pushing a new object into the array)
+        if (path === this.path + ".length") {
+            path = this.path + "." + (newValue - 1);
+            this.addSingle(path, this.renderer.getLiteralValue(path));
+
+            return true;
+        }
+
+        const realPath = groups.literal + "." + groups.index + (groups.value ?  "." + this.options.var + "." + groups.value : "");
+
+        debug("path %s was converted to %s", path, realPath);
+
+        return this.reactor.triggerChangeFor(realPath, newValue);
+    }
+
+    public handle(path: string, newValue: any) {
+        // Check if it's the root array value
+        if (this.path === path) {
+            this.handleAll(path, newValue);
+        } else {
+            this.handleSingle(path, newValue);
+        }
+    }
+}

+ 9 - 0
src/core/renderer/reactors/HTML.ts

@@ -0,0 +1,9 @@
+import { Reactive } from "../Reactive";
+
+export default class HTMLReactor extends Reactive.AbstractReactor {
+    public static readonly Type: "html";
+
+    public handle(path: string, newValue: any) {
+        (this.element as HTMLElement).innerHTML = newValue;
+    }
+}

+ 9 - 0
src/core/renderer/reactors/HTMLAttribute.ts

@@ -0,0 +1,9 @@
+import { Reactive } from "../Reactive";
+
+export default class HTMLAttributeReactor extends Reactive.AbstractReactor {
+    public static readonly Type: "attribute";
+
+    public handle(path: string, newValue: any) {
+        (this.element as HTMLElement).setAttribute(this.options.key, newValue);
+    }
+}

+ 9 - 0
src/core/renderer/reactors/Text.ts

@@ -0,0 +1,9 @@
+import { Reactive } from "../Reactive";
+
+export default class TextReactor extends Reactive.AbstractReactor {
+    public static readonly Type: "text";
+
+    public handle(path: string, newValue: any) {
+        this.element.textContent = newValue;
+    }
+}

+ 2 - 2
src/pupper.ts

@@ -1,5 +1,5 @@
-import { Compiler } from "./core/Compiler";
-import { Renderer } from "./core/Renderer";
+import Compiler from "./core/Compiler";
+import Renderer from "./core/Renderer";
 
 export {
     Compiler,

+ 28 - 7
test/browser.js

@@ -3,15 +3,36 @@ const { default: Renderer } = require("../out/core/Renderer");
 const template = new Renderer(require("./template.pupper"));
 
 window.data = {
-    item: {
-        title: "This is a title",
-        description: "This is a description",
-        thumbnail: "https://placedog.net/800"
-    }
+    page: {
+        title: "pupper.js is awesome!",
+        description: "I use pupper.js because I love puppies!",
+        lead: "Also we love puppers, shiberinos and other doggos too! 🐶"
+    },
+    puppies: [
+        {
+            id: 1,
+            title: "A cutie pup",
+            description: "Look at this cutie",
+            thumbnail: "https://placedog.net/800"
+        },
+        {
+            id: 2,
+            title: "Another cute pup",
+            description: "Isn't it a cute doggo?!",
+            thumbnail: "https://placedog.net/400"
+        }
+    ]
 };
 
-template.setData(data);
+window.data = template.setData(data);
 
 window.template = template;
 
-template.renderTo(document.getElementById("app"));
+template.renderTo(document.getElementById("app"));
+
+window.data.puppies.push({
+    id: 3,
+    title: "Wow, a shibe!",
+    description: "Cute shiberino!!!",
+    thumbnail: "https://media.istockphoto.com/photos/happy-shiba-inu-dog-on-yellow-redhaired-japanese-dog-smile-portrait-picture-id1197121742?k=20&m=1197121742&s=170667a&w=0&h=SDkUmO-JcBKWXl7qK2GifsYzVH19D7e6DAjNpAGJP2M="
+});

+ 34 - 8
test/template.pupper

@@ -1,8 +1,34 @@
-.item.card.col-12.col-xl-6(data-id={{ item.id }})
-    img.card-img-top(src={- item.thumbnail -}, crossorigin="auto")
-
-    .card-header
-        .row.justify-content-between.align-items-center
-            .col-auto.d-flex.align-items-center
-                h5.mb-0.d-inline-block={{ item.title }}
-                p.description={- item.description -}
+//- Stylesheets
+link(href="https://getbootstrap.com/docs/4.0/dist/css/bootstrap.min.css", rel="stylesheet")
+link(href="https://getbootstrap.com/docs/4.0/examples/cover/cover.css", rel="stylesheet")
+
+.text-center
+    .cover-container.d-flex.h-100.p-3.mx-auto.flex-column
+        header.masthead.mb-auto 
+            .inner
+                h3.masthead-brand={{ page.title }}
+
+        //- Main contents
+        main.inner.cover(role="main")
+            h1.cover-heading={{ page.description }}
+
+            p.lead={{ page.lead }}
+
+            .row.mt-5.justify-content-around.align-items-center
+                //- Reactive puppies
+                foreach puppy of puppies
+                    .col-5.mb-5
+                        .puppy.card.px-0(data-id={{ puppy.id }}).text-dark
+                            img.card-img-top(src={- puppy.thumbnail -}, crossorigin="auto")
+
+                            .card-header
+                                h5.card-title={{ puppy.title }}
+                                small.text-muted|Served by pupper.js
+
+                            .card-body
+                                ={- puppy.description -}
+
+        footer.mastfoot.mt-auto
+            .inner
+                p
+                    |Cover template for <a href="https://getbootstrap.com/">Bootstrap</a>, by <a href="https://twitter.com/mdo">@mdo</a>

+ 9 - 2
webpack.config.js

@@ -2,15 +2,22 @@ module.exports = {
     entry: __dirname + "/test/browser.js",
     output: {
         path: __dirname + "/test/out",
-        filename: "index.js"
+        filename: "index.js",
+        publicPath: "./"
     },
     watch: true,
+    mode: "development",
     module: {
         rules: [
             {
                 test: /\.pupper$/,
                 use: [__dirname + "/modules/webpack"]
-            }
+            },
+            {
+                test: /\.js$/,
+                enforce: "pre",
+                use: ["source-map-loader"],
+            },
         ]
     },
     mode: "development"