Skip to content

Repository files navigation

fhtml — Fluid Hypertext Markup Language

Website · Docs · Spec · Benchmark · Changelog

npm version MIT license

fhtml — Fluid HTML

A whitespace-based markup language (.fhtml) that compiles 1:1 to HTML. Like Pug, but built for two things Pug wasn't:

  • Token-cheap LLM/agent output — no closing tags, no angle brackets, no class="…" wrappers. Measured on a 48-component Tailwind corpus: 14% fewer tokens than pretty HTML overall, 20–25% on markup that isn't dominated by inline SVG payload (bench/RESULTS.md).
  • Tailwind-native — bare tokens after the tag are the class list, copied to output byte-for-byte. hover:bg-blue-500, w-1/2, data-[state=open]:bg-red-500 — no escaping, ever.
div flex items-center gap-4 rounded-xl bg-white p-6 shadow-md
  img(src=/img/ava.jpg alt="Erin's avatar") size-12 rounded-full
  .
    p text-lg font-semibold text-gray-900 "Erin Lindford"
    p text-gray-500 "Product Engineer"
  button ml-auto rounded-full px-4 py-1 text-sm hover:bg-purple-600 hover:text-white "Message"

compiles to

<div class="flex items-center gap-4 rounded-xl bg-white p-6 shadow-md">
  <img src="/img/ava.jpg" alt="Erin's avatar" class="size-12 rounded-full">
  <div>
    <p class="text-lg font-semibold text-gray-900">Erin Lindford</p>
    <p class="text-gray-500">Product Engineer</p>
  </div>
  <button class="ml-auto rounded-full px-4 py-1 text-sm hover:bg-purple-600 hover:text-white">Message</button>
</div>

The rules in 30 seconds

Line shape: tag(attrs) #id classes… "text" — everything after the tag is optional.

  • Indentation nests (Python's rules exactly); no closing tags.
  • Bare tokens are classes, verbatim. The compiler never parses inside a class token.
  • Attributes live in parens, butted against the tag: a(href=/about target=_blank).
  • . alone means div; #id as a token sets the id.
  • Text is quoted (span "Sign in"), HTML-escaped; | lines for text blocks.
  • li > a(href=/docs) "Docs" chains a single inline child.
  • script/style bodies are raw text (SPEC §6.3): every line indented under the tag emits verbatim — no escaping, no {…} interpolation.
  • A line starting with < is raw HTML passthrough — the escape hatch.
  • \ at end of line continues it; // comments; doctype<!DOCTYPE html>.

That's the whole markup layer. SPEC.md is the normative definition.

Install

Rust toolchain required (no other dependencies):

cargo install --path .                     # the fhtml compiler (zero-dep)
cargo install --path . --features convert  # + the html2fhtml converter

Usage

fhtml page.fhtml                 # compile to stdout (minified)
echo 'p "hi"' | fhtml            # stdin → stdout, pipeline-friendly
fhtml page.fhtml -o page.html    # compile to a file (pretty)
fhtml build src/ -o dist/        # compile a directory tree of .fhtml files
fhtml fmt src/                   # reformat to canonical style, in place

--pretty / --min override the defaults (pretty when writing files, minified on stdout). Errors carry line and column; non-fatal hazards (e.g. uneven indent steps) are warnings on stderr.

Templates

{expr} interpolation and if/elif/else, for/empty statements render with JSON data (SPEC §9–§10):

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

Repetition factors into components: def declares one (top level, closed over nothing — parameters only), +name(args) instantiates it, and the call's indented block becomes children (SPEC §10.3–§10.4):

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%."

include ./partials/head splices another file — its defs join the namespace, its markup emits at the include site (SPEC §10.5). Paths are relative to the including file; cycles and def collisions are errors.

fhtml page.fhtml --data data.json            # render with data
fhtml page.fhtml --data d.json --ctx c.json  # + the read-only `ctx` root
fhtml build src/ -o dist/ --target=js        # emit ES modules instead of HTML
fhtml page.fhtml --no-templates              # enforce pure static markup

Without --data, template files render with every name null. --target=js emits a self-contained ES module per file exporting (data, ctx = {}) => string — no imports, no runtime dependency, byte-identical output to the native renderer:

import render from "./dist/page.js";
document.body.innerHTML = render({ items: [{ title: "Ship it" }] });

fhtml fmt normalizes to 2-space indentation, . for div, and minimal quoting. Formatting never changes the compiled output. The intended agent workflow is write → fmt → build.

html2fhtml

The reverse direction, for migrating existing markup (requires the convert feature):

html2fhtml page.html                # HTML → fhtml on stdout
html2fhtml src/ -o out/             # convert a directory tree (.html/.htm → .fhtml)
html2fhtml --check page.html        # verify the round-trip: HTML → fhtml → same DOM
html2fhtml --fragment=table row.html  # parse as a fragment (e.g. bare <tr>)

Output is always canonical (fhtml fmt on it is a no-op). Anything fhtml can't express natively (exotic attribute names, <svg> by default) falls back to raw HTML lines, with a warning on stderr; --convert-svg converts SVG subtrees instead.

Class shorthand

An opt-in codebook (SPEC §3.2) contracts common Tailwind utilities to short codes — measured at −9% total tokens across the benchmark corpus. A file opens with #!shorthand as its first line and bare class tokens decode on compile:

#!shorthand
div fx ic g4
  p ti4 "Hello"     // → <p class="text-indigo-400">Hello</p>

html2fhtml --shorthand emits this form (only for codes that provably round-trip); fhtml --shorthand / --no-shorthand force decoding on or off regardless of the directive; =ti4 escapes one token to stay literal; fhtml fmt preserves the authored codes and the directive. Without the directive nothing changes — every class token is verbatim, exactly as before.

fhtml fmt --contract rewrites a file into this form (adding the directive and escaping collisions), and fmt --expand rewrites it back out; compiled output is identical in both directions. Shorthand is a write-time compression for tooling — benchmarks show models should never be asked to emit codes, so the intended flow is: generate plain classes, then fmt --contract to store.

Writing fhtml with agents

llms.md is the complete language reference in prompt form — paste it into a project's CLAUDE.md, AGENTS.md, or .cursorrules and the agent writes correct fhtml. Its syntax and component sections are the exact prompts the generation benchmark validated across models (bench/RESULTS.md). The site serves it under the llms.txt convention: llms.txt · llms-full.txt.

The recommended path is the skills/fhtml skill, which adds the practices llms.md deliberately leaves out — project structure, partials, when to factor a def, no inline JS — on top of that reference:

Agent Install
Claude Code cp -r skills/fhtml ~/.claude/skills/fhtml
Codex CLI append skills/fhtml/AGENTS.md to AGENTS.md
Gemini CLI append the same file to GEMINI.md
anything else the same file is self-contained plain markdown (skill.md)

As a library

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)?;

