Templating serveur
& composants front

L'impossible amour ?

Nicolas Grisey Demengel, developer @hopwork

demengel.net / @NicolasDemengel / @moreunit

Templating coté serveur ou client ?

  • app dans le browser
  • premier rendu
  • version dégradée
  • etc.

Notre problème

  • templates JSP
  • composants JS
  • comment tester ça facilement ?

Si on était full JS...

Mocha, chai, jsdom, et voilà !

Si on était full JS...

Une vue


var View = require('ampersand-view');
var templates = require('./templates');

module.exports = View.extend({

  template: templates.myView,

  events: {
    'click .foo': 'doSomething'
  },

  doSomething: function () {
    this.trigger('some-custom-event');
  }
});
						

Si on était full JS...

Un test


describe('MyView', function () {
  it('should xxx when yyy', withDocument(function () {
    // given
    var MyView = require('../src/my-view');
    var view = new MyView({ /* some args */ }).render();

    var listener = sinon.spy();
    view.on('some-custom-event', listener);

    // when
    $('.foo').click();
    // then
    listener.should.have.been.calledWith(view);
  }));
});
						

Mais on utilise des JSP


<c:if test="${1 == 2}">ben non</c:if>

<c:forEach items="${things}" var="thing">
  ${thing}
  <c:out value="${thing}"/>
</c:forEach>

<spring:message code="my.message.key"/>

<form:input type="number" path="description"/>

<jsp:include page="other.jsp">
  <jsp:param name="foo" value="bar"/>
</jsp:include>
						

Et si...

  1. Interpréteur de JSP en JS, anyone ?
    Pas trouvé :-(    (en même temps...)
  2. Appeler le compilo de JSP depuis les tests JS ?
    Hum, hum... :-S
  3. Traduire la JSP en un autre système de templating ?
    O_o' Mais pourquoi ?

Aucun lien, fils unique

Lodash's _.template()


// using the "interpolate" delimiter to create a compiled template
var compiled = _.template('hello <%= user %>!');
compiled({ 'user': 'fred' });
// → 'hello fred!'

// using the HTML "escape" delimiter to escape data property values
var compiled = _.template('<b><%- value %></b>');
compiled({ 'value': '<script>' });
// → '<b>&lt;script&gt;</b>'

// using the "evaluate" delimiter to execute JavaScript and generate HTML
var compiled = _.template('<% _.forEach(users, function(user) { %><li><%- user %></li><% }); %>');
compiled({ 'users': ['fred', 'barney'] });
// → '<li>fred</li><li>barney</li>'

// using the ES delimiter as an alternative to the default "interpolate" delimiter
var compiled = _.template('hello ${ user }!');
compiled({ 'user': 'pebbles' });
// → 'hello pebbles!'

// using custom template delimiters
_.templateSettings.interpolate = /{{([\s\S]+?)}}/g;
var compiled = _.template('hello {{ user }}!');
compiled({ 'user': 'mustache' });
// → 'hello mustache!'
						

Et si...

  1. Interpréteur de JSP en JS, anyone ?
    Pas trouvé :-(
  2. Appeler le compilo de JSP depuis les tests JS ?
    Hum, hum... :-S
  3. Traduire la JSP en un autre système de templating ?
    O_o' Mais pourquoi ?
    → ça fait beacoup de similitudes quand même :-)

Exemple de similitudes


<% things.forEach(function(thing) { %>
  <li><%- thing %></li>
  <li>${thing}</li>
<% }); %>
                        

<c:forEach items="${things}" var="thing">
  <li><c:out value="${thing}"/></li>
  <li>${thing}</li>
</c:forEach>
                        

Sprechen Sie JSP?

Comment traduire une JSP en template lodash ?

Mais bien sûr...

des Regex(p) !

Sprechen Sie JSP?

Allez, juste une heure de coding, pour rire !

Bon OK, 4 petites heures plus tard...

Sprechen Sie JSP?

LE test :-° (1/3)


describe('Jsp Translator', function () {
  it('should translate a big soup of JSP tags', function () {
    var attributes = {bar: 'toto'...};

    var jspPath = path.resolve(__dirname, 'main.jsp');
    var expectedHtml = readAsString(__dirname, 'expected.html'));
    expect(jsp.resolveFile(jspPath, attributes)).to.equal(expectedHtml);
  });
});

