I made JSX for Lua (because I hate static sites)

This website now runs on a custom language called LuaX.

It’s like JSX, but for Lua. You just write a Lua file that returns a chunk of HTML. You can compose more interesting pages by writing functions that return HTML. Here’s what the home page of my site looks like in LuaX:

-- I can define whatever data I like in plain old Lua.
local articles = {
  {
    title = "I made JSX for Lua (because I hate static sites)",
    description = "This site now runs on a custom dialect of Lua.",
    slug = "luax",
    date = os.time({ year = 2023, month = 12, day = 27 }),
  },
  -- more articles...
}

-- "Components" are just Lua functions that return HTML.
-- Lua expressions can be placed in attributes or text.
function Article(atts, children)
  local a = atts.article
  return <article>
    <header>
      <h1><a href={ absurl(a.slug) }>{{ a.title }}</a></h1>
      <span class="post-details">
        <time datetime={ os.date("%Y-%m-%dT%H:%M:%S%z", atts.date) } itemprop="datePublished">
          {{ os.date("%B %-d, %Y", atts.date) }}
        </time>
      </span>
    </header>
    <p>
      {{ a.description }}
    </p>
  </article>
end

-- I can use Lua's package system to organize my templates.
require("base")

-- Whatever the file returns will be rendered and sent to the browser.
return <Base>
  <div class="list">
    {{ map(articles, function (a)
      <Article article={ a } />
    end) }}
  </div>
</Base>

But…why?

Because I hate static sites. No, seriously. I actually hate static sites.

I got into programming because I wanted to make websites. When I started out, you could just copy HTML files up to your server and you had a website. It was magical! And PHP made it even better; you could just throw in a little snippet of server-side code and you had a dynamic page.

Those days are gone. Today I spend my time organizing Markdown files into folders so a janky tool can cobble them together into a website. I spend my time learning weird template languages with weird functions and bad control flow. I spend my time in cloud control panels getting confused by bucket permissions and CDN settings.

It feels like we’ve regressed. 15 years ago it was fine to run a script from scratch on every single request, but today we insist that we have to serve every personal website from a CDN. That means no fun allowed—if you so much as want the ©2024 in the footer to update, you’ll need to rebuild and redeploy the site.

I don’t want that. I want my ©2024 to update dynamically, dammit.

What’s saddest to me is that when people do want that dynamism (and they often do), they end up doing it with JavaScript, making their pages heavier and slower. Then to compensate for this, they’ll add some kind of “serverless” backend, server-side rendering + hydration, and a pile of extra complexity. Instead of just doing a little server-side code, they’re wasting people’s CPU time and their own money.

I don’t miss PHP, of course. But I do miss the workflow. For my own personal site, I don’t want to worry about a “backend” and “frontend”—I just want to send some HTML and JavaScript to the browser. I want a “deployment” to be as simple as copying a new batch of files to my server, but I want to be able to do interesting logic in those files too.

I want a dynamic site, not a static one.

But…why JSX?

Because I like it.

I have tried so many template languages over the years. I’ve used mustache, Liquid, nunjucks, Blade, Django, and of course Go templates (commonly seen in systems like Hugo).

All of these languages have basically the same design, which I’ll call the preprocessor model. They all start with the assumption that a “template” is a mostly static piece of content (in this case HTML) with some slots to paste in dynamic values. It’s more or less just the C preprocessor, a simple find-and-replace pass over the original content.

But this is never actually enough. You start out with simple values and ranges, but rapidly escalate to tricky conditional logic, weird inheritance systems, and eventually custom functions written in the host language that are injected into the template language.

Just like the C preprocessor, these template languages are not real languages. They are inflexible and inextensible. The few tools they provide for reuse and composition are incredibly weak. And just like C macros, these languages aren’t powerful enough to manipulate the data they’re given, only to blindly spit it back out.

Basically, these languages are too weak to do anything interesting. When writing my article about doing Advent of Code on a PS5, I found myself frequently reusing specific layouts, e.g. a wide layout that displayed two columns side-by-side but reflowed to vertical on mobile. In Go templates, this was an utter disaster:

