fhtml

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.

hello.fhtmlfhtml
section py-10
  h1 text-3xl font-bold "Hello"
  p text-slate-600 "Whitespace nests. No closing tags."
hello.htmlhtml
<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.

classes.fhtmlfhtml
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-500

Attributes

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.

attrs.fhtmlfhtml
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-8

Div, 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.

structure.fhtmlfhtml
. #hero grid gap-4
  ul divide-y
    li > a(href=/docs) "Docs"
    li > a(href=/install) "Install"
structure.htmlhtml
<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.

text.fhtmlfhtml
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.

raw.fhtmlfhtml
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>.

page.fhtmlfhtml
// 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).

profile.fhtmlfhtml
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.

list.fhtmlfhtml
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.

badge.fhtmlfhtml
// 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.)

card.fhtmlfhtml
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.

page.fhtmlfhtml
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.

terminalsh
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 data

Formatter

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.

terminalsh
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.

terminalsh
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 too

Class 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.

compact.fhtmlfhtml
#!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.

main.rsrust
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.

app.jsjs
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.

main.jsjs
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.

input.csscss
@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.