├── .gitignore ├── .travis.yml ├── LICENSE ├── README.txt ├── compilers ├── common.js ├── dom.js └── html.js ├── docs ├── index.css ├── index.html ├── index.js └── src │ ├── header.min │ ├── index.min │ ├── index.styl │ └── sections │ ├── comments.min │ ├── conditionals.min │ ├── filters.min │ ├── iteration.min │ ├── mixins.min │ ├── tags.min │ ├── text.min │ └── values.min ├── index.js ├── lexer.js ├── package.json ├── parser.js └── test ├── browser.js ├── cache.js ├── cases.js ├── cases ├── attrs-data.html ├── attrs-data.min ├── attrs.html ├── attrs.min ├── basic.html ├── basic.min ├── blanks.html ├── blanks.min ├── classes-empty.html ├── classes-empty.min ├── comments.html ├── comments.min ├── conditionals.html ├── conditionals.min ├── escape.html ├── escape.min ├── format.html ├── format.min ├── nest.html ├── nest.min ├── pipeless-tag.html └── pipeless-tag.min ├── fixtures ├── cached.min ├── mixins │ ├── mixin.min │ ├── withmixin.min │ └── withoutmixin.min ├── output-dirs │ ├── cli-input │ │ ├── a │ │ │ └── index.min │ │ ├── b │ │ │ └── index.min │ │ └── c │ │ │ └── index.min │ └── cli-output │ │ ├── a │ │ └── index.html │ │ ├── b │ │ └── index.html │ │ └── c │ │ └── index.html └── output-single │ ├── cli-input │ ├── a │ │ └── a.min │ └── index.min │ └── cli-output │ └── index.html ├── mixins.js ├── not-escaped.js └── script-not-escaped.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .DS_Store 3 | /*.log 4 | /*.sw* 5 | 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: node_js 3 | node_js: 4 | - "4.2" 5 | addons: 6 | apt: 7 | packages: 8 | - xvfb 9 | install: 10 | - export DISPLAY=':99.0' 11 | - Xvfb :99 -screen 0 1024x768x24 > /dev/null 2>&1 & 12 | - npm install 13 | 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | ===================== 3 | 4 | Copyright (c) 2012-2015 Voltra Co. 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 7 | 8 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 9 | 10 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 11 | 12 | -------------------------------------------------------------------------------- /README.txt: -------------------------------------------------------------------------------- 1 | ABOUT 2 | Mineral is a language that compiles to markup or dom. It's similar to Jade (aka 3 | Pug). Its goal is to be much smaller and simpler than pug, and integrate well 4 | with modern client side frameworks. 5 | 6 | DOCS 7 | http://voltraco.github.io/mineral/ 8 | 9 | INSTALL 10 | npm i mineral 11 | 12 | CLI 13 | Watch and compile mineral files https://github.com/voltraco/mineral-cli 14 | 15 | WEBPACK 16 | Use https://github.com/voltraco/mineral-loader to read mineral files and parse 17 | them into trees. This is easy, just `require('./file.min')`. 18 | 19 | BROWSERIFY 20 | For browserify, use the individual components. 21 | 22 | const tree = require('mineral/parser')(string) 23 | const el = require('mineral/compilers/dom')(tree) 24 | 25 | -------------------------------------------------------------------------------- /compilers/common.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const fmt = require('util').format 3 | const path = require('path') 4 | const callsites = require('callsites') 5 | 6 | const parse = require('../parser') 7 | 8 | const CLASS_RE = /\.[^.]+/g 9 | const ID_RE = /#[^. ]+/g 10 | 11 | exports.unclosed = [ 12 | 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 13 | 'keygen', 'link', 'meta', 'param', 'source', 'track', 'wbr', 14 | 'doctype' 15 | ] 16 | 17 | exports.resolveTagOrSymbol = function resolveTagOrSymbol (string) { 18 | const props = { 19 | tagname: 'div', 20 | classname: [], 21 | id: '', 22 | content: '' 23 | } 24 | 25 | string = string.replace(ID_RE, function (id) { 26 | props.id = id.slice(1) 27 | return '' 28 | }) 29 | 30 | string = string.replace(CLASS_RE, function (cls) { 31 | props.classname.push(cls.slice(1)) 32 | return '' 33 | }) 34 | 35 | if (string) props.tagname = string 36 | props.classname = props.classname.join(' ') 37 | return props 38 | } 39 | 40 | exports.resolveInclude = function resolver (info, shouldParse) { 41 | let dirname = info.location 42 | 43 | const cs = callsites() 44 | 45 | if (!dirname && cs.length) { 46 | for (let c of cs) { 47 | cs.shift() 48 | const f = c.getFileName() 49 | const index = f.indexOf('/mineral/index') > -1 50 | if (index) break 51 | } 52 | 53 | if (cs[1]) dirname = path.dirname(cs[1].getFileName()) 54 | } 55 | 56 | let stat = null 57 | 58 | try { 59 | stat = fs.statSync(info.location) 60 | } catch (_) { 61 | } 62 | 63 | if (stat && !stat.isDirectory()) { 64 | dirname = path.dirname(info.location) 65 | } 66 | 67 | const location = path.resolve(dirname, info.path) 68 | const text = fs.readFileSync(location, 'utf8') 69 | 70 | return { 71 | tree: shouldParse ? parse(text) : text, 72 | location: location 73 | } 74 | } 75 | 76 | exports.die = function die (info, name, message) { 77 | const msg = fmt('%s:%d:%d', message, info.pos.column, info.pos.lineno) 78 | const err = new Error(msg) 79 | err.stack = '' 80 | err.name = name 81 | throw err 82 | } 83 | 84 | exports.scopedExpression = function scopedExpression (data, info, str) { 85 | const args = Object.keys(data).concat(['__format', 'return ' + str + ';']) 86 | const fn = Function.apply(null, args) 87 | const values = Object.keys(data).map(k => data[k]) 88 | values.push(fmt) 89 | 90 | try { 91 | return fn.apply(data, values) 92 | } catch (ex) { 93 | console.warn('%s: %s in %s %s:%s', 94 | ex.name, ex.message, info.location, info.pos.column, info.pos.lineno) 95 | return '' 96 | } 97 | } 98 | 99 | exports.each = function each (o, f) { 100 | const has = Object.prototype.hasOwnProperty 101 | if (Array.isArray(o)) { 102 | for (let i = 0; i < o.length; ++i) f(o[i], i) 103 | } else { 104 | for (let k in o) if (has.call(o, k)) f(o[k], k) 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /compilers/dom.js: -------------------------------------------------------------------------------- 1 | const he = require('he') 2 | const common = require('./common') 3 | // const fmt = require('util').format 4 | const cache = {} 5 | 6 | const IF_RE = /^\s*if\s*/ 7 | const IN_RE = /\s*in\s*/ 8 | const FMT_RE = /["'](.*?)["'], / 9 | const EQ_RE = /^\s*=\s*/ 10 | 11 | // const COLON = 58 12 | // const PLUS = 43 13 | const HYPHEN = 45 14 | const A = 65 15 | const Z = 90 16 | 17 | // *** experiment with creating dom nodes *** 18 | 19 | function dom (tree, node, data) { 20 | let findElseBranch = false 21 | let logical = false 22 | 23 | function getValue (data, info, str) { 24 | if (!EQ_RE.test(str)) return str 25 | let exp = str.replace(EQ_RE, '') 26 | if (FMT_RE.test(exp)) exp = '__format(' + exp + ')' 27 | logical = true 28 | const value = common.scopedExpression(data, info, exp) 29 | return value // he.escape(value + '') 30 | } 31 | 32 | function compile (child, index) { 33 | // if (child.dom) return node.appendChild(child.dom) 34 | const locationData = { pos: child.pos || {}, location } 35 | 36 | // if (child.unescaped) { 37 | // child.content = he.escape(child.content) 38 | // } 39 | 40 | const firstLetter = child.tagOrSymbol.charCodeAt(0) 41 | 42 | // 43 | // first handle any flow control statements 44 | // 45 | if (child.tagOrSymbol === 'else') { 46 | if (!findElseBranch) return 47 | 48 | logical = true 49 | // if this is an else-if 50 | if (IF_RE.test(child.content)) { 51 | const exp = child.content.replace(IF_RE, '') 52 | if (common.scopedExpression(data, locationData, exp)) { 53 | findElseBranch = false 54 | const branch = document.createDocumentFragment() 55 | const children = dom(child, branch, data) 56 | if (children) node.appendChild(branch) 57 | } 58 | return 59 | } 60 | 61 | findElseBranch = false 62 | const branch = document.createDocumentFragment() 63 | const children = dom(child, branch, data) 64 | if (children) node.appendChild(branch) 65 | return 66 | } 67 | 68 | if (child.tagOrSymbol === 'comment') { 69 | const comment = document.createComment(child.content) 70 | node.appendChild(comment) 71 | return 72 | } 73 | 74 | if (child.tagOrSymbol === 'if') { 75 | logical = true 76 | if (common.scopedExpression(data, locationData, child.content)) { 77 | const branch = document.createDocumentFragment() 78 | const children = dom(child, branch, data) 79 | if (children) node.appendChild(branch) 80 | return 81 | } 82 | findElseBranch = true 83 | return 84 | } 85 | 86 | if (child.tagOrSymbol === 'while') { 87 | logical = true 88 | while (common.scopedExpression(data, locationData, child.content)) { 89 | const children = dom(child, node, data) 90 | if (children) node.appendChild(children) 91 | } 92 | return 93 | } 94 | 95 | if (child.tagOrSymbol === 'for') { 96 | logical = true 97 | 98 | const parts = child.content.split(IN_RE) 99 | if (!parts[0]) common.die(locationData, 'TypeError', 'Unknown mixin') 100 | 101 | const object = common.scopedExpression(data, locationData, parts[1]) 102 | 103 | common.each(object, function (val, key) { 104 | // determine if there are identifiers for key and value 105 | const identifiers = parts[0].split(',') 106 | const keyIdentifier = identifiers[0] 107 | const valIdentifier = identifiers[1] 108 | const locals = { [keyIdentifier]: key } 109 | 110 | if (valIdentifier) { 111 | locals[valIdentifier] = val 112 | } 113 | // create a new shallow scope so that locals don't persist 114 | const scope = Object.assign({}, data, locals) 115 | const children = dom(child, node, scope) 116 | if (children) node.appendChild(children) 117 | }) 118 | return 119 | } 120 | 121 | if (child.tagOrSymbol === 'each') { 122 | common.die(locationData, 'TypeError', 'Each not supported (use for)') 123 | } 124 | 125 | // treat all piped text as plain content 126 | if (child.tagOrSymbol === '|') { 127 | return (node.textContent += getValue(data, locationData, child.content)) 128 | } 129 | 130 | if (firstLetter === HYPHEN) { 131 | common.die(locationData, 'Error', 'No inline code!') 132 | } 133 | 134 | /* if (firstLetter === COLON && cb) { 135 | logical = true 136 | const name = 'jstransformer-' + child.tagOrSymbol.slice(1) 137 | let t = null 138 | try { 139 | t = transformer(require(name)) 140 | } catch (ex) { 141 | common.die(child.pos, 'Error', fmt('%s not installed', name)) 142 | } 143 | const path = child.content 144 | const data = cb({ path, location }) 145 | const parsed = t.render(data.tree, child.attributes) 146 | node.textContent += parsed.body 147 | return 148 | } 149 | 150 | // anything prefixed with '+' is a mixin call. 151 | if (firstLetter === PLUS && cb) { 152 | logical = true 153 | const name = child.tagOrSymbol.slice(1) 154 | if (!cache[name]) { 155 | common.die(child.pos, 'TypeError', 'Unknown mixin') 156 | } 157 | 158 | const locals = {} 159 | const args = Object.keys(child.attributes).map(attr => { 160 | return common.scopedExpression(data, child.pos, attr) 161 | }) 162 | 163 | cache[name].keys.map((k, index) => (locals[k] = args[index])) 164 | const scope = Object.assign({}, data, locals) 165 | const children = dom(cache[name].child, node, scope) 166 | node.appendChild(children) 167 | return 168 | } */ 169 | 170 | // defines a mixin 171 | if (firstLetter >= A && firstLetter <= Z) { 172 | logical = true 173 | const keys = Object.keys(child.attributes) 174 | cache[child.tagOrSymbol] = { child, keys } 175 | return 176 | } 177 | 178 | // 179 | // everything else is a tag 180 | // 181 | const props = common.resolveTagOrSymbol(child.tagOrSymbol) 182 | 183 | let el = document.createElement(props.tagname) 184 | 185 | if (props.id) el.id = props.id 186 | if (props.classname) el.className = props.classname 187 | 188 | if (child.attributes) { 189 | Object.keys(child.attributes).map(key => { 190 | let value = child.attributes[key] 191 | 192 | // if this attribute is a boolean, make its value its key 193 | if (typeof value === 'boolean') { 194 | el.setAttribute(key, key) 195 | } 196 | 197 | if (value) { 198 | // all values are expressions 199 | value = common.scopedExpression(data, locationData, value) 200 | // a class should not be empty 201 | if (key === 'class' && !value) return 202 | 203 | // data-* attributes should be escaped 204 | if (key.indexOf('data-') === 0) { 205 | if (typeof value !== 'string' && typeof value !== 'number') { 206 | value = JSON.stringify(value) 207 | } 208 | } 209 | } 210 | el.setAttribute(key, value) 211 | }) 212 | } 213 | 214 | if (child.content) { 215 | el.textContent = (child.tagOrSymbol === 'script') 216 | ? child.content 217 | : getValue(data, locationData, child.content) 218 | } 219 | 220 | // nothing left to decide, recurse if there are child nodes 221 | if (child.children.length) { 222 | const children = dom(child, el, data) 223 | if (children) node.appendChild(children) 224 | } 225 | 226 | // if this is not a logical node, we can make an optimization 227 | // if (!logical) child.dom = el 228 | node.appendChild(el) 229 | } 230 | 231 | if (tree.children && tree.children.length) { 232 | tree.children.map(compile) 233 | } 234 | return node 235 | } 236 | 237 | module.exports = function compiler (tree, data) { 238 | data = data || {} 239 | const node = document.createDocumentFragment() 240 | return dom(tree, node, data) 241 | } 242 | 243 | -------------------------------------------------------------------------------- /compilers/html.js: -------------------------------------------------------------------------------- 1 | const transformer = require('jstransformer') 2 | const he = require('he') 3 | const resFrom = require('resolve-from') 4 | const common = require('./common') 5 | const fmt = require('util').format 6 | 7 | global.cache = {} 8 | 9 | const IF_RE = /^\s*if\s*/ 10 | const IN_RE = /\s*in\s*/ 11 | const FMT_RE = /["'](.*?)["'], / 12 | const MIN_FILE_RE = /\.min$/ 13 | const EQ_RE = /^\s*=\s*/ 14 | 15 | const COLON = 58 16 | const PLUS = 43 17 | const HYPHEN = 45 18 | const A = 65 19 | const Z = 90 20 | 21 | function html (tree, data, location, cb) { 22 | let findElseBranch = false 23 | let logical = false 24 | 25 | function getValue (data, info, str) { 26 | if (!EQ_RE.test(str)) return str 27 | let exp = str.replace(EQ_RE, '') 28 | if (FMT_RE.test(exp)) exp = '__format(' + exp + ')' 29 | logical = true 30 | return common.scopedExpression(data, info, exp) 31 | } 32 | 33 | function unescapedValue (data, info, str) { 34 | return getValue(data, info, str) 35 | } 36 | 37 | // determine if this is a path or just regular content 38 | function escapedValue (data, info, str) { 39 | return he.escape(getValue(data, info, str) + '') 40 | } 41 | 42 | function compile (child, index) { 43 | // if (child.html) return child.html 44 | const locationData = { pos: child.pos || {}, location } 45 | 46 | // if (child.unescaped) { 47 | // child.content = child.content 48 | // } 49 | 50 | const firstLetter = child.tagOrSymbol.charCodeAt(0) 51 | // 52 | // first handle any flow control statements 53 | // 54 | if (child.tagOrSymbol === 'else') { 55 | if (!findElseBranch) return '' 56 | 57 | logical = true 58 | // if this is an else-if 59 | if (IF_RE.test(child.content)) { 60 | const exp = child.content.replace(IF_RE, '') 61 | if (common.scopedExpression(data, locationData, exp)) { 62 | findElseBranch = false 63 | return html(child, data, location, cb) 64 | } 65 | return '' 66 | } 67 | 68 | findElseBranch = false 69 | return html(child, data, location, cb) 70 | } 71 | 72 | if (child.tagOrSymbol === 'comment') { 73 | return '' 74 | } 75 | 76 | if (child.tagOrSymbol === 'if') { 77 | logical = true 78 | if (common.scopedExpression(data, locationData, child.content)) { 79 | return html(child, data, location, cb) 80 | } 81 | findElseBranch = true 82 | return '' 83 | } 84 | 85 | if (child.tagOrSymbol === 'while') { 86 | logical = true 87 | let value = '' 88 | while (common.scopedExpression(data, locationData, child.content)) { 89 | value += html(child, data, location, cb) 90 | } 91 | return value 92 | } 93 | 94 | if (child.tagOrSymbol === 'for') { 95 | logical = true 96 | 97 | const parts = child.content.split(IN_RE) 98 | if (!parts[0]) common.die(locationData, 'TypeError', 'Not enough arguments') 99 | 100 | const object = common.scopedExpression(data, locationData, parts[1]) 101 | 102 | let value = '' 103 | common.each(object, function (val, key) { 104 | // determine if there are identifiers for key and value 105 | const identifiers = parts[0].split(',') 106 | const keyIdentifier = identifiers[0] 107 | const valIdentifier = identifiers[1] 108 | const locals = { [keyIdentifier]: key } 109 | 110 | if (valIdentifier) { 111 | locals[valIdentifier] = val 112 | } 113 | // create a new shallow scope so that locals don't persist 114 | const scope = Object.assign({}, data, locals) 115 | value += html(child, scope, location, cb) 116 | }) 117 | return value 118 | } 119 | 120 | if (child.tagOrSymbol === 'each') { 121 | common.die(locationData, 'TypeError', 'Each not supported (use for)') 122 | } 123 | 124 | // treat all piped text as plain content 125 | if (child.tagOrSymbol === '|') { 126 | return (' ' + escapedValue(data, locationData, child.content)) 127 | } 128 | 129 | if (child.tagOrSymbol === '!') { 130 | return (' ' + unescapedValue(data, locationData, child.content)) 131 | } 132 | 133 | if (firstLetter === HYPHEN) { 134 | common.die(locationData, 'Error', 'No inline code!') 135 | } 136 | 137 | if (firstLetter === COLON && cb) { 138 | logical = true 139 | const name = 'jstransformer-' + child.tagOrSymbol.slice(1) 140 | let t = null 141 | 142 | try { 143 | t = transformer(require(resFrom('.', name))) 144 | } catch (ex) { 145 | const msg = fmt('%s could not load (%s)', name, ex.message) 146 | common.die(locationData, 'Error', msg) 147 | } 148 | const path = child.content 149 | const data = cb({ path, location }) 150 | const parsed = t.render(data.tree, child.attributes) 151 | return parsed.body 152 | } 153 | 154 | // anything prefixed with '+' is a mixin call. 155 | if (firstLetter === PLUS && cb) { 156 | logical = true 157 | const name = child.tagOrSymbol.slice(1) 158 | if (!global.cache[name]) { 159 | const msg = fmt('Unknown mixin (%s) in %s', name, location) 160 | common.die(locationData, 'Error', msg) 161 | } 162 | 163 | const locals = {} 164 | const args = Object.keys(child.attributes).map(attr => { 165 | return common.scopedExpression(data, locationData, attr) 166 | }) 167 | 168 | global.cache[name].keys.map((k, index) => (locals[k] = args[index])) 169 | const scope = Object.assign({}, data, locals) 170 | return html(global.cache[name].child, scope, location, cb) 171 | } 172 | 173 | // defines a mixin 174 | if (firstLetter >= A && firstLetter <= Z) { 175 | logical = true 176 | const keys = Object.keys(child.attributes) 177 | global.cache[child.tagOrSymbol] = { child, keys } 178 | return '' 179 | } 180 | 181 | if (child.tagOrSymbol === 'include') { 182 | // pass location to the cb so includes can be relative 183 | logical = true 184 | const path = child.content 185 | 186 | if (global.watcher) { 187 | global.addToWatcher(location, path) 188 | } 189 | 190 | // if the include is not a .min extension, it's plain text 191 | if (MIN_FILE_RE.test(path)) { 192 | const resolved = cb({ path, location }, true) 193 | return html(resolved.tree, data, resolved.location, cb) 194 | } 195 | return cb({ path, location }) 196 | } 197 | 198 | // 199 | // everything else is a tag 200 | // 201 | const props = common.resolveTagOrSymbol(child.tagOrSymbol) 202 | 203 | let tag = ['<', props.tagname] 204 | 205 | if (props.id) { 206 | tag.push(' id="', props.id, '"') 207 | } 208 | 209 | if (props.classname) { 210 | tag.push(' class="', props.classname, '"') 211 | } 212 | 213 | if (child.attributes) { 214 | let attrs = Object.keys(child.attributes).map(key => { 215 | let value = child.attributes[key] 216 | 217 | // if this attribute is a boolean, make its value its key 218 | if (typeof value === 'boolean') { 219 | return [key, '=', `"${key}"`].join('') 220 | } 221 | 222 | if (value) { 223 | // all values are expressions 224 | value = common.scopedExpression(data, locationData, value) 225 | 226 | // a class should not be empty 227 | if (key === 'class' && !value) return '' 228 | 229 | // data-* attributes should be escaped 230 | if (key.indexOf('data-') === 0) { 231 | if (typeof value !== 'string' && typeof value !== 'number') { 232 | value = he.escape(JSON.stringify(value) + '') 233 | } else { 234 | value = '"' + value + '"' 235 | } 236 | } else { 237 | value = JSON.stringify(value) 238 | } 239 | } 240 | return [key, '=', value].join('') 241 | }) 242 | attrs = attrs.filter(a => !!a) 243 | if (attrs.length) tag.push(' ', attrs.join(' ')) 244 | } 245 | 246 | tag.push('>') // html5 doesn't care about self closing tags 247 | 248 | if (child.content) { 249 | const isScript = props.tagname === 'script' 250 | tag.push(isScript 251 | ? child.content 252 | : escapedValue(data, locationData, child.content) 253 | ) 254 | } 255 | 256 | // nothing left to decide, recurse if there are child nodes 257 | if (child.children.length) { 258 | tag.push(html(child, data, location, cb)) 259 | } 260 | 261 | // decide if the tag needs to be closed or not 262 | if (common.unclosed.indexOf(props.tagname) === -1) { 263 | tag.push('') 264 | } 265 | 266 | var s = tag.join('') 267 | // if this is not a logical node, we can make an optimization 268 | // if (!logical) child.html = s 269 | return s 270 | } 271 | 272 | if (tree.children && tree.children.length) { 273 | return tree.children.map(compile).join('') 274 | } 275 | return '' 276 | } 277 | 278 | module.exports = function (tree, data, location, cb) { 279 | cb = cb || common.resolveInclude 280 | data = data || {} 281 | return html(tree, data, location, cb) 282 | } 283 | -------------------------------------------------------------------------------- /docs/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: 'Open Sans', sans-serif; 3 | font-size: 1em; 4 | padding: 20%; 5 | } 6 | h1, 7 | h2, 8 | h3, 9 | h4 { 10 | font-family: 'Source Sans Pro', sans-serif; 11 | } 12 | h1 { 13 | font-size: 2.5em; 14 | } 15 | h2 { 16 | margin-top: 100px; 17 | } 18 | a, 19 | a:visited, 20 | .red { 21 | color: #ff5252; 22 | } 23 | a { 24 | text-decoration: none; 25 | padding-bottom: 2px; 26 | border-bottom: 1px; 27 | outline: none; 28 | } 29 | p { 30 | line-height: 23px; 31 | } 32 | pre, 33 | code { 34 | font-family: 'Source Code Pro', monospace; 35 | font-size: 1em; 36 | overflow: auto; 37 | } 38 | pre { 39 | position: relative; 40 | padding: 10px; 41 | background-color: #fafafa; 42 | border-bottom: 1px solid #d5d5d5; 43 | } 44 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | Mineral

