From 0de9ddba5fd889887520aa9fd246130279671bfe Mon Sep 17 00:00:00 2001 From: Jeff Escalante Date: Thu, 7 Jul 2016 11:58:42 -0400 Subject: [PATCH 01/17] reboot --- index.js | 98 ----------------------------- lib/attrs.js | 18 ------ lib/each.js | 47 -------------- lib/exps.js | 29 --------- lib/get.js | 8 --- lib/if.js | 21 ------- lib/index.js | 44 +++++++++++++ lib/local.js | 11 ---- lib/parser.js | 65 +++++++++++++++++++ lib/part.js | 34 ---------- lib/pipe.js | 34 ---------- lib/style.js | 14 ----- package.json | 51 +++++---------- test/fixtures/basic.expected.html | 2 + test/fixtures/basic.html | 2 + test/fixtures/imports/button.html | 1 - test/fixtures/imports/template.html | 3 - test/fixtures/imports/text.txt | 1 - test/fixtures/index.html | 46 -------------- test/fixtures/locals.js | 43 ------------- test/index.js | 36 +++++------ 21 files changed, 145 insertions(+), 463 deletions(-) delete mode 100644 index.js delete mode 100644 lib/attrs.js delete mode 100644 lib/each.js delete mode 100644 lib/exps.js delete mode 100644 lib/get.js delete mode 100644 lib/if.js create mode 100644 lib/index.js delete mode 100644 lib/local.js create mode 100644 lib/parser.js delete mode 100644 lib/part.js delete mode 100644 lib/pipe.js delete mode 100644 lib/style.js create mode 100644 test/fixtures/basic.expected.html create mode 100644 test/fixtures/basic.html delete mode 100644 test/fixtures/imports/button.html delete mode 100644 test/fixtures/imports/template.html delete mode 100644 test/fixtures/imports/text.txt delete mode 100644 test/fixtures/index.html delete mode 100644 test/fixtures/locals.js diff --git a/index.js b/index.js deleted file mode 100644 index 53e3d4e..0000000 --- a/index.js +++ /dev/null @@ -1,98 +0,0 @@ -module.exports = function (options = {}) { - -const attrs = require('./lib/attrs') -const exps = require('./lib/exps') -const pipe = require('./lib/pipe') -const each = require('./lib/each') -const partial = require('./lib/part') - - if (typeof options.locals === 'string') { - options.locals = require(options.locals) - } - - let style = options.style || '{{' - let local = options.locals || {} - - return function PostHTMLExps (tree) { - tree.walk((node) => { - const attributes = node.attrs || {} - const content = node.content || [] - - let exp - - Object.keys(attributes).forEach(attr => { - exp = attributes[attr] - - if (exp.includes(style)) { - if (!exp.includes('.')) { - node.attrs[attr] = attrs(local, style, exp) - } - - if (exp.includes('.')) { - node.attrs[attr] = attrs(local, style, exp) - } - } - }) - - if (content.length === 1) { - if (typeof content[0] === 'string') { - exp = content[0].trim() - - if (content[0].includes(exp)) { - if (!exp.includes('.')) { - if (exp.includes(`${style}`) || - exp.includes(` ${style}`) && - !exp.includes(`>`) && - !exp.includes('|') && - !exp.includes('?') && - !exp.includes('...') - ) { - node.content = exps(local, style, exp, node.content[0]) - } - - // if (exp.includes('?')) { - // node.content = condition(local, style, exp) - // } - - if (exp.includes('|')) { - node.content = pipe(local, style, exp).trim() - } - - if (exp.includes('> ') || exp.includes(' > ')) { - node.content = partial(local, style, exp).trim() + '\n ' - } - - if (exp.includes('...')) { - return each(node, local, style, exp) - } - } - - if (exp.includes('.')) { - if (exp.includes(`${style}`) && - !exp.includes(`|`) && - !exp.includes(`>`) && - !exp.includes(`...`) - ) { - node.content = exps(local, style, exp, node.content[0]) - } - - if (exp.includes('|')) { - node.content = pipe(local, style, exp).trim() - } - - if (exp.includes(`> `)) { - node.content = partial(local, style, exp).trim() + '\n ' - } - - if (exp.includes('...')) { - return each(node, local, style, exp) - } - } - } - } - } - return node - }) - return tree - } -} diff --git a/lib/attrs.js b/lib/attrs.js deleted file mode 100644 index fdaf556..0000000 --- a/lib/attrs.js +++ /dev/null @@ -1,18 +0,0 @@ -const get = require('./get') -const locals = require('./local') - -module.exports = function (local, style, exp) { - let exps, attr - - if (!exp.includes('.')) { - exps = locals(style, exp) - attr = get(local, exps) - } - - if (exp.includes('.')) { - exps = locals(style, exp).split('.') - attr = get(local, exps) - } - - return attr -} diff --git a/lib/each.js b/lib/each.js deleted file mode 100644 index 5d72cd4..0000000 --- a/lib/each.js +++ /dev/null @@ -1,47 +0,0 @@ -const get = require('./get') -const locals = require('./local') - -module.exports = function (node, local, style, exp) { - let exps = locals(style, exp).replace('... ', '').trim() - - let nodes = [] - - if (!exp.includes('.')) { - for (let i = 0; i < get(local, exps).length; i++) { - if (typeof get(local, exps)[i] === 'object') { - Object.keys(get(local, exps)[i]).forEach(key => { - node.content = get(local, exps)[i][key] - nodes.push(' ', Object.assign({}, node), '\n ') - }) - } - - if (typeof get(local, exps)[i] === 'string') { - node.content = get(local, exps)[i] - nodes.push(' ', Object.assign({}, node), '\n ') - } - } - } - - if (exp.includes('.')) { - exps = locals(style, exp).replace('...', '').trim().split('.') - - for (let i = 0; i < get(local, exps).length; i++) { - if (typeof get(local, exps)[i] === 'object') { - Object.keys(get(local, exps)[i]).forEach(key => { - node.content = get(local, exps)[i][key] - nodes.push(' ', Object.assign({}, node), '\n ') - }) - } - - if (typeof get(local, exps)[i] === 'string') { - node.content = get(local, exps)[i] - nodes.push(' ', Object.assign({}, node), '\n ') - } - } - } - - nodes.pop() - nodes.shift() - - return nodes -} diff --git a/lib/exps.js b/lib/exps.js deleted file mode 100644 index 2aa46b7..0000000 --- a/lib/exps.js +++ /dev/null @@ -1,29 +0,0 @@ -const get = require('./get') -const locals = require('./local') - -exports = module.exports = function (local, style, exp, content) { - const exps = exp.split(' ') - - function isExp (element, index, array) { - if (element.match(/^{|{{|{%|@/)) { - return element - } - } - - if (!exp.includes('.')) { - exps.filter(isExp).forEach(ex => { - content = content.replace(ex, get(local, locals(style, ex))) - }) - } - - if (exp.includes('.')) { - let exprs - - exps.filter(isExp).forEach(ex => { - exprs = locals(style, ex).split('.') - content = content.replace(ex, get(local, exprs)) - }) - } - - return content -} diff --git a/lib/get.js b/lib/get.js deleted file mode 100644 index 35bcd80..0000000 --- a/lib/get.js +++ /dev/null @@ -1,8 +0,0 @@ -module.exports = function (locals, el) { - if (Array.isArray(el)) { - if (el.length === 2) return locals[el[0]][el[1]] - if (el.length === 3) return locals[el[0]][el[1]][el[2]] - if (el.length === 4) return locals[el[0]][el[1]][el[2]][el[3]] - } - return locals[el] -} diff --git a/lib/if.js b/lib/if.js deleted file mode 100644 index 08dc344..0000000 --- a/lib/if.js +++ /dev/null @@ -1,21 +0,0 @@ -const get = require('./get') -const locals = require('./local') - -module.exports = function (local, style, exp) { - const exps = locals(style, exp).replace('?', '').split(':') - let condition - - function isDefined (element, index, array) { - if (get(local, element) !== undefined) { - return element - } - } - - exps.filter(isDefined).map(ex => { - console.log(ex) - condition = get(local, ex) - console.log(condition) - }) - - return condition -} diff --git a/lib/index.js b/lib/index.js new file mode 100644 index 0000000..611d6fa --- /dev/null +++ b/lib/index.js @@ -0,0 +1,44 @@ +const vm = require('vm') +const parseAndReplace = require('./parser') + +let ctx, delimiters, delimiterRegex + +module.exports = function PostHTMLExpressions (options = {}) { + ctx = vm.createContext(options.locals) + delimiters = options.delimiters || ['{{', '}}'] + delimiterRegex = new RegExp(`.*${delimiters[0]}(.*)${delimiters[1]}.*`, 'g') + + return walk.bind(null, options) +} + +function walk (opts, nodes) { + // loop through each node in the tree + return nodes.map((node) => { + // if we have a string, match and replace it + if (typeof node !== 'object') { + // if there are any matches, we parse and replace the results + if (node.match(delimiterRegex)) { + node = parseAndReplace(ctx, delimiters, node) + } + return node + } + + // if not, we have an object, so we need to run the attributes and contents + if (node.attrs) { + for (let key in node.attrs) { + const val = node.attrs[key] + if (val.match(delimiterRegex)) { + node.attrs[key] = parseAndReplace(ctx, delimiters, val) + } + } + } + + // if the node has content, recurse + if (node.content) { + node.content = walk(opts, node.content) + } + + // return the modified node + return node + }) +} diff --git a/lib/local.js b/lib/local.js deleted file mode 100644 index f9ed62c..0000000 --- a/lib/local.js +++ /dev/null @@ -1,11 +0,0 @@ -module.exports = function (style, el) { - if (style === '{{') { - return el.replace('{{', '').replace('}}', '') - } else if (style === '{%') { - return el.replace('{%', '').replace('%}', '') - } else if (style === '@') { - return el.replace('@', '') - } - - return el.replace('{', '').replace('}', '') -} diff --git a/lib/parser.js b/lib/parser.js new file mode 100644 index 0000000..ae211fe --- /dev/null +++ b/lib/parser.js @@ -0,0 +1,65 @@ +const vm = require('vm') + +/** + * This is a full character-by-character parse. Might as well do it right, regex + * is just a little too janky. + */ +module.exports = function parseAndReplace (ctx, delimiters, input) { + let current = 0 + let char = input[current] + let buf = [] + + while (current < input.length) { + // If we can match the full open delimiter, we pull its contents so we can + // parse the expression + if (char === delimiters[0][0] && + matchDelimiter(char, delimiters[0]) === delimiters[0]) { + // Move past the open delimiter + next(delimiters[0].length) + + // Loop until we find the close delimiter + let expression = '' + while (matchDelimiter(char, delimiters[1]) !== delimiters[1]) { + expression += char + next() + } + + // move past the close delimiter + next(delimiters[1].length - 1) + + // evaluate the expression and push it to the output + // TODO: implement html escaping here + buf.push(vm.runInContext(expression.trim(), ctx)) + } else { + buf.push(char) + } + + next() + } + + // return the full string with expressions replaced + return buf.join('') + + // Utility: From the current character, looks ahead to pull back a potential + // delimiter match. + function matchDelimiter (c, d) { + return c + lookahead(d.length - 1) + } + + // Utility: Move to the next character in the parse + function next (n = 1) { + for (let i = 0; i < n; i++) { char = input[++current] } + } + + // Utility: looks ahead n characters and returns the result + function lookahead (n) { + let counter = current + const target = current + n + let res = '' + while (counter < target) { + res += input[counter] + counter++ + } + return res + } +} diff --git a/lib/part.js b/lib/part.js deleted file mode 100644 index fcba37f..0000000 --- a/lib/part.js +++ /dev/null @@ -1,34 +0,0 @@ -const fs = require('fs') -const get = require('./get') -const locals = require('./local') - -module.exports = function (local, style, exp) { - const exps = exp.replace('> ', '').split(' ') - let exprs, replace, partial - - function isExp (element, index, array) { - if (element.match(/^{|{{|{%|@/)) { - return element - } - } - - if (!exp.includes('.')) { - exps.filter(isExp).forEach(ex => { - exprs = locals(style, ex).replace('> ', '') - replace = ex.replace(`${style}`, `${style}> `) - partial = fs.readFileSync(get(local, locals(style, exprs)), 'utf8') - partial = exp.replace(replace, partial) - }) - } - - if (exp.includes('.')) { - exps.filter(isExp).forEach(ex => { - replace = ex.replace(`${style}`, `${style}> `) - exprs = locals(style, ex).replace('>', '').split('.') - partial = fs.readFileSync(get(local, exprs), 'utf8').trim() - partial = exp.replace(replace, partial) - }) - } - - return partial -} diff --git a/lib/pipe.js b/lib/pipe.js deleted file mode 100644 index 3c96d04..0000000 --- a/lib/pipe.js +++ /dev/null @@ -1,34 +0,0 @@ -const get = require('./get') -const locals = require('./local') - -module.exports = function (local, style, exp) { - const exps = locals(style, exp).split('|') - let pipe = '' - - if (!exp.includes('.')) { - exps.forEach(ex => { - pipe += get(local, ex) + ' ' - }) - } - - if (exp.includes('.')) { - let exprs - - exprs = exps.shift().split('.') - console.log(exprs) - exps.forEach(ex => { - let test = [] - - test.push(ex) - console.log(test) - let result = exprs.concat(test) - console.log(result) - - pipe += get(local, result) + ' ' - - console.log(pipe) - }) - } - - return pipe -} diff --git a/lib/style.js b/lib/style.js deleted file mode 100644 index 1e450f7..0000000 --- a/lib/style.js +++ /dev/null @@ -1,14 +0,0 @@ -module.exports = function (style, el) { - if (el) { - if (style === '$') { - return `\${${el}}` - } else if (style === '{{') { - return `{{${el}}}` - } else if (style === '{%') { - return `{%${el}%}` - } else if (style === '@') { - return `@${el}` - } - return `{${el}}` - } -} diff --git a/package.json b/package.json index 35e1460..34b58ca 100644 --- a/package.json +++ b/package.json @@ -1,51 +1,34 @@ { "name": "posthtml-exp", + "description": "Local variables, expressions, loops, and conditionals for posthtml", "version": "0.8.1", - "description": "Expressions for PostHTML", - "engines": {"node": ">=6"}, - "main": "index.js", - "scripts": { - "clean": "echo '=> Cleaning' && sudo rm -rf .nyc_output coverage", - "pretest": "echo '=> Linting' && standard", - "test": "echo '=> Testing' && npm run clean && nyc ava 'test/index.js'", - "start": "echo '=> Starting' && sudo npm test" - }, + "author": "Jeff Escalante", "ava": { "verbose": "true" }, - "nyc": { - "all": true, - "include": [ - "lib", - "test" - ], - "extension": [ - ".js" - ] + "bugs": { + "url": "https://github.com/posthtml/posthtml-exp/issues" }, - "dependencies": {}, "devDependencies": { "ava": "^0.15.2", - "nyc": "^6.6.1", - "standard": "^7.1.2" + "posthtml": "^0.9.0" + }, + "engines": { + "node": ">=6" }, + "homepage": "https://github.com/posthtml/posthtml-exp", "keywords": [ - "html", + "expressions", "posthtml", - "posthtmlplugin", - "expressions" + "posthtmlplugin" ], - "author": { - "name": "Michael Ciniawky", - "email": "michael.ciniawsky@gmail.com" - }, + "license": "MIT", + "main": "index.js", "repository": { "type": "git", - "url": "https://github.com/posthtml/posthtml-exp" - }, - "bugs": { - "url": "https://github.com/posthtml/posthtml-exp/issues" + "url": "https:/github.com/posthtml/posthtml-exp" }, - "homepage": "https://github.com/posthtml/posthtml-exp#readme", - "license": "MIT" + "scripts": { + "test": "ava" + } } diff --git a/test/fixtures/basic.expected.html b/test/fixtures/basic.expected.html new file mode 100644 index 0000000..e654e59 --- /dev/null +++ b/test/fixtures/basic.expected.html @@ -0,0 +1,2 @@ +x wow x 2 x +

x wow x

diff --git a/test/fixtures/basic.html b/test/fixtures/basic.html new file mode 100644 index 0000000..87c5439 --- /dev/null +++ b/test/fixtures/basic.html @@ -0,0 +1,2 @@ +x {{ test }} x {{ 1 + 1 }} x +

x {{ test }} x

diff --git a/test/fixtures/imports/button.html b/test/fixtures/imports/button.html deleted file mode 100644 index 9d5164e..0000000 --- a/test/fixtures/imports/button.html +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/test/fixtures/imports/template.html b/test/fixtures/imports/template.html deleted file mode 100644 index f46fb8d..0000000 --- a/test/fixtures/imports/template.html +++ /dev/null @@ -1,3 +0,0 @@ - \ No newline at end of file diff --git a/test/fixtures/imports/text.txt b/test/fixtures/imports/text.txt deleted file mode 100644 index cee7a92..0000000 --- a/test/fixtures/imports/text.txt +++ /dev/null @@ -1 +0,0 @@ -Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. diff --git a/test/fixtures/index.html b/test/fixtures/index.html deleted file mode 100644 index 1eb31d2..0000000 --- a/test/fixtures/index.html +++ /dev/null @@ -1,46 +0,0 @@ - - - - - - PostHTML Title - - - -

{{firstname}} {{middlename}} {{lastname}}

-

My name is {{firstname}} {{middlename}} {{lastname}}

-

My name is {{fullname.firstname}} {{fullname.lastname}}

- -
{{? firstname : lastname}}
- - -
{{article.content|title|text}}
-
{{article.content.chapter|title|text}}
- -
{{> button}}
-
TEXT: {{> button}}
-
HTML: {{> button}}
- -
Nested TEXT: {{> imports.button}}
-
Nested HTML: {{> imports.button}}
- - -
-

{{article.title}}

-

{{article.content.title}}

-
- - - - - - - - - diff --git a/test/fixtures/locals.js b/test/fixtures/locals.js deleted file mode 100644 index 6c0aabc..0000000 --- a/test/fixtures/locals.js +++ /dev/null @@ -1,43 +0,0 @@ -module.exports = { - test: 'Hello', - pipe: 'World!', - firstname: 'Post', - middlename: 'HTML', - lastname: 'Exps', - fullname: { - firstname: 'Post', - middlename: 'HTML', - lastname: 'Exps' - }, - text: './imports/text.txt', - button: './imports/button.html', - imports: { - text: './imports/text.txt', - button: './imports/button.html', - include: { - button: './imports/button.html' - } - }, - items: [{name: 'Hans', age: 65}, 'Two', 'Three'], - list: { - items: [{name: 'Hans', age: 65}, 'Two', 'Three'] - }, - note: { - list: { - items: [ - {name: 'Hans', age: 65}, 'Two', 'Three' - ] - } - }, - article: { - title: 'Article Title', - content: { - title: 'Content Title', - text: 'Hello World!', - chapter: { - title: '

Chapter Title

\n', - text: '

./imports/text.txt

' - } - } - } -} diff --git a/test/index.js b/test/index.js index 9b50f78..21f74d9 100644 --- a/test/index.js +++ b/test/index.js @@ -1,25 +1,19 @@ -// ------------------------------------ -// #POSTHTML - EXP - TEST -// ------------------------------------ - -'use strict' - const test = require('ava') - -const { join } = require('path') -const { readFileSync } = require('fs') - -const fixtures = (file) => readFileSync(join(__dirname, 'fixtures', file), 'utf8') -const expected = (file) => readFileSync(join(__dirname, 'expected', file), 'utf8') - +const path = require('path') const posthtml = require('posthtml') -const plugin = require('..') +const exp = require('../lib') +const {readFileSync} = require('fs') +const fixtures = path.join(__dirname, 'fixtures') -test('', (t) => { - posthtml([ plugin({ style: '{{', locals: './test/locals.js' }) ]) - .process(fixtures('index.html')) - .then((result) => { - console.log(result.html) - // t.is(expected('index.html'), result.html) - }) +test('basic', (t) => { + return matchExpected(t, 'basic', { locals: { test: 'wow' } }) }) + +function matchExpected (t, name, config) { + const html = readFileSync(path.join(fixtures, `${name}.html`), 'utf8') + const expected = readFileSync(path.join(fixtures, `${name}.expected.html`), 'utf8') + + return posthtml([exp(config)]) + .process(html) + .then((res) => { t.truthy(res.html === expected) }) +} From 9c281844ab23f4c5b2faaaf2edae70efbd04f9da Mon Sep 17 00:00:00 2001 From: Jeff Escalante Date: Thu, 7 Jul 2016 12:17:47 -0400 Subject: [PATCH 02/17] clean up template files --- .editorconfig | 3 -- .gitignore | 5 --- .npmignore | 6 --- .travis.yml | 16 ++------ CONTRIBUTING.md | 41 ++++++-------------- LICENSE | 2 +- README.md | 100 +++++++++++------------------------------------- 7 files changed, 40 insertions(+), 133 deletions(-) diff --git a/.editorconfig b/.editorconfig index 5553459..57cf0ec 100644 --- a/.editorconfig +++ b/.editorconfig @@ -8,8 +8,5 @@ indent_style = space trim_trailing_whitespace = true insert_final_newline = true -[{bower,package}.json] -indent_size = 2 - [*.md] trim_trailing_whitespace = false diff --git a/.gitignore b/.gitignore index a4580cc..e00fa5e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,4 @@ -# OS - ._* .DS_Store - -# NODEJS - node_modules npm-debug.log diff --git a/.npmignore b/.npmignore index 37bb2ca..bc0451b 100644 --- a/.npmignore +++ b/.npmignore @@ -1,11 +1,5 @@ -# FILES - .editorconfig npm-debug.log - test.js - -# DIRECTORIES - test node_modules diff --git a/.travis.yml b/.travis.yml index e1cd325..5bbca54 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,14 +1,6 @@ -sudo: true language: node_js +sudo: false node_js: - - v6 - - v4 -cache: - directories: - - node_modules -before_script: - - npm i -script: - - npm test -after_success: -- './node_modules/.bin/nyc report --reporter=text-lcov | ./node_modules/.bin/coveralls' + - 6 +after_script: + - npm run coveralls diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a383569..c80cf3e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,11 +4,9 @@ Contributions welcome! **Before spending lots of time on something, ask for feedback on your idea first!** -Please search issues and pull requests before adding something new to avoid duplicating -efforts and conversations. +Please search issues and pull requests before adding something new to avoid duplicating efforts and conversations. -This project welcomes non-code contributions, too! The following types of contributions -are welcome: +This project welcomes non-code contributions, too! The following types of contributions are welcome: - **Ideas**: participate in an issue thread or start your own to have your voice heard. - **Writing**: contribute your expertise in an area by helping expand the included docs. @@ -25,9 +23,7 @@ to! ## Project Governance -Individuals making significant and valuable contributions are given commit-access to the -project to contribute as they see fit. This project is more like an open wiki than a -standard guarded open source project. +Individuals making significant and valuable contributions are given commit-access to the project to contribute as they see fit. This project is more like an open wiki than a standard guarded open source project. ### Rules @@ -35,10 +31,8 @@ There are a few basic ground-rules for contributors: 1. **No `--force` pushes** or modifying the Git history in any way. 2. **Non-master branches** should be used for ongoing work. -3. **Significant modifications** like API changes should be subject to a **pull request** - to solicit feedback from other contributors. -4. **Pull requests** are *encouraged* for all contributions to solicit feedback, but left to - the discretion of the contributor. +3. **Significant modifications** like API changes should be subject to a **pull request** to solicit feedback from other contributors. +4. **Pull requests** are *encouraged* for all contributions to solicit feedback, but left to the discretion of the contributor. ### Releases @@ -46,30 +40,19 @@ Declaring formal releases remains the prerogative of the project maintainer. ### Changes to this arrangement -This is an experiment and feedback is welcome! This document may also be subject to pull- -requests or changes by contributors where you believe you have something valuable to add -or change. +This is an experiment and feedback is welcome! This document may also be subject to pull-requests or changes by contributors where you believe you have something valuable to add or change. ## Developer's Certificate of Origin 1.1 By making a contribution to this project, I certify that: -- (a) The contribution was created in whole or in part by me and I have the right to - submit it under the open source license indicated in the file; or +- (a) The contribution was created in whole or in part by me and I have the right to submit it under the open source license indicated in the file; or -- (b) The contribution is based upon previous work that, to the best of my knowledge, is - covered under an appropriate open source license and I have the right under that license - to submit that work with modifications, whether created in whole or in part by me, under - the same open source license (unless I am permitted to submit under a different - license), as indicated in the file; or +- (b) The contribution is based upon previous work that, to the best of my knowledge, is covered under an appropriate open source license and I have the right under that license to submit that work with modifications, whether created in whole or in part by me, under the same open source license (unless I am permitted to submit under a different license), as indicated in the file; or -- (c) The contribution was provided directly to me by some other person who certified - (a), (b) or (c) and I have not modified it. +- (c) The contribution was provided directly to me by some other person who certified (a), (b) or (c) and I have not modified it. -- (d) I understand and agree that this project and the contribution are public and that a - record of the contribution (including all personal information I submit with it, - including my sign-off) is maintained indefinitely and may be redistributed consistent - with this project or the open source license(s) involved. +- (d) I understand and agree that this project and the contribution are public and that a record of the contribution (including all personal information I submit with it, including my sign-off) is maintained indefinitely and may be redistributed consistent with this project or the open source license(s) involved. - [standard-image]: https://cdn.rawgit.com/feross/standard/master/badge.svg - [standard-url]: https://github.com/feross/standard +[standard-image]: https://cdn.rawgit.com/feross/standard/master/badge.svg +[standard-url]: https://github.com/feross/standard diff --git a/LICENSE b/LICENSE index 4ddc5ad..f17d9e3 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License (MIT) -Copyright (c) 2016 Michael Ciniawsky +Copyright (c) 2016 Jeff Escalante, Michael Ciniawsky Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 625bd11..2c1f2c8 100644 --- a/README.md +++ b/README.md @@ -1,34 +1,29 @@ +# PostHTML Expressions + [![NPM][npm]][npm-url] [![Deps][deps]][deps-url] [![Tests][travis]][travis-url] [![Coverage][cover]][cover-url] [![Standard Code Style][style]][style-url] -
- -

Expressions Plugin

-

Local variables, expressions, loops, conditionals and unicorns 👍

-
- -

Install

+Local variables, expressions, loops, and conditionals in your html. -```bash -npm i -D posthtml-exp -``` +## Installation -

Usage

+First, install from npm with `npm i posthtml-exp --save`, then add it as a plugin to your posthtml pipeline: ```js -const { readFileSync } = require('fs') - const posthtml = require('posthtml') const exp = require('posthtml-exp') +const {readFileSync} = require('fs') posthtml(exp({ locals: { foo: 'bar' } })) - .process(readFileSync('index.html', 'utf8')) - .then((result) => console.log(result.html)) + .process(readFileSync('exampleFile.html', 'utf8')) + .then(console.log) ``` +## Usage + This plugin provides a syntax for including local variables and expressions in your templates, and also extends custom tags to act as helpers for conditionals and looping. You have full control over the delimiters used for injecting locals, as well as the tag names for the conditional and loop helpers, if you need them. All options that can be passed to the `exp` plugin are shown below: @@ -47,22 +42,22 @@ You can inject locals into any piece of content in your html templates, other th ```js exp({ - locals: { class: 'intro', name: 'Jeff' } + locals: { myClassName: 'introduction', myName: 'Jeff' } }) ``` And compiled with the following template: ```html -
- My name is {{name}} +
+ My name is {{ myName }}
``` You would get this as your output: ```html -
+
My name is Jeff
``` @@ -73,14 +68,14 @@ By default, special characters will be escaped so that they show up as text, rat ```js exp({ - locals: { statement: 'wow!' } + locals: { strongStatement: 'wow!' } }) ``` And you rendered it into a tag like this: ```html -

The fox said, {{ statement }}

+

The fox said, {{ strongStatement }}

``` You would see the following output: @@ -92,8 +87,7 @@ You would see the following output: In your browser, you would see the angle brackets, and it would appear as intended. However, if you wanted it instead to be parsed as html, you would need to use the `unescapeDelimiters`, which by default are three curly brackets, like this: ```html -

The fox said, {{{ statement }}}

- +

The fox said, {{{ strongStatement }}}

``` In this case, your code would render as html: @@ -112,7 +106,6 @@ You are not limited to just directly rendering local variables either, you can i With this in mind, it is strongly recommended to limit the number and complexity of expressions that are run directly in your template. You can always move the logic back to your config file and provide a function to the locals object for a smoother and easier result. For example: - ```js exp({ locals: { @@ -159,7 +152,7 @@ Your result would be only this: Anything in the `condition` attribute is evaluated directly as an expression. -It should be noted that this is slightly cleaner-looking if you are using the [SugarML](https://github.com/posthtml/sugarml). But then again so is every other part of html. +It should be noted that this is slightly cleaner-looking if you are using the [SugarML parser](https://github.com/posthtml/sugarml). But then again so is every other part of html. ```sml if(condition="foo === 'bar'") @@ -177,8 +170,8 @@ You can use the `each` tag to build loops. It works with both arrays and objects ```js exp({ locals: { - array: ['foo', 'bar'], - object: { foo: 'bar' } + anArray: ['foo', 'bar'], + anObject: { foo: 'bar' } } }) ``` @@ -222,57 +215,10 @@ So this would also be fine: So you don't need to declare all the available variables (in this case, the index is skipped), and the expression after `in` doesn't need to be a local variable, it can be any expression. -

Example

- -```js -const { readFileSync } = require('fs') - -const posthtml = require('posthtml') -const exp = require('posthtml-exp') - -const html = readFileSync('./index.html', 'utf8') - -posthtml([ exp ]) - .process(html) - .then(result => console.log(result.html)) -``` - -###### Input - -```html - -``` - -###### Output - -```html - -``` - -

LICENSE

- -> MIT License (MIT) - -> Copyright (c) 2016 PostHTML Jeff Escalante - Michael Ciniawsky - -> 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: - -> The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. +### License & Contributing -> 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. +- Licensed under [MIT](LICENSE) +- See [guidelines for contribution](CONTRIBUTING.md) [npm]: https://img.shields.io/npm/v/posthtml-exp.svg [npm-url]: https://npmjs.com/package/posthtml-exp From 594aaa07dfe5dc29f063240f11b3183560e39072 Mon Sep 17 00:00:00 2001 From: Jeff Escalante Date: Thu, 7 Jul 2016 13:49:00 -0400 Subject: [PATCH 03/17] html entity escpaing --- lib/parser.js | 36 ++++++++++++++++++++++--- test/fixtures/escape_html.expected.html | 4 +++ test/fixtures/escape_html.html | 4 +++ test/index.js | 7 ++++- 4 files changed, 47 insertions(+), 4 deletions(-) create mode 100644 test/fixtures/escape_html.expected.html create mode 100644 test/fixtures/escape_html.html diff --git a/lib/parser.js b/lib/parser.js index ae211fe..22a1bd3 100644 --- a/lib/parser.js +++ b/lib/parser.js @@ -4,7 +4,7 @@ const vm = require('vm') * This is a full character-by-character parse. Might as well do it right, regex * is just a little too janky. */ -module.exports = function parseAndReplace (ctx, delimiters, input) { +module.exports = function parseAndReplace (ctx, delimiters, input, escape = true) { let current = 0 let char = input[current] let buf = [] @@ -28,8 +28,13 @@ module.exports = function parseAndReplace (ctx, delimiters, input) { next(delimiters[1].length - 1) // evaluate the expression and push it to the output - // TODO: implement html escaping here - buf.push(vm.runInContext(expression.trim(), ctx)) + let expressionEval = vm.runInContext(expression.trim(), ctx) + + // escape html if necessary + if (escape) expressionEval = escapeHtml(expressionEval) + + // push the full evaluated/escaped expression to the output buffer + buf.push(expressionEval) } else { buf.push(char) } @@ -62,4 +67,29 @@ module.exports = function parseAndReplace (ctx, delimiters, input) { } return res } + + // Utility: shamelessly stolen from jade/pug's runtime + function escapeHtml (input) { + const htmlRegex = /["&<>]/ + const regexResult = htmlRegex.exec(input) + if (!regexResult) return input + if (!input.match(htmlRegex)) return input + + let result = '' + let i, lastIndex, escape + for (i = regexResult.index, lastIndex = 0; i < input.length; i++) { + switch (input.charCodeAt(i)) { + case 34: escape = '"'; break + case 38: escape = '&'; break + case 60: escape = '<'; break + case 62: escape = '>'; break + default: continue + } + if (lastIndex !== i) result += input.substring(lastIndex, i) + lastIndex = i + 1 + result += escape + } + if (lastIndex !== i) return result + input.substring(lastIndex, i) + else return result + } } diff --git a/test/fixtures/escape_html.expected.html b/test/fixtures/escape_html.expected.html new file mode 100644 index 0000000..59cffee --- /dev/null +++ b/test/fixtures/escape_html.expected.html @@ -0,0 +1,4 @@ +x&x +x"x +x<x +x>x diff --git a/test/fixtures/escape_html.html b/test/fixtures/escape_html.html new file mode 100644 index 0000000..a502961 --- /dev/null +++ b/test/fixtures/escape_html.html @@ -0,0 +1,4 @@ +{{ 'x&x' }} +{{ 'x"x' }} +x{{ lt }}x +x{{ gt }}x diff --git a/test/index.js b/test/index.js index 21f74d9..90ee31b 100644 --- a/test/index.js +++ b/test/index.js @@ -9,11 +9,16 @@ test('basic', (t) => { return matchExpected(t, 'basic', { locals: { test: 'wow' } }) }) -function matchExpected (t, name, config) { +test('escaped html', (t) => { + return matchExpected(t, 'escape_html', { locals: { lt: '<', gt: '>' } }) +}) + +function matchExpected (t, name, config, log = false) { const html = readFileSync(path.join(fixtures, `${name}.html`), 'utf8') const expected = readFileSync(path.join(fixtures, `${name}.expected.html`), 'utf8') return posthtml([exp(config)]) .process(html) + .then((res) => { log && console.log(res.html); return res }) .then((res) => { t.truthy(res.html === expected) }) } From 620031c953929ace17f8d6857708ac412447f1d6 Mon Sep 17 00:00:00 2001 From: Jeff Escalante Date: Thu, 7 Jul 2016 17:03:45 -0400 Subject: [PATCH 04/17] unescaped buffered code --- lib/index.js | 19 +++++++-- lib/parser.js | 60 ++++++++++++++++----------- test/fixtures/unescaped.expected.html | 2 + test/fixtures/unescaped.html | 2 + test/index.js | 6 +++ 5 files changed, 60 insertions(+), 29 deletions(-) create mode 100644 test/fixtures/unescaped.expected.html create mode 100644 test/fixtures/unescaped.html diff --git a/lib/index.js b/lib/index.js index 611d6fa..38de86b 100644 --- a/lib/index.js +++ b/lib/index.js @@ -1,13 +1,23 @@ const vm = require('vm') const parseAndReplace = require('./parser') -let ctx, delimiters, delimiterRegex +let ctx, delimiters, unescapeDelimiters, delimiterRegex, unescapeDelimiterRegex module.exports = function PostHTMLExpressions (options = {}) { + // the context in which expressions are evaluated ctx = vm.createContext(options.locals) + + // set up delimiter options and detection delimiters = options.delimiters || ['{{', '}}'] + unescapeDelimiters = options.unescapeDelimiters || ['{{{', '}}}'] delimiterRegex = new RegExp(`.*${delimiters[0]}(.*)${delimiters[1]}.*`, 'g') + unescapeDelimiterRegex = new RegExp(`.*${unescapeDelimiters[0]}(.*)${unescapeDelimiters[1]}.*`, 'g') + + // identification for delimiter options, for the parser + delimiters.push('escaped') + unescapeDelimiters.push('unescaped') + // kick off the parsing return walk.bind(null, options) } @@ -17,8 +27,9 @@ function walk (opts, nodes) { // if we have a string, match and replace it if (typeof node !== 'object') { // if there are any matches, we parse and replace the results - if (node.match(delimiterRegex)) { - node = parseAndReplace(ctx, delimiters, node) + if (node.match(delimiterRegex) || node.match(unescapeDelimiterRegex)) { + // TODO: this could be optimized by starting at the regex match index + node = parseAndReplace(ctx, [delimiters, unescapeDelimiters], node) } return node } @@ -28,7 +39,7 @@ function walk (opts, nodes) { for (let key in node.attrs) { const val = node.attrs[key] if (val.match(delimiterRegex)) { - node.attrs[key] = parseAndReplace(ctx, delimiters, val) + node.attrs[key] = parseAndReplace(ctx, [delimiters, unescapeDelimiters], val) } } } diff --git a/lib/parser.js b/lib/parser.js index 22a1bd3..93a2190 100644 --- a/lib/parser.js +++ b/lib/parser.js @@ -9,36 +9,47 @@ module.exports = function parseAndReplace (ctx, delimiters, input, escape = true let char = input[current] let buf = [] + // We arrange delimiter search order by length, since it's possible that one + // delimiter could 'contain' another delimiter, like '{{' and '{{{'. But if + // you sort by length, the longer one will always match first. + delimiters = delimiters.sort((d) => d.length) + while (current < input.length) { - // If we can match the full open delimiter, we pull its contents so we can - // parse the expression - if (char === delimiters[0][0] && - matchDelimiter(char, delimiters[0]) === delimiters[0]) { - // Move past the open delimiter - next(delimiters[0].length) - - // Loop until we find the close delimiter - let expression = '' - while (matchDelimiter(char, delimiters[1]) !== delimiters[1]) { - expression += char - next() - } + // Since we are matching multiple sets of delimiters, we need to run a loop + // here to match each one. + for (let i = 0; i < delimiters.length; i++) { + // current delimiter set + const d = delimiters[i] + + // If we can match the full open delimiter, we pull its contents so we can + // parse the expression + if (char === d[0][0] && matchDelimiter(char, d[0]) === d[0]) { + // Move past the open delimiter + next(d[0].length) - // move past the close delimiter - next(delimiters[1].length - 1) + // Loop until we find the close delimiter + let expression = '' + while (matchDelimiter(char, d[1]) !== d[1]) { + expression += char + next() + } - // evaluate the expression and push it to the output - let expressionEval = vm.runInContext(expression.trim(), ctx) + // move past the close delimiter + next(d[1].length) - // escape html if necessary - if (escape) expressionEval = escapeHtml(expressionEval) + // evaluate the expression and push it to the output + let expressionEval = vm.runInContext(expression.trim(), ctx) - // push the full evaluated/escaped expression to the output buffer - buf.push(expressionEval) - } else { - buf.push(char) + // escape html if necessary + if (d[2] === 'escaped') expressionEval = escapeHtml(expressionEval) + + // push the full evaluated/escaped expression to the output buffer + buf.push(expressionEval) + } } + buf.push(char) + next() } @@ -62,8 +73,7 @@ module.exports = function parseAndReplace (ctx, delimiters, input, escape = true const target = current + n let res = '' while (counter < target) { - res += input[counter] - counter++ + res += input[++counter] } return res } diff --git a/test/fixtures/unescaped.expected.html b/test/fixtures/unescaped.expected.html new file mode 100644 index 0000000..491b13d --- /dev/null +++ b/test/fixtures/unescaped.expected.html @@ -0,0 +1,2 @@ +y x&x y x&x y +wow diff --git a/test/fixtures/unescaped.html b/test/fixtures/unescaped.html new file mode 100644 index 0000000..5bbf14f --- /dev/null +++ b/test/fixtures/unescaped.html @@ -0,0 +1,2 @@ +y {{ 'x&x' }} y {{{ 'x&x' }}} y +{{{ el }}} diff --git a/test/index.js b/test/index.js index 90ee31b..ee3362c 100644 --- a/test/index.js +++ b/test/index.js @@ -13,6 +13,12 @@ test('escaped html', (t) => { return matchExpected(t, 'escape_html', { locals: { lt: '<', gt: '>' } }) }) +test('unescaped', (t) => { + return matchExpected(t, 'unescaped', { + locals: { el: 'wow' } + }) +}) + function matchExpected (t, name, config, log = false) { const html = readFileSync(path.join(fixtures, `${name}.html`), 'utf8') const expected = readFileSync(path.join(fixtures, `${name}.expected.html`), 'utf8') From 971223d3af18920d50ac14233c6f6bb43bc1923b Mon Sep 17 00:00:00 2001 From: Jeff Escalante Date: Thu, 7 Jul 2016 17:15:00 -0400 Subject: [PATCH 05/17] spacing test --- test/fixtures/expression_spacing.expected.html | 2 ++ test/fixtures/expression_spacing.html | 2 ++ test/index.js | 4 ++++ 3 files changed, 8 insertions(+) create mode 100644 test/fixtures/expression_spacing.expected.html create mode 100644 test/fixtures/expression_spacing.html diff --git a/test/fixtures/expression_spacing.expected.html b/test/fixtures/expression_spacing.expected.html new file mode 100644 index 0000000..d0b86a5 --- /dev/null +++ b/test/fixtures/expression_spacing.expected.html @@ -0,0 +1,2 @@ +x X x X x X x X x +xXxXxXxXx diff --git a/test/fixtures/expression_spacing.html b/test/fixtures/expression_spacing.html new file mode 100644 index 0000000..3781235 --- /dev/null +++ b/test/fixtures/expression_spacing.html @@ -0,0 +1,2 @@ +x {{foo}} x {{ foo }} x {{{ foo }}} x {{{foo}}} x +x{{foo}}x{{ foo }}x{{{ foo }}}x{{{foo}}}x diff --git a/test/index.js b/test/index.js index ee3362c..342fda2 100644 --- a/test/index.js +++ b/test/index.js @@ -19,6 +19,10 @@ test('unescaped', (t) => { }) }) +test('expression spacing', (t) => { + return matchExpected(t, 'expression_spacing', { locals: { foo: 'X' } }) +}) + function matchExpected (t, name, config, log = false) { const html = readFileSync(path.join(fixtures, `${name}.html`), 'utf8') const expected = readFileSync(path.join(fixtures, `${name}.expected.html`), 'utf8') From b83a770bd918f262ea820eb2878d97c517770dfb Mon Sep 17 00:00:00 2001 From: Jeff Escalante Date: Thu, 7 Jul 2016 18:06:24 -0400 Subject: [PATCH 06/17] basic if logic --- lib/index.js | 16 ++++++++++++++-- test/fixtures/conditional.expected.html | 1 + test/fixtures/conditional.html | 3 +++ test/index.js | 10 +++++++++- 4 files changed, 27 insertions(+), 3 deletions(-) create mode 100644 test/fixtures/conditional.expected.html create mode 100644 test/fixtures/conditional.html diff --git a/lib/index.js b/lib/index.js index 38de86b..a77eff9 100644 --- a/lib/index.js +++ b/lib/index.js @@ -1,7 +1,7 @@ const vm = require('vm') const parseAndReplace = require('./parser') -let ctx, delimiters, unescapeDelimiters, delimiterRegex, unescapeDelimiterRegex +let ctx, delimiters, unescapeDelimiters, delimiterRegex, unescapeDelimiterRegex, conditionals module.exports = function PostHTMLExpressions (options = {}) { // the context in which expressions are evaluated @@ -17,13 +17,16 @@ module.exports = function PostHTMLExpressions (options = {}) { delimiters.push('escaped') unescapeDelimiters.push('unescaped') + // conditional and loop options + conditionals = options.conditionalTags || ['if', 'elseif', 'else'] + // kick off the parsing return walk.bind(null, options) } function walk (opts, nodes) { // loop through each node in the tree - return nodes.map((node) => { + return nodes.map((node, i) => { // if we have a string, match and replace it if (typeof node !== 'object') { // if there are any matches, we parse and replace the results @@ -44,6 +47,15 @@ function walk (opts, nodes) { } } + // if we have an element matching "if", we've got a conditional + if (node.tag === conditionals[0] && node.attrs && node.attrs.condition) { + // run the condition + if (vm.runInContext(node.attrs.condition, ctx)) { + nodes.splice(i, 1, ...node.content) + return '' + } + } + // if the node has content, recurse if (node.content) { node.content = walk(opts, node.content) diff --git a/test/fixtures/conditional.expected.html b/test/fixtures/conditional.expected.html new file mode 100644 index 0000000..e8320e1 --- /dev/null +++ b/test/fixtures/conditional.expected.html @@ -0,0 +1 @@ +

it works!

diff --git a/test/fixtures/conditional.html b/test/fixtures/conditional.html new file mode 100644 index 0000000..61efb66 --- /dev/null +++ b/test/fixtures/conditional.html @@ -0,0 +1,3 @@ + +

it works!

+
diff --git a/test/index.js b/test/index.js index 342fda2..ec625e0 100644 --- a/test/index.js +++ b/test/index.js @@ -23,6 +23,14 @@ test('expression spacing', (t) => { return matchExpected(t, 'expression_spacing', { locals: { foo: 'X' } }) }) +test('conditional', (t) => { + return matchExpected(t, 'conditional', { locals: { foo: 'bar' } }) +}) + +// +// Utility +// + function matchExpected (t, name, config, log = false) { const html = readFileSync(path.join(fixtures, `${name}.html`), 'utf8') const expected = readFileSync(path.join(fixtures, `${name}.expected.html`), 'utf8') @@ -30,5 +38,5 @@ function matchExpected (t, name, config, log = false) { return posthtml([exp(config)]) .process(html) .then((res) => { log && console.log(res.html); return res }) - .then((res) => { t.truthy(res.html === expected) }) + .then((res) => { t.truthy(res.html.trim() === expected.trim()) }) } From dbaa6cd285db28ad8fd3df934b4157659d7f9a5a Mon Sep 17 00:00:00 2001 From: Jeff Escalante Date: Fri, 8 Jul 2016 14:20:39 -0400 Subject: [PATCH 07/17] basic conditional logic --- lib/index.js | 80 +++++++++++++++++++++++-- test/fixtures/conditional.expected.html | 7 ++- test/fixtures/conditional.html | 11 ++++ 3 files changed, 91 insertions(+), 7 deletions(-) diff --git a/lib/index.js b/lib/index.js index a77eff9..2f7a4d2 100644 --- a/lib/index.js +++ b/lib/index.js @@ -47,8 +47,68 @@ function walk (opts, nodes) { } } + // if the node has content, recurse + if (node.content) { + node.content = walk(opts, node.content) + } + // if we have an element matching "if", we've got a conditional - if (node.tag === conditionals[0] && node.attrs && node.attrs.condition) { + // this comes after the recursion to correctly handle nested loops + if (node.tag === conditionals[0]) { + // throw an error if it's missing the "condition" attribute + if (!node.attrs && !node.attrs.condition) { + throw new Error('the "if" tag must have a "condition" attribute') + } + + // build the expression object, which we will turn into js and eval later + const ast = [{ + statement: 'if', + condition: node.attrs.condition, + content: node.content + }] + + // move through the nodes and collect all others that are part of the same + // conditional statement + let [current, nextTag] = getNextTag(nodes, ++i) + while (conditionals.slice(-2).indexOf(nextTag.tag) > -1) { + const obj = { statement: nextTag.tag, content: nextTag.content } + + // ensure the "else" tag is represented in our little AST as 'else', + // even if a custom tag was used + if (nextTag.tag === conditionals[2]) obj.statement = 'else' + + // add the condition if it's an else if + if (nextTag.tag === conditionals[1]) { + // throw an error if an "else if" is missing a condition + if (!nextTag.attrs && !nextTag.attrs.condition) { + throw new Error('the "elseif" tag must have a "condition" attribute') + } + obj.condition = nextTag.attrs.condition + + // while we're here, expand "elseif" to "else if" + obj.statement = 'else if' + } + ast.push(obj) + + ;[current, nextTag] = getNextTag(nodes, ++current) + } + + // format into an expression + const expression = ast.reduce((m, e, i) => { + m += e.statement + if (e.condition) m += ` (${e.condition})` + m += ` { ${i} } ` + return m + }, '') + + // evaluate the expression, get the winning node + const expResult = ast[vm.runInContext(expression, ctx)].content + + // remove all of the conditional tags from the tree + // we subtract 1 from i as it's incremented from the initial if statement + // in order to get the next node + nodes.splice(i - 1, current - i, ...expResult) + // run the condition if (vm.runInContext(node.attrs.condition, ctx)) { nodes.splice(i, 1, ...node.content) @@ -56,12 +116,20 @@ function walk (opts, nodes) { } } - // if the node has content, recurse - if (node.content) { - node.content = walk(opts, node.content) - } - // return the modified node return node }) } + +function getNextTag (nodes, i, nodeCount) { + // loop until we get the next tag (bypassing newlines etc) + while (i < nodes.length) { + const node = nodes[i] + if (typeof node === 'object') { + return [i, node] + } else { + i++ + } + } + return [i, { tag: undefined }] +} diff --git a/test/fixtures/conditional.expected.html b/test/fixtures/conditional.expected.html index e8320e1..3638141 100644 --- a/test/fixtures/conditional.expected.html +++ b/test/fixtures/conditional.expected.html @@ -1 +1,6 @@ -

it works!

+

x

+ +

it works!

+ + +

x

diff --git a/test/fixtures/conditional.html b/test/fixtures/conditional.html index 61efb66..d3491df 100644 --- a/test/fixtures/conditional.html +++ b/test/fixtures/conditional.html @@ -1,3 +1,14 @@ +

x

it works!

+ +

it doesn't work

+
+ +

it really doesn't work

+
+ +

it definitely doesn't work

+
+

x

From cf7cda94b81f14a90a68c604f97fdcdd20353a80 Mon Sep 17 00:00:00 2001 From: Jeff Escalante Date: Fri, 8 Jul 2016 15:48:48 -0400 Subject: [PATCH 08/17] fix a bug, cleaner output tree with reduce --- lib/index.js | 38 +++++++++++++--------- test/fixtures/conditional.expected.html | 2 -- test/fixtures/conditional_if.expected.html | 4 +++ test/fixtures/conditional_if.html | 5 +++ test/index.js | 12 +++++++ 5 files changed, 43 insertions(+), 18 deletions(-) create mode 100644 test/fixtures/conditional_if.expected.html create mode 100644 test/fixtures/conditional_if.html diff --git a/lib/index.js b/lib/index.js index 2f7a4d2..b24fbbf 100644 --- a/lib/index.js +++ b/lib/index.js @@ -25,8 +25,15 @@ module.exports = function PostHTMLExpressions (options = {}) { } function walk (opts, nodes) { + // After a conditional has been resolved, we remove the conditional elements + // from the tree. This variable determines how many to skip afterwards. + let skip + // loop through each node in the tree - return nodes.map((node, i) => { + return nodes.reduce((m, node, i) => { + // if we're skipping this node, return immediately + if (skip) { skip--; return m } + // if we have a string, match and replace it if (typeof node !== 'object') { // if there are any matches, we parse and replace the results @@ -34,7 +41,8 @@ function walk (opts, nodes) { // TODO: this could be optimized by starting at the regex match index node = parseAndReplace(ctx, [delimiters, unescapeDelimiters], node) } - return node + m.push(node) + return m } // if not, we have an object, so we need to run the attributes and contents @@ -94,11 +102,11 @@ function walk (opts, nodes) { } // format into an expression - const expression = ast.reduce((m, e, i) => { - m += e.statement - if (e.condition) m += ` (${e.condition})` - m += ` { ${i} } ` - return m + const expression = ast.reduce((m2, e, i) => { + m2 += e.statement + if (e.condition) m2 += ` (${e.condition})` + m2 += ` { ${i} } ` + return m2 }, '') // evaluate the expression, get the winning node @@ -107,18 +115,16 @@ function walk (opts, nodes) { // remove all of the conditional tags from the tree // we subtract 1 from i as it's incremented from the initial if statement // in order to get the next node - nodes.splice(i - 1, current - i, ...expResult) - - // run the condition - if (vm.runInContext(node.attrs.condition, ctx)) { - nodes.splice(i, 1, ...node.content) - return '' - } + skip = current - i + m.push(...expResult) + return m + // nodes.splice(i - 1, current - i, ...expResult) } // return the modified node - return node - }) + m.push(node) + return m + }, []) } function getNextTag (nodes, i, nodeCount) { diff --git a/test/fixtures/conditional.expected.html b/test/fixtures/conditional.expected.html index 3638141..b9a79e5 100644 --- a/test/fixtures/conditional.expected.html +++ b/test/fixtures/conditional.expected.html @@ -1,6 +1,4 @@

x

it works!

- -

x

diff --git a/test/fixtures/conditional_if.expected.html b/test/fixtures/conditional_if.expected.html new file mode 100644 index 0000000..b9a79e5 --- /dev/null +++ b/test/fixtures/conditional_if.expected.html @@ -0,0 +1,4 @@ +

x

+ +

it works!

+

x

diff --git a/test/fixtures/conditional_if.html b/test/fixtures/conditional_if.html new file mode 100644 index 0000000..2bae958 --- /dev/null +++ b/test/fixtures/conditional_if.html @@ -0,0 +1,5 @@ +

x

+ +

it works!

+
+

x

diff --git a/test/index.js b/test/index.js index ec625e0..313a28a 100644 --- a/test/index.js +++ b/test/index.js @@ -23,10 +23,22 @@ test('expression spacing', (t) => { return matchExpected(t, 'expression_spacing', { locals: { foo: 'X' } }) }) +test.todo('expression error') + test('conditional', (t) => { return matchExpected(t, 'conditional', { locals: { foo: 'bar' } }) }) +test('conditional - only "if" condition', (t) => { + return matchExpected(t, 'conditional_if', { locals: { foo: 'bar' } }) +}) + +test.todo('conditional - "if" tag missing condition') +test.todo('conditional - "elseif" tag missing condition') +test.todo('conditional - other tag in middle of statement') +test.todo('conditional - nested conditionals') +test.todo('conditional - expression error') + // // Utility // From a465801eb5ea871e8a38d7d96c031610194d8560 Mon Sep 17 00:00:00 2001 From: Jeff Escalante Date: Fri, 8 Jul 2016 16:06:32 -0400 Subject: [PATCH 09/17] more tests --- lib/index.js | 8 ++++---- test/fixtures/conditional_elseif_error.html | 6 ++++++ test/fixtures/conditional_if_error.html | 6 ++++++ test/index.js | 22 +++++++++++++++++++-- 4 files changed, 36 insertions(+), 6 deletions(-) create mode 100644 test/fixtures/conditional_elseif_error.html create mode 100644 test/fixtures/conditional_if_error.html diff --git a/lib/index.js b/lib/index.js index b24fbbf..5814212 100644 --- a/lib/index.js +++ b/lib/index.js @@ -64,8 +64,8 @@ function walk (opts, nodes) { // this comes after the recursion to correctly handle nested loops if (node.tag === conditionals[0]) { // throw an error if it's missing the "condition" attribute - if (!node.attrs && !node.attrs.condition) { - throw new Error('the "if" tag must have a "condition" attribute') + if (!(node.attrs && node.attrs.condition)) { + throw new Error(`the "${conditionals[0]}" tag must have a "condition" attribute`) } // build the expression object, which we will turn into js and eval later @@ -88,8 +88,8 @@ function walk (opts, nodes) { // add the condition if it's an else if if (nextTag.tag === conditionals[1]) { // throw an error if an "else if" is missing a condition - if (!nextTag.attrs && !nextTag.attrs.condition) { - throw new Error('the "elseif" tag must have a "condition" attribute') + if (!(nextTag.attrs && nextTag.attrs.condition)) { + throw new Error(`the "${conditionals[1]}" tag must have a "condition" attribute`) } obj.condition = nextTag.attrs.condition diff --git a/test/fixtures/conditional_elseif_error.html b/test/fixtures/conditional_elseif_error.html new file mode 100644 index 0000000..8a7bef4 --- /dev/null +++ b/test/fixtures/conditional_elseif_error.html @@ -0,0 +1,6 @@ + +

hi

+
+ +

hi

+ diff --git a/test/fixtures/conditional_if_error.html b/test/fixtures/conditional_if_error.html new file mode 100644 index 0000000..c3e4dd8 --- /dev/null +++ b/test/fixtures/conditional_if_error.html @@ -0,0 +1,6 @@ + +

hi

+
+ +

hi

+
diff --git a/test/index.js b/test/index.js index 313a28a..211afdc 100644 --- a/test/index.js +++ b/test/index.js @@ -33,8 +33,18 @@ test('conditional - only "if" condition', (t) => { return matchExpected(t, 'conditional_if', { locals: { foo: 'bar' } }) }) -test.todo('conditional - "if" tag missing condition') -test.todo('conditional - "elseif" tag missing condition') +test('conditional - "if" tag missing condition', (t) => { + return expectError('conditional_if_error', (err) => { + t.truthy(err.toString() === 'Error: the "if" tag must have a "condition" attribute') + }) +}) + +test('conditional - "elseif" tag missing condition', (t) => { + return expectError('conditional_elseif_error', (err) => { + t.truthy(err.toString() === 'Error: the "elseif" tag must have a "condition" attribute') + }) +}) + test.todo('conditional - other tag in middle of statement') test.todo('conditional - nested conditionals') test.todo('conditional - expression error') @@ -52,3 +62,11 @@ function matchExpected (t, name, config, log = false) { .then((res) => { log && console.log(res.html); return res }) .then((res) => { t.truthy(res.html.trim() === expected.trim()) }) } + +function expectError (name, cb) { + const html = readFileSync(path.join(fixtures, `${name}.html`), 'utf8') + + return posthtml([exp()]) + .process(html) + .catch(cb) +} From da15af736c2f74b606d70119a0448ae346b5fc89 Mon Sep 17 00:00:00 2001 From: Jeff Escalante Date: Fri, 8 Jul 2016 16:22:30 -0400 Subject: [PATCH 10/17] more conditional tests --- lib/index.js | 4 ++-- test/fixtures/conditional_nested.expected.html | 7 +++++++ test/fixtures/conditional_nested.html | 9 +++++++++ test/fixtures/conditional_norender.expected.html | 2 ++ test/fixtures/conditional_norender.html | 5 +++++ test/fixtures/conditional_tag_break.expected.html | 5 +++++ test/fixtures/conditional_tag_break.html | 7 +++++++ test/index.js | 14 ++++++++++++-- 8 files changed, 49 insertions(+), 4 deletions(-) create mode 100644 test/fixtures/conditional_nested.expected.html create mode 100644 test/fixtures/conditional_nested.html create mode 100644 test/fixtures/conditional_norender.expected.html create mode 100644 test/fixtures/conditional_norender.html create mode 100644 test/fixtures/conditional_tag_break.expected.html create mode 100644 test/fixtures/conditional_tag_break.html diff --git a/lib/index.js b/lib/index.js index 5814212..5664850 100644 --- a/lib/index.js +++ b/lib/index.js @@ -110,13 +110,13 @@ function walk (opts, nodes) { }, '') // evaluate the expression, get the winning node - const expResult = ast[vm.runInContext(expression, ctx)].content + const expResult = ast[vm.runInContext(expression, ctx)] // remove all of the conditional tags from the tree // we subtract 1 from i as it's incremented from the initial if statement // in order to get the next node skip = current - i - m.push(...expResult) + if (expResult) m.push(...expResult.content) return m // nodes.splice(i - 1, current - i, ...expResult) } diff --git a/test/fixtures/conditional_nested.expected.html b/test/fixtures/conditional_nested.expected.html new file mode 100644 index 0000000..d96bf8d --- /dev/null +++ b/test/fixtures/conditional_nested.expected.html @@ -0,0 +1,7 @@ +

x

+ +

y

+ +

z

+

y

+

x

diff --git a/test/fixtures/conditional_nested.html b/test/fixtures/conditional_nested.html new file mode 100644 index 0000000..91f8709 --- /dev/null +++ b/test/fixtures/conditional_nested.html @@ -0,0 +1,9 @@ +

x

+ +

y

+ +

z

+
+

y

+
+

x

diff --git a/test/fixtures/conditional_norender.expected.html b/test/fixtures/conditional_norender.expected.html new file mode 100644 index 0000000..2b4a5f8 --- /dev/null +++ b/test/fixtures/conditional_norender.expected.html @@ -0,0 +1,2 @@ +

x

+

x

diff --git a/test/fixtures/conditional_norender.html b/test/fixtures/conditional_norender.html new file mode 100644 index 0000000..f3eb9ed --- /dev/null +++ b/test/fixtures/conditional_norender.html @@ -0,0 +1,5 @@ +

x

+ +

hi

+
+

x

diff --git a/test/fixtures/conditional_tag_break.expected.html b/test/fixtures/conditional_tag_break.expected.html new file mode 100644 index 0000000..69990cc --- /dev/null +++ b/test/fixtures/conditional_tag_break.expected.html @@ -0,0 +1,5 @@ +

if

+

x

+ +

else

+
diff --git a/test/fixtures/conditional_tag_break.html b/test/fixtures/conditional_tag_break.html new file mode 100644 index 0000000..e228ab7 --- /dev/null +++ b/test/fixtures/conditional_tag_break.html @@ -0,0 +1,7 @@ + +

if

+
+

x

+ +

else

+
diff --git a/test/index.js b/test/index.js index 211afdc..3476cd5 100644 --- a/test/index.js +++ b/test/index.js @@ -33,6 +33,10 @@ test('conditional - only "if" condition', (t) => { return matchExpected(t, 'conditional_if', { locals: { foo: 'bar' } }) }) +test('conditional - no render', (t) => { + return matchExpected(t, 'conditional_norender', {}) +}) + test('conditional - "if" tag missing condition', (t) => { return expectError('conditional_if_error', (err) => { t.truthy(err.toString() === 'Error: the "if" tag must have a "condition" attribute') @@ -45,8 +49,14 @@ test('conditional - "elseif" tag missing condition', (t) => { }) }) -test.todo('conditional - other tag in middle of statement') -test.todo('conditional - nested conditionals') +test('conditional - other tag in middle of statement', (t) => { + return matchExpected(t, 'conditional_tag_break', {}) +}) + +test('conditional - nested conditionals', (t) => { + return matchExpected(t, 'conditional_nested', {}) +}) + test.todo('conditional - expression error') // From dd835ee2c183246ca27db73bba8ab085c7a33405 Mon Sep 17 00:00:00 2001 From: Jeff Escalante Date: Fri, 8 Jul 2016 17:03:00 -0400 Subject: [PATCH 11/17] expression error tests --- test/fixtures/conditional_expression_error.html | 3 +++ test/fixtures/expression_error.html | 1 + test/index.js | 12 ++++++++++-- 3 files changed, 14 insertions(+), 2 deletions(-) create mode 100644 test/fixtures/conditional_expression_error.html create mode 100644 test/fixtures/expression_error.html diff --git a/test/fixtures/conditional_expression_error.html b/test/fixtures/conditional_expression_error.html new file mode 100644 index 0000000..8dc68c9 --- /dev/null +++ b/test/fixtures/conditional_expression_error.html @@ -0,0 +1,3 @@ + +

hi

+
diff --git a/test/fixtures/expression_error.html b/test/fixtures/expression_error.html new file mode 100644 index 0000000..4b6e279 --- /dev/null +++ b/test/fixtures/expression_error.html @@ -0,0 +1 @@ +{{ @#!$R }} diff --git a/test/index.js b/test/index.js index 3476cd5..21cd3db 100644 --- a/test/index.js +++ b/test/index.js @@ -23,7 +23,11 @@ test('expression spacing', (t) => { return matchExpected(t, 'expression_spacing', { locals: { foo: 'X' } }) }) -test.todo('expression error') +test('expression error', (t) => { + return expectError('expression_error', (err) => { + t.truthy(err.toString() === 'SyntaxError: Unexpected token ILLEGAL') + }) +}) test('conditional', (t) => { return matchExpected(t, 'conditional', { locals: { foo: 'bar' } }) @@ -57,7 +61,11 @@ test('conditional - nested conditionals', (t) => { return matchExpected(t, 'conditional_nested', {}) }) -test.todo('conditional - expression error') +test('conditional - expression error', (t) => { + return expectError('conditional_expression_error', (err) => { + t.truthy(err.toString() === 'SyntaxError: Unexpected token ILLEGAL') + }) +}) // // Utility From 9591be73d595aec6f53e4e2136df3881a28689b7 Mon Sep 17 00:00:00 2001 From: Jeff Escalante Date: Fri, 8 Jul 2016 17:18:34 -0400 Subject: [PATCH 12/17] naming --- lib/{parser.js => expression_parser.js} | 0 lib/index.js | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename lib/{parser.js => expression_parser.js} (100%) diff --git a/lib/parser.js b/lib/expression_parser.js similarity index 100% rename from lib/parser.js rename to lib/expression_parser.js diff --git a/lib/index.js b/lib/index.js index 5664850..5b1785e 100644 --- a/lib/index.js +++ b/lib/index.js @@ -1,5 +1,5 @@ const vm = require('vm') -const parseAndReplace = require('./parser') +const parseAndReplace = require('./expression_parser') let ctx, delimiters, unescapeDelimiters, delimiterRegex, unescapeDelimiterRegex, conditionals From 9ed7be30961a4c8b249069e1c0de1420b4f0f0db Mon Sep 17 00:00:00 2001 From: Jeff Escalante Date: Mon, 11 Jul 2016 14:04:37 -0400 Subject: [PATCH 13/17] basic loop implementation --- lib/index.js | 112 ++++++++++++++++++++++++++++--- package.json | 4 ++ test/fixtures/loop.expected.html | 9 +++ test/fixtures/loop.html | 5 ++ test/index.js | 4 ++ 5 files changed, 126 insertions(+), 8 deletions(-) create mode 100644 test/fixtures/loop.expected.html create mode 100644 test/fixtures/loop.html diff --git a/lib/index.js b/lib/index.js index 5b1785e..487ae22 100644 --- a/lib/index.js +++ b/lib/index.js @@ -1,12 +1,11 @@ const vm = require('vm') +const merge = require('lodash.merge') +const cloneDeep = require('lodash.cloneDeep') const parseAndReplace = require('./expression_parser') -let ctx, delimiters, unescapeDelimiters, delimiterRegex, unescapeDelimiterRegex, conditionals +let delimiters, unescapeDelimiters, delimiterRegex, unescapeDelimiterRegex, conditionals, loops module.exports = function PostHTMLExpressions (options = {}) { - // the context in which expressions are evaluated - ctx = vm.createContext(options.locals) - // set up delimiter options and detection delimiters = options.delimiters || ['{{', '}}'] unescapeDelimiters = options.unescapeDelimiters || ['{{{', '}}}'] @@ -19,12 +18,16 @@ module.exports = function PostHTMLExpressions (options = {}) { // conditional and loop options conditionals = options.conditionalTags || ['if', 'elseif', 'else'] + loops = options.loopTags || ['each'] // kick off the parsing return walk.bind(null, options) } function walk (opts, nodes) { + // the context in which expressions are evaluated + const ctx = vm.createContext(opts.locals) + // After a conditional has been resolved, we remove the conditional elements // from the tree. This variable determines how many to skip afterwards. let skip @@ -55,8 +58,8 @@ function walk (opts, nodes) { } } - // if the node has content, recurse - if (node.content) { + // if the node has content, recurse (unless it's a loop, handled later) + if (node.content && node.tag !== 'each') { node.content = walk(opts, node.content) } @@ -118,10 +121,48 @@ function walk (opts, nodes) { skip = current - i if (expResult) m.push(...expResult.content) return m - // nodes.splice(i - 1, current - i, ...expResult) } - // return the modified node + // parse loops + if (node.tag === loops[0]) { + if (!(node.attrs && node.attrs.loop)) { + throw new Error(`the "${conditionals[1]}" tag must have a "loop" attribute`) + } + // parse the "loop" param + const loopParams = parseLoopStatement(node.attrs.loop) + const target = vm.runInContext(loopParams.expression, ctx) + + if (typeof target !== 'object') { + throw new Error('You must provide an array or object to loop through') + } + + if (loopParams.length < 1) { + throw new Error('You must provide at least one loop argument') + } + + if (Array.isArray(target)) { + for (let index = 0; index < target.length; index++) { + const item = target[index] + // add item and optional index loop locals + const scopedLocals = {} + scopedLocals[loopParams.keys[0]] = item + if (loopParams.keys[1]) scopedLocals[loopParams.keys[1]] = index + // merge nondestructively into existing locals + const scopedOptions = merge(opts, { locals: scopedLocals }) + // provide the modified options to the content evaluation + // we need to clone the node because the normal operation modifies + // the node directly + const content = cloneDeep(node.content) + const res = walk(scopedOptions, content) + m.push(res) + } + return m + } else { + // object loop + } + } + + // return the node m.push(node) return m }, []) @@ -139,3 +180,58 @@ function getNextTag (nodes, i, nodeCount) { } return [i, { tag: undefined }] } + +function parseLoopStatement (input) { + let current = 0 + let char = input[current] + + // parse through keys `each **foo, bar** in x`, which is everything before + // the word "in" + const keys = [] + let key = '' + while (!`${char}${lookahead(3)}`.match(/\sin\s/)) { + key += char + next() + + // if we hit a comma, we're on to the next key + if (char === ',') { + keys.push(key.trim()) + key = '' + next() + } + + // if we reach the end of the string without getting "in", it's an error + if (typeof char === 'undefined') { + throw new Error("Loop statement lacking 'in' keyword") + } + } + keys.push(key.trim()) + + // Bypass the word " in", and ensure there's a space after + next(4) + + // the rest of the string is evaluated as the array/object to loop + let expression = '' + while (current < input.length) { + expression += char + next() + } + + return {keys, expression} + + // Utility: Move to the next character in the parse + function next (n = 1) { + for (let i = 0; i < n; i++) { char = input[++current] } + } + + // Utility: looks ahead n characters and returns the result + function lookahead (n) { + let counter = current + const target = current + n + let res = '' + while (counter < target) { + res += input[++counter] + } + return res + } +} diff --git a/package.json b/package.json index 34b58ca..a091a49 100644 --- a/package.json +++ b/package.json @@ -30,5 +30,9 @@ }, "scripts": { "test": "ava" + }, + "dependencies": { + "lodash.clonedeep": "^4.3.2", + "lodash.merge": "^4.4.0" } } diff --git a/test/fixtures/loop.expected.html b/test/fixtures/loop.expected.html new file mode 100644 index 0000000..bbdaa77 --- /dev/null +++ b/test/fixtures/loop.expected.html @@ -0,0 +1,9 @@ +

x

+ +

0: 1

+ +

1: 2

+ +

2: 3

+ +

x

diff --git a/test/fixtures/loop.html b/test/fixtures/loop.html new file mode 100644 index 0000000..b95045b --- /dev/null +++ b/test/fixtures/loop.html @@ -0,0 +1,5 @@ +

x

+ +

{{index}}: {{item}}

+
+

x

diff --git a/test/index.js b/test/index.js index 21cd3db..0721f88 100644 --- a/test/index.js +++ b/test/index.js @@ -67,6 +67,10 @@ test('conditional - expression error', (t) => { }) }) +test('loop', (t) => { + return matchExpected(t, 'loop', { locals: { items: [1, 2, 3] } }) +}) + // // Utility // From acd06dc44fc42e1fca3e62e6aca44426ffbf63eb Mon Sep 17 00:00:00 2001 From: Jeff Escalante Date: Mon, 11 Jul 2016 14:21:35 -0400 Subject: [PATCH 14/17] object loop --- lib/index.js | 23 +++++++++++++++++++---- test/fixtures/loop_object.expected.html | 7 +++++++ test/fixtures/loop_object.html | 5 +++++ test/index.js | 4 ++++ 4 files changed, 35 insertions(+), 4 deletions(-) create mode 100644 test/fixtures/loop_object.expected.html create mode 100644 test/fixtures/loop_object.html diff --git a/lib/index.js b/lib/index.js index 487ae22..a1a07b5 100644 --- a/lib/index.js +++ b/lib/index.js @@ -142,10 +142,10 @@ function walk (opts, nodes) { if (Array.isArray(target)) { for (let index = 0; index < target.length; index++) { - const item = target[index] - // add item and optional index loop locals + const value = target[index] + // add value and optional index loop locals const scopedLocals = {} - scopedLocals[loopParams.keys[0]] = item + scopedLocals[loopParams.keys[0]] = value if (loopParams.keys[1]) scopedLocals[loopParams.keys[1]] = index // merge nondestructively into existing locals const scopedOptions = merge(opts, { locals: scopedLocals }) @@ -158,7 +158,22 @@ function walk (opts, nodes) { } return m } else { - // object loop + for (let key in target) { + const value = target[key] + // add item and optional index loop locals + const scopedLocals = {} + scopedLocals[loopParams.keys[0]] = key + if (loopParams.keys[1]) scopedLocals[loopParams.keys[1]] = value + // merge nondestructively into existing locals + const scopedOptions = merge(opts, { locals: scopedLocals }) + // provide the modified options to the content evaluation + // we need to clone the node because the normal operation modifies + // the node directly + const content = cloneDeep(node.content) + const res = walk(scopedOptions, content) + m.push(res) + } + return m } } diff --git a/test/fixtures/loop_object.expected.html b/test/fixtures/loop_object.expected.html new file mode 100644 index 0000000..5f35d4d --- /dev/null +++ b/test/fixtures/loop_object.expected.html @@ -0,0 +1,7 @@ +

x

+ +

a: b

+ +

c: d

+ +

x

diff --git a/test/fixtures/loop_object.html b/test/fixtures/loop_object.html new file mode 100644 index 0000000..be874d8 --- /dev/null +++ b/test/fixtures/loop_object.html @@ -0,0 +1,5 @@ +

x

+ +

{{key}}: {{value}}

+
+

x

diff --git a/test/index.js b/test/index.js index 0721f88..cf1d806 100644 --- a/test/index.js +++ b/test/index.js @@ -71,6 +71,10 @@ test('loop', (t) => { return matchExpected(t, 'loop', { locals: { items: [1, 2, 3] } }) }) +test('loop object', (t) => { + return matchExpected(t, 'loop_object', { locals: { items: { a: 'b', c: 'd' } } }) +}) + // // Utility // From e0166e3f21b2939a03ac0d1158bf19bac26d1ea5 Mon Sep 17 00:00:00 2001 From: Jeff Escalante Date: Mon, 11 Jul 2016 16:03:04 -0400 Subject: [PATCH 15/17] cleanup, switch value and key positions for object loop --- README.md | 2 +- lib/index.js | 60 ++++++++++++++++++---------------- test/fixtures/loop_object.html | 2 +- test/index.js | 4 +++ 4 files changed, 38 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 2c1f2c8..aa3cc2b 100644 --- a/README.md +++ b/README.md @@ -192,7 +192,7 @@ Output: And an example using an object: ```html - +

{{ key }}: {{ value }}

``` diff --git a/lib/index.js b/lib/index.js index a1a07b5..d4c1dfb 100644 --- a/lib/index.js +++ b/lib/index.js @@ -125,13 +125,16 @@ function walk (opts, nodes) { // parse loops if (node.tag === loops[0]) { + // handle syntax error if (!(node.attrs && node.attrs.loop)) { throw new Error(`the "${conditionals[1]}" tag must have a "loop" attribute`) } + // parse the "loop" param const loopParams = parseLoopStatement(node.attrs.loop) const target = vm.runInContext(loopParams.expression, ctx) + // handle additional syntax errors if (typeof target !== 'object') { throw new Error('You must provide an array or object to loop through') } @@ -140,41 +143,19 @@ function walk (opts, nodes) { throw new Error('You must provide at least one loop argument') } + // run the loop, different types of loops for arrays and objects if (Array.isArray(target)) { for (let index = 0; index < target.length; index++) { - const value = target[index] - // add value and optional index loop locals - const scopedLocals = {} - scopedLocals[loopParams.keys[0]] = value - if (loopParams.keys[1]) scopedLocals[loopParams.keys[1]] = index - // merge nondestructively into existing locals - const scopedOptions = merge(opts, { locals: scopedLocals }) - // provide the modified options to the content evaluation - // we need to clone the node because the normal operation modifies - // the node directly - const content = cloneDeep(node.content) - const res = walk(scopedOptions, content) - m.push(res) + m.push(executeLoop(loopParams.keys, target[index], index, node, opts)) } - return m } else { for (let key in target) { - const value = target[key] - // add item and optional index loop locals - const scopedLocals = {} - scopedLocals[loopParams.keys[0]] = key - if (loopParams.keys[1]) scopedLocals[loopParams.keys[1]] = value - // merge nondestructively into existing locals - const scopedOptions = merge(opts, { locals: scopedLocals }) - // provide the modified options to the content evaluation - // we need to clone the node because the normal operation modifies - // the node directly - const content = cloneDeep(node.content) - const res = walk(scopedOptions, content) - m.push(res) + m.push(executeLoop(loopParams.keys, target[key], key, node, opts)) } - return m } + + // return directly out of the loop, which will skip the "each" tag + return m } // return the node @@ -196,6 +177,10 @@ function getNextTag (nodes, i, nodeCount) { return [i, { tag: undefined }] } +/** + * Given a "loop" parameter from an "each" tag, parses out the param names and + * expression to be looped through using a mini text parser. + */ function parseLoopStatement (input) { let current = 0 let char = input[current] @@ -250,3 +235,22 @@ function parseLoopStatement (input) { return res } } + +/** + * Creates a set of local variables within the loop, and evaluates all nodes + * within the loop, returning their contents + */ +function executeLoop (loopParams, p1, p2, node, opts) { + // two loop locals are allowed + // - for arrays it's the current value and the index + // - for objects, it's the value and the key + const scopedLocals = {} + scopedLocals[loopParams[0]] = p1 + if (loopParams[1]) scopedLocals[loopParams[1]] = p2 + // merge nondestructively into existing locals + const scopedOptions = merge(opts, { locals: scopedLocals }) + // walk through the contents and run replacements with modified options + // we need to clone the node because the normal operation modifies + // the node directly + return walk(scopedOptions, cloneDeep(node.content)) +} diff --git a/test/fixtures/loop_object.html b/test/fixtures/loop_object.html index be874d8..2eb0fe2 100644 --- a/test/fixtures/loop_object.html +++ b/test/fixtures/loop_object.html @@ -1,5 +1,5 @@

x

- +

{{key}}: {{value}}

x

diff --git a/test/index.js b/test/index.js index cf1d806..a7eed5e 100644 --- a/test/index.js +++ b/test/index.js @@ -75,6 +75,10 @@ test('loop object', (t) => { return matchExpected(t, 'loop_object', { locals: { items: { a: 'b', c: 'd' } } }) }) +test.todo('loop with other locals included') +test.todo('loop with conflicting locals') +test.todo('nested loops') + // // Utility // From ac18ed9e2191df6a3211e0d2a4e87ea3e3cbc227 Mon Sep 17 00:00:00 2001 From: Jeff Escalante Date: Mon, 11 Jul 2016 16:31:59 -0400 Subject: [PATCH 16/17] module name correction --- lib/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/index.js b/lib/index.js index d4c1dfb..86fa828 100644 --- a/lib/index.js +++ b/lib/index.js @@ -1,6 +1,6 @@ const vm = require('vm') const merge = require('lodash.merge') -const cloneDeep = require('lodash.cloneDeep') +const cloneDeep = require('lodash.clonedeep') const parseAndReplace = require('./expression_parser') let delimiters, unescapeDelimiters, delimiterRegex, unescapeDelimiterRegex, conditionals, loops From 3464aa54917a195c66bbbb21c514a0790f9866d0 Mon Sep 17 00:00:00 2001 From: Jeff Escalante Date: Mon, 11 Jul 2016 17:13:47 -0400 Subject: [PATCH 17/17] more tests --- .editorconfig | 2 +- lib/index.js | 13 ++++++------ test/fixtures/loop_conflict.expected.html | 9 +++++++++ test/fixtures/loop_conflict.html | 5 +++++ test/fixtures/loop_locals.expected.html | 9 +++++++++ test/fixtures/loop_locals.html | 5 +++++ test/fixtures/loop_nested.expected.html | 21 ++++++++++++++++++++ test/fixtures/loop_nested.html | 8 ++++++++ test/index.js | 24 +++++++++++++++++++---- 9 files changed, 85 insertions(+), 11 deletions(-) create mode 100644 test/fixtures/loop_conflict.expected.html create mode 100644 test/fixtures/loop_conflict.html create mode 100644 test/fixtures/loop_locals.expected.html create mode 100644 test/fixtures/loop_locals.html create mode 100644 test/fixtures/loop_nested.expected.html create mode 100644 test/fixtures/loop_nested.html diff --git a/.editorconfig b/.editorconfig index 57cf0ec..600f905 100644 --- a/.editorconfig +++ b/.editorconfig @@ -8,5 +8,5 @@ indent_style = space trim_trailing_whitespace = true insert_final_newline = true -[*.md] +[*.{md,html}] trim_trailing_whitespace = false diff --git a/lib/index.js b/lib/index.js index 86fa828..47079db 100644 --- a/lib/index.js +++ b/lib/index.js @@ -3,9 +3,10 @@ const merge = require('lodash.merge') const cloneDeep = require('lodash.clonedeep') const parseAndReplace = require('./expression_parser') -let delimiters, unescapeDelimiters, delimiterRegex, unescapeDelimiterRegex, conditionals, loops +let delimiters, unescapeDelimiters, delimiterRegex, unescapeDelimiterRegex, conditionals, loops, options -module.exports = function PostHTMLExpressions (options = {}) { +module.exports = function PostHTMLExpressions (_options = {}) { + options = _options // set up delimiter options and detection delimiters = options.delimiters || ['{{', '}}'] unescapeDelimiters = options.unescapeDelimiters || ['{{{', '}}}'] @@ -146,11 +147,11 @@ function walk (opts, nodes) { // run the loop, different types of loops for arrays and objects if (Array.isArray(target)) { for (let index = 0; index < target.length; index++) { - m.push(executeLoop(loopParams.keys, target[index], index, node, opts)) + m.push(executeLoop(loopParams.keys, target[index], index, node)) } } else { for (let key in target) { - m.push(executeLoop(loopParams.keys, target[key], key, node, opts)) + m.push(executeLoop(loopParams.keys, target[key], key, node)) } } @@ -240,7 +241,7 @@ function parseLoopStatement (input) { * Creates a set of local variables within the loop, and evaluates all nodes * within the loop, returning their contents */ -function executeLoop (loopParams, p1, p2, node, opts) { +function executeLoop (loopParams, p1, p2, node) { // two loop locals are allowed // - for arrays it's the current value and the index // - for objects, it's the value and the key @@ -248,7 +249,7 @@ function executeLoop (loopParams, p1, p2, node, opts) { scopedLocals[loopParams[0]] = p1 if (loopParams[1]) scopedLocals[loopParams[1]] = p2 // merge nondestructively into existing locals - const scopedOptions = merge(opts, { locals: scopedLocals }) + const scopedOptions = merge(cloneDeep(options), { locals: scopedLocals }) // walk through the contents and run replacements with modified options // we need to clone the node because the normal operation modifies // the node directly diff --git a/test/fixtures/loop_conflict.expected.html b/test/fixtures/loop_conflict.expected.html new file mode 100644 index 0000000..f6834ed --- /dev/null +++ b/test/fixtures/loop_conflict.expected.html @@ -0,0 +1,9 @@ +

bar

+ +

0: 1

+ +

1: 2

+ +

2: 3

+ +

bar

diff --git a/test/fixtures/loop_conflict.html b/test/fixtures/loop_conflict.html new file mode 100644 index 0000000..2a0ca11 --- /dev/null +++ b/test/fixtures/loop_conflict.html @@ -0,0 +1,5 @@ +

{{item}}

+ +

{{index}}: {{item}}

+
+

{{item}}

diff --git a/test/fixtures/loop_locals.expected.html b/test/fixtures/loop_locals.expected.html new file mode 100644 index 0000000..42da316 --- /dev/null +++ b/test/fixtures/loop_locals.expected.html @@ -0,0 +1,9 @@ +

x

+ +

0, 1, bar

+ +

1, 2, bar

+ +

2, 3, bar

+ +

x

diff --git a/test/fixtures/loop_locals.html b/test/fixtures/loop_locals.html new file mode 100644 index 0000000..43041f6 --- /dev/null +++ b/test/fixtures/loop_locals.html @@ -0,0 +1,5 @@ +

x

+ +

{{index}}, {{item}}, {{ foo }}

+
+

x

diff --git a/test/fixtures/loop_nested.expected.html b/test/fixtures/loop_nested.expected.html new file mode 100644 index 0000000..ba9e537 --- /dev/null +++ b/test/fixtures/loop_nested.expected.html @@ -0,0 +1,21 @@ +

x

+ +

c1: [1,2,3]

+ +

1

+ +

2

+ +

3

+ + +

c2: [4,5,6]

+ +

4

+ +

5

+ +

6

+ + +

x

\ No newline at end of file diff --git a/test/fixtures/loop_nested.html b/test/fixtures/loop_nested.html new file mode 100644 index 0000000..66a1c9f --- /dev/null +++ b/test/fixtures/loop_nested.html @@ -0,0 +1,8 @@ +

x

+ +

{{ key }}: {{ JSON.stringify(item_category) }}

+ +

{{ item }}

+
+
+

x

diff --git a/test/index.js b/test/index.js index a7eed5e..4663a39 100644 --- a/test/index.js +++ b/test/index.js @@ -72,12 +72,28 @@ test('loop', (t) => { }) test('loop object', (t) => { - return matchExpected(t, 'loop_object', { locals: { items: { a: 'b', c: 'd' } } }) + return matchExpected(t, 'loop_object', { + locals: { items: { a: 'b', c: 'd' } } + }) }) -test.todo('loop with other locals included') -test.todo('loop with conflicting locals') -test.todo('nested loops') +test('loop with other locals included', (t) => { + return matchExpected(t, 'loop_locals', { + locals: { items: [1, 2, 3], foo: 'bar' } + }) +}) + +test('loop with conflicting locals', (t) => { + return matchExpected(t, 'loop_conflict', { + locals: { items: [1, 2, 3], item: 'bar' } + }) +}) + +test('nested loops', (t) => { + return matchExpected(t, 'loop_nested', { + locals: { items: { c1: [1, 2, 3], c2: [4, 5, 6] } } + }) +}) // // Utility