Repository: buxlabs/boxwood
Pull request #612 (2020-04-14 19:43:44)

Basic data tag implementation


Patch:
diff --git a/src/Component/curlyTagReduction.spec.js b/src/Component/curlyTagReduction.spec.js
deleted file mode 100644
index 200eb9f..0000000
--- a/src/Component/curlyTagReduction.spec.js
+++ /dev/null
@@ -1,52 +0,0 @@
-'use strict'
-
-const test = require('ava')
-const curlyTagReduction = require('./curlyTagReduction')
-
-test('curlyTagReduction: simplifies expressions in curly tags', assert => {
-  assert.deepEqual(curlyTagReduction('{foo}', []), '')
-  assert.deepEqual(curlyTagReduction('{foo || ""}', []), '')
-  assert.deepEqual(curlyTagReduction('{foo || "bar"}', []), 'bar')
-
-  assert.deepEqual(curlyTagReduction('{foo}', [{ key: 'foo', value: 'bar' }]), 'bar')
-  assert.deepEqual(curlyTagReduction('{foo || ""}', [{ key: 'foo', value: 'bar' }]), 'bar')
-  assert.deepEqual(curlyTagReduction('{foo || "bar"}', [{ key: 'foo', value: 'bar' }]), 'bar')
-
-  assert.deepEqual(curlyTagReduction('{foo || bar}', []), '')
-  assert.deepEqual(curlyTagReduction('{foo || bar}', [{ key: 'foo', value: 'foo' }]), 'foo')
-  assert.deepEqual(curlyTagReduction('{foo || bar}', [{ key: 'bar', value: 'bar' }]), 'bar')
-  assert.deepEqual(curlyTagReduction('{foo || bar}', [{ key: 'foo', value: 'foo' }, { key: 'bar', value: 'bar' }]), 'foo')
-
-  assert.deepEqual(curlyTagReduction('{foo}', [{ key: 'foo', value: '{foo}' }]), '{foo}')
-  assert.deepEqual(curlyTagReduction('{foo || ""}', [{ key: 'foo', value: '{foo}' }]), '{foo || ""}')
-  assert.deepEqual(curlyTagReduction('{foo || "bar"}', [{ key: 'foo', value: '{foo}' }]), '{foo || "bar"}')
-
-  assert.deepEqual(curlyTagReduction('{foo}', [{ key: 'foo', value: '{foo.bar}' }]), '{foo.bar}')
-  assert.deepEqual(curlyTagReduction('{foo || ""}', [{ key: 'foo', value: '{foo.bar}' }]), '{foo.bar || ""}')
-  assert.deepEqual(curlyTagReduction('{foo || "bar"}', [{ key: 'foo', value: '{foo.bar}' }]), '{foo.bar || "bar"}')
-
-  assert.deepEqual(curlyTagReduction('{foo}', [{ key: 'foo', value: '{foo[bar]}' }]), '{foo[bar]}')
-  assert.deepEqual(curlyTagReduction('{foo || ""}', [{ key: 'foo', value: '{foo[bar]}' }]), '{foo[bar] || ""}')
-  assert.deepEqual(curlyTagReduction('{foo || "bar"}', [{ key: 'foo', value: '{foo[bar]}' }]), '{foo[bar] || "bar"}')
-
-  // TODO: Ensure that is needed
-  assert.deepEqual(curlyTagReduction('{foo}', [{ key: 'foo', value: '{true}' }]), '{true}')
-  assert.deepEqual(curlyTagReduction('{foo || ""}', [{ key: 'foo', value: '{true}' }]), '{true}')
-  assert.deepEqual(curlyTagReduction('{foo || "bar"}', [{ key: 'foo', value: '{true}' }]), '{true}')
-
-  assert.deepEqual(curlyTagReduction('{foo}', [{ key: 'foo', value: '{false}' }]), '{false}')
-  assert.deepEqual(curlyTagReduction('{foo || ""}', [{ key: 'foo', value: '{false}' }]), '')
-  assert.deepEqual(curlyTagReduction('{foo || "bar"}', [{ key: 'foo', value: '{false}' }]), 'bar')
-
-  assert.deepEqual(curlyTagReduction('{foo}{bar}', []), '')
-  assert.deepEqual(curlyTagReduction('{foo} {bar}', []), '')
-
-  assert.deepEqual(curlyTagReduction('<div class="{foo}"></div>', []), '<div></div>')
-  assert.deepEqual(curlyTagReduction('<div class="{foo}"></div>', [{ key: 'foo', value: '' }]), '<div></div>')
-  assert.deepEqual(curlyTagReduction('<div class="{foo}"></div>', [{ key: 'foo', value: '  ' }]), '<div></div>')
-  assert.deepEqual(curlyTagReduction('<div class="{foo}"></div>', [{ key: 'foo', value: undefined }]), '<div class=\'{undefined}\'></div>')
-  assert.deepEqual(curlyTagReduction('<div class="{foo}"></div>', [{ key: 'foo', value: null }]), '<div class=\'{null}\'></div>')
-  assert.deepEqual(curlyTagReduction('<div padding="{{bottom:30}}"></div>', []), '<div padding=\'{({\n  bottom: 30\n})}\'></div>')
-
-  assert.deepEqual(curlyTagReduction('<style>.foo { color: red }</style>', []), '<style>.foo { color: red }</style>')
-})
diff --git a/src/Component/index.js b/src/Component/index.js
deleted file mode 100644
index 4e3217e..0000000
--- a/src/Component/index.js
+++ /dev/null
@@ -1,16 +0,0 @@
-'use strict'
-
-const curlyTagReduction = require('./curlyTagReduction')
-
-class Component {
-  constructor (source, variables) {
-    this.source = source
-    this.variables = variables
-  }
-
-  optimize () {
-    this.source = curlyTagReduction(this.source, this.variables)
-  }
-}
-
-module.exports = Component
diff --git a/src/Renderer.js b/src/Renderer.js
index e3a8ae0..47c78ab 100644
--- a/src/Renderer.js
+++ b/src/Renderer.js
@@ -8,6 +8,7 @@ const collect = require('./utilities/collect')
 const { getFilter } = require('./utilities/filters')
 const { unique } = require('pure-utilities/array')
 const Statistics = require('./Statistics')