mineral

Mineral is a language that compiles to markup or dom. It's similar to Jade (aka Pug). Its goal is to be much smaller and simpler than pug, and integrate well with modern client side frameworks.

tags

Lowercase text at the start of a line (or after only white space) is considered an html tag. Indenting a tag will nest it, creating the tree-like structure that can be rendered into html. Tag attributes look similar to html (with optional comma), but their values are regular javascript.

html
137 | head
138 |   link(href="index.css" rel="stylesheet")
139 | body
140 |   h1 Hello World
141 | 

the above code will produce the following html

<html>
142 |   <head>
143 |     <link href="index.css" rel="stylesheet">
144 |   </head>
145 |   <body>
146 |     <h1>Hello World</h1>
147 |   </body>
148 | </html>
149 | 

note It's ideal to disambiguate expressions or declarations when using them as attribute values.

a(href='/save' data-date=(new Date()))
150 | 

A tag that starts with a dot will be considered a div, and the name will be used as the class.

.band The Misfits
<div class="band">The Misfits</div>

note Unlike Jade or Pug, Mineral does not support the (rather superfluous) class-after-attribute syntax.

a(href='/save').button save // Bad
151 | a.button(href='/save') // Good
152 | 

A tag that starts with a hash will be considered a div, and the name will be used as the id. It's fine for the id to come before or after the class.

