DRYML

A template engine for Node and Express.

buffer

lib/buffer.js

Module dependencies.

var _ = require('underscore');

Buffer object

  • param: Object callback

  • return: Object

  • api: public

var Buffer = function(callback) {
    var self = {
        callback: (callback) ? callback: function() {},
        indexes: 0,
        count: 0,
        buffers: [],
        str: "",
        stackTrace: [],
        replacements: {},
        shouldEnd: false,
        fieldId: 9,
        lastIf: null,
        lastIfStack: [],
        trace: function(filename, tag, line, column) {
            self.stackTrace.push('at ' + tag + ' (' + filename + ':' + line + ':' + column + ')');
        },
        exit: function() {
            self.stackTrace.pop();
        },        
        print: function(str, middleware) {
            if (str !== null) {
                self.str = self.str.concat((middleware) ? middleware(str) : str);
            }
        },
        async: function(scope, callback, trace) {
            var buffer = self;
            buffer.indexes++;
            buffer.count++;
            var index = buffer.indexes,
                replacementStr = '§B' + index + '§';
            
            if (trace) buffer.trace.apply(this, trace);
            var asyncBuffer = buffer[index] = {
                stackTrace: _.clone(buffer.stackTrace),
                str: "",
                lastIf: null,
                lastIfStack: [],                
                newFieldId: buffer.newFieldId,
                trace: function(filename, tag, line, column) {
                    asyncBuffer.stackTrace.push('at ' + tag + ' (' + filename + ':' + line + ':' + column + ')');
                },
                exit: function() {
                    asyncBuffer.stackTrace.pop();
                },                
                print: function(str, middleware) {
                    if (str !== null) {                    
                        asyncBuffer.str = asyncBuffer.str.concat((middleware) ? middleware(str) : str);
                    }
                },
                end: function() {
                    buffer.replacements[replacementStr] = asyncBuffer.str;
                    buffer.count--;
                    if (buffer.shouldEnd) {
                        buffer.end();
                    }
                },
                async: function(scope, callback) {
                    return buffer.async(scope, callback);
                },
                error: function(err, errBuffer) {
                    buffer.error(err, errBuffer);
                },
                startIfContext: function() {
                    asyncBuffer.lastIfStack.push(asyncBuffer.lastIf);
                    asyncBuffer.lastIf = null;
                },
                endIfContext: function() {
                    asyncBuffer.lastIf = asyncBuffer.lastIfStack.pop();
                }
            };
            try {
                callback.call(scope, asyncBuffer);
            } catch(err) {
                asyncBuffer.error(err, asyncBuffer);
            }  
            if (trace) buffer.exit();              
            return replacementStr;
        },
        end: function() {
            if (self.count === 0) {
                for (var i = 0; i < 2; i++) {
                    for (var replacementStr in self.replacements) {
                        if (typeof(self.replacements[replacementStr]) == 'string') {
                            self.str = self.str.replace(replacementStr, self.replacements[replacementStr]);
                        }
                    }
                }
                self.str = self.str.replace(/\s*\n/g, "\n");
                self.callback(null, self);
            } else {
                self.shouldEnd = true;
            }
            return self.str;
        },
        error: function(err, errBuffer) {
            if (!errBuffer) errBuffer = self;
            err = new Error(err.message);
            err.stack = err.stack.replace('\n    at',  '\n    ' + errBuffer.stackTrace.slice( - 5).reverse().join('\n    ') + '\n    at');
            self.callback(err, self);
        },
        newFieldId: function() {
            self.fieldId += 1;
            return 'field-' + self.fieldId.toString(16);;
        },
        startIfContext: function() {
            self.lastIfStack.push(self.lastIf);
            self.lastIf = null;
        },
        endIfContext: function() {
            self.lastIf = self.lastIfStack.pop();
        }       
    };
    return self;
};

module.exports = Buffer;

dryml

lib/dryml.js

Module dependencies.

var fs = require('fs'),
    toStructure = require('./toStructure'),
    toFunctionSource = require('./toFunctionSource'),
    Buffer = require('./buffer'),
    renderFunctions = require('./renderFunctions'),
    isValidTagname = require('./isValidName').isValidTagname,
    isValidAttributename = require('./isValidName').isValidAttributename,
    cache = {};

Defaults

exports.root = process.cwd() + '/views';
exports.ext = 'dryml';
exports.version = "0.1.5";
exports.cache = true;
exports.encodeEntities = true;

Determine actual file path for specific view file

  • param: String view

  • param: String root

  • return: String

  • api: public

