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:
- Reading it. Fenced code blocks tagged
jsxortsxgo through arborium, which parses them with the same tree-sitter grammars an editor uses and colours tags, attributes and expressions accordingly. - Writing it. Every template in the repository — layouts, pages and the components in
_components/— is written in Astro syntax and expanded at build time into plain HTML.
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:
article,a,ulandliare intrinsic elements and are coloured as variables.Tagis capitalised, so the grammar treats it as a component and colours it as a constructor, the same distinction React makes.className,data-open,href,onClickandkeyare coloured as properties, separately from both the tag and the value.- Inside every
{ ... }expression the grammar is back to ordinary JavaScript, souseState,!openand the arrow function highlight normally, and the nested<li>insidetags.mapis parsed as JSX again rather than as a less-than sign. - The template literal inside that expression keeps its own nesting, so
${title}is highlighted inside the backticks inside the braces.
#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:
- No client behaviour. There is no runtime, no state, no hydration.
onClickin a code block is a string being coloured, not a handler. Anything interactive has to be written as HTML and CSS. - The expression language is a subset. No arithmetic, no arbitrary method calls, no array literals.
.map()is special-cased, and paths index groups positionally (content.posts.0.title). - One pass, in one order. A post body is expanded as a template first, then rendered as markdown. Fenced blocks and inline code spans are masked out before that pass, which is the only reason every example above survived long enough to be printed.
- Slots are text, not a tree. A component receives its children as rendered markup and drops them in. It cannot inspect them, count them, or render them twice.
- Capitalisation is the whole resolution rule.
<Note />is a component if_components/Note.astroexists, and passes through untouched if it does not.
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.