#danzig.band The Misfits
<div id="danzig" class="band">The Misfits</div>
153 | 
154 | 

mixins

All html is lowercase, anything starting with an uppercase letter is a mixin. arguments and parens are optional. mixins are global (less fiddling with paths).

Person(firstName, lastName)
155 |   h1= firstName
156 |   h2= lastName
157 | 

use mixins by putting a `+` before the name of the mixin. Arguments and parens are optional.

+Person('Jello', 'Biafra')
158 | 

conditionals

Conditional statements (`if`, `else if` and `else`) have no parenthesis. They will evaluate a javascript expression to determine truthyness. Mineral does not implement `case` or `unless` statements.

if 1 === 'x'
159 |   p this statement is false
160 | else if 100 > 99
161 |   p this statement is also false, and optional
162 | else
163 |   p this is the final branch of this logic
164 | 
165 | 

iteration

Iterate over objects or arrays using `for` or `while`. Mineral does not implelent the `each` statement. Here are some examples using the following JSON data.

{
166 |   count: 2,
167 |   people: [
168 |     { first: 'Tom', last: 'Waits' },
169 |     { first: 'Dick', last: 'Dale' }
170 |   ]
171 | }
172 | 

A for loop provides the key (or index) and optionally the value for each item in an array or object.

for key, val in people
173 |   +Foo(val.first, val.last)
174 | 