Sprechen Sie JSP?

LE test :-° (2/3)


<%@ page contentType="text/html" %>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
<%@ taglib prefix="form" uri="http://www.springframework.org/tags/form"%>
<%@ taglib prefix="spring" uri="http://www.springframework.org/tags" %>

<%-- some comment --%>
<c:choose>
  <c:when test="${myVar != 'someVal'}">
    <p>should not be shown</p>
  </c:when>
  <c:when test="${myVar2 == 'someOtherVal'}">
    <p>should be shown 1</p>
  </c:when>
  <c:otherwise>
    <p>should also not be shown</p>
  </c:otherwise>
</c:choose>
<c:choose>
  <c:when test="${myVar == 'someVal'}">
    <p>should be shown 2</p>
  </c:when>
  <c:when test="${myVar != 'someVal'}">
    <p>should not be shown</p>
  </c:when>
  <c:otherwise>
    <p>should also not be shown</p>
  </c:otherwise>
</c:choose>
<c:choose>
  <c:when test="${false}">
    <p>should not be shown</p>
  </c:when>
  <c:otherwise>
    <p>should be shown 3</p>
  </c:otherwise>
</c:choose>
<form:form modelAttribute="formModel">
  <c:forEach items="${things}" var="thing">
    <form:input type="number" path="formModelMember" data-plop="${thing.name}"/>
    <c:if test="${myVar == 'someVal'}">
      <input type="date" value="${bar}">
    </c:if>
    <c:if test="${myVar2 != 'someVal'}">
      <input type="number" value="42">
    </c:if>
  </c:forEach>
  <c:out value="notavar"/>
  <c:out value="${avar}"/>
  <un:supported jsp="tag"></un:supported>
  <a title="<spring:message code="message.code" javascriptEscape="true"/>" href="#">Link</a>
  <!-- @include sibling1: -->
  <%@ include file="sibling1.jsp" %>
  <c:set var="anewvar" value="${anoldvar}"/>
  <p>anewvar: ${anewvar}</p>
  <c:set var="multilineSet">
    foo
    bar
  </c:set>
  <p>multilineSet: ${multilineSet}</p>
  <c:set var="escapedSet">
    <c:out value="${avar}"/>
  </c:set>
  <p>escapedSet: ${escapedSet}</p>
  <!-- jsp:include sibling1: -->
  <jsp:include page="sibling1.jsp"/>
  <c:if test="${empty emptyVar and empty emptyVar2}">
    <p>I support "empty"!</p>
  </c:if>
  <c:if test="${not empty nonEmptyVar}">
    <p>I support "not empty"!</p>
  </c:if>
  <form:radiobutton path="${radioPath}" value="1" id="${radioPath}1"/>
  <c:forEach begin="${one}" end="3" var="idx">
    <span>${idx}</span>
  </c:forEach>
</form:form>

Sprechen Sie JSP?

LE test :-° (3/3)