<!-- Template definitions -->
{{ define "wide-start" }}
<div class="wide flex justify-center mv4">
  <div class="flex flex-column flex-row-l {{ if not . }}items-center{{ else }}{{ . }}{{ end }} g4">
    <div class="w-100 flex-fair-l p-dumb">
{{ end }}
{{ define "wide-middle" }}
    </div>
    <div class="w-100 flex-fair-l p-dumb">
{{ end }}
{{ define "wide-end" }}
    </div>
  </div>
</div>
{{ end }}

<!-- Later in the article -->
{{ template "wide-start" }}
<p>Before we go further, let me introduce you to programming in Dreams...</p>
{{ template "wide-middle" . }}
{{ template "video" "basics" }}
{{ template "wide-end" . }}

The problem is that no preprocessor-style template language actually allows you to manipulate the HTML as data. The HTML is opaque; the template language doesn’t understand it. But web pages are complex documents that often need serious logic to assemble—logic that requires the template language to work with the document structure instead of just text.

Even PHP falls short of this standard. Language jank and terrible design aside, PHP is at least a “real” programming language. It has arrays and loops and functions and so on. But it still doesn’t understand HTML. It’s basically just a fancier version of the preprocessor model, and it suffers from the same problems. (This design also can be blamed for the long history of XSS vulnerabilities in PHP apps. PHP itself doesn’t understand how to escape things, so the developer must be constantly vigilant.)

The preprocessor model is fundamentally wrong. HTML documents should be built with a system that understands HTML.

This is why JSX is so much better. Instead of JS-inside-HTML, it’s HTML-inside-JS. It flips the ownership around. When you need to do something interesting, you don’t need to learn some underpowered language, or contort your work to fit a broken system, or remember which escaping functions to use. You just write code and the rest sorts itself out.

It’s important to note that JSX is distinct from React, or Svelte, or whatever other frontend framework it is usually associated with. I’m not a fan of those frameworks; they have lots of ideas about client-side reactivity that I don’t like and don’t care about. But I really like writing HTML inside a real programming language.

But…why Lua?

Because I like it.

I considered just using JSX. But my website is built in Go, and I wanted to keep it that way. I find Go’s tooling to be best-in-class when it comes to deploying on a server—build a binary, start it up, the end. In contrast, every time I’ve deployed a Node app, I have suffered immensely.

What I wanted was something I could embed easily in an existing app. Lua fit the bill nicely. But even more importantly, Lua is extremely easy to parse. The entire language grammar fits on one screen, and the official parser is less than 1500 lines of code. Furthermore, Lua didn’t use < for anything except comparisons, so there was no ambiguity—HTML tags could be used wherever tables were used.

It was therefore very easy to create a custom recursive descent parser. I mostly just ported the official parser to Go, and modified parseSimpleExp to parse tags as well as tables.

func (t *Transpiler) parseSimpleExp() {
  switch tok := t.peekToken(); tok {
  case "nil", "true", "false", "...":
    t.nextToken()
  case "{":
    t.parseTable()
  case "<":                    // these two lines were all I needed to add :)
    t.parseTag(t.indent, true) // (well and t.parseTag itself obviously)
  case "function":
    t.nextToken()
    t.parseFuncBody()
  default:
    if isNumber(tok) || isString(tok) {
      t.nextToken()
      return
    }
    t.parseSuffixedExp()
  }
}

My transpiler doesn’t even produce an abstract syntax tree. It doesn’t need to. It just emits the vanilla Lua code as-is, and converts HTML tags to Lua tables as it goes.

-- Before:
local image = <img class="w-100" src="cool.png" />
-- After:
local image = { type = "html", name = "img", atts = { class = "w-100", src = "cool.png" }, children = {}, }

The end result is less than 1000 lines of code. About 700 of them are just parsing Lua, with the remaining ~300 being used for my new features. And it took me only a couple days!

Before and after

I’ve ported every page of this website over to LuaX, and the more I use it, the more I like it. It elegantly handles all the things that were so painful to do in previous template systems.