function realPath(view, root) {
    var returnPath = (view[0] == '/') ? view: fs.realpathSync((root || exports.root) + '/' + view + '.' + exports.ext);
    return returnPath;
}

Render DRYML string to a buffer

  • param: String str

  • param: Object options

  • param: Object callback

  • return: Object

  • api: public

var render = exports.render = function(str, options, callback) {
    var path,
        compiled,
        buffer = Buffer(callback);

    try {
        options = options || {};
        options.locals = options.locals || {};
        options.cache = (options.cache === null || typeof(options.cache) == 'undefined') ? exports.cache : options.cache;        
        options.encodeEntities = (options.encodeEntities === null || typeof(options.encodeEntities) == 'undefined') ? exports.encodeEntities : options.encodeEntities;
        options.str = options.str || str;
        if (options.filename && options.filename.indexOf('/') > 0) {
            var lastSlashIndex = options.filename.lastIndexOf('/');
            options.root = exports.root + '/' + options.filename.slice(0, lastSlashIndex);
            options.filename = options.filename.slice(lastSlashIndex + 1);
        } else {
            options.root = exports.root;
        }
        var context = options.scope || {};

        if (options.debug || options.cache != true) {
            cache = {};
        }

        if (!options.str && options.filename) {
            path = options.filepath = realPath(options.filename, options.root);
            // Use cached if available, otherwise compile
            compiled = cache[path];
            if (!compiled) {
                if (options.debug) console.log('Compiling view at: ' + path);
                if (!options.str) {
                    options.str = '' + fs.readFileSync(path);
                }
                compile(options.str, 'page', options,
                function(err, compiled) {
                    if (err) {
                        callback(err);
                    } else {                    
                        try {
                            cache[path] = compiled;
                            compiled.fn(context, compiled.taglib, options.locals, buffer);
                        } catch(err) {
                            buffer.error(err);
                        }
                    }
                });
            } else {
                compiled.fn(context, compiled.taglib, options.locals, buffer);
            }
        } else {
            compile(options.str, 'page', options,
            function(err, compiled) {
                if (err) {
                    callback(err);
                } else {
                    try {
                        compiled.fn(context, compiled.taglib, options.locals, buffer);
                    } catch(err) {
                        buffer.error(err);
                    }                    
                }
            });
        }
    } catch(err) {
        buffer.error(err);
    }
    return buffer;
};

Render view to the given response (Express)

  • param: String view

  • param: Object options

  • param: Object response

  • api: public

var renderView = exports.renderView = function(view, options, response) {
    options = options || {};
    options.filename = view;
    render(null, options, (typeof(response) == 'function') ? response :
    function(err, buffer) {
        if (err) {
            response.req.next(err);
        } else {
            if (options.debug) console.log(buffer.str);            
            response.send(buffer.str);
        }
    });
};

Compile the given dryml string into an object with function with compiled taglibs

  • param: String str

  • param: String type

  • param: Object options

  • param: Function callback

  • api: public

var compile = exports.compile = function(str, type, options, callback) {
    // Convert string to structure
    toStructure(str, options,
    function(err, structure) {
        if (err) { 
            callback(err);
        } else {
            if (type == 'page') {
                // Inject core taglib
                var corePath = realPath('core', __dirname + '/support');
                structure.unshift({
                    type: "tag",
                    element: "taglib",
                    attrs: {
                        src: corePath
                    }
                });
            }
            // Import all taglibs and definitions in structure into this taglib
            importTaglibs({},
            structure, options,
            function(err, taglib) {
                if (err) {
                    callback(err);
                } else {
                    if (type == 'page') {
                        // Parse rest of file for buffer output and compile into function
                        var source = toFunctionSource(structure, 'page', options),
                            fn = new Function('locals, taglib, buffer, _renderFunctions', source);

                        if (options.debug) {
                            console.log('-- Debug:');
                            console.log(JSON.stringify(structure, null, '  '));
                            console.log(source);
                            console.log(JSON.stringify(taglib, null, '  '));
                            console.log('--');
                        }

                        callback(null, {
                            fn: function(context, taglib, locals, buffer) {
                                fn.call(context, locals, taglib, buffer, renderFunctions);
                            },
                            taglib: taglib
                        });
                    } else {
                        callback(null, {
                            fn: function() {},
                            taglib: taglib
                        });
                    }
                }
            });
        }
    });
};

isValidName

lib/isValidName.js