<p>should be shown 1</p>
<p>should be shown 2</p>
<p>should be shown 3</p>
<form modelAttribute="formModel">
  <input type="number" value="form model member value"  id="formModelMember" name="formModelMember" data-plop="name 1"/>
  <input type="date" value="toto">
  <input type="number" value="42">
  <input type="number" value="form model member value"  id="formModelMember" name="formModelMember" data-plop="name 2"/>
  <input type="date" value="toto">
  <input type="number" value="42">
  notavar
  A VALUE TO &amp;SCAPE
  <!-- unsupported JSP tag removed: <un:supported jsp="tag"> --><!-- unsupported JSP tag removed: </un:supported> -->
  <a title="i18n_message.code" href="#">Link</a>
  <!-- @include sibling1: -->
  <p>Text from sibling.jsp with some toto</p>
  <dl>
    <dt>Param 1</dt><dd>static</dd>
    <dt>Param 2</dt><dd>dynamic, resolved</dd>
  </dl>
  <p>anewvar: anoldvalue</p>
  <p>multilineSet: foo
    bar</p>
  <p>escapedSet: A VALUE TO &amp;SCAPE</p>
  <!-- jsp:include sibling1: -->
  <p>Text from sibling.jsp with some toto</p>
  <dl>
    <dt>Param 1</dt><dd></dd>
    <dt>Param 2</dt><dd></dd>
  </dl>
  <p>I support "empty"!</p>
  <p>I support "not empty"!</p>
  <input type="radio"  name="someRadioButtonPath" value="1" id="someRadioButtonPath1"/>
  <span>1</span>
  <span>2</span>
  <span>3</span>
</form>

Sprechen Sie JSP?

Le résultat (~300 lignes)


'use strict';

var fs = require('fs');
var path = require('path');
var _ = require('lodash');

function unwrapElExpression(value) {
  return value.replace(/^\$\{([^}]*?)\}$/, '$1');
}