For example: I have an image-resizing system built into my site, designed for use with the HTML <picture> tag. When using this system with Go templates, I couldn’t include the <picture> tag in the template because I needed to be able to set arbitrary CSS classes on it. This meant that using the template required me to manually write the outer tag, then use the template on the inside. Furthermore, I had to built a custom “options” system for the alt attribute, and of course I had to implement all this in a custom template function written in Go. This is all easy now and I don’t have to switch languages.

Before (Go templates)

"picturesource": func(abspath string, scale int, opts ...PictureOpt) (template.HTML, error) {
  if scale < 1 {
    scale = 1
  }

  // code to fetch image variants omitted

  optionsByType := make(map[string][]string)
  for _, variant := range processed.Variants {
    options := optionsByType[variant.ContentType]
    optionsByType[variant.ContentType] = append(options, fmt.Sprintf(
      `%s %dx`,
      variant.url, variant.Scale,
    ))
  }

  var altAttr string
  for _, opt := range opts {
    if opt.ImgAlt != "" {
      altAttr = fmt.Sprintf("alt=\"%s\"", html.EscapeString(opt.ImgAlt))
    }
  }

  var b strings.Builder
  for contentType, options := range optionsByType {
    b.WriteString(fmt.Sprintf(
      `<source srcset="%s" type="%s">`,
      strings.Join(options, ", "), contentType,
    ))
  }
  b.WriteString(fmt.Sprintf(`<img src="%s"%s>`, AbsURL(r.R, abspath), altAttr))

  return template.HTML(b.String()), nil
},
<picture class="w-100">
    {{
        picturesource "triangle.png" 1
        (alt "Performance, Velocity, and Adaptability")
    }}
</picture>

After (LuaX)

function Picture(atts, children)
  atts.scale = atts.scale or 1
  
  local variants = images.variants(atts.src, atts.scale)
  
  local optsByType = {}
  for _, variant in ipairs(variants) do
    opts = optsByType[variant.contentType] or {}
    table.insert(opts, string.format("%s %dx", variant.url, variant.scale))
    optsByType[variant.contentType] = opts
  end
  
  local sources = {}
  for contentType, opts in pairs(optsByType) do
    table.insert(sources, <source
      srcset={ table.concat(opts, ", ") }
      type={ contentType }
    />)
  end
  
  return <picture class={ atts.class }>
    {{ sources }}
    <img src={ absurl(atts.src) } src={ atts.alt } />
  </picture>
end
<Picture
  class="w-100"
  src="triangle.png"
  alt="Performance, Velocity, and Adaptability"
/>

My Desmos 3D article required me to generate unique IDs for each Desmos graph. In Hugo I had to use the “scratch” system to store and manipulate data in Hugo itself. Now I can just do a global in my LuaX file. Furthermore, I had to register a new desmos shortcode globally across my entire site; this is now just a Lua function specific to that article.

Before (Hugo, Go templates)

