Git Product home page Git Product logo

meta-template's Introduction

meta-template

Maintaining templates can be a pain in the butt, especially if you need to maintain templates for multiple engines or host languages. Meta-template aims to solve the problem of multi-engine template maintenance by making it possible to treat Nunjucks templates (which are theoretically compatible with Jinja out of the box, and almost compatible with Django, Liquid, and Twig) as the source of truth and programmatically transform them into other formats (such as ERB, Handlebars, Mustache) and even other languages, such as JSX or PHP.

How it works

At a high level, there are three steps in the template conversion process:

  1. Use Nunjucks to parse a template into an abstract syntax tree (AST)
const mt = require('meta-template');
const ast = mt.parse.string('{% if foo %}{{ foo }}{% else %}no foo!{% endif %}');
  1. Make any necessary transformations to the AST to match the output format
mt.ast.walk(ast, node => {
  if (node.type === 'TemplateData') {
    // do something with node.value here to modify the output, e.g.
    node.value = '(' + node.value + ')';
  }
});
  1. Format the AST into a string with a function that declaratively handles different types of AST "node" (If, Output, etc.), and automatically throws errors for unsupported node types
const out = mt.format.php(ast);
console.log(out);
// produces:
// '<?php if ($foo): ?><?= $foo ?><?php else: ?>(no foo!)<?php endif; ?>'

You can try it yourself by combining the above snippets into a standalone script and run it through the php command with:

node njk2php.js | php
# (no foo!)

The abstract syntax tree

The abstract syntax tree, or AST, is a tree structure of JavaScript objects that describes the parsed template. Some common nodes in the tree are:

  • TemplateData represents a raw string of template output
  • Output represents template data output, such as a variable
  • If represents a conditional control structure with cond (condition), body (the output when cond succeeds), and optional else_ child nodes
  • Symbol represents a "simple" variable expression, e.g. foo
  • LookupVal represents a nested variable expression, e.g. foo.bar[0]
  • Literal represents literals like true, false, and null (which must be converted to their language-specific equivalents in Ruby and Python)
  • Include the Nunjucks/Jinja/Liquid implementation of template partials

TODO: explain the parse and AST bits.

The format API

TODO: explain the abstract and concrete format APIs.

Play with it!

Currently I'm experimenting with different output formats, starting with Liquid (most useful for us Jekyll users at 18F) and PHP (which seemed to me the most potentially difficult). You can test these out by cloning the repo, running npm install to get the dependencies, then running the bin/parse.js script:

# output the Nunjucks AST in JSON format
./bin/parse.js path/to/template.html

# do the same without line and col info (--clean), trim input (--trim)
./bin/parse.js --clean --trim path/to/template.html

# or use stdin
echo 'foo {{ bar }} baz {% if x %}hi{% endif %}' | ./bin/parse.js

# reformat the AST as Nunjucks (this _should_ produce the same output)
echo 'foo {{ bar }} baz...' | ./bin/parse.js --format

# reformat as Liquid
echo 'foo {{ bar }} baz...' | ./bin/parse.js --format liquid

# reformat as PHP!
echo 'foo {{ bar }} baz...' | ./bin/parse.js --format php

Roadmap

This project is in its infancy, but here is a very rough roadmap:

  • Adapt the Nunjucks parser to parse templates into an abstract syntax tree (AST)
  • Write a format API that can transform AST nodes back into Nunjucks template strings
  • Flesh out the conversion API in JavaScript
  • Do some research on template engine popularity in order to prioritize target formats
  • Determine the "lowest common denominator" set of template features to support in parsing so that we can warn when source templates use features that aren't available in the desired output format(s)
  • Make a command line tool
  • Write some API docs
  • Profit?

meta-template's People

Contributors

36degrees avatar dsingleton avatar shawnbot avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar

meta-template's Issues

React support