The value variable is optional.

for p in people
175 |   +Foo(people[p].first, people[p].last)
176 | 

while loops evaluate a javascript expression until it becomes falsey.

ul
177 |   while count--
178 |     span.name= people[count].first
179 | 

values

When the `=` symbol follows a tag, it indicates that the value should be an expression. An expression can include values from the data passed to the template or any valid javascript.

h1 = 'Hello, ' + name
180 | 

Mineral detects `console.log`-like statements.

h1 = 'Hello %s', foo
181 | 

text

To continue the text content of a tag, you can use the `|` symbol.

p Danzig is an American heavy metal band, formed in 1987 in Lodi,
182 | | New Jersey, United States. The band is the musical outlet for singer
183 | | and songwriter Glenn Danzig, preceded by the horror punk bands the
184 | | Misfits and Samhain. They play in a bluesy doom-driven heavy metal
185 | | style influenced by the early sound of Black Sabbath.[1]
186 | 

For multiline textblocks, add a `.` after the tag. The compiler wont evaluate any code inside these blocks. It will also preserve whitespace.

.foo.
187 |   Hello
188 |   world.
189 | 
<div class="foo">
190 |   Hello
191 |   world.
192 | </div>
193 | 

This feature is pretty important when adding javascript to a script tag.

script.
194 |   var s = 'hello, world'
195 |   alert(s)
196 | h1 I sent you an alert.
197 | 

filters

Use any jstramsformer module. For example, `npm install --save jstransformer-marked`. Arguments and parens are optional.

:marked(gfm=true) ./readme.md
198 | 

comments

Single-line

// single line
199 | 

Multi-line (`.beep` and `.boop` are commented)

.foo1
200 | //
201 |   .beep
202 |     .boop
203 | .foo2
204 | 

note Comments have no business in compiled code, so they are removed entirely. Conditional comments are no longer supported by any IE version, so they wont be supported. If for some reason you really want comments, you can use the comment tag.

comment Hey good lookin'
<!-- Hey good lookin' -->
205 | 

