UNPKG

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