function resolve(jsp, attributes, context) {
  context = context || {jspDir: __dirname};

  var ATTRIBUTE = /(?:\s+?([\w-]+?=".*?"))/g;
  var EL_EXPRESSION = /\$\{([^}]*?)\}/g;
  var FORM_INPUT = /(<form:\w+\s+.*?\b)path=".*?"(.*?\/?>)/g;
  var FORM = /(<\/?)form:/g;
  var INCLUDE_DIRECTIVE = /<%@\s*include\s+file="(.*?)"\s*%>/g;
  var JSP_DIRECTIVE_OR_COMMENT = /<%(@|--).*?(--)?%>/g;
  var JSP_TAG = /<\/?(\w+:\w+)(\s+[^>]*?)?\s*\/?>/g;

  function readAttributes(str) {
    var attrs = {};

    var res;
    while ((res = ATTRIBUTE.exec(str)) !== null) {
      var kv = res[1].split('=');
      var key = kv[0];
      var value = kv.slice(1).join('=');
      if (value) {
        value = value.replace(/^"(.*?)"$/, '$1');
        attrs[key] = replaceElOperators(value);
      } else {
        attrs[key] = null;
      }
    }

    return attrs;
  }

  function replaceElOperators(jsp) {
    var res;
    var buf = [];
    var idx = 0;

    while ((res = EL_EXPRESSION.exec(jsp)) !== null) {
      buf.push(jsp.substring(idx, res.index));
      buf.push('${', res[1].replace(/\bnot\s+empty\s+/g, '!!')
        .replace(/\bempty\s+/g, '!')
        .replace(/\s+and\s+/, ' && ')
        .replace(/\s+or\s+/, ' || '), '}');

      idx = EL_EXPRESSION.lastIndex;
    }

    buf.push(jsp.substring(idx));

    return buf.join('');
  }

  function replaceFormInputs(jsp) {
    var res;
    var buf = [];
    var idx = 0;

    while ((res = FORM_INPUT.exec(jsp)) !== null) {
      buf.push(jsp.substring(idx, res.index));
      buf.push(res[1]);

      var attrs = readAttributes(res[0]);

      if (!attrs.id) {
        buf.push(' id="', attrs.path, '"');
      }
      if (!attrs.name) {
        buf.push(' name="', attrs.path, '"');
      }
      buf.push(res[2]);
      idx = FORM_INPUT.lastIndex;
    }

    buf.push(jsp.substring(idx));

    return buf.join('');
  }

  function resolveInclude(file, attrs) {
    var filePath = path.resolve(context.jspDir, file);
    return resolveFile(filePath, attrs);
  }

  function replaceJspTags(jsp) {
    var res;
    var buf = [];
    var bufBackup;
    var idx = 0;

    var currentJspParams, currentJspPageToInclude, currentChoose, currentModelAttribute;

    function jspInclude() {
      var inclusionAttrs = _.assign({
        param: currentJspParams
      }, attributes);

      var str = resolveInclude(currentJspPageToInclude, inclusionAttrs);

      currentJspPageToInclude = null;
      currentJspParams = null;
      return str;
    }

    var STRATEGIES = {
      DEFAULT: {
        open: function (buf, res) {
          buf.push('<!-- unsupported JSP tag removed: ', res[0], ' -->');
        },
        close: function (buf, res) {
          buf.push('<!-- unsupported JSP tag removed: ', res[0], ' -->');
        }
      },
      'c:choose': {
        open: function () {
          currentChoose = {};
        },
        close: function () {
          buf.push('<% } %>');
        }
      },
      'c:otherwise': {
        open: function (buf) {
          buf.push('<% } else { %>');
        },
        close: function (buf) {
        }
      },
      'c:when': {
        open: function (buf, res, attrs) {
          if (currentChoose.firstWhenPassed) {
            buf.push('<% } else if (', unwrapElExpression(attrs.test), ') { %>');
          } else {
            buf.push('<% if (', unwrapElExpression(attrs.test), ') { %>');
          }
          currentChoose.firstWhenPassed = true;
        },
        close: function (buf) {
        }
      },
      'c:forEach': {
        open: function (buf, res, attrs) {
          var items;
          if (attrs.items) {
            items = unwrapElExpression(attrs.items);
          } else {
            items = '_.range(' + unwrapElExpression(attrs.begin) + ', ' + unwrapElExpression(attrs.end) + ' + 1)';
          }
          buf.push('<% ', items, '.forEach(function (', attrs.var, ') { %>');
        },
        close: function (buf) {
          buf.push('<% }); %>');
        }
      },
      'c:if': {
        open: function (buf, res, attrs) {
          buf.push('<% if (', unwrapElExpression(attrs.test), ') { %>');
        },
        close: function (buf) {
          buf.push('<% } %>');
        }
      },
      'c:set': {
        open: function (buf, res, attrs) {
          if (attrs.value) {
            if (attrs.value.indexOf('${') === 0) {
              buf.push('<% var ', attrs.var, ' = ', unwrapElExpression(attrs.value), '; %>');
            } else {
              buf.push('<% var ', attrs.var, ' = "', attrs.value, '"; %>');
            }
          } else {
            buf.push('<% var ', attrs.var, ' = ');
          }
        },
        close: function (buf, res, setContent) {
          if (!setContent) {
            return;
          }

          setContent = setContent.trim();

          var match = setContent.match(/^<%-\s*(.*?)\s*%>$/);
          if (match) {
            buf.push('_.escape(', match[1], '); %>');
          } else {
            match = setContent.match(/^<%=?\s*(.*?)\s*%>$/);
            if (match) {
              buf.push(match[1], '; %>');
            } else {
              buf.push('"', setContent.replace(/\n/g, '\\n'), '"; %>');
            }
          }
        }
      },
      'c:out': {
        open: function (buf, res, attrs) {
          if (attrs.value.indexOf('${') === 0) {
            buf.push('<%- ', unwrapElExpression(attrs.value), ' %>');
          } else {
            buf.push(attrs.value);
          }
        }
      },
      'form:form': {
        open: function (buf, res, attrs) {
          if (attrs.modelAttribute) {
            currentModelAttribute = attrs.modelAttribute;
          }
          // handled later
          buf.push(res[0]);
        },
        close: function (buf, res) {
          currentModelAttribute = null;
          // handled later
          buf.push(res[0]);
        }
      },
      'form:input': {
        open: function (buf, res, attrs) {
          if (attrs.path) {
            var path = attrs.path;
            if (currentModelAttribute) {
              if (path.indexOf('${') === 0) {
                path = currentModelAttribute + '[' + unwrapElExpression(path) + ']';
              } else {
                path = currentModelAttribute + '.' + path;
              }
            }
            var value = attrs.value;
            if (value) {
              buf.push(res[0]);
            } else {
              if (path.indexOf('${') === 0) {
                value = '';  // not implemented, too complex
              } else {
                value = '${' + path + '}';
              }
              buf.push(res[0].replace(/\bpath=/, 'value="' + value + '" path='));
            }
          }
          else {
            // handled later
            buf.push(res[0]);
          }
        },
        close: function (buf, res) {
          // handled later
          buf.push(res[0]);
        }
      },
      'form:radiobutton': {
        open: function (buf, res, attrs) {
          res[0] = res[0].replace('radiobutton', 'input type="radio"');
          STRATEGIES['form:input'].open(buf, res, attrs);
        },
        close: function (buf, res) {
          STRATEGIES['form:input'].close(buf, res);
        }
      },
      'jsp:include': {
        open: function (buf, res, attrs, selfClosing) {
          currentJspPageToInclude = attrs.page;
          currentJspParams = {};
          if (selfClosing) {
            buf.push(jspInclude());
          }
        },
        close: function (buf) {
          buf.push(jspInclude());
        }
      },
      'jsp:param': {
        open: function (buf, res, attrs) {
          currentJspParams[attrs.name] = attrs.value;
        }
      },
      'spring:message': {
        open: function (buf, res, attrs) {
          buf.push('i18n_' + attrs.code);
        }
      }
    };

    while ((res = JSP_TAG.exec(jsp)) !== null) {
      buf.push(jsp.substring(idx, res.index));

      var tag = res[0];
      var tagName = res[1];

      var closing = tag.indexOf('</') === 0;
      if (!closing) {
        var selfClosing = tag.indexOf('/>') === tag.length - 2;
        var attrs = readAttributes(tag);

        var strategy = STRATEGIES[tagName] || STRATEGIES.DEFAULT;
        var open = strategy.open || STRATEGIES.DEFAULT.open;

        open(buf, res, attrs, selfClosing);
        if (tagName === 'c:set' && !selfClosing) {
          bufBackup = buf;
          buf = [];
        }
      }
      else {
        var strategy = STRATEGIES[tagName] || STRATEGIES.DEFAULT;
        var close = strategy.close || STRATEGIES.DEFAULT.close;

        if (bufBackup) {
          var content = buf.join('');
          buf = bufBackup;
          bufBackup = null;
          close(buf, res, content);
        } else {
          close(buf, res);
        }
      }
      idx = JSP_TAG.lastIndex;
    }

    buf.push(jsp.substring(idx));

    return buf.join('');
  }

  function replaceIncludes(jsp) {
    var res;
    var buf = [];
    var idx = 0;

    while ((res = INCLUDE_DIRECTIVE.exec(jsp)) !== null) {
      buf.push(jsp.substring(idx, res.index));
      buf.push(resolveInclude(res[1], attributes));
      idx = INCLUDE_DIRECTIVE.lastIndex;
    }

    buf.push(jsp.substring(idx));

    return buf.join('');
  }

  function convert(jsp) {
    jsp = replaceElOperators(jsp);
    jsp = replaceJspTags(jsp);
    jsp = replaceFormInputs(jsp);
    jsp = jsp.replace(FORM, '$1');
    jsp = replaceIncludes(jsp);

    return jsp.replace(JSP_DIRECTIVE_OR_COMMENT, '');
  }

  return _.template(convert(jsp))(attributes)
    .replace(/\n+\s*\n+/g, '\n');
}

