UNPKG

14.5 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(
165 clearRawTag(
166 walk(
167 {
168 locals: { ...options.locals, ...locals },
169 strictMode: options.strictMode,
170 missingLocal: options.missingLocal
171 }, tree)
172 ), tree.options)
173 }
174}
175
176function walk (opts, nodes) {
177 // the context in which expressions are evaluated
178 const ctx = vm.createContext(opts.locals)
179
180 // After a conditional has been resolved, we remove the conditional elements
181 // from the tree. This variable determines how many to skip afterwards.
182 let skip
183
184 // loop through each node in the tree
185 return [].concat(nodes).reduce((m, node, i) => {
186 // if we're skipping this node, return immediately
187 if (skip) { skip--; return m }
188
189 // don't parse ignoredTag
190 if (node.tag === ignored) {
191 m.push(node)
192
193 return m
194 }
195
196 // if we have a string, match and replace it
197 if (typeof node === 'string') {
198 node = placeholders(node, ctx, delimitersSettings, opts)
199 node = node
200 .replace(unescapeDelimitersReplace, delimitersSettings[0].text[0])
201 .replace(delimitersReplace, delimitersSettings[1].text[0])
202
203 m.push(node)
204
205 return m
206 }
207
208 // if not, we have an object, so we need to run the attributes and contents
209 if (node.attrs) {
210 for (const key in node.attrs) {
211 if (typeof node.attrs[key] === 'string') {
212 node.attrs[key] = placeholders(node.attrs[key], ctx, delimitersSettings, opts)
213 node.attrs[key] = node.attrs[key]
214 .replace(unescapeDelimitersReplace, delimitersSettings[0].text[0])
215 .replace(delimitersReplace, delimitersSettings[1].text[0])
216 }
217
218 // if key is parametr
219 const _key = placeholders(key, ctx, delimitersSettings, opts)
220 if (key !== _key) {
221 node.attrs[_key] = node.attrs[key]
222 delete node.attrs[key]
223 }
224 }
225 }
226
227 // if the node has content, recurse (unless it's a loop, handled later)
228 if (node.content && loops.includes(node.tag) === false && node.tag !== scopes[0]) {
229 node.content = walk(opts, node.content)
230 }
231
232 // if we have an element matching "if", we've got a conditional
233 // this comes after the recursion to correctly handle nested loops
234 if (node.tag === conditionals[0]) {
235 // throw an error if it's missing the "condition" attribute
236 if (!(node.attrs && node.attrs.condition)) {
237 throw new Error(`the "${conditionals[0]}" tag must have a "condition" attribute`)
238 }
239
240 // сalculate the first path of condition expression
241 let expressionIndex = 1
242 let expression = `if (${node.attrs.condition}) { 0 } `
243
244 const branches = [node.content]
245
246 // move through the nodes and collect all others that are part of the same
247 // conditional statement
248 let computedNextTag = getNextTag(nodes, ++i)
249
250 let current = computedNextTag[0]
251 let nextTag = computedNextTag[1]
252
253 while (conditionals.slice(1).indexOf(nextTag.tag) > -1) {
254 let statement = nextTag.tag
255 let condition = ''
256
257 // ensure the "else" tag is represented in our little AST as 'else',
258 // even if a custom tag was used
259 if (nextTag.tag === conditionals[2]) statement = 'else'
260
261 // add the condition if it's an else if
262 if (nextTag.tag === conditionals[1]) {
263 // throw an error if an "else if" is missing a condition
264 if (!(nextTag.attrs && nextTag.attrs.condition)) {
265 throw new Error(`the "${conditionals[1]}" tag must have a "condition" attribute`)
266 }
267 condition = nextTag.attrs.condition
268
269 // while we're here, expand "elseif" to "else if"
270 statement = 'else if'
271 }
272 branches.push(nextTag.content)
273
274 // calculate next part of condition expression
275 expression += statement + (condition ? ` (${condition})` : '') + ` { ${expressionIndex++} } `
276
277 computedNextTag = getNextTag(nodes, ++current)
278
279 current = computedNextTag[0]
280 nextTag = computedNextTag[1]
281 }
282
283 // evaluate the expression, get the winning condition branch
284 let branch
285 try {
286 branch = branches[vm.runInContext(expression, ctx)]
287 } catch (error) {
288 if (opts.strictMode) {
289 throw new SyntaxError(error)
290 }
291 }
292
293 // remove all of the conditional tags from the tree
294 // we subtract 1 from i as it's incremented from the initial if statement
295 // in order to get the next node
296 skip = current - i
297
298 // recursive evaluate of condition branch
299 if (branch) Array.prototype.push.apply(m, walk(opts, branch))
300
301 return m
302 }
303
304 // switch tag
305 if (node.tag === switches[0]) {
306 // throw an error if it's missing the "expression" attribute
307 if (!(node.attrs && node.attrs.expression)) {
308 throw new Error(`the "${switches[0]}" tag must have a "expression" attribute`)
309 }
310
311 // сalculate the first path of condition expression
312 let expressionIndex = 0
313 let expression = `switch(${node.attrs.expression}) {`
314
315 const branches = []
316
317 for (let i = 0; i < node.content.length; i++) {
318 const currentNode = node.content[i]
319 if (typeof currentNode === 'string') {
320 continue
321 }
322
323 if (currentNode.tag === switches[1]) {
324 // throw an error if it's missing the "n" attribute
325 if (!(currentNode.attrs && currentNode.attrs.n)) {
326 throw new Error(`the "${switches[1]}" tag must have a "n" attribute`)
327 }
328 expression += `case ${currentNode.attrs.n}: {${expressionIndex++}}; break; `
329 } else if (currentNode.tag === switches[2]) {
330 expression += `default: {${expressionIndex++}}`
331 } else {
332 throw new Error(`the "${switches[0]}" tag can contain only "${switches[1]}" tags and one "${switches[2]}" tag`)
333 }
334 branches.push(currentNode)
335 }
336
337 expression += '}'
338
339 // evaluate the expression, get the winning switch branch
340 const branch = branches[vm.runInContext(expression, ctx)]
341
342 // recursive evaluate of branch
343 Array.prototype.push.apply(m, walk(opts, branch.content))
344
345 return m
346 }
347
348 // parse loops
349 if (loops.includes(node.tag)) {
350 // handle syntax error
351 if (!(node.attrs && node.attrs.loop)) {
352 throw new Error(`the "${node.tag}" tag must have a "loop" attribute`)
353 }
354
355 // parse the "loop" param
356 const loopParams = parseLoopStatement(node.attrs.loop)
357 let target = {}
358 try {
359 target = vm.runInContext(loopParams.expression, ctx)
360 } catch (error) {
361 if (opts.strictMode) {
362 throw new SyntaxError(error)
363 }
364 }
365
366 // handle additional syntax errors
367 if (typeof target !== 'object' && opts.strictMode) {
368 throw new Error('You must provide an array or object to loop through')
369 }
370
371 if (loopParams.keys.length < 1 || loopParams.keys[0] === '') {
372 throw new Error('You must provide at least one loop argument')
373 }
374
375 // converts nodes to a string. These nodes will be changed within the loop
376 const treeString = JSON.stringify(node.content)
377 const keys = loopParams.keys
378
379 // creates a copy of the keys that will be changed within the loop
380 const localsBackup = makeLocalsBackup(keys, opts.locals)
381
382 // run the loop, different types of loops for arrays and objects
383 if (Array.isArray(target)) {
384 for (let index = 0; index < target.length; index++) {
385 opts.locals.loop = getLoopMeta(index, target)
386 m.push(executeLoop(keys, target[index], index, opts.locals, treeString))
387 }
388 } else {
389 for (const key in target) {
390 opts.locals.loop = getLoopMeta(key, target)
391 m.push(executeLoop(keys, target[key], key, opts.locals, treeString))
392 }
393 }
394
395 // returns the original keys values that was changed within the loop
396 opts.locals = revertBackupedLocals(keys, opts.locals, localsBackup)
397
398 // return directly out of the loop, which will skip the "each" tag
399 return m
400 }
401
402 // parse scopes
403 if (node.tag === scopes[0]) {
404 // handle syntax error
405 if (!node.attrs || !node.attrs.with) {
406 throw new Error(`the "${scopes[0]}" tag must have a "with" attribute`)
407 }
408
409 const target = vm.runInContext(node.attrs.with, ctx)
410
411 // handle additional syntax errors
412 if (typeof target !== 'object' || Array.isArray(target)) {
413 throw new Error('You must provide an object to make scope')
414 }
415
416 const keys = Object.keys(target)
417
418 // creates a copy of the keys that will be changed within the loop
419 const localsBackup = makeLocalsBackup(keys, opts.locals)
420
421 m.push(executeScope(target, opts.locals, node))
422
423 // returns the original keys values that was changed within the loop
424 opts.locals = revertBackupedLocals(keys, opts.locals, localsBackup)
425
426 // return directly out of the loop, which will skip the "scope" tag
427 return m
428 }
429
430 // return the node
431 m.push(node)
432
433 return m
434 }, [])
435}
436
437function clearRawTag (tree) {
438 return tree.reduce((m, node) => {
439 if (node.content) {
440 node.content = clearRawTag(node.content)
441 }
442
443 if (node.tag === ignored) {
444 node.tag = false
445 }
446
447 m.push(node)
448
449 return m
450 }, [])
451}
452function normalizeTree (tree, options) {
453 return parser(render(tree), options)
454}