HTML tags belonging to strict XHTML, not reserved tag names and characters (see http://htmldog.com/reference/htmltags/, plus additional tags from http://remysharp.com/2009/01/07/html5-enabling-script/)

var html = exports.html = "a,abbr,acronym,address,area,b,base,bdo,big,blockquote,body,br,button,caption,cite,code,col,colgroup,dd,del,dfn,div,dl,DOCTYPE,dt,em,fieldset,form,h1,h2,h3,h4,h5,h6,head,html,hr,i,img,input,ins,kbd,label,legend,li,link,map,meta,noscript,object,ol,optgroup,option,p,param,pre,q,samp,script,select,small,span,strong,style,sub,sup,table,tbody,td,textarea,tfoot,th,thead,title,tr,tt,ul,var,abbr,article,aside,audio,canvas,details,figcaption,figure,footer,header,hgroup,mark,meter,nav,output,progress,section,summary,time,video".split(','),
    restricted = exports.restricted = "def,tagbody,attr,taglib,document".split(','),
    validTagCharacters = exports.validTagCharacters = new RegExp("^[A-Za-z]([A-Za-z0-9._\-])*"),
    validAttributeCharacters = exports.validAttributeCharacters = new RegExp("^[a-z]([A-Za-z0-_])*"),
    reservedWordsJS = "break,case,catch,continue,debugger,default,delete,do,else,finally,for,function,if,in,instanceof,new,return,switch,this,throw,try,typeof,var,void,while,with,class,enum,export,extends,import,super,implements,interface,let,package,private,protected,public,static,yield".split(','),
    reservedWordsDRYML = "context,attributes,taglib,buffer,tagbody,sup,locals".split(','),
    reservedWords = ['global', 'process', 'require', 'module', 'console', 'sys', 'fs'].concat(reservedWordsJS).concat(reservedWordsDRYML);

exports.isValidTagname = function(name) {
    if (html.indexOf(name) == -1 && restricted.indexOf(name) == -1 && validTagCharacters.test(name)) {
        return true;
    } else {
        return false;
    }
};

exports.isValidAttributename = function(name) {
    if (reservedWords.indexOf(name) == -1 && validAttributeCharacters.test(name)) {
        return true;
    } else {
        return false;
    }    
}

renderFunctions

lib/renderFunctions.js

Tags to self-close (see http://www.w3schools.com/tags/default.asp)

var selfClosingTags = ["area", "base", "basefont", "br", "col", "frame", "hr", "img", "input", "link", "meta", "param"];

Functions used during rendering

var self = module.exports = {
    _encode:  require('Js-entities').xml.encode,
    '_': require('underscore'),
    _mergeAttributes: function(tagAttributes, contextAttributes, definedKeys) {
        if (typeof(tagAttributes['merge-attrs'].split) == 'function' && typeof(contextAttributes) == 'object') {
            var mergeKeys = tagAttributes['merge-attrs'].split(','),
                allKeys = Object.keys(contextAttributes);
            delete(tagAttributes['merge-attrs']);
            
            var includeUnspecified = mergeKeys.indexOf('*');
            if (includeUnspecified > -1) {
                delete(mergeKeys[includeUnspecified]);
                includeUnspecified = true;
            } else {
                includeUnspecified = false;
            }
            var includeAll = (mergeKeys.indexOf('') > -1);
            
            for (var attr in contextAttributes) {
                if (mergeKeys.indexOf(attr) > -1 || (attr != 'class' && (includeAll || (includeUnspecified && definedKeys.indexOf(attr) == -1))) && !tagAttributes[attr] && contextAttributes[attr]) 
                    tagAttributes[attr] = contextAttributes[attr];
            }
        }
    },
    _mergeClassAttribute: function(tagAttributes, contextAttributes ) {
        var classAttribute = tagAttributes['merge-class'],
            contextClassAttribute = contextAttributes['class'];
        delete(tagAttributes['merge-class']);
        if (self._.isString(classAttribute)) {
            if (self._.isString(contextClassAttribute) ) {
                tagAttributes['class'] = self._.uniq(self._.select(contextClassAttribute.split(' ').concat(classAttribute.split(' ')), function(v){return v;})).join(' ');
            } else {
                tagAttributes['class'] = classAttribute;
            }
        }
    },
    _applyTag: function(context, taglib, name, attributes, buffer, callback) {
        var tag = taglib[name];
        if (tag) {
            if (attributes.obj) {
                context = attributes.obj;
                delete(attributes.obj);
            }
            var definedAttributes = {
                attributes: attributes,
                _keys: tag.attributes
            };

            for (var i in tag.attributes) {
                var key = tag.attributes[i];
                definedAttributes[key] = (typeof(attributes[key]) != 'undefined') ? attributes[key] : null;
            }

            tag.fn.call(context, this, definedAttributes, taglib, buffer, callback);
        } else {
            var attributesStr = '';

            for (var attr in attributes) {
                if (attributes[attr] != null) {
                    attributesStr = attributesStr.concat(attr + '="' + self._encode(attributes[attr]) + '" ');
                }
            }
            if (attributesStr.length > 0) attributesStr = ' ' + attributesStr.trim();
            if (callback) {
                buffer.print('<' + name + attributesStr + '>');
                callback.call(context);
                buffer.print('</' + name + '>');
            } else {
                if (selfClosingTags.indexOf(name) &gt; -1) {
                    buffer.print('<' + name + attributesStr + '/>');
                } else {
                    buffer.print('<' + name + attributesStr + '></' + name + '>');
                }
                
            }
        }
    },
    _clone: function(obj, mergeObj) {
        var out = self._.clone(obj);
        
        if (typeof(mergeObj) === 'object') {
            for (i in mergeObj) {
                out[i] = arguments.callee(mergeObj[i]);
            }            
        }
        return out;
    },
    _deepCopy: function(obj, mergeObj) {
        if (Object.prototype.toString.call(obj) === '[object Array]') {
            var out = [],
                i = 0,
                len = obj.length;
            for (; i &lt; len; i++) {
                out[i] = arguments.callee(obj[i]);
            }
            return out;
        }
        if (typeof obj === 'object' &amp;&amp; obj != null) {
            var out = {},
                i;

            if (typeof mergeObj === 'object') {
                for (i in obj) {
                    out[i] = arguments.callee(obj[i]);
                }
                for (i in mergeObj) {
                    out[i] = arguments.callee(mergeObj[i]);
                }
            } else {
                for (i in obj) {
                    out[i] = obj[i];
                }                
            }
            return out;
        }
        return obj;
    }
};

toFunctionSource

lib/toFunctionSource.js

Parse the given dryml structure, returning the function source.

  • param: Object structure

  • param: String type [page, definition, attribute, tagbody]

  • param: Object options

  • return: String

  • api: public

var toFunctionSource = module.exports = function(structure, type, options) {
    var tmp;
    var end = ['}'];

    switch (type) {
    case 'page':
        tmp = [&quot;try { with(_renderFunctions) { with (_clone(locals, { attributes: locals, _attributes:{} })) { &quot;];
        end = ['}} buffer.end(); } catch (err) { buffer.error(err); }'];
        break;
    case 'definition':
        tmp = [&quot;try { with(_renderFunctions) { with (_clone(attributes, { _attributes:{} })) { &quot;];
        end = ['}}} catch (err) { buffer.error(err); }'];
        break;
    default:
        tmp = [&quot;with ({ _attributes:{} }) {&quot;];
        break;
    }
    
    tmp.push('var filepath = "' + options.filepath + '";');
    
    for (var i in structure) {
        var tag = structure[i];

        switch (tag.type) {
        case 'text':
            tmp.push('buffer.print(unescape("' + escape(tag.content) + '"), ' + ((options.encodeEntities === true) ? '_encode' : 'null') + ');');
            break;
        case 'comment':
            tmp.push('buffer.print(unescape("' + escape('<!-- ' + tag.content.trim() + ' -->') + '"));');
            break;
        default:
            tmp.push('buffer.trace(filepath, "' + tag.element + '", ' + tag.line + ', ' + tag.column + ');');
            switch (tag.element) {
            case '%':
                var ejsType = tag.attrs.ejs[0],
                    ejs = tag.attrs.ejs.slice(1, tag.attrs.ejs.length - 1).trim();

                switch (ejsType) {
                case '=':
                    tmp.push('buffer.print(' + ejs + ');');
                    break;
                case '?':
                    tmp.push('buffer.print(buffer.async(this, function(buffer){ ' + ejs + ' }));');
                    break;
                default:
                    tmp.push('\n' + ejsType + ejs + '\n');
                    break;
                }
                break;
            case 'def':
                // Ignore
                // TODO: Error
                break;
            case 'taglib':
                // Ignore
                // TODO: Error
                break;
            case 'tagbody':
                tmp.push('if (tagbody) { tagbody.call(this); };');
                break;
            default:
                // Extract attributes from direct children											
                if (tag.children) {
                    for (var j in tag.children) {
                        var child = tag.children[j];
                        if (child.type == 'tag' &amp;&amp; child.prefix == 'attr') {
                            var name = child.element;
                            if (!tag.attrs) tag.attrs = {};
                            tag.attrs[name] = '%{ ' + 
                            'buffer.async(this, function(buffer){ ' + toFunctionSource(child.children, 'attribute', options) + 'buffer.end(); },' +
                            ' [filepath, "' + child.prefix + ':' + child.element + '", ' + child.line + ', ' + child.column + '])' + 
                            ' }';
                            delete(tag.children[j]);
                        }
                    }
                }

                // Parse attributes with ejs and merge
                tmp.push('_attributes = {');
                for (var key in tag.attrs) {
                    var attr = tag.attrs[key];
                    if (attr[attr.length - 1] == '}' &amp;&amp; attr.indexOf('%{') === 0) {
                        tmp.push('"' + key + '": (' + attr.slice(2, attr.length - 1) + '), ');
                    } else if (attr[attr.length - 1] == '}' &amp;&amp; attr.indexOf('#{') === 0) {
                        tmp.push('"' + key + '": buffer.error(new Error("#{} encountered")), ');
                    } else {
                        tmp.push('"' + key + '": unescape("' + escape(attr) + '"), ');
                    }
                }
                tmp.push('};');

                // Merge class attribute
                tmp.push('if (typeof(_attributes["merge-class"]) != "undefined") _mergeClassAttribute(_attributes, attributes);')
                
                // Merge attributes
                tmp.push('if (typeof(_attributes["merge-attrs"]) != "undefined") _mergeAttributes(_attributes, attributes, _keys);')
                
                if (tag.children &amp;&amp; tag.children.length &gt; 0) {
                    tmp.push('_applyTag(this, taglib, "' + tag.element + '", _attributes, buffer, function(){');
                    tmp.push(toFunctionSource(tag.children, 'tagbody', options));
                    tmp.push('});');
                } else {
                    tmp.push('_applyTag(this, taglib, "' + tag.element + '", _attributes, buffer, null);');
                }
                break;
            }
            tmp.push('buffer.exit();');
            break;
        }
    }
    tmp.push(end);
    return tmp.join('');
};

toStructure

lib/toStructure.js

Module dependencies.

var sys = require('sys'),
    fs = require('fs'),
    xml = require(&quot;node-xml&quot;);

Use XML parser to convert into object structure

  • param: String str

  • param: Object options

  • param: Function callback

  • api: public

module.exports = function(str, options, callback) {
    str = prepareString(str);
    var stack = [{
        children: []
    }],
    parser = new xml.SaxParser(function(cb) {
        function onCharacters(chars) {
            chars = (options.trimWhitespace) ? chars.trim() : chars;
            if (chars.length &gt; 0) {
                stack[stack.length - 1].children.push({
                    type: 'text',
                    content: chars,
                    line: parser.getLineNumber(),
                    column: parser.getColumnNumber(),
                });
            }
        }
        cb.onStartDocument(function() {
            // Ignore
            });
        cb.onEndDocument(function() {
            callback(null, stack[0].children[0].children);
        });
        cb.onStartElementNS(function(elem, attrs, prefix, uri, namespaces) {
            var attributes = {};
            if (elem == '%') {
                attributes['ejs'] = unescape(attrs[0][1]);
            } else {
                for (var attr in attrs) {
                    var value = attrs[attr][1];
                    if (value[value.length - 1] == '}' &amp;&amp; value.indexOf('%{') === 0) {
                        value = unescape(value);
                    }
                    attributes[attrs[attr][0]] = value;
                }
            }
            current = {
                element: elem,
                type: 'tag',
                attrs: attributes,
                prefix: prefix,
                uri: uri,
                namespaces: namespaces,
                line: parser.getLineNumber(),
                column: parser.getColumnNumber(),
                children: []
            };
            stack.push(current);
        });
        cb.onEndElementNS(function(elem, prefix, uri) {
            var top = stack.pop();

            stack[stack.length - 1].children.push(top);
        });
        cb.onCharacters(onCharacters);
        cb.onCdata(onCharacters);
        cb.onComment(function(msg) {
            stack[stack.length - 1].children.push({
                type: 'comment',
                content: msg,
                line: parser.getLineNumber(),
                column: parser.getColumnNumber(),
            });
        });
        cb.onWarning(function(msg) {
            // Ignore
            });
        cb.onError(function(msg) {
            var filename = (options.filepath) ? options.filepath + ':': '',
                description = '\n    at ' + stack[stack.length - 1].element + ' (' + filename + parser.getLineNumber() + ':' + parser.getColumnNumber() + ')';
            var err = new Error(msg.replace(':', ''));
            err.stack = err.stack.replace('\n    at', description + '\n    at');
            callback(err);
        });
    });

    parser.parseString(str);
}