function resolveFile(jspFile, attributes, jspDir) {
  var jsp = fs.readFileSync(jspFile).toString();
  return resolve(jsp, attributes, {jspDir: jspDir || path.dirname(jspFile)});
}

module.exports = {
  resolve: resolve,
  resolveFile: resolveFile
};

Résumé :


function unwrapElExpression(value) {}

function resolve(jsp, attributes, context) {
  context = context || {jspDir: __dirname};

  var ATTRIBUTE = /(?:\s+?([\w-]+?=".*?"))/g;
  var EL_EXPRESSION = /\$\{([^}]*?)\}/g;
  var FORM_INPUT = /(<form:\w+\s+.*?\b)path=".*?"(.*?\/?>)/g;
  var FORM = /(<\/?)form:/g;
  var INCLUDE_DIRECTIVE = /<%@\s*include\s+file="(.*?)"\s*%>/g;
  var JSP_DIRECTIVE_OR_COMMENT = /<%(@|--).*?(--)?%>/g;
  var JSP_TAG = /<\/?(\w+:\w+)(\s+[^>]*?)?\s*\/?>/g;

  function readAttributes(str) {}

  function replaceElOperators(jsp) {}

  function replaceFormInputs(jsp) {}

  function resolveInclude(file, attrs) {}

  function replaceOtherJspTags(jsp) {
    /* ... */

    var STRATEGIES = {
      DEFAULT: {
        open: function (buf, res, attrs, selfClosing) {
          buf.push('<!-- unsupported JSP tag removed: ', res[0], ' -->');
        },
        close: function (buf, res) {
          buf.push('<!-- unsupported JSP tag removed: ', res[0], ' -->');
        }
      },
      'c:choose': {},
      'c:otherwise': {},
      'c:when': {},
      'c:forEach': {},
      'c:if': {},
      'c:set': {},
      'c:out': {},
      'form:form': {},
      'form:input': {},
      'form:radiobutton': {},
      'jsp:include': {},
      'jsp:param': {},
      'spring:message': {}
    };

    while ((res = JSP_TAG.exec(jsp)) !== null) {
      buf.push(jsp.substring(idx, res.index));

      var closing = res[0].indexOf('</') === 0;
      if (!closing) {
        var selfClosing = res[0].indexOf('/>') === res[0].length - 2;
        var attrs = readAttributes(res[0]);

        var strategy = STRATEGIES[res[1]] || STRATEGIES.DEFAULT;
        var open = strategy.open || STRATEGIES.DEFAULT.open;
        open(buf, res, attrs, selfClosing);
      }
      else {
        var strategy = STRATEGIES[res[1]] || STRATEGIES.DEFAULT;
        var close = strategy.close || STRATEGIES.DEFAULT.close;
        close(buf, res);
      }
      idx = JSP_TAG.lastIndex;
    }

    buf.push(jsp.substring(idx));

    return buf.join('');
  }

  function replaceIncludes(jsp) {}

  function convert(jsp) {
    jsp = replaceOtherJspTags(jsp);
    jsp = replaceFormInputs(jsp);
    jsp = jsp.replace(FORMS, '$1');
    jsp = replaceIncludes(jsp);

    return jsp.replace(JSP_DIRECTIVE_OR_COMMENT, '');
  }

  return _.template(convert(jsp))(attributes)
    .replace(/\n+\s*\n+/g, '\n');
}

function resolveFile(jspFile, attributes, jspDir) {
    var jsp = fs.readFileSync(jspFile).toString();
    return resolve(jsp, attributes, {jspDir: jspDir || path.dirname(jspFile)});
}

Hop, plus qu'à écrire des tests !

Démo

Conclusions

  • C'est utile ? Donc ce n'est peut-être pas si stupide !
  • Si vous êtes dans la même situation...

Merci pour la séance !

(Au fait, vous en pensez quoi : bonne ou mauvaise idée ?)

Source du jingle : https://www.youtube.com/watch?v=GIQQaXfqJFU (oué, aucune pitié)