React support would be really interesting. I'm not sure what the JSX AST looks like, but with a little clever pattern matching I think this could be doable. Here are some potential stumbling blocks off the top of my head:

  • AFAIK, React components must return a single element. So we'd either need to enforce that relationship strictly, or detect (with an HTML parser? ugh) whether a template contains more than one top-level element and wrap in a <div></div> accordingly.

  • You can't arbitrarily wrap attributes or open and close tags in conditionals in JSX; dynamic attributes must be set with attr={value}, and conditional nesting is more complicated.

  • Conditional attributes in particular would require detecting patterns like attr="{{ value }}", class="static {{ dynamic }}", or class="{% if x }}x{% else %}y{% endif %}" and transforming to JSX-compatible expressions (attr={value}, class={`static ${dynamic}`}, and class={x ? `x` : `y`}, respectively).

  • Mapping from template variables to props might be tricky. This looks like a pretty straightforward transformation from:

    <ul>
      {% for link in links %}
      <li><a href="{{ link.href }}">{{ link.text }}</a></li>
      {% endfor %}
    </ul>

    to:

    export ({links}) => {
      return (
        <ul>
        {links.map(link => {
          return <li><a href={link.href}>{link.text}</a></li>;
        })}
        </ul>
      );
    };

    But does the component necessarily get {links: links}, or the links array as props? Do we have to detect all of the "top-level" template variables so that we can generate the right destructuring expression?

One nice thing, at least, is that we wouldn't have to worry about whitespace and could just pipe the generated code through prettier. ✨

Reverse-engineer templates?

What if you could use meta-template to figure out all of the variables a given template expects? All of the following should be theoretically possible to figure out from the AST:

  • You can derive top-level variable names from statements like {% if foo %}, {{ foo }}
  • You can reasonably assume that foo is an object with an optional bar property from {% if foo.bar %}, or expressions like {{ foo.bar[0] }}
  • You can infer arrays from loops like {% for x in foo %}
  • Conditional statements could suggest whether certain data properties are optional; whereas a "naked" expression like {{ foo }} suggests that foo is required

The other way that this could work is as a validation tool in which you would provide, say, the JSON Schema for the data a template expects (which would serve as the template's "API"), and then validate a template against that schema to confirm that it doesn't attempt to access invalid properties, attempt to iterate over primitives, etc.

Add CI tests

The trick will be getting this to work with the new integration tests, since services like Travis and Circle can be tricky to get working with multiple language environments.

Escape template delimiters in "raw" template data

Currently, Nunjucks' raw filter doesn't produce an AST node that we can use to easily map to the equivalent escape tags in other templating systems:

$ echo '{% raw %}{{hi}}{% endraw %}' | ./bin/parse.js -ct

produces:

{
  "children": [
    {
      "children": [
        {
          "value": "{{hi}}",
          "type": "TemplateData"
        }
      ],
      "type": "Output"
    }
  ],
  "type": "Root"
}

It looks like what we have to do is check if each TemplateData node's value contains any of the format's O_OPEN, O_CLOSE, C_OPEN, or C_CLOSE strings, and escape it accordingly. This actually makes more sense because the delimiters will vary by engine, so ERBs can escape only the values they need to.

Create integration tests for supported template formats

The simplest thing here would probably be to have a script that runs a standard set of converted templates (the feature lower common denominator) through a reference implementation of the target engine (ideally a shell one-liner) to ensure that we get the expected output. For instance, we might test the Handlebars conversion with a this, which we might call handlebars:

#!/usr/bin/env node
const fs = require('fs');
const op = require('yargs').argv;
const template = fs.readFileSync(op.template);
const data = JSON.parse(fs.readFileSync(op.data));
console.log(
  require('handlebars').compile(template)(data)
);

which the testing script could call with a diff.sh that looks something like:

#!/bin/bash
# transform the fixture template with the input format
meta-template fixtures/$(1)/template.njk -f $(2) \
  | ./bin/$(2) --data fixtures/$(1)/data.json \
  > fixtures/symbol/$(2).txt
# then diff it against the fixture's expected output
diff fixtures/$(1)/{output,$(2)}.txt

which you'd call with:

./bin/diff.sh symbol handlebars

(or do it in Node.) The layout might look like:

test/
├── fixtures
|   ├── lookup
|   |   ├── data.json
|   |   ├── template.njk
|   |   └── output.txt
|   └── symbol
|       ├── data.json
|       ├── template.njk
|       └── output.txt
├── bin
|   ├── diff.sh
|   ├── handlebars.sh
|   └── liquid.sh
├── handlebars.spec.js
└── liquid.spec.js

@dsingleton, do you have any thoughts on this?

Automate generation of a support matrix

It should be possible to generate a matrix of support for specific features from a set of Nunjucks template snippets. For instance, these roughly map to the more common AST node types:

symbol:  '{{ foo }}'
lookup:  '{{ foo.bar[1].baz[0][1] }}'
literal: '{{ true }}'
filter:  '{{ foo | bar }}'
# maybe for Mustache's {{}} vs. {{{}}}?
escape:  '{{ foo | escape }}'
if:      '{% if foo %}foo{% endif %}'
# there is no Else or ElseIf node type, but these need their own tests:
else:    '{% if foo %}foo{% else %}bar{% endif %}'
elseif:  '{% if foo %}foo{% elseif bar %}bar{% else %}baz{% endif %}'
unless:  '{% unless foo %}bar{% endunless %}'
for:     '{% for x in foo %}foo {{ x }}{% endfor %}'
include: '{% include "foo.html" %}'
block:   '{% block foo %}foo{% endblock %}'
extends: '{% extends "foo.html" %}{% block foo %}foo{% endblock %}'

Brainstorming: "suite" conversion

I think it'd be really cool if meta-template could batch-convert a collection or "suite" of templates into a supported format, and use knowledge of templates' inter-relationships to convert them in more reliable and useful ways.

For instance, in order to "correctly" convert templates that use {% extends %} to Jekyll layouts, you need to put those in the right directory (_layouts vs. _includes). Most templating systems (including Liquid, which Jekyll uses under the hood) just want a bucket of files in a single directory, but Jekyll and some CMS-specific engines assume a bit more structure. I guess for Jekyll it's possible that a "suite" of templates could be output to a site's (or theme's) _includes directory and that the layouts could {% include %} the appropriate page templates?

custom tags -nunjucks

I added custom tags to use in nunjucks templates, how can I add them to those recognized by the parser ?

Add macro support?

In Nunjucks and Jinja, the macro tag is the closest we can get to functional programming with templates, and is a much clearer and succinct way of handling defaults than the convention of components or partials expecting specifically named, global template variables. For instance, to create a nested list component without macros, you end up doing something like this:

<ul>
  {% for item in links %}
  <li><a href="{{ item.href }}">{{ item.text }}</a>
    {% if item.links %}
      {% set links = item.links %}
      {% include 'list.html' %}
    {% endif %}
  </li>
  {% endfor %}
</ul>

...which is clunky and brittle because:

  1. As a user of the component or partial, you have to find the for loop in order to know that it expects the links variable to be set, and trust that none of your other templates reference that variable.
  2. If the component maintainer has to change the variable name for whatever reason, all of the templates that reference it need to be changed, too.
  3. The include needs to reference the full path to the template within the environment path(s).

The solution is something much more elegant:

{% macro list(links) %}
<ul>
  {% for item in links %}
  <li>
    <a href="{{ item.href }}">{{ item.text }}</a>
    {% if item.links %}{{ list(item.links) }}{% endif %}
  </li>
  {% endfor %}
</ul>
{% endmacro %}

I'm not sure what compatibility would look like for other languages, but here's a first pass:

  1. Jekyll could do this with include parameters, e.g. {% include list.html links=item.links %}

  2. ERB might be tricky because I'm not sure if it's valid to define a function and break it up with delimiters like this:

     <% def list(links) %>
       <ul>
       <% for item in links %>
         <li>
           <a href="<%= item.href %>"><%= item.text %></a>
           <% if item.links %><%= list(item.links) %><% end %>
         </li>
       <% end %>
       </ul>
     <% end %>
  3. Twig has macros, but PHP would need to look more like ERB.

  4. I'm pretty sure that Handlebars would require registering a helper in JS. ☹️

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.