UNPKG

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