{{/*
  hooray, a whole new set of math functions specifically
  for Hugo's scratch system
/*}}
{{ .Page.Scratch.Add "desmosid" 1 }}
{{ $did := print "desmos-" (.Page.Scratch.Get "desmosid") }}

<div class="desmos-container">
  <div class="desmos" id="desmos-{{ $did }}"></div>
  <a class="desmos-reset" href="#" onClick="desmosReset('desmos-{{ $did }}'); return false;">
    Reset
  </a>
</div>

<script>
  desmosConfigs['{{ $did }}'] = function() {
    var elt = document.getElementById('desmos-{{ $did }}');
    var d = Desmos.GraphingCalculator(elt, {
      border: false,
      {{ .Get "opts" | safeJS }}
    });
    {{ .Inner | safeJS }}
  };
  desmosReset('desmos-{{ $did }}');
</script>
{{< desmos >}}
d.setExpression({ latex: '\\left\\{x<n:\\sin(x),-x\\right\\}' });
d.setExpression({ latex: 'n=0' });
{{< /desmos >}}

After (LuaX)

local desmosID = 0

function Desmos(atts, children)
  desmosID = desmosID + 1
  return <>
    <div class="desmos-container">
      <div class="desmos" id={ "desmos-"..desmosID }></div>
      <a
        class="desmos-reset"
        href="#"
        onClick={ "desmosReset('desmos-"..desmosID.."'); return false;" }
      >
        Reset
      </a>
    </div>

    <script>
      desmosConfigs['desmos-{{ desmosID }}'] = function() {
        var elt = document.getElementById('desmos-{{ desmosID }}');
        var d = Desmos.GraphingCalculator(elt, {
          border: false,
          {{ atts.opts }}
        });
        {{ atts.js }}
      };
      desmosReset('desmos-{{ desmosID }}');
    </script>
  </>
end
<Desmos js=[[
  d.setExpression({ latex: '\\left\\{x<n:\\sin(x),-x\\right\\}' });
  d.setExpression({ latex: 'n=0' });
]] />

A tag’s children can be accessed programmatically, enabling me to make wrapper components like Wide with ease. This was extraordinarily painful in Go templates, requiring me to define separate templates for the beginning, middle, and end of the HTML.

Before (Go templates)

{{ define "wide-start" }}
<div class="wide flex justify-center mv4">
  <div class="flex flex-column flex-row-l {{ if not . }}items-center{{ else }}{{ . }}{{ end }} g4">
    <div class="w-100 flex-fair-l p-dumb">
{{ end }}
{{ define "wide-middle" }}
    </div>
    <div class="w-100 flex-fair-l p-dumb">
{{ end }}
{{ define "wide-end" }}
    </div>
  </div>
</div>
{{ end }}
{{ template "wide-start" }}
<p>
  Widgets can be snapped onto a Microchip...
</p>
{{ template "wide-middle" . }}
{{ template "video" "microchips" }}
{{ template "wide-end" . }}

After (LuaX)

function Wide(atts, children)
  return <div class="wide flex justify-center mv4">
    <div class={{
      "flex flex-column flex-row-l",
      atts.class or "items-center",
      "g4"
    }}>
      <div class="w-100 flex-fair-l p-dumb">
        {{ children[1] }}
      </div>
      <div class="w-100 flex-fair-l p-dumb">
        {{ children[2] }}
      </div>
    </div>
  </div>
end
<Wide>
  <p>
    Widgets can be snapped onto a Microchip...
  </p>
  <Video slug="microchips" />
</Wide>

But by far the biggest impact was on my Advent of Code on a PlayStation article. Before, due to the limited expressiveness of Go templates, I had to carefully construct all the HTML for each day of Advent of Code, with templates only for smaller reusable chunks. Doing things differently with Go templates would have required me to encode all the data in my Go code and then pass it into the template, which might be reasonable but didn’t fit my workflow. With LuaX, though, I can just loop over a Lua table and call it a day—no need to switch languages.

Before (Go templates)

{{ define "preview-start" }}
<div class="preview flex-column flex-row-l items-center g4 ph3 pv4 dn" data-day="{{ . }}">
  <div class="flex-fair-l w-100">
{{ end }}
{{ define "preview-middle" }}
  </div>
  <div class="flex-fair-l w-100">
{{ end }}
{{ define "preview-end" }}
  </div>
</div>
{{ end }}
<div class="week mb3 dn flex-l">
  <div class="heading">Sunday</div>
  <div class="heading">Monday</div>
  <div class="heading">Tuesday</div>
  <div class="heading">Wednesday</div>
  <div class="heading">Thursday</div>
  <div class="heading">Friday</div>
  <div class="heading">Saturday</div>
</div>
<div class="week dn flex-l">
  <div class="day"></div>
  <div class="day"></div>
  <div class="day"></div>
  <div class="day"></div>
  {{ template "day" "1/Calorie Counting" }}
  {{ template "day" "2/Rock Paper Scissors" }}
  {{ template "day" "3/Rucksack Reorganization" }}
</div>
<div class="previews">
  <div class="preview-container">
    {{ template "preview-start" "1" }}
    <h2>Day 1: Calorie Counting</h2>
    <!-- note the redundant day number and title -->
    <!-- preview info, manually entered -->
    {{ template "preview-middle" }}
    {{ template "youtube-deferred" "5s55Ks7wdcc/6862" }}
    {{ template "preview-end" }}

    {{ template "preview-start" "2" }}
    <h2>Day 2: Rock Paper Scissors</h2>
    <!-- preview info, manually entered -->
    {{ template "preview-middle" }}
    {{ template "youtube" "07GstsK8Aos?start=6890" }}
    {{ template "preview-end" }}
  </div>
</div>
<div class="week dn flex-l">
  {{ template "day" "4/Camp Cleanup/*" }}
  {{ template "day" "5/Supply Stacks/*" }}
  {{ template "day" "6/Tuning Trouble" }}
  {{ template "day" "7/No Space Left On Device" }}
  {{ template "day" "8/Treetop Tree House/*" }}
  {{ template "day" "9/Rope Bridge/*" }}
  {{ template "day" "10/Cathode-Ray Tube" }}
</div>
<!-- and so on...week, previews, week, previews -->

After (LuaX)

local days = {
  {
    num = 1,
    name = "Calorie Counting",
    tdreams = "1:45:00",
    tjs = "~15:00",
    dreamsid = "dAFfSQkZJKq",
    vods = {"5s55Ks7wdcc"},
    highlightid = "5s55Ks7wdcc",
    highlightts = "6862",
    desc = <><!-- description here --></>,
  },
  -- ...
}

local weeks = {
  {false, false, false, false, days[1], days[2], days[3]},
  {days[4], days[5], days[6], days[7], days[8], days[9], days[10]},
  {days[11], days[12], days[13], days[14], days[15], false, false},
}

function Preview(atts, children)
  -- automatically compute preview info from table entry

  return <div
    class="preview flex-column flex-row-l items-center g4 ph3 pv4 dn"
    data-day={ atts.day.num }
  >
    <div class="flex-fair-l w-100">
      <h2>Day {{ atts.day.num }}: {{ atts.day.name }}</h2>
      <div class="info">
        {{ join(
          map(infos, function (info)
            return <div>{{ info }}</div>
          end),
          " / "
        ) }}
      </div>
      <div class="desc mt3 p-dumb">
        {{ atts.day.desc }}
      </div>
    </div>
    <div class="flex-fair-l w-100">
      <YouTube id={ atts.day.highlightid } start={ atts.day.highlightts } deferred />
    </div>
  </div>
end

function Week(atts, children)
  return <>
    <div class="week dn flex-l">
      {{ map(atts.week, function (day)
        if day then
          return <Day day={ day } />
        else
          return <div class="day"></div>
        end
      end) }}
    </div>
    <div class="previews">
      <div class="preview-container">
        {{ map(atts.week, function (day)
          if day then
            return <Preview day={ day } />
          end
        end) }}
      </div>
    </div>
  </>
end
<div class="week mb3 dn flex-l">
  <div class="heading">Sunday</div>
  <div class="heading">Monday</div>
  <div class="heading">Tuesday</div>
  <div class="heading">Wednesday</div>
  <div class="heading">Thursday</div>
  <div class="heading">Friday</div>
  <div class="heading">Saturday</div>
</div>
{{ map(weeks, function (week)
  return <Week week={ week } />
end) }}

Closing thoughts

My goal with this project was to make myself happy, and I think I succeeded. 🙂

I’m very happy with how LuaX feels so far. It’s not “ready for production”, in the sense that I have cut several corners on the parser, but it meets all my needs right now and I love using it.

Taking a step back, though, it’s very gratifying to be able to bang out a transpiler in a weekend. Earlier in my career I would have started with lex and yacc and probably gotten lost. (This has happened to me multiple times.) These days though I understand compilers well enough to just sit down and write one, and it feels great.

I spent almost all of this year working on projects for other people. While I am proud of the work I did, and enjoyed it, there’s still something special about making tools for yourself. Does anyone else in the world want to make their websites in a weird dialect of Lua? Maybe not. But I do, and that’s enough.

I don’t know what the future holds for LuaX. It’s not ready for other people to use, and I’m not sure it will ever be. So rather than tell you all to go to luax dot dev and start building your websites with my tool, here’s what I’ll say instead:

Try building something for yourself. Try writing code for you, and you alone. Don’t worry about whether it will look good on your resumé or attract lots of stars on GitHub. Just write something that feels good to you. Explore a weird idea and see where it takes you.

Who knows—maybe someday other people will like it too.