SFDigitalServices / formio-sfds

The form.io theme for sf.gov
https://formio-sfds.herokuapp.com/
MIT License
15 stars 2 forks source link

Find a better templating solution #108

Open shawnbot opened 4 years ago

shawnbot commented 4 years ago

TL;DR: I think we need a better template format for our form.io components. In this issue I'm attempting to work through the benefits and downsides of what we have and investigate other options.

I've been thinking a lot lately about how painful our EJS(-ish) templates, which we inherited from formio.js, are to edit. EJS is certainly one of, if not the, most minimal templating "languages", and the biggest advantage that it has over other languages is the lack of a runtime. For example, this:

<h1>{{ ctx.title }}</h1>

Is compiled into something like this:

module.exports = ctx => {
  let out = '<h1>'
  out += ctx.title
  out += '</h1>'
  return out
}

However, EJS has some authoring "ergonomics" issues. It quickly gets illegible when you start wrapping control structures (if, else, for loops, etc.) around the output directives ({{ output }}), especially with formio.js's choice of Django/Jinja/Nunjucks-style delimiters ({% %} instead of <% %>):

{% if (title) { %}
  <h1>{{ title }}</h1>
{% } %}

I get a headache just looking at it; there are just too many curly braces, and there's no syntax highlighter that understands it. Compare the Django/Jinja/Nunjucks equivalent, which GitHub and any text editor worth its salt know how to syntax-highlight:

{% if title %}
  <h1>{{ title }}</h1>
{% endif %}

Twig

Twig templates have a very similar syntax, and the benefit of using a common template format is that we could possibly share "components" (templates) between this repo and sf.gov. However, while the ergonomics of the template syntax are nice, the runtime alone weighs ~26K gzipped. In other words, we'd have to bring along 26K just to render the templates at runtime, whereas EJS "compiles down" to just a plain old JavaScript function that returns a string.

I don't know that we gain enough from using Twig to justify a 26K (+40% compared to the current 65K) bundle size increase.

JSX

Over in https://github.com/SFDigitalServices/formio-sfds/pull/107 I attempted to see what using JSX syntax as a template format, but with a lightweight string renderer called vdo in place of the React runtime. The gist is that this:

export default props => <h1>{props.title}</h1>

Gets compiled into:

const { createElement: h } = require('vdo') // this gets hoisted elsewhere
module.exports = props => h('h1', null, [props.title])

And the h() function just returns the HTML string representation of the element, as in:

h('div', {id: 'foo'}, ['bar']) === '<div id="foo">bar</div>'

The vdo runtime weighs in at ~1K. You can see in this comment that with a couple of templates translated to JSX and vdo in the mix, the bundle is ~3K (4%) larger. I think that if we were to convert the rest of our templates to JSX the bundle would end up smaller. (Imagine every out+='...' statement replaced by a nested h('div', ...) call, attributes expressed as JS object literals, shorter conditionals, etc.)

Anyway, more here in the near future!

shawnbot commented 4 years ago

Side note: in #107 I first experimented with vdo, but jsx-pragmatic appears to be a better option. It's been updated recently, and supports fragments (and the more concise <>{some}{content}</> syntax) out of the box (vdo does not, and requires some hacky compiler-level workarounds). There's an additional render step involved in jsx-pragmatic, which means that we'd need to write our templates like this:

/** jsx node */
import { node } from 'jsx-pragmatic'
import renderable from '../lib/renderable'
export default renderable(props => <h1>{props.title}</h1>)

and then implement the "renderable" function, which returns a function that renders the component as HTML:

import { html } from 'jsx-pragmatic'
const renderer = html()
export default function renderable(component) {
  return (...args) => component(...args).render(renderer)
}

Preact's preact-render-to-string would also do the trick:

// component.jsx
/** @jsx h */
import { h } from 'preact'
import renderable from '../lib/renderable'
export default renderable(props => <h1>{props.title}</h1>)

// renderable.js
import render from 'preact-render-to-string'
export default function renderable(component) {
  return (...args) => render(component(...args))
}

Both of the renderer runtimes weigh in at ~2K.

shawnbot commented 3 years ago

It looks an the even simpler option is to use vhtml:

/* @jsx h */
import h from 'vhtml'

export default ({ component, input: { type: InputType, attr, content } }) => (
  <InputType {...attr}>{content}</InputType>
)