JSX without JavaScript

2026-09-06

This blog is generated by a couple of thousand lines of Perl driven by POSIX shell. There is no Node, no bundler, and not a single byte of JavaScript in the output. It still speaks JSX, in two separate senses worth keeping apart:

The second one is why this paragraph is followed by a box:

#Highlighting JSX

The generator doesn't highlight anything itself. It hands each code block to the arborium CLI, which parses it with a tree-sitter grammar and wraps every token in a short custom element: <a-k> for a keyword, <a-s> for a string, <a-cr> for a constructor. A stylesheet then colours those elements with GitHub's light and dark themes.

import { useState } from "react";

// A component, with a few of the shapes JSX allows
export default function Card({ title, href, tags = [] }) {
  const [open, setOpen] = useState(false);

  return (
    <article className="card" data-open={open}>
      <a href={href} onClick={() => setOpen(!open)}>
        {title}
      </a>
      {open && <p className="body">{`Opened ${title}`}</p>}
      <ul>
        {tags.map((tag) => (
          <li key={tag}>
            <Tag name={tag} />
          </li>
        ))}
      </ul>
    </article>
  );
}

Because the grammar actually parses the code, the colours follow its structure rather than guessing from characters:

#TSX

tsx uses the TypeScript grammar, which colours types wherever they appear and gives tags and attribute names their own elements:

type CardProps = {
  title: string;
  href: string;
  tags?: readonly string[];
};

const Card = ({ title, href }: CardProps): JSX.Element => (
  <a className="card" href={href}>
    {title}
  </a>
);

#Astro

arborium has no Astro grammar, so an astro block prints as plain text:

---
import Layout from "../layouts/Layout.astro";
const posts = await Astro.glob("../posts/*.md");
---

<Layout title="Blog">
  <ul>
    {posts.map((post) => (
      <li>
        <a href={post.url}>{post.frontmatter.title}</a>
      </li>
    ))}
  </ul>
</Layout>

#Fragments and self-closing tags

Two cases a hand-written lexer easily gets wrong come free with a real parser. A self-closing tag doesn't change the nesting depth, and a fragment has no tag name at all:

const Row = () => (
  <>
    <Icon name="star" />
    <span>after the self-closing tag</span>
  </>
);

#Writing Astro

The templates are not merely highlighted as Astro, they are written in it. Here is the whole layout that wraps every page on this site:

<!DOCTYPE html>
<html lang={site.lang}>
<head>
<Head />
</head>
<body>
<slot />
</body>
</html>

{expression} interpolates and escapes. <slot /> is where the wrapped content goes. <Head /> is a component, which is also how partials work — there is no separate include mechanism.

#Expressions

The expression language is a small, deliberate subset of JavaScript: paths, string and template literals, +, ==, !=, !, &&, ||, the ternary, and function calls.

<title>{page.seoTitle}</title>
<meta name="description" content={page.seoDescription}>
<link rel="canonical" href={absUrl(page.url)}>
<aside class={`callout callout-${props.kind}`}>
{page.date && (<time datetime={dateIso(page.date)}>{dateLong(page.date)}</time>)}
{site.inlineCss ? (<style set:html={site.css}></style>) : (<link rel="stylesheet" href={url("/assets/css/main.css")}>)}

The functions stand in for what a template language would call filters: url, absUrl, upper, lower, stripHtml, xml, default, dateLong, dateRfc822 and dateIso.

Because {expression} escapes by default, raw HTML needs saying so. <slot /> inserts already-rendered content, and set:html does the same for a value, which is how the stylesheet gets inlined above.

#Lists

.map() is the only iteration construct, and it looks like the real thing. content.posts is every document under content/posts/, newest first — a directory is a list without being declared anywhere. This is the entire component that renders the index on the home page:

<ul class="post-list">
{content.posts.map(post => (<li><a href={url(post.url)}>{post.date} - {post.title}</a></li>))}
</ul>

Inside a map body, index, first and last are in scope. Which produces, live, on this page:

#Components and props

A component is a file in _components/. Its own --- frontmatter sets prop defaults, {props.x} reads a prop, and <slot /> is whatever the tag wrapped:

---
kind: note
title: Note
---
<aside class={`callout callout-${props.kind}`}>
<strong>{props.title}</strong>
<slot />
</aside>

Attribute values come in three flavours: a quoted value is a literal, a braced value is an expression, and a bare attribute is true.

<Note title="Fixed text" />
<Note title={`Interpolated: ${site.title}`} />
<Note title={content.posts.0.title} />
<Note wide />

Components nest, and each gets its own scope, so an inner component's props do not leak into the outer one:

#Where it stops

The honest limits, because a component system that expands at build time is not React:

None of that has cost me anything yet. The build for this site takes under a second, ships no JavaScript, and the only reason JSX appears in it at all is that the syntax turns out to be a pleasant way to call a partial written in Perl.