compile is the static path (template constructs are an error there); render/render_full evaluate the template layer; compile_to_js emits the ES-module target; format reformats source to canonical form; the _full variants also return warnings. render_full_from and compile_to_js_from take the source's file path, which makes include resolvable — the string-only entry points reject it (no base path). The _opts_from variants take Options for the shorthand policy (SPEC §3.2) and output mode.

Use from JavaScript

integrations/npm/ ships @fhtml/core — the same compiler as WebAssembly (a 261 KB fhtml.wasm plus ~100 lines of dependency-free ESM glue), for Node, Bun, Deno, browsers, and edge runtimes where a native binary can't go:

import { init, render, compileToJs, format, analyze } from "@fhtml/core";

await init();
const { html } = render('div grid\n  span rounded "hi"\n');

render takes a source string or a {name: source} file map (includes resolve against the map); compileToJs emits the same self-contained --target=js module, so the request-time render path carries no wasm; analyze returns the LSP's diagnostics and symbols for browser editors. On Node, the @fhtml/core/node subpath skips the file map: renderFile("views/page.fhtml", { data }) reads the file and its includes from disk. Framework adapters ship as subpaths too: @fhtml/core/express (a view engine — app.engine("fhtml", engine()), then res.render("page", locals)) and @fhtml/core/hono (a renderer middleware for c.render, edge-ready). Output is byte-identical to the native CLI — that parity is the package's release gate.

Vite integration

integrations/vite/ ships vite-plugin-fhtml (dependency-free, usable via a file: path — not yet on npm):

// vite.config.js
import fhtml from "vite-plugin-fhtml";
export default { plugins: [fhtml()] };
import render from "./card.fhtml";      // (data, ctx = {}) => string  — the --target=js module
import hero from "./hero.fhtml?html";   // the static HTML string      — fhtml --static --min

The plugin shells out to the installed fhtml binary (bin option → $FHTML_BIN$PATH). Compile errors surface in Vite's overlay at the .fhtml line:column; editing an included partial hot-reloads every importer (the watch list comes from fhtml deps). A complete Vite + Tailwind page lives in integrations/vite/example/.

Tailwind integration

Tailwind v4's scanner picks up fhtml classes as-is — they're plain space-separated tokens:

@source "./src/**/*.fhtml";

Verified against tailwindcss v4.3.2 (bench/tailwind_scan.sh): CSS built from the benchmark corpus as fhtml covers every utility the HTML build finds, arbitrary values and data-[…]: variants included.

One rule: never build class names from expressions — Tailwind's scanner is static, so a class assembled at render time gets no CSS. The compiler enforces it (SPEC §9.1): an interpolation glued to class text (bg-{color}-100) is a hard error, and a class built by + concatenation ({"bg-" + color}) compiles but warns. Interpolate whole class names and switch between them instead:

button {active ? "bg-blue-600 text-white" : "bg-gray-100 text-gray-900"}

--deny-warnings makes any warning fail the build, for CI.

Editor support

Install fhtml from the VS Code Marketplace or Open VSX (VSCodium, Cursor); the source lives in editors/vscode/. The grammar covers the full language including the template layer, and raw < lines are highlighted as embedded HTML.

License

MIT

About

A whitespace-based markup language that compiles 1:1 to HTML. Rust compiler with LSP, VS Code support, and a WASM/npm build.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages