UNPKG

14 kBJavaScriptView Raw
1'use strict'
2
3const vm = require('vm')
4const parser = require('posthtml-parser')
5const render = require('posthtml-render')
6
7const getNextTag = require('./tags')
8const parseLoopStatement = require('./loops')
9const escapeRegexpString = require('./escape')
10const makeLocalsBackup = require('./backup').make
11const revertBackupedLocals = require('./backup').revert
12const placeholders = require('./placeholders')
13
14const delimitersSettings = []
15let conditionals, switches, loops, scopes, ignored, delimitersReplace, unescapeDelimitersReplace
16
17/**
18 * @description Creates a set of local variables within the loop, and evaluates all nodes within the loop, returning their contents
19 *
20 * @method executeLoop
21 *
22 * @param {Array} params Parameters
23 * @param {String} p1 Parameter 1
24 * @param {String} p2 Parameter 2
25 * @param {Object} locals Locals
26 * @param {String} tree Tree
27 *
28 * @return {Function} walk Walks the tree and parses all locals within the loop
29 */
30function executeLoop (params, p1, p2, locals, tree) {
31 // two loop locals are allowed
32 // - for arrays it's the current value and the index
33 // - for objects, it's the value and the key
34 const scopes = locals
35
36 scopes[params[0]] = p1
37
38 if (params[1]) scopes[params[1]] = p2
39
40 return walk({ locals: scopes }, JSON.parse(tree))
41}
42
43/**
44 * @description Runs walk function with arbitrary set of local variables
45 *
46 * @method executeScope
47 *
48 * @param {Object} scope Scoped Locals
49 * @param {Object} locals Locals
50 * @param {Object} node Node
51 *
52 * @return {Function} walk Walks the tree and parses all locals in scope
53 */
54function executeScope (scope, locals, node) {
55 scope = Object.assign(locals, scope)
56
57 return walk({ locals: scope }, node.content)
58}
59
60/**
61 * @description Returns an object containing loop metadata
62 *
63 * @method getLoopMeta
64 *
65 * @param {Integer|Object} index Current iteration
66 * @param {Object} target Object being iterated
67 *
68 * @return {Object} Object containing loop metadata
69 */
70function getLoopMeta (index, target) {
71 index = Array.isArray(target) ? index : Object.keys(target).indexOf(index)
72 const arr = Array.isArray(target) ? target : Object.keys(target)
73
74 return {
75 index: index,
76 remaining: arr.length - index - 1,
77 first: arr.indexOf(arr[index]) === 0,
78 last: index + 1 === arr.length,
79 length: arr.length
80 }
81}
82
83/**
84 * @author Jeff Escalante Denis (@jescalan),
85 * Denis Malinochkin (mrmlnc),
86 * Michael Ciniawsky (@michael-ciniawsky)
87 * @description Expressions Plugin for PostHTML
88 * @license MIT
89 *
90 * @module posthtml-expressions
91 * @version 1.0.0
92 *
93 * @requires vm
94 *
95 * @requires ./tags
96 * @requires ./loops
97 * @requires ./escape
98 * @requires ./backup
99 * @requires ./placeholders
100 *
101 * @param {Object} options Options
102 *
103 * @return {Object} tree PostHTML Tree
104 */
105module.exports = function postHTMLExpressions (options) {
106 // set default options
107 options = Object.assign({
108 locals: {},
109 delimiters: ['{{', '}}'],
110 unescapeDelimiters: ['{{{', '}}}'],
111 conditionalTags: ['if', 'elseif', 'else'],
112 switchTags: ['switch', 'case', 'default'],
113 loopTags: ['each'],
114 scopeTags: ['scope'],
115 ignoredTag: 'raw',
116 strictMode: true
117 }, options)
118
119 // set tags
120 loops = options.loopTags
121 scopes = options.scopeTags
122 conditionals = options.conditionalTags
123 switches = options.switchTags
124 ignored = options.ignoredTag
125
126 // make a RegExp's to search for placeholders
127 let before = escapeRegexpString(options.delimiters[0])
128 let after = escapeRegexpString(options.delimiters[1])
129
130 const delimitersRegexp = new RegExp(`(?<!@)${before}(.+?)${after}`, 'g')
131
132 before = escapeRegexpString(options.unescapeDelimiters[0])
133 after = escapeRegexpString(options.unescapeDelimiters[1])
134
135 const unescapeDelimitersRegexp = new RegExp(`(?<!@)${before}(.+?)${after}`, 'g')
136
137 // make array of delimiters
138 const delimiters = [
139 { text: options.delimiters, regexp: delimitersRegexp, escape: true },
140 { text: options.unescapeDelimiters, regexp: unescapeDelimitersRegexp, escape: false }
141 ]
142
143 // we arrange delimiter search order by length, since it's possible that one
144 // delimiter could 'contain' another delimiter, like '{{' and '{{{'. But if
145 // you sort by length, the longer one will always match first.
146 if (options.delimiters.join().length > options.unescapeDelimiters.join().length) {
147 delimitersSettings[0] = delimiters[0]
148 delimitersSettings[1] = delimiters[1]
149 } else {
150 delimitersSettings[0] = delimiters[1]
151 delimitersSettings[1] = delimiters[0]
152 }
153
154 delimitersReplace = new RegExp(`@${escapeRegexpString(delimitersSettings[1].text[0])}`, 'g')
155 unescapeDelimitersReplace = new RegExp(`@${escapeRegexpString(delimitersSettings[0].text[0])}`, 'g')
156
157 // kick off the parsing
158 return function (tree) {
159 return normalizeTree(clearRawTag(walk({ locals: options.locals, strictMode: options.strictMode }, tree)), tree.options)
160 }
161}
162
163function walk (opts, nodes) {
164 // the context in which expressions are evaluated
165 const ctx = vm.createContext(opts.locals)
166
167 // After a conditional has been resolved, we remove the conditional elements
168 // from the tree. This variable determines how many to skip afterwards.
169 let skip
170
171 // loop through each node in the tree
172 return [].concat(nodes).reduce((m, node, i) => {
173 // if we're skipping this node, return immediately
174 if (skip) { skip--; return m }
175
176 // don't parse ignoredTag
177 if (node.tag === ignored) {
178 m.push(node)
179
180 return m
181 }
182
183 // if we have a string, match and replace it
184 if (typeof node === 'string') {
185 node = placeholders(node, ctx, delimitersSettings)
186 node = node
187 .replace(unescapeDelimitersReplace, delimitersSettings[0].text[0])
188 .replace(delimitersReplace, delimitersSettings[1].text[0])
189
190 m.push(node)
191
192 return m
193 }
194
195 // if not, we have an object, so we need to run the attributes and contents
196 if (node.attrs) {
197 for (const key in node.attrs) {
198 if (typeof node.attrs[key] === 'string') {
199 node.attrs[key] = placeholders(node.attrs[key], ctx, delimitersSettings)
200 node.attrs[key] = node.attrs[key]
201 .replace(unescapeDelimitersReplace, delimitersSettings[0].text[0])
202 .replace(delimitersReplace, delimitersSettings[1].text[0])
203 }
204
205 // if key is parametr
206 const _key = placeholders(key, ctx, delimitersSettings)
207 if (key !== _key) {
208 node.attrs[_key] = node.attrs[key]
209 delete node.attrs[key]
210 }
211 }
212 }
213
214 // if the node has content, recurse (unless it's a loop, handled later)
215 if (node.content && loops.includes(node.tag) === false && node.tag !== scopes[0]) {
216 node.content = walk(opts, node.content)
217 }
218
219 // if we have an element matching "if", we've got a conditional
220 // this comes after the recursion to correctly handle nested loops
221 if (node.tag === conditionals[0]) {
222 // throw an error if it's missing the "condition" attribute
223 if (!(node.attrs && node.attrs.condition)) {
224 throw new Error(`the "${conditionals[0]}" tag must have a "condition" attribute`)
225 }
226
227 // сalculate the first path of condition expression
228 let expressionIndex = 1
229 let expression = `if (${node.attrs.condition}) { 0 } `
230
231 const branches = [node.content]
232
233 // move through the nodes and collect all others that are part of the same
234 // conditional statement
235 let computedNextTag = getNextTag(nodes, ++i)
236
237 let current = computedNextTag[0]
238 let nextTag = computedNextTag[1]
239
240 while (conditionals.slice(1).indexOf(nextTag.tag) > -1) {
241 let statement = nextTag.tag
242 let condition = ''
243
244 // ensure the "else" tag is represented in our little AST as 'else',
245 // even if a custom tag was used
246 if (nextTag.tag === conditionals[2]) statement = 'else'
247
248 // add the condition if it's an else if
249 if (nextTag.tag === conditionals[1]) {
250 // throw an error if an "else if" is missing a condition
251 if (!(nextTag.attrs && nextTag.attrs.condition)) {
252 throw new Error(`the "${conditionals[1]}" tag must have a "condition" attribute`)
253 }
254 condition = nextTag.attrs.condition
255
256 // while we're here, expand "elseif" to "else if"
257 statement = 'else if'
258 }
259 branches.push(nextTag.content)
260
261 // calculate next part of condition expression
262 expression += statement + (condition ? ` (${condition})` : '') + ` { ${expressionIndex++} } `
263
264 computedNextTag = getNextTag(nodes, ++current)
265
266 current = computedNextTag[0]
267 nextTag = computedNextTag[1]
268 }
269
270 // evaluate the expression, get the winning condition branch
271 let branch
272 try {
273 branch = branches[vm.runInContext(expression, ctx)]
274 } catch (error) {
275 if (opts.strictMode) {
276 throw new SyntaxError(error)
277 }
278 }
279
280 // remove all of the conditional tags from the tree
281 // we subtract 1 from i as it's incremented from the initial if statement
282 // in order to get the next node
283 skip = current - i
284
285 // recursive evaluate of condition branch
286 if (branch) Array.prototype.push.apply(m, walk(opts, branch))
287
288 return m
289 }
290
291 // switch tag
292 if (node.tag === switches[0]) {
293 // throw an error if it's missing the "expression" attribute
294 if (!(node.attrs && node.attrs.expression)) {
295 throw new Error(`the "${switches[0]}" tag must have a "expression" attribute`)
296 }
297
298 // сalculate the first path of condition expression
299 let expressionIndex = 0
300 let expression = `switch(${node.attrs.expression}) {`
301
302 const branches = []
303
304 for (let i = 0; i < node.content.length; i++) {
305 const currentNode = node.content[i]
306 if (typeof currentNode === 'string') {
307 continue
308 }
309
310 if (currentNode.tag === switches[1]) {
311 // throw an error if it's missing the "n" attribute
312 if (!(currentNode.attrs && currentNode.attrs.n)) {
313 throw new Error(`the "${switches[1]}" tag must have a "n" attribute`)
314 }
315 expression += `case ${currentNode.attrs.n}: {${expressionIndex++}}; break; `
316 } else if (currentNode.tag === switches[2]) {
317 expression += `default: {${expressionIndex++}}`
318 } else {
319 throw new Error(`the "${switches[0]}" tag can contain only "${switches[1]}" tags and one "${switches[2]}" tag`)
320 }
321 branches.push(currentNode)
322 }
323
324 expression += '}'
325
326 // evaluate the expression, get the winning switch branch
327 const branch = branches[vm.runInContext(expression, ctx)]
328
329 // recursive evaluate of branch
330 Array.prototype.push.apply(m, walk(opts, branch.content))
331
332 return m
333 }
334
335 // parse loops
336 if (loops.includes(node.tag)) {
337 // handle syntax error
338 if (!(node.attrs && node.attrs.loop)) {
339 throw new Error(`the "${node.tag}" tag must have a "loop" attribute`)
340 }
341
342 // parse the "loop" param
343 const loopParams = parseLoopStatement(node.attrs.loop)
344 const target = vm.runInContext(loopParams.expression, ctx)
345
346 // handle additional syntax errors
347 if (typeof target !== 'object') {
348 throw new Error('You must provide an array or object to loop through')
349 }
350
351 if (loopParams.keys.length < 1 || loopParams.keys[0] === '') {
352 throw new Error('You must provide at least one loop argument')
353 }
354
355 // converts nodes to a string. These nodes will be changed within the loop
356 const treeString = JSON.stringify(node.content)
357 const keys = loopParams.keys
358
359 // creates a copy of the keys that will be changed within the loop
360 const localsBackup = makeLocalsBackup(keys, opts.locals)
361
362 // run the loop, different types of loops for arrays and objects
363 if (Array.isArray(target)) {
364 for (let index = 0; index < target.length; index++) {
365 opts.locals.loop = getLoopMeta(index, target)
366 m.push(executeLoop(keys, target[index], index, opts.locals, treeString))
367 }
368 } else {
369 for (const key in target) {
370 opts.locals.loop = getLoopMeta(key, target)
371 m.push(executeLoop(keys, target[key], key, opts.locals, treeString))
372 }
373 }
374
375 // returns the original keys values that was changed within the loop
376 opts.locals = revertBackupedLocals(keys, opts.locals, localsBackup)
377
378 // return directly out of the loop, which will skip the "each" tag
379 return m
380 }
381
382 // parse scopes
383 if (node.tag === scopes[0]) {
384 // handle syntax error
385 if (!node.attrs || !node.attrs.with) {
386 throw new Error(`the "${scopes[0]}" tag must have a "with" attribute`)
387 }
388
389 const target = vm.runInContext(node.attrs.with, ctx)
390
391 // handle additional syntax errors
392 if (typeof target !== 'object' || Array.isArray(target)) {
393 throw new Error('You must provide an object to make scope')
394 }
395
396 const keys = Object.keys(target)
397
398 // creates a copy of the keys that will be changed within the loop
399 const localsBackup = makeLocalsBackup(keys, opts.locals)
400
401 m.push(executeScope(target, opts.locals, node))
402
403 // returns the original keys values that was changed within the loop
404 opts.locals = revertBackupedLocals(keys, opts.locals, localsBackup)
405
406 // return directly out of the loop, which will skip the "scope" tag
407 return m
408 }
409
410 // return the node
411 m.push(node)
412
413 return m
414 }, [])
415}
416
417function clearRawTag (tree) {
418 return tree.reduce((m, node) => {
419 if (node.content) {
420 node.content = clearRawTag(node.content)
421 }
422
423 if (node.tag === ignored) {
424 node.tag = false
425 }
426
427 m.push(node)
428
429 return m
430 }, [])
431}
432function normalizeTree (tree, options) {
433 return parser(render(tree), options)
434}