-------------------------------------------------------------------------------- /docs/index.js: -------------------------------------------------------------------------------- 1 | var requestAnimFrame = (function () { 2 | return window.requestAnimationFrame || 3 | window.webkitRequestAnimationFrame || 4 | window.mozRequestAnimationFrame || 5 | function (callback) { 6 | window.setTimeout(callback, 1000 / 60) 7 | } 8 | })() 9 | 10 | function ease (pos) { 11 | if ((pos /= 0.5) < 1) { 12 | return 0.5 * Math.pow(pos, 5) 13 | } 14 | return 0.5 * (Math.pow((pos - 2), 5) + 2) 15 | } 16 | 17 | var scrolling = false 18 | 19 | function scrollToY (scrollTargetY, speed) { 20 | var scrollY = window.scrollY 21 | var currentTime = 0 22 | var pos = Math.abs(scrollY - scrollTargetY) 23 | var time = Math.max(0.1, Math.min(pos / speed, 0.8)) 24 | 25 | function nextFrame () { 26 | currentTime += 1 / 60 27 | scrolling = true 28 | 29 | var p = currentTime / time 30 | var t = ease(p) 31 | 32 | if (p < 1) { 33 | requestAnimFrame(nextFrame) 34 | window.scrollTo(0, scrollY + ((scrollTargetY - scrollY) * t)) 35 | } else { 36 | window.scrollTo(0, scrollTargetY) 37 | scrolling = false 38 | } 39 | } 40 | nextFrame() 41 | } 42 | 43 | var links = [].slice.call(document.querySelectorAll('li a')) 44 | var ranges = [] 45 | var current 46 | 47 | links.map(function (link) { 48 | var id = link.getAttribute('href').slice(1) 49 | var section = document.getElementById(id) 50 | 51 | ranges.push({ 52 | upper: section.offsetTop, 53 | lower: section.offsetTop + section.offsetHeight, 54 | id: id, 55 | link: link 56 | }) 57 | 58 | var delay 59 | 60 | link.addEventListener('click', function (event) { 61 | clickInvoked = true 62 | event.preventDefault() 63 | 64 | var prev = document.querySelector('a.active') 65 | if (prev) prev.className = '' 66 | link.className = 'active' 67 | scrollToY(section.offsetTop, 1500) 68 | }) 69 | }) 70 | 71 | var nav = document.querySelector('nav') 72 | var hamburger = document.querySelector('.hamburger') 73 | var showAfterClick = false 74 | var lastpos 75 | 76 | hamburger.addEventListener('click', function () { 77 | nav.style.opacity = 1 78 | hamburger.style.zIndex = -1 79 | hamburger.style.opacity = 0 80 | showAfterClick = true 81 | }) 82 | 83 | function onscroll (event) { 84 | if (scrolling) return 85 | 86 | var pos = document.body.scrollTop 87 | 88 | if (pos <= 90 && pos >= 0 && !showAfterClick) { 89 | nav.style.opacity = (pos / 100) 90 | } 91 | 92 | if (pos <= 0) { 93 | hamburger.style.zIndex = 20 94 | hamburger.style.opacity = 1 95 | } 96 | 97 | if (pos < lastpos) { 98 | showAfterClick = false 99 | } 100 | 101 | if (pos > 100) { 102 | showAfterClick = false 103 | nav.style.opacity = .9 104 | } 105 | 106 | lastpos = pos 107 | pos = pos + 100 108 | 109 | ranges.map(function (range) { 110 | if (pos >= range.upper && pos <= range.lower) { 111 | if (range.id === current) return 112 | current = range.id 113 | var prev = document.querySelector('a.active') 114 | if (prev) prev.className = '' 115 | range.link.className = 'active' 116 | } 117 | }) 118 | } 119 | 120 | window.addEventListener('scroll', onscroll) 121 | 122 | -------------------------------------------------------------------------------- /docs/src/header.min: -------------------------------------------------------------------------------- 1 | head 2 | link( 3 | href="https://fonts.googleapis.com/css?family=Open+Sans|Source+Code+Pro|Source+Sans+Pro" 4 | rel="stylesheet" 5 | ) 6 | style 7 | :stylus ./index.styl 8 | 9 | // Title 10 | title Mineral 11 | 12 | meta(name="description" content="A terse language that compiles to html and dom trees") 13 | 14 | meta(name="keywords" content="templates, templating, javascript, js, jade, jade-lang, pug, pugjs, pug-js, mineraljs, mineral-lang, mineral-js") 15 | meta(http-equiv='Content-Type', content='text/html; charset=utf-8') 16 | meta(name="viewport" content="width=device-width, initial-scale=1") 17 | 18 | // Viewport 19 | meta(name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no") 20 | 21 | // Favicon 22 | link(rel="icon" type="image/png" href="https://voltra.co/favicon-32x32.png" sizes="32x32") 23 | link(rel="icon" type="image/png" href="https://voltra.co/favicon-16x16.png" sizes="16x16") 24 | 25 | -------------------------------------------------------------------------------- /docs/src/index.min: -------------------------------------------------------------------------------- 1 | html 2 | 3 | include ./header.min 4 | 5 | body 6 | header 7 | h1 mineral 8 | .hamburger 9 | .inner 10 | nav 11 | em mineral 12 | ul 13 | li 14 | a.active(href="#tags") tags 15 | li 16 | a(href="#mixins") mixins 17 | li 18 | a(href="#conditionals") conditionals 19 | li 20 | a(href="#iteration") iteration 21 | li 22 | a(href="#values") values 23 | li 24 | a(href="#text") text 25 | li 26 | a(href="#filters") filters 27 | li 28 | a(href="#comments") comments 29 | 30 | p 31 | a(href="https://github.com/voltraco/mineral") Mineral 32 | | is a language that compiles to markup or dom. It's similar to Jade (aka 33 | | Pug). Its goal is to be much smaller and simpler than pug, and integrate 34 | | well with modern client side frameworks. 35 | 36 | include ./sections/tags.min 37 | include ./sections/mixins.min 38 | include ./sections/conditionals.min 39 | include ./sections/iteration.min 40 | include ./sections/values.min 41 | include ./sections/text.min 42 | include ./sections/filters.min 43 | include ./sections/comments.min 44 | 45 | script(src="./index.js") 46 | -------------------------------------------------------------------------------- /docs/src/index.styl: -------------------------------------------------------------------------------- 1 | $red = #ff5252 2 | $gray = #333 3 | $lightgray = rgba(0,0,0,0.14) 4 | 5 | body 6 | font-family 'Open Sans', sans-serif 7 | font-size 1em 8 | max-width 800px 9 | margin 15% auto 10 | padding 5% 11 | 12 | section 13 | padding-top 30px 14 | 15 | h1, h2, h3, h4 16 | font-family 'Source Sans Pro', sans-serif 17 | 18 | h1 19 | font-size 2em 20 | vertical-align bottom 21 | display inline 22 | text-transform uppercase 23 | line-height 1.6em 24 | 25 | h2 26 | border 2px solid $red 27 | padding 4px 8px 5px 8px 28 | display inline-block 29 | text-transform uppercase 30 | letter-spacing .05em 31 | font-size 1em 32 | margin-top 100px 33 | 34 | a, a:visited, .red 35 | color $red 36 | 37 | a 38 | text-decoration none 39 | padding-bottom 2px 40 | border-bottom 1px 41 | outline none 42 | 43 | p 44 | line-height 23px 45 | 46 | pre, code 47 | font-family 'Source Code Pro', monospace 48 | font-size 1em 49 | overflow auto 50 | 51 | pre 52 | position relative 53 | padding 10px 54 | background-color #fafafa 55 | border-bottom 1px solid #d5d5d5 56 | &.warn 57 | border-bottom 1px solid red 58 | 59 | .hamburger 60 | opacity 1 61 | transition all .4s 62 | position absolute 63 | top 20px 64 | right 20px 65 | width 26px 66 | height 20px 67 | border-top 1px solid $gray 68 | border-bottom 1px solid $gray 69 | z-index 20 70 | .inner 71 | height 10px 72 | border-bottom 1px solid $gray 73 | 74 | nav 75 | opacity 0 76 | z-index 10 77 | position fixed 78 | top 0 79 | left 0 80 | right 0 81 | height 60px 82 | background white 83 | border-bottom 1px solid $lightgray 84 | box-shadow 0px 2px 3px $lightgray 85 | 86 | em 87 | font-style normal 88 | display inline-block 89 | margin 16px 20px 90 | font-size 21px 91 | text-transform uppercase 92 | font-family 'Source Sans Pro', sans-serif 93 | 94 | ul 95 | display inline-block 96 | float right 97 | margin-right 10px 98 | list-style none 99 | li 100 | float left 101 | 102 | a, a:visited 103 | transition all .4s 104 | color $gray 105 | font-size 12px 106 | text-transform uppercase 107 | margin 14px 108 | text-decoration none 109 | border 1px solid transparent 110 | letter-spacing .03em 111 | padding 4px 112 | 113 | &:hover, &.active 114 | color $red 115 | 116 | &.active 117 | border 1px solid 118 | padding 4px 119 | 120 | @media screen and (max-width: 900px) 121 | nav, .hamburger 122 | display none 123 | 124 | -------------------------------------------------------------------------------- /docs/src/sections/comments.min: -------------------------------------------------------------------------------- 1 | section#comments 2 | 3 | a(href="#comments") 4 | h2 comments 5 | 6 | p Single-line 7 | 8 | pre.input 9 | code. 10 | // single line 11 | 12 | p Multi-line (`.beep` and `.boop` are commented) 13 | 14 | pre.input 15 | code. 16 | .foo1 17 | // 18 | .beep 19 | .boop 20 | .foo2 21 | 22 | p 23 | span.red note 24 | | Comments have no business in compiled code, so they are removed entirely. 25 | | Conditional comments are no longer supported by any IE version, so they 26 | | wont be supported. If for some reason you really want comments, you can 27 | | use the comment tag. 28 | pre.input 29 | code. 30 | comment Hey good lookin' 31 | pre.output 32 | code. 33 | 34 | -------------------------------------------------------------------------------- /docs/src/sections/conditionals.min: -------------------------------------------------------------------------------- 1 | section#conditionals 2 | 3 | a(href="#conditionals") 4 | h2 conditionals 5 | 6 | p Conditional statements (`if`, `else if` and `else`) have no parenthesis. 7 | | They will evaluate a javascript expression to determine truthyness. 8 | | Mineral does not implement `case` or `unless` statements. 9 | 10 | pre.input 11 | code. 12 | if 1 === 'x' 13 | p this statement is false 14 | else if 100 > 99 15 | p this statement is also false, and optional 16 | else 17 | p this is the final branch of this logic 18 | 19 | -------------------------------------------------------------------------------- /docs/src/sections/filters.min: -------------------------------------------------------------------------------- 1 | section#filters 2 | 3 | a(href="#filters") 4 | h2 filters 5 | 6 | p Use any 7 | a(href="https://www.npmjs.com/browse/keyword/jstransformer") jstramsformer 8 | | module. For example, `npm install --save jstransformer-marked`. Arguments 9 | | and parens are optional. 10 | 11 | pre.input 12 | code. 13 | :marked(gfm=true) ./readme.md 14 | -------------------------------------------------------------------------------- /docs/src/sections/iteration.min: -------------------------------------------------------------------------------- 1 | section#iteration 2 | a(href="#iteration") 3 | h2 iteration 4 | 5 | p Iterate over objects or arrays using `for` or `while`. Mineral does not 6 | | implelent the `each` statement. Here are some examples using the 7 | | following JSON data. 8 | 9 | pre.input 10 | code. 11 | { 12 | count: 2, 13 | people: [ 14 | { first: 'Tom', last: 'Waits' }, 15 | { first: 'Dick', last: 'Dale' } 16 | ] 17 | } 18 | 19 | p A for loop provides the key (or index) and optionally the value for each 20 | | item in an array or object. 21 | pre.input 22 | code. 23 | for key, val in people 24 | +Foo(val.first, val.last) 25 | 26 | p The value variable is optional. 27 | pre.input 28 | code. 29 | for p in people 30 | +Foo(people[p].first, people[p].last) 31 | 32 | p while loops evaluate a javascript expression until it becomes falsey. 33 | pre.input 34 | code. 35 | ul 36 | while count-- 37 | span.name= people[count].first 38 | -------------------------------------------------------------------------------- /docs/src/sections/mixins.min: -------------------------------------------------------------------------------- 1 | section#mixins 2 | a(id="mixins" href="#mixins") 3 | h2 mixins 4 | 5 | p All html is lowercase, anything starting with an uppercase letter is a 6 | | mixin. arguments and parens are optional. mixins are global (less fiddling 7 | | with paths). 8 | 9 | pre.input 10 | code. 11 | Person(firstName, lastName) 12 | h1= firstName 13 | h2= lastName 14 | 15 | p use mixins by putting a `+` before the name of the mixin. Arguments and 16 | | parens are optional. 17 | 18 | pre.input 19 | code. 20 | +Person('Jello', 'Biafra') 21 | -------------------------------------------------------------------------------- /docs/src/sections/tags.min: -------------------------------------------------------------------------------- 1 | section#tags 2 | 3 | a(href="#tags") 4 | h2 tags 5 | 6 | p Lowercase text at the start of a line (or after only white space) is 7 | | considered an html tag. Indenting a tag will nest it, creating the 8 | | tree-like structure that can be rendered into html. Tag attributes 9 | | look similar to html (with optional comma), but their values are regular 10 | | javascript. 11 | 12 | pre.input 13 | code. 14 | html 15 | head 16 | link(href="index.css" rel="stylesheet") 17 | body 18 | h1 Hello World 19 | 20 | p the above code will produce the following html 21 | pre.output 22 | code. 23 | 24 | 25 | 26 | 27 | 28 |

Hello World

