Documentation
The fhtml language
A whitespace markup language that compiles 1:1 to HTML, with an optional template layer on top. This page is the practical guide; the SPEC is the normative definition.
Overview
Every line is one element. The shape is
tag(attrs) #id classes… "text", and everything after the tag is optional. Indentation nests exactly
like Python — deeper lines are children — and there are no closing tags.
Bare tokens after the tag are the class list, copied to the output
byte-for-byte; the compiler never looks inside a class token.
section py-10
h1 text-3xl font-bold "Hello"
p text-slate-600 "Whitespace nests. No closing tags."<section class="py-10">
<h1 class="text-3xl font-bold">Hello</h1>
<p class="text-slate-600">Whitespace nests. No closing tags.</p>
</section>Elements & classes
The first token is the tag name. Every bare token after it is a class,
emitted verbatim — arbitrary values and variants included, with no
escaping. Tailwind's
hover:,
w-1/2, and
data-[state=open]:bg-red-500 all pass straight through.
button rounded-full px-4 py-1 text-sm hover:bg-purple-600 hover:text-white "Message"
div w-1/2 data-[state=open]:bg-red-500Attributes
Attributes live in parens, butted against the tag with no space. Values
are bare words or quoted strings; a bare
name with no value is a boolean attribute. Anything outside the parens is
still a class.
a(href=/about target=_blank) text-indigo-600 hover:underline "About"
input(type=email name=email required) w-full rounded border px-3 py-2
img(src=/logo.svg alt="Company logo") h-8Div, id & inline child
A lone
. means
div, so a bare class list can hang off it. A
#id token sets the element id. And
> chains a single inline child on the same line — handy for
li > a link lists.
. #hero grid gap-4
ul divide-y
li > a(href=/docs) "Docs"
li > a(href=/install) "Install"<div id="hero" class="grid gap-4">
<ul class="divide-y">
<li><a href="/docs">Docs</a></li>
<li><a href="/install">Install</a></li>
</ul>
</div>Text
A quoted string at the end of a line is the element's text, HTML-escaped.
For multi-line prose, use
| text lines: each one emits its content, and inline elements can sit
between them to weave markup through a paragraph.
p text-slate-600
| By
|
a(href=/spec) font-medium "the spec"
| , text is escaped and safe.
blockquote > p "One quoted string is the simplest form."Raw HTML
A line starting with
< is passed through verbatim and consumes the deeper-indented block below
it — the escape hatch for anything fhtml can't express natively, like a
hand-tuned
<svg>. The bodies of
script and
style are raw text too.
figure
<svg viewBox="0 0 24 24"><path d="M4 12l5 5L20 6"/></svg>
style
.brand { color: rebeccapurple; }Comments, continuation & doctype
Lines starting with
// are comments and vanish from the output. A trailing
\ continues a long line onto the next. And
doctype emits
<!DOCTYPE html>.
// top-level page shell
doctype
html(lang=en)
head > title "My page"
body bg-white \
text-slate-900 "…"Template layer
Interpolation
With
--data, a JSON object binds names you can drop into text and attributes with
{expr}. Expressions are a small closed language — property access, arithmetic,
comparisons, ternaries — not arbitrary JavaScript.
{!expr} skips escaping. In class position an interpolation must be a whole token,
so switch between full class names rather than building them from pieces —
and a boolean or falsy result drops out entirely (see
Conditional classes).
a(href="/u/{user.handle}") font-medium "{user.name}"
span {user.online ? 'text-green-600' : 'text-slate-400'} "{user.status}"
span rounded px-2 {user.pro && 'bg-amber-100 text-amber-800'} "{user.plan}"Conditionals & loops
if/
elif/
else branch on an expression;
for x, i in xs iterates with an optional index, and
empty provides a fallback when the collection is empty.
ul divide-y
for item, i in items
li py-2 {i % 2 == 0 && 'bg-gray-50'} "{i + 1}. {item.title}"
empty
li text-gray-400 "Nothing here yet."Conditional classes
A class-position interpolation whose result is a boolean or otherwise
falsy emits no classes at all — no stray
false,
0, or empty token. That turns
&& and
|| into
clsx-style class guards with no helper and no runtime.
// guard: add the classes, or nothing
span rounded px-2 {user.pro && 'bg-amber-100 text-amber-800'} "{user.plan}"
// default: fall back when the value is falsy
p {size || 'text-sm'} "{body}"
// negation needs a space — {! is the raw form
button { !disabled && 'hover:bg-indigo-500'} "Save"
// a real two-way choice is still a ternary
span {online ? 'text-green-600' : 'text-slate-400'} "{status}"The rule is class-position only — everywhere else a falsy value still
stringifies as usual. A literal class that happens to be spelled like a
boolean stays writable as a bare token (
false) or a string (
{'false'}).
Components
def declares a component (top level, parameters only — it closes over
nothing).
+name(args) instantiates it, and the call's indented block becomes
children inside the definition. Parameters can carry defaults. (This very page is
built from
def components — the nav, footer, and every code card above.)
def card(title wide=false)
. rounded-xl bg-white p-6 shadow {wide && 'col-span-2'}
h3 text-lg font-semibold "{title}"
children
+card(title="Monthly stats" wide=true)
p text-sm text-gray-600 "Revenue is up 12%."Includes
include ./partials/head splices another file: its
def s join the namespace and its markup emits at the include site. Paths are
relative to the including file; cycles and
def collisions are errors. Includes are inlined, so emitted JS modules stay
self-contained.
include ./partials/head
body
+header(active="home")
main > p "Shared def, one source of truth."Tooling
The CLI
Compile a file to stdout, pipe through stdin, write a file, or build a
whole tree. Output is minified on stdout and pretty when writing files;
--pretty/
--min override. Errors carry line and column.
fhtml page.fhtml # compile to stdout (minified)
echo 'p "hi"' | fhtml # stdin → stdout
fhtml page.fhtml -o page.html # compile to a file (pretty)
fhtml build src/ -o dist/ # compile a directory tree
fhtml build src/ -o dist/ --target=js # emit ES modules instead
fhtml page.fhtml --data data.json # render with dataFormatter
fhtml fmt normalizes to 2-space indentation,
. for
div, and minimal quoting. Formatting never changes the compiled output — the
recommended workflow is
write → fmt → build.
fhtml fmt src/ # reformat in place, canonical style
fhtml page.fhtml --no-templates # enforce pure markup (no template layer)html2fhtml
The reverse direction, for migrating existing markup (needs the
convert feature). Output is always canonical, and
--check verifies the round-trip produces the same DOM.
html2fhtml page.html # HTML → fhtml on stdout
html2fhtml src/ -o out/ # convert a directory tree
html2fhtml --check page.html # verify HTML → fhtml → same DOM
html2fhtml --convert-svg page.html # convert <svg> subtrees tooClass shorthand
An opt-in codebook contracts common Tailwind utilities to short codes,
measured at −9% more tokens across the corpus. A file opts in with
#!shorthand as its first line; codes decode on compile. It's a write-time
compression for tooling — generate plain classes, then
fmt --contract to store.
#!shorthand
div fx ic g4
p ti4 "Hello" // → <p class="text-indigo-400">Hello</p>Ecosystem
As a library
The crate exposes the compiler directly.
compile is the static path;
render evaluates the template layer;
compile_to_js emits the ES-module target. The core is zero-dependency.
use fhtml::{compile, render, json, Mode};
let html = compile("p text-lg \"Hello\"", Mode::Pretty)?;
let data = json::parse(r#"{"name": "Erin"}"#)?;
let html = render("p \"Hi, {name}\"", &data, Mode::Min)?;JavaScript & WASM
@fhtml/core ships the same compiler as WebAssembly plus dependency-free ESM glue, for
Node, browsers, and edge runtimes. Output is byte-identical to the native
CLI — that parity is the release gate. On Node,
@fhtml/core/node reads a file and its includes from disk;
@fhtml/core/express is an Express view engine and
@fhtml/core/hono a Hono renderer middleware for the edge.
import { init, render, compileToJs, analyze } from "@fhtml/core";
await init();
const { html } = render('div grid\n span rounded "hi"\n');Vite
vite-plugin-fhtml imports a
.fhtml file as a render function, or
?html as a static string. Compile errors surface in Vite's overlay; editing an
included partial hot-reloads every importer.
import render from "./card.fhtml"; // (data, ctx) => string
import hero from "./hero.fhtml?html"; // the static HTML string
document.body.innerHTML = render({ title: "Ship it" });Tailwind
Tailwind v4's scanner picks up fhtml classes as-is — they're plain
space-separated tokens. Point
@source at your
.fhtml files. One rule: never build a class name from an expression — the
scanner is static, so switch between whole class names instead.
Conditional classes need no helper: booleans and falsy results emit
no classes, so
{active && 'ring-2'} adds the class or nothing, clsx-style.
@import "tailwindcss";
@source "./src/**/*.fhtml";Editors
A VS Code extension is on the
Marketplace and
Open VSX, covering the full language including the template layer, with raw
< lines highlighted as embedded HTML. A language server backs diagnostics,
formatting, symbols, definitions, and completion. See the
install guide for setup.