+const DataPlugin = require('./plugins/DataPlugin')
 const CurlyStylesPlugin = require('./plugins/CurlyStylesPlugin')
 const ScopedStylesPlugin = require('./plugins/ScopedStylesPlugin')
 const InternationalizationPlugin = require('./plugins/InternationalizationPlugin')
@@ -56,6 +57,7 @@ class Renderer {
     const promises = []
     const errors = []
     const plugins = [
+      new DataPlugin(),
       new InlinePlugin(),
       new BoxModelPlugin(options),
       new CurlyStylesPlugin(),
@@ -81,7 +83,8 @@ class Renderer {
             fragment,
             options,
             assets,
-            ...fragment
+            ...fragment,
+            pass: 'renderer'
           })
         })
       } catch (exception) {
diff --git a/src/plugins/DataPlugin.js b/src/plugins/DataPlugin.js
new file mode 100644
index 0000000..da59a2c
--- /dev/null
+++ b/src/plugins/DataPlugin.js
@@ -0,0 +1,40 @@
+const Plugin = require('./Plugin')
+const { parseData, getDataFormat } = require('../utilities/data')
+const { isPlainObject } = require('pure-conditions')
+const { optimizeNode } = require('../utilities/optimize')
+
+class DataPlugin extends Plugin {
+  beforeprerun () {
+    this.variables = []
+  }
+
+  prerun ({ tag, fragment, keys, pass }) {
+    if (pass !== 'renderer') return null
+    // TODO right data must be defined above usage
+    // consider changing interface of plugins
+    // to allow having 2 passes of walk instead of just one
+    // it could be declarative, e.g. new Plugin({ passes: 2 })
+    if (tag === 'data') {
+      const format = getDataFormat(keys)
+      const { content } = fragment.children[0]
+      const data = parseData(format, content)
+      if (isPlainObject(data)) {
+        for (const key in data) {
+          if (Object.prototype.hasOwnProperty.call(data, key)) {
+            const value = data[key]
+            this.variables.push({ key, value })
+          }
+        }
+      }
+    }
+    if (this.variables.length > 0) {
+      optimizeNode(fragment, this.variables, [], true)
+    }
+  }
+
+  afterrun () {
+    this.variables = []
+  }
+}
+
+module.exports = DataPlugin
diff --git a/src/plugins/InternationalizationPlugin.js b/src/plugins/InternationalizationPlugin.js
index f13fa71..2688503 100644
--- a/src/plugins/InternationalizationPlugin.js
+++ b/src/plugins/InternationalizationPlugin.js
@@ -1,44 +1,11 @@
 'use strict'
 
-const AbstractSyntaxTree = require('abstract-syntax-tree')
-const { load } = require('yaml-js')
 const { findAsset } = require('../utilities/files')
 const Plugin = require('./Plugin')
 const hash = require('string-hash')
+const { parseData, getDataFormat } = require('../utilities/data')
 const { TranslationError } = require('../utilities/errors')
 
-function parseYAML (content) {
-  try {
-    return load(content)
-  } catch (exception) {
-    throw new TranslationError('YAML translation is unparseable')
-  }
-}
-
-function parseJSON (content) {
-  try {
-    return JSON.parse(content)
-  } catch (exception) {
-    throw new TranslationError('JSON translation is unparseable')
-  }
-}
-
-function parseJS (content) {
-  try {
-    const tree = new AbstractSyntaxTree(content)
-    const node = tree.first('ExportDefaultDeclaration')
-    return AbstractSyntaxTree.serialize(node.declaration)
-  } catch (exception) {
-    throw new TranslationError('JS translation is unparseable')
-  }
-}
-
-function parse (format, content) {
-  if (format === '.yaml') return parseYAML(content)
-  if (format === '.json') return parseJSON(content)
-  return parseJS(content)
-}
-
 function merge (value, translations, languages) {
   if (value.includes('{') && value.includes('}')) return translations
   if (!translations[value]) throw new TranslationError(`There is no translation for the ${value} key`)
@@ -53,12 +20,6 @@ function getExtension (path) {
   return values[values.length - 1]
 }
 
-function getTranslationsFormat (keys) {
-  if (keys.includes('yaml')) return '.yaml'
-  if (keys.includes('json')) return '.json'
-  return 'js'
-}
-
 function isI18nTag (tag, keys) {
   return (tag === 'script' && keys.includes('i18n')) || tag === 'i18n'
 }
@@ -94,16 +55,16 @@ class InternationalizationPlugin extends Plugin {
         throw new TranslationError('The translation script cannot be empty')
       }
       leaf.used = true
-      const format = getTranslationsFormat(keys)
-      const data = parse(format, leaf.content)
+      const format = getDataFormat(keys)
+      const data = parseData(format, leaf.content)
       for (const key in data) {
         if (this.translations[key]) { throw new TranslationError('Translation already exists') }
         this.translations[`${key}_${id}`] = data[key]
       }
     } else if (isDataTag(tag)) {
-      const format = getTranslationsFormat(keys)
+      const format = getDataFormat(keys)
       const leaf = fragment.children[0]
-      const data = parse(format, leaf.content)
+      const data = parseData(format, leaf.content)
       const { i18n } = data
       for (const key in i18n) {
         if (this.translations[key]) { throw new TranslationError('Translation already exists') }
diff --git a/src/utilities/collect.js b/src/utilities/collect.js
index 64fb0e3..c6ece02 100644
--- a/src/utilities/collect.js
+++ b/src/utilities/collect.js
@@ -11,7 +11,7 @@ const { parse } = require('./html')
 const { inlineLocalVariables, inlineExpressions } = require('./inline')
 const { clone } = require('./object')
 const { isImportTag } = require('./string')
-const Component = require('../Component')
+const { optimize } = require('./optimize')
 const tags = require('../tags')
 const { hasShorthandSyntax, normalizeAttributes } = require('./node')
 const { getComponentNames } = require('./attributes')
@@ -159,9 +159,7 @@ function isSlotOrYield (node) {
 
 function resolveComponent (content, path, component, fragment, queue, plugins, errors, assets, options) {
   const localVariables = normalizeAttributes(fragment.attributes)
-  const htmlComponent = new Component(content, localVariables)
-  htmlComponent.optimize()
-  let htmlTree = parse(htmlComponent.source)
+  let htmlTree = parse(optimize(content, localVariables))
   htmlTree = inlineData(htmlTree, content, path, assets, plugins, errors, localVariables, options)
   walk(htmlTree, current => {
     if (component) { current.imported = true }
diff --git a/src/utilities/data.js b/src/utilities/data.js
new file mode 100644
index 0000000..aa63577
--- /dev/null
+++ b/src/utilities/data.js
@@ -0,0 +1,46 @@
+const AbstractSyntaxTree = require('abstract-syntax-tree')
+const { load } = require('yaml-js')
+const { TranslationError } = require('./errors')
+
+function parseYAML (content) {
+  try {
+    return load(content)
+  } catch (exception) {
+    throw new TranslationError('YAML translation is unparseable')
+  }
+}
+
+function parseJSON (content) {
+  try {
+    return JSON.parse(content)
+  } catch (exception) {
+    throw new TranslationError('JSON translation is unparseable')
+  }
+}
+
+function parseJS (content) {
+  try {
+    const tree = new AbstractSyntaxTree(content)
+    const node = tree.first('ExportDefaultDeclaration')
+    return AbstractSyntaxTree.serialize(node.declaration)
+  } catch (exception) {
+    throw new TranslationError('JS translation is unparseable')
+  }
+}
+
+function parseData (format, content) {
+  if (format === '.yaml') return parseYAML(content)
+  if (format === '.json') return parseJSON(content)
+  return parseJS(content)
+}
+
+function getDataFormat (keys) {
+  if (keys.includes('yaml')) return '.yaml'
+  if (keys.includes('json')) return '.json'
+  return 'js'
+}
+
+module.exports = {
+  parseData,
+  getDataFormat
+}
diff --git a/src/Component/curlyTagReduction.js b/src/utilities/optimize.js
similarity index 67%
rename from src/Component/curlyTagReduction.js
rename to src/utilities/optimize.js
index 70207ed..6a249d4 100644
--- a/src/Component/curlyTagReduction.js
+++ b/src/utilities/optimize.js
@@ -1,6 +1,6 @@
 'use strict'
 
-const lexer = require('../utilities/lexer')
+const lexer = require('./lexer')
 const AbstractSyntaxTree = require('abstract-syntax-tree')
 const {
   logicalExpressionReduction,
@@ -9,12 +9,14 @@ const {
   memberExpressionReduction,
   ifStatementRemoval
 } = require('astoptech')
-const { isCurlyTag, getTagValue, curlyTag } = require('../utilities/string')
-const { parse, stringify } = require('../utilities/html')
-const { inlineLocalVariablesInTags } = require('../utilities/inline')
+const { isCurlyTag, getTagValue, curlyTag } = require('./string')
+const { parse, stringify } = require('./html')
+const { inlineLocalVariablesInTags } = require('./inline')
 const walk = require('himalaya-walk')
-const { addPlaceholders, removePlaceholders } = require('../utilities/keywords')
-const { GLOBAL_VARIABLE } = require('../utilities/enum')
+const { addPlaceholders, removePlaceholders } = require('./keywords')
+const { GLOBAL_VARIABLE } = require('./enum')
+const { parseData, getDataFormat } = require('./data')
+const { isPlainObject } = require('pure-conditions')
 
 function canInlineTree ({ body }) {
   const statement = body[0]
@@ -24,10 +26,12 @@ function canInlineTree ({ body }) {
     typeof statement.expression.value === 'string'
 }
 
-function inlineVariables (node, parent, variables, newVariables) {
+function inlineVariables (node, parent, variables, newVariables, loose) {
   if (node.inlined) return node
   if (parent.type === 'MemberExpression' && node === parent.property) return node
   if (parent.type === 'Property') return node
+  // TODO foo.bar.baz should be optimized
+  // right now it will just inject objects into the root key
   if (node.type === 'Identifier') {
     const variable = variables.find(variable => variable.key === node.name)
     if (variable) {
@@ -43,7 +47,7 @@ function inlineVariables (node, parent, variables, newVariables) {
       const variable = newVariables.find(variable => variable.key === node.name)
       if (!variable) {
         if (parent.type === 'BinaryExpression') return
-        node.name = 'undefined'
+        if (!loose) { node.name = 'undefined' }
       }
     }
   }
@@ -100,7 +104,8 @@ function isGlobalVariable (tree) {
     node.expression.object.name === GLOBAL_VARIABLE
 }
 
-function optimizeCurlyTag (value, variables, newVariables) {
+function optimizeCurlyTag (value, variables, newVariables, loose) {
+  if (loose) { return looseOptimizeCurlyTag(value, variables) }
   value = addPlaceholders(value)
   if (isObject(value)) value = `(${value})`
   const tree = new AbstractSyntaxTree(value)
@@ -123,6 +128,21 @@ function optimizeCurlyTag (value, variables, newVariables) {
   return curlyTag(tree.source.replace(/;\n$/, ''))
 }
 
+function looseOptimizeCurlyTag (value, variables) {
+  value = addPlaceholders(value)
+  if (isObject(value)) value = `(${value})`
+  const tree = new AbstractSyntaxTree(value)
+  if (isGlobalVariable(tree)) { return curlyTag(value) }
+  tree.replace({ enter: (node, parent) => inlineVariables(node, parent, variables, [], true) })
+  if (canInlineTree(tree)) {
+    const { value } = tree.body[0].expression
+    return value
+  } else if (!tree.source) {
+    return ''
+  }
+  return curlyTag(tree.source.replace(/;\n$/, ''))
+}
+
 function isNodeAddingNewVariables (node) {
   return isForTag(node) || isForeachTag(node) || isEachTag(node)
 }
@@ -147,6 +167,19 @@ function curlyTagReduction (string, variables) {
     if (node.forbidden) return
     if (node.tagName === 'var') {
       variables.push(node.attributes[0])
+    } else if (node.tagName === 'data') {
+      const keys = node.attributes.map(attribute => attribute.key)
+      const format = getDataFormat(keys)
+      const { content } = node.children[0]
+      const data = parseData(format, content)
+      if (isPlainObject(data)) {
+        for (const key in data) {
+          if (Object.prototype.hasOwnProperty.call(data, key)) {
+            const value = data[key]
+            variables.push({ key, value })
+          }
+        }
+      }
     }
     if (isNodeAddingNewVariables(node)) {
       if (node.attributes.length === 3) {
@@ -159,7 +192,6 @@ function curlyTagReduction (string, variables) {
         newVariables.push({ key: attribute2.key, value: attribute2.value })
       }
     }
-    const { attributes } = node
     if (FORBIDDEN_TAGS.includes(node.tagName)) {
       node.children.forEach(node => {
         node.forbidden = true
@@ -173,24 +205,29 @@ function curlyTagReduction (string, variables) {
       })
     ]
     inlineLocalVariablesInTags(node, vars, true)
-    if (node.type === 'text') {
-      node.content = optimizeText(node.content, variables, newVariables)
-    } else if (attributes && attributes.length > 0) {
-      attributes.forEach(attribute => {
-        attribute.value = optimizeText(attribute.value, variables, newVariables)
-      })
-      node.attributes = attributes.filter(attribute => attribute.value !== '')
-    }
+    optimizeNode(node, variables, newVariables)
   })
   return stringify(tree, string)
 }
 
-function optimizeText (text, variables, newVariables) {
+function optimizeNode (node, variables, newVariables = [], loose) {
+  const { attributes } = node
+  if (node.type === 'text') {
+    node.content = optimizeText(node.content, variables, newVariables, loose)
+  } else if (attributes && attributes.length > 0) {
+    attributes.forEach(attribute => {
+      attribute.value = optimizeText(attribute.value, variables, newVariables, loose)
+    })
+    node.attributes = attributes.filter(attribute => attribute.value !== '')
+  }
+}
+
+function optimizeText (text, variables, newVariables, loose) {
   if (!text) return text
   let tokens = lexer(text)
   tokens = tokens.map(token => {
     if (token.type === 'expression') {
-      token.value = optimizeCurlyTag(token.value, variables, newVariables)
+      token.value = optimizeCurlyTag(token.value, variables, newVariables, loose)
     }
     return token
   })
@@ -198,4 +235,8 @@ function optimizeText (text, variables, newVariables) {
   return value.trim()
 }
 
-module.exports = curlyTagReduction
+function optimize (source, variables) {
+  return curlyTagReduction(source, variables)
+}
+
+module.exports = { optimize, optimizeNode }
diff --git a/src/utilities/string.js b/src/utilities/string.js
index de7815d..034af74 100644
--- a/src/utilities/string.js
+++ b/src/utilities/string.js
@@ -7,7 +7,7 @@ function curlyTag (string) {
 }
 
 function isTag (value, startTag, endTag) {
-  return value && value.startsWith(startTag) && value.endsWith(endTag)
+  return value && typeof value === 'string' && value.startsWith(startTag) && value.endsWith(endTag)
 }
 
 function containsTag (value, startTag, endTag) {


Test patch:
diff --git a/test/fixtures/acceptance/data/actual.html b/test/fixtures/acceptance/data/actual.html
index b43199c..5998b74 100644
--- a/test/fixtures/acceptance/data/actual.html
+++ b/test/fixtures/acceptance/data/actual.html
@@ -1,3 +1,19 @@
 <import foo from="components/foo.html" />
 <import bar from="components/bar.html" />
 <foo /><bar />
+<data yaml>
+i18n:
+  hello:
+    pl: Hej
+    en: Hello
+foo: This is foo
+qux:
+  quux: QUUUX
+</data>
+{foo}
+{bar}
+{baz}
+{i18n.hello.pl}
+{i18n.hello.en}
+{i18n.hello[language]}
+{ qux.quux }
diff --git a/test/fixtures/acceptance/data/components/bar.html b/test/fixtures/acceptance/data/components/bar.html
index 5a6012f..9809097 100644
--- a/test/fixtures/acceptance/data/components/bar.html
+++ b/test/fixtures/acceptance/data/components/bar.html
@@ -4,4 +4,6 @@ i18n:
   qux:
     en: NE
     pl: LP
+foo: BAR
 </data>
+{foo}
diff --git a/test/fixtures/acceptance/data/components/foo.html b/test/fixtures/acceptance/data/components/foo.html
index 1b676b0..bc1ddb5 100644
--- a/test/fixtures/acceptance/data/components/foo.html
+++ b/test/fixtures/acceptance/data/components/foo.html
@@ -4,4 +4,6 @@ i18n:
   qux:
     en: EN
     pl: PL
+xxx: FOO
 </data>
+{xxx}
diff --git a/test/fixtures/acceptance/data/data.json b/test/fixtures/acceptance/data/data.json
index a6042a4..d5ee09d 100644
--- a/test/fixtures/acceptance/data/data.json
+++ b/test/fixtures/acceptance/data/data.json
@@ -1,3 +1,5 @@
 {
-  "language": "en"
+  "language": "en",
+  "bar": "BAR",
+  "baz": "BAZ"
 }
diff --git a/test/fixtures/acceptance/data/expected.html b/test/fixtures/acceptance/data/expected.html
index 39f09c5..4faac98 100644
--- a/test/fixtures/acceptance/data/expected.html
+++ b/test/fixtures/acceptance/data/expected.html
@@ -1,1 +1,11 @@
-ENNE
+EN
+FOO
+NE
+BAR
+This is foo
+BAR
+BAZ
+Hej
+Hello
+Hello
+QUUUX