29 | 30 | 31 | 32 | p 33 | span.red note 34 | | It's ideal to disambiguate expressions or declarations when using them as 35 | | attribute values. 36 | pre.warn 37 | code. 38 | a(href='/save' data-date=(new Date())) 39 | 40 | br 41 | p A tag that starts with a dot will be considered a div, and the name will 42 | | be used as the class. 43 | pre 44 | code. 45 | .band The Misfits 46 | pre 47 | code. 48 |
The Misfits
49 | p 50 | span.red note 51 | | Unlike Jade or Pug, Mineral does not support the (rather superfluous) 52 | | class-after-attribute syntax. 53 | pre.warn 54 | code. 55 | a(href='/save').button save // Bad 56 | a.button(href='/save') // Good 57 | 58 | br 59 | p A tag that starts with a hash will be considered a div, and the name will 60 | | be used as the id. It's fine for the id to come before or after the class. 61 | pre 62 | code. 63 | #danzig.band The Misfits 64 | pre 65 | code. 66 |
The Misfits
67 | 68 | -------------------------------------------------------------------------------- /docs/src/sections/text.min: -------------------------------------------------------------------------------- 1 | section#text 2 | a(id="text" href="#text") 3 | h2 text 4 | 5 | p To continue the text content of a tag, you can use the `|` symbol. 6 | 7 | pre.input 8 | code. 9 | p Danzig is an American heavy metal band, formed in 1987 in Lodi, 10 | | New Jersey, United States. The band is the musical outlet for singer 11 | | and songwriter Glenn Danzig, preceded by the horror punk bands the 12 | | Misfits and Samhain. They play in a bluesy doom-driven heavy metal 13 | | style influenced by the early sound of Black Sabbath.[1] 14 | 15 | p For multiline textblocks, add a `.` after the tag. The compiler wont 16 | | evaluate any code inside these blocks. It will also preserve whitespace. 17 | 18 | pre.input 19 | code. 20 | .foo. 21 | Hello 22 | world. 23 | 24 | pre.output 25 | code. 26 |
27 | Hello 28 | world. 29 |
30 | 31 | p This feature is pretty important when adding javascript to a script tag. 32 | 33 | pre.input 34 | code. 35 | script. 36 | var s = 'hello, world' 37 | alert(s) 38 | h1 I sent you an alert. 39 | -------------------------------------------------------------------------------- /docs/src/sections/values.min: -------------------------------------------------------------------------------- 1 | section#values 2 | 3 | a(id="values" href="#values") 4 | h2 values 5 | 6 | p When the `=` symbol follows a tag, it indicates that the value should be an 7 | | expression. An expression can include values from the data passed to the 8 | | template or any valid javascript. 9 | 10 | pre.input 11 | code. 12 | h1 = 'Hello, ' + name 13 | 14 | p Mineral detects `console.log`-like statements. 15 | pre.input 16 | code. 17 | h1 = 'Hello %s', foo 18 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const fs = require('fs') 3 | 4 | const parse = require('./parser') 5 | const html = require('./compilers/html') 6 | 7 | const root = path.dirname(arguments[2].parent.filename) 8 | delete require.cache[__filename] 9 | 10 | exports.readFileSync = f => { 11 | const location = path.join(root, f) 12 | const source = fs.readFileSync(location, 'utf8') 13 | const tree = parse(source) 14 | return data => html(tree, data, root) 15 | } 16 | -------------------------------------------------------------------------------- /lexer.js: -------------------------------------------------------------------------------- 1 | const QUOTED_RE = /^"|'/ 2 | const WORD_RE = /^[^,\n\r =]+/ 3 | const DELIMITER_RE = /^\s*=\s*/ 4 | const SEP_RE = /^,/ 5 | const EOV_RE = /^\s+[$_A-Za-z]+/ // possible end of value 6 | const WHITESPACE_RE = /^[^\S\x0a\x0d]*/ // but not new lines 7 | const ANYSPACE_RE = /^\s*/ // any whitespace 8 | const NON_WHITESPACE_RE = /^\S+/ // any non whitespace 9 | const TAGORSYMBOL_RE = /[\.#]?[^=(\s]*/ // any valid tag or symbol 10 | const NEWLINE_RE = /[\x0a\x0d]+/ // the next NL 11 | 12 | // 13 | // Lexer() provides a simple API that can read and reduce a source stream. 14 | // each method is either a helper or a matcher that represents a token. 15 | // 16 | module.exports = function Lexer (str, options) { 17 | options = options || {} 18 | 19 | const lexer = { 20 | source: str.slice() 21 | } 22 | 23 | let lineno = 1 24 | let column = 1 25 | 26 | function updatePosition (str) { 27 | const lines = str.match(/\n/g) 28 | if (lines) lineno += lines.length 29 | const i = str.lastIndexOf('\n') 30 | column = ~i ? str.length - i : column + str.length 31 | } 32 | 33 | function matcher (re) { 34 | const m = re.exec(lexer.source) 35 | if (m === null) return 36 | const str = m[0] 37 | updatePosition(str) 38 | lexer.source = lexer.source.slice(m.index + str.length) 39 | // console.log('>', arguments.callee.caller.name, "'" + m + "'") 40 | return m 41 | } 42 | 43 | lexer.data = function () { 44 | return lexer.source 45 | } 46 | 47 | lexer.pos = function () { 48 | return { column: column, lineno: lineno } 49 | } 50 | 51 | lexer.length = function () { 52 | return lexer.source.length 53 | } 54 | 55 | lexer.exec = function (re) { 56 | return re.exec(lexer.source) 57 | } 58 | 59 | lexer.peek = function (index, len) { 60 | return lexer.source.slice(index || 0, len || 1) 61 | } 62 | 63 | lexer.pop = function (index, len) { 64 | const s = lexer.source.slice(index || 0, len || 1) 65 | lexer.source = lexer.source.slice(len || 1) 66 | return s 67 | } 68 | 69 | lexer.error = function error (msg) { 70 | const err = new SyntaxError([ 71 | msg, ':', 72 | lineno, ':', 73 | column 74 | ].join('')) 75 | err.reason = msg 76 | err.line = lineno 77 | err.column = column 78 | throw err 79 | } 80 | 81 | var pm = lexer.match = {} 82 | 83 | pm.whitespace = function whitespace () { 84 | const m = matcher(WHITESPACE_RE) 85 | return m && m[0] 86 | } 87 | 88 | pm.anyspace = function anyspace () { 89 | return matcher(ANYSPACE_RE) 90 | } 91 | 92 | pm.newline = function newline () { 93 | return matcher(NEWLINE_RE) 94 | } 95 | 96 | pm.string = function string () { 97 | const quote = matcher(QUOTED_RE) 98 | if (!quote) return 99 | 100 | let value = '' 101 | while (lexer.source[0] !== quote[0]) { 102 | if (lexer.length() === 0) { 103 | lexer.error('missing end of string') 104 | } 105 | value += lexer.source[0] 106 | lexer.source = lexer.source.slice(1) 107 | } 108 | lexer.source = lexer.source.slice(1) 109 | updatePosition(value) 110 | return value 111 | } 112 | 113 | pm.nonwhitespace = function nonwhitespace () { 114 | return matcher(NON_WHITESPACE_RE) 115 | } 116 | 117 | pm.comment = function comment () { 118 | const pair = lexer.peek(0, 2) 119 | let value = '' 120 | 121 | if (pair === '//') { 122 | value = lexer.pop(0, 2) 123 | 124 | while (true) { 125 | const ch = lexer.peek() 126 | if (/[\x0a\x0d]+/.test(ch)) break 127 | value += lexer.pop() 128 | } 129 | 130 | updatePosition(value) 131 | } 132 | return value 133 | } 134 | 135 | pm.parens = function parens () { 136 | let ch = lexer.peek() 137 | let value = '' 138 | 139 | if (ch === '(') { 140 | var open = 1 141 | lexer.pop() 142 | 143 | while (true) { 144 | if (lexer.length() === 0) { 145 | lexer.error('missing closing paren') 146 | } 147 | 148 | ch = lexer.peek() 149 | 150 | if (NEWLINE_RE.test(ch)) { 151 | lexer.pop() 152 | continue 153 | } 154 | 155 | if (ch === '(') { 156 | ++open 157 | } else if (ch === ')') { 158 | if (!--open) { 159 | lexer.pop() 160 | break 161 | } 162 | } 163 | 164 | value += lexer.pop(0, 1) 165 | updatePosition(value) 166 | } 167 | } 168 | return value 169 | } 170 | 171 | pm.value = function value () { 172 | let val = '' 173 | let openSingle = false 174 | let openDouble = false 175 | let openBrace = false 176 | let openBracket = false 177 | let openParen = false 178 | 179 | while (true) { 180 | if (lexer.length() === 0) { 181 | lexer.error('Unexpected end of source') 182 | } 183 | 184 | let next = lexer.peek(0, 2) 185 | let ch = lexer.pop() 186 | 187 | if (ch === '\'') openSingle = !openSingle 188 | else if (ch === '"') openDouble = !openDouble 189 | else if (ch === '{') openBrace = true 190 | else if (ch === '}') openBrace = false 191 | else if (ch === '[') openBracket = true 192 | else if (ch === ']') openBracket = false 193 | else if (ch === '(') openParen = true 194 | else if (ch === ')') openParen = false 195 | 196 | let closed = !openSingle && !openDouble && 197 | !openBracket && !openBrace && !openParen 198 | 199 | if (closed && /[\r\n,]/.test(ch)) break 200 | 201 | // ticky bit -- if closed, after one or more whitespace, we don't 202 | // find an operator, we can asume that this is not an expression, it 203 | // must be the end of the value. 204 | if (closed && EOV_RE.test(next)) { 205 | // val += ch 206 | break 207 | } 208 | val += ch 209 | if (!lexer.length()) break 210 | } 211 | return val 212 | } 213 | 214 | pm.word = function word () { 215 | const m = matcher(WORD_RE) 216 | return m && m[0] 217 | } 218 | 219 | pm.delimiter = function delimiter () { 220 | const m = matcher(DELIMITER_RE) 221 | return m && m[0] 222 | } 223 | 224 | pm.separator = function separator () { 225 | const m = matcher(SEP_RE) 226 | return m && m[0] 227 | } 228 | 229 | pm.tagOrSymbol = function tagOrSymbol () { 230 | const m = matcher(TAGORSYMBOL_RE) 231 | return m && m[0] 232 | } 233 | 234 | pm.content = function content (preserveComments) { 235 | let value = '' 236 | let lastch = '' 237 | 238 | while (true) { 239 | let ch = lexer.peek(0, 1) 240 | 241 | if (ch === '/' && !preserveComments) { 242 | const nextch = lexer.peek(1, 2) 243 | if (lastch !== ':' && nextch === '/') { 244 | value = value.trim() // its a line comment, not a url 245 | break 246 | } 247 | } else if (NEWLINE_RE.test(ch) || lexer.length() === 0) { 248 | break 249 | } 250 | lastch = lexer.pop() 251 | value += lastch 252 | } 253 | return value 254 | } 255 | return lexer 256 | } 257 | 258 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mineral", 3 | "version": "3.0.13", 4 | "description": "A simplified fork of the jade template langauge", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "tape test/*.js", 8 | "build": "./node_modules/.bin/min ./docs/src/index.min -o ./docs" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/voltraco/mineral.git" 13 | }, 14 | "author": "hij1nx", 15 | "license": "ISC", 16 | "bugs": { 17 | "url": "https://github.com/voltraco/mineral/issues" 18 | }, 19 | "homepage": "https://github.com/voltraco/mineral#readme", 20 | "devDependencies": { 21 | "benchmark": "^2.1.0", 22 | "domready": "^1.0.8", 23 | "faucet": "0.0.1", 24 | "jade": "^1.11.0", 25 | "json-stringify-safe": "^5.0.1", 26 | "jstransformer-stylus": "^1.3.0", 27 | "mineral-cli": "^1.0.0", 28 | "node-chrome": "^1.1.1", 29 | "tape": "^4.6.3", 30 | "tape-run": "^2.1.3" 31 | }, 32 | "dependencies": { 33 | "callsites": "^2.0.0", 34 | "he": "^1.1.0", 35 | "jstransformer": "^1.0.0", 36 | "resolve-from": "^2.0.0" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /parser.js: -------------------------------------------------------------------------------- 1 | const Lexer = require('./lexer') 2 | 3 | function parseAttributes (str) { 4 | const lexer = Lexer(str) 5 | const args = {} 6 | 7 | while (true) { 8 | lexer.match.whitespace() 9 | const name = lexer.match.word() 10 | const delimiter = lexer.match.delimiter() 11 | let value = true 12 | 13 | if (delimiter) value = lexer.match.value() 14 | if (!name) break 15 | 16 | lexer.match.whitespace() 17 | const separator = lexer.match.separator() 18 | args[name] = value 19 | // console.log('[%s] [%s] [%s] [%s]', name, delimiter, value, lexer.data()) 20 | if (!lexer.length()) break 21 | } 22 | return args 23 | } 24 | 25 | module.exports = function Parser (source) { 26 | source = source.replace(/\t/g, ' ') 27 | 28 | const lexer = Lexer(source) 29 | const root = { tagOrSymbol: 'document', children: [] } 30 | 31 | let parent = root 32 | let lastIndent = 0 33 | let lastSibling = null 34 | let contentTarget = null 35 | let contentTargetIndent = 0 36 | 37 | while (lexer.length()) { 38 | const whitespace = lexer.match.whitespace() 39 | 40 | if (contentTarget) { 41 | if (whitespace.length <= contentTargetIndent) { 42 | contentTarget.content = contentTarget.content.slice(0, -1) 43 | contentTarget = null 44 | } else { 45 | // add newlines and whitespace, but trim to the current indent. 46 | const trimmed = whitespace // .slice(contentTargetIndent + 2) 47 | contentTarget.content += trimmed + lexer.match.content(true) 48 | contentTarget.content += (lexer.match.newline() || '') 49 | continue 50 | } 51 | } 52 | 53 | lexer.match.comment() 54 | const tagOrSymbol = lexer.match.tagOrSymbol() 55 | 56 | if (tagOrSymbol) { 57 | const attributes = (!contentTarget && 58 | parseAttributes(lexer.match.parens())) 59 | 60 | lexer.match.whitespace() 61 | lexer.match.comment() 62 | 63 | const indent = whitespace.length 64 | if (indent % 2 !== 0) lexer.error('Uneven indent') 65 | 66 | const tag = { 67 | tagOrSymbol: tagOrSymbol, 68 | attributes: attributes, 69 | content: lexer.match.content(), 70 | indent: indent, 71 | children: [], 72 | pos: lexer.pos() 73 | } 74 | 75 | if (tagOrSymbol.slice(-1) === '.') { 76 | tag.tagOrSymbol = tagOrSymbol.slice(0, -1) 77 | tag.unescaped = true 78 | contentTarget = tag 79 | contentTargetIndent = indent 80 | } 81 | 82 | if (indent > lastIndent) { 83 | tag.parent = (lastSibling || parent) 84 | tag.parent.children.push(tag) 85 | lastSibling = null 86 | parent = tag 87 | } else if (indent < lastIndent) { 88 | while (parent && parent.indent >= indent) { 89 | parent = parent.parent 90 | } 91 | tag.parent = parent 92 | tag.parent.children.push(tag) 93 | lastSibling = null 94 | parent = tag 95 | } else { 96 | tag.parent = parent.parent || parent 97 | tag.parent.children.push(tag) 98 | lastSibling = tag 99 | } 100 | lastIndent = indent 101 | } 102 | lexer.match.newline() 103 | } 104 | return root 105 | } 106 | 107 | -------------------------------------------------------------------------------- /test/browser.js: -------------------------------------------------------------------------------- 1 | const Chrome = require('node-chrome') 2 | const stringify = require('json-stringify-safe') 3 | 4 | module.exports = (source, data = {}, opts, cb) => { 5 | if (typeof opts === 'function') { 6 | cb = opts 7 | opts = {} 8 | } 9 | 10 | data = stringify(data) 11 | 12 | const chrome = Chrome(` 13 | const path = require('path') 14 | const compiler = require(path.resolve('compilers/dom')) 15 | const parser = require(path.resolve('parser')) 16 | 17 | window.onload = () => { 18 | const source = \`${source}\` 19 | const text= ${!!opts.text} 20 | const node = compiler(parser(source), ${data}) 21 | 22 | if (text) { 23 | console.log(node.textContent) 24 | return window.close() 25 | } 26 | 27 | document.body.appendChild(node) 28 | console.log(document.body.innerHTML) 29 | window.close() 30 | } 31 | `) 32 | 33 | let out = '' 34 | let err = null 35 | 36 | chrome.on('stdout', d => (out += d)) 37 | chrome.on('stderr', e => (err += e)) 38 | chrome.on('exit', (code, sig) => cb(err, out)) 39 | } 40 | 41 | -------------------------------------------------------------------------------- /test/cache.js: -------------------------------------------------------------------------------- 1 | const test = require('tape') 2 | const fs = require('fs') 3 | const path = require('path') 4 | const parse = require('../parser') 5 | const compile = require('../compilers/html') 6 | 7 | const base = path.join(__dirname, 'fixtures') 8 | const read = s => fs.readFileSync(path.join(base, s), 'utf8') 9 | 10 | test('a tree can be cached', assert => { 11 | const m = parse(read('./cached.min')) 12 | 13 | const template = d => compile(m, d) 14 | 15 | assert.equal(template({ foo: 100 }), '

100

') 16 | assert.equal(template({ foo: 200 }), '

200

') 17 | assert.end() 18 | }) 19 | -------------------------------------------------------------------------------- /test/cases.js: -------------------------------------------------------------------------------- 1 | const test = require('tape') 2 | const fs = require('fs') 3 | const path = require('path') 4 | const parse = require('../parser') 5 | const compile = require('../compilers/html') 6 | const browser = require('./browser') 7 | 8 | const read = s => fs.readFileSync(path.join(__dirname, 'cases', s), 'utf8') 9 | const unique = a => a.filter((v, i, a) => i <= a.indexOf(v)) 10 | 11 | let files = fs.readdirSync(path.join(__dirname, 'cases')) 12 | 13 | files = unique(files.map(file => file.replace(path.extname(file), ''))) 14 | 15 | const data = {} 16 | data['attrs-data'] = { 'user': { 'name': 'tobi' } } 17 | 18 | test('cases', assert => { 19 | files.map(file => { 20 | function onFile (assert) { 21 | const source = read(file + '.min') 22 | const actual = compile(parse(source), data[file]) + '\n' 23 | const expected = read(file + '.html') 24 | assert.equal(actual, expected, 'node output matches') 25 | 26 | browser(source, data[file], (code, html) => { 27 | assert.equal(html + '\n', expected, 'browser output matches') 28 | assert.end() 29 | }) 30 | } 31 | 32 | test(file, onFile) 33 | }) 34 | assert.end() 35 | }) 36 | 37 | -------------------------------------------------------------------------------- /test/cases/attrs-data.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /test/cases/attrs-data.min: -------------------------------------------------------------------------------- 1 | foo(data-user=user) 2 | foo(data-items=[1,2,3]) 3 | foo(data-username='tobi') 4 | foo(data-escaped={message: "Let's rock!"}) 5 | foo(data-ampersand={message: "a quote: " this & that"}) 6 | foo(data-epoc=(new Date(0))) 7 | 8 | -------------------------------------------------------------------------------- /test/cases/attrs.html: -------------------------------------------------------------------------------- 1 | contactcontact 2 | -------------------------------------------------------------------------------- /test/cases/attrs.min: -------------------------------------------------------------------------------- 1 | a(href='/contact') contact 2 | a(foo, bar, baz) 3 | a(foo='foo, bar, baz', bar=1) 4 | 5 | a(foo='((foo))', bar= (1) ? 1 : 0) 6 | 7 | select 8 | option(value='foo', selected) Foo 9 | option(selected, value='bar') Bar 10 | a(foo="class:") 11 | input(pattern='\\S+') 12 | 13 | a(href='/contact') contact 14 | a(foo bar baz) 15 | a(foo='foo, bar, baz' bar=1) 16 | a(foo='((foo))' bar= (1) ? 1 : 0 ) 17 | select 18 | option(value='foo' selected) Foo 19 | option(selected value='bar') Bar 20 | a(foo="class:") 21 | input(pattern='\\S+') 22 | foo(terse="true") 23 | foo(date=(new Date(0))) 24 | 25 | foo(abc 26 | ,def) 27 | foo(abc, 28 | def) 29 | foo(abc, 30 | def) 31 | foo(abc 32 | ,def) 33 | foo(abc 34 | def) 35 | foo(abc 36 | def) 37 | 38 | -------------------------------------------------------------------------------- /test/cases/basic.html: -------------------------------------------------------------------------------- 1 |

Title

2 | -------------------------------------------------------------------------------- /test/cases/basic.min: -------------------------------------------------------------------------------- 1 | html 2 | body 3 | h1 Title 4 | -------------------------------------------------------------------------------- /test/cases/blanks.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /test/cases/blanks.min: -------------------------------------------------------------------------------- 1 | 2 | ul 3 | li foo 4 | 5 | li bar 6 | 7 | li baz 8 | -------------------------------------------------------------------------------- /test/cases/classes-empty.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /test/cases/classes-empty.min: -------------------------------------------------------------------------------- 1 | a(class='') 2 | a(class=null) 3 | a(class=undefined) 4 | 5 | -------------------------------------------------------------------------------- /test/cases/comments.html: -------------------------------------------------------------------------------- 1 |

five

2 | -------------------------------------------------------------------------------- /test/cases/comments.min: -------------------------------------------------------------------------------- 1 | 2 | // foo 3 | ul 4 | // bar 5 | li one 6 | // baz 7 | li two 8 | 9 | // 10 | ul 11 | li foo 12 | // block 13 | // inline follow 14 | li three 15 | // block 16 | // inline followed by tags 17 | ul 18 | li four 19 | //if IE lt 9 20 | // inline 21 | script(src='/lame.js') 22 | // end-inline 23 | p five 24 | 25 | .foo // not a comment 26 | comment id10t 27 | 28 | -------------------------------------------------------------------------------- /test/cases/conditionals.html: -------------------------------------------------------------------------------- 1 |

foo

bar

baz

yay

2 | -------------------------------------------------------------------------------- /test/cases/conditionals.min: -------------------------------------------------------------------------------- 1 | if true 2 | p foo 3 | p bar 4 | p baz 5 | else 6 | p bar 7 | 8 | if 'nested' 9 | if 'works' 10 | p yay 11 | 12 | if false 13 | else 14 | .bar 15 | if true 16 | .bar 17 | else 18 | .quxx 19 | 20 | if false 21 | .quxx 22 | else if false 23 | .bar 24 | else 25 | .foo 26 | 27 | -------------------------------------------------------------------------------- /test/cases/escape.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /test/cases/escape.min: -------------------------------------------------------------------------------- 1 | textarea 2 | -------------------------------------------------------------------------------- /test/cases/format.html: -------------------------------------------------------------------------------- 1 |

Hello world

2 | -------------------------------------------------------------------------------- /test/cases/format.min: -------------------------------------------------------------------------------- 1 | h1= 'Hello %s', 'world' 2 | -------------------------------------------------------------------------------- /test/cases/nest.html: -------------------------------------------------------------------------------- 1 |
2 | -------------------------------------------------------------------------------- /test/cases/nest.min: -------------------------------------------------------------------------------- 1 | .a 2 | .aa 3 | .ab 4 | .aba 5 | .abab 6 | .ababa 7 | .ac 8 | .b 9 | .ba 10 | .baa 11 | .bb 12 | .bc 13 | 14 | -------------------------------------------------------------------------------- /test/cases/pipeless-tag.html: -------------------------------------------------------------------------------- 1 |
    what
2 |   is going on
3 | 
4 | 
5 | -------------------------------------------------------------------------------- /test/cases/pipeless-tag.min: -------------------------------------------------------------------------------- 1 | pre. 2 | what 3 | is going on 4 | 5 | -------------------------------------------------------------------------------- /test/fixtures/cached.min: -------------------------------------------------------------------------------- 1 | .bar 2 | h1 = foo 3 | -------------------------------------------------------------------------------- /test/fixtures/mixins/mixin.min: -------------------------------------------------------------------------------- 1 | Foo 2 | h1 Foobar 3 | -------------------------------------------------------------------------------- /test/fixtures/mixins/withmixin.min: -------------------------------------------------------------------------------- 1 | include ./fixtures/mixins/mixin.min 2 | 3 | .bazz 4 | +Foo 5 | 6 | -------------------------------------------------------------------------------- /test/fixtures/mixins/withoutmixin.min: -------------------------------------------------------------------------------- 1 | .bazz 2 | +Foo 3 | 4 | -------------------------------------------------------------------------------- /test/fixtures/output-dirs/cli-input/a/index.min: -------------------------------------------------------------------------------- 1 | h1 Hello 2 | -------------------------------------------------------------------------------- /test/fixtures/output-dirs/cli-input/b/index.min: -------------------------------------------------------------------------------- 1 | h2 Hello 2 | -------------------------------------------------------------------------------- /test/fixtures/output-dirs/cli-input/c/index.min: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/voltraco/mineral/072c6b125e562d6e2b97d9e3ff5362998a0193a9/test/fixtures/output-dirs/cli-input/c/index.min -------------------------------------------------------------------------------- /test/fixtures/output-dirs/cli-output/a/index.html: -------------------------------------------------------------------------------- 1 |

Hello

-------------------------------------------------------------------------------- /test/fixtures/output-dirs/cli-output/b/index.html: -------------------------------------------------------------------------------- 1 |

Hello

-------------------------------------------------------------------------------- /test/fixtures/output-dirs/cli-output/c/index.html: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/voltraco/mineral/072c6b125e562d6e2b97d9e3ff5362998a0193a9/test/fixtures/output-dirs/cli-output/c/index.html -------------------------------------------------------------------------------- /test/fixtures/output-single/cli-input/a/a.min: -------------------------------------------------------------------------------- 1 | .bar 2 | -------------------------------------------------------------------------------- /test/fixtures/output-single/cli-input/index.min: -------------------------------------------------------------------------------- 1 | .foo 2 | include ./a/a.min 3 | 4 | -------------------------------------------------------------------------------- /test/fixtures/output-single/cli-output/index.html: -------------------------------------------------------------------------------- 1 |
-------------------------------------------------------------------------------- /test/mixins.js: -------------------------------------------------------------------------------- 1 | const test = require('tape') 2 | const fs = require('fs') 3 | const path = require('path') 4 | const min = require('../index') 5 | 6 | const base = path.join(__dirname, 'fixtures', 'mixins') 7 | 8 | test('mixin not found', assert => { 9 | const m = min.readFileSync('./fixtures/mixins/withoutmixin.min') 10 | try { 11 | m({}, base) 12 | } catch (ex) { 13 | const r = /Unknown mixin \(Foo\) in (.*):7:2/ 14 | assert.ok(r.test(ex.message)) 15 | assert.end() 16 | } 17 | }) 18 | 19 | test('mixin found', assert => { 20 | const m = min.readFileSync('./fixtures/mixins/withmixin.min') 21 | const actual = m({}, base) 22 | assert.equal(actual, '

Foobar

') 23 | assert.end() 24 | }) 25 | 26 | -------------------------------------------------------------------------------- /test/not-escaped.js: -------------------------------------------------------------------------------- 1 | const test = require('tape') 2 | const parse = require('../parser') 3 | const compile = require('../compilers/html') 4 | 5 | test('allow text to be unescaped', assert => { 6 | const m = parse(` 7 | span 8 | | "test check" & 'quot; 9 | ! "test check" & 'quot; 10 | | "test check" & 'quot; 11 | != val 12 | `) 13 | 14 | assert.equal( 15 | compile(m, { val: `"test check" & 'quot;` }), 16 | ` ` + 17 | `"test check" &amp; 'quot; ` + 18 | `"test check" & 'quot; ` + 19 | `"test check" ` + 20 | `&amp; 'quot; ` + 21 | `"test check" & 'quot;` + 22 | `` 23 | ) 24 | assert.end() 25 | }) 26 | -------------------------------------------------------------------------------- /test/script-not-escaped.js: -------------------------------------------------------------------------------- 1 | const test = require('tape') 2 | const browser = require('./browser') 3 | 4 | test('script should be unescaped', assert => { 5 | const source = ` 6 | script. 7 | alert('x') 8 | ` 9 | 10 | const opts = { 11 | text: true 12 | } 13 | 14 | assert.comment('script should not be escaped') 15 | 16 | browser(source, {}, opts, (err, html) => { 17 | if (err) console.log(err) 18 | assert.equal(html, ` alert(\'x\')`) 19 | assert.end() 20 | }) 21 | }) 22 | --------------------------------------------------------------------------------