Skip to content

Repository files navigation

HTMG

Functional HTML generation for Ruby.

HTMG treats HTML as data structures, not text files. Build stateless, testable, reusable UI components through functional composition—no templating language tax.

Philosophy: The "MatzLisp" Way

Templates (ERB, Slim) separate logic from structure, often leading to "magic" context and implicit dependencies. HTMG takes a different approach: Your view is just a function.

  • Code is Data: HTML structure is defined by nesting Ruby function calls.
  • Unidirectional Data Flow: Data is passed explicitly. Views never "pull" data.
  • Explicit over Implicit: No magical scope or instance_variables. If a view needs data, it must be passed as an argument.
  • Boring Technology: Just Ruby. No parsers, no distinct compilation step.

Installation

gem'htmg'

The ARGS Style

HTMG uses ARGS style for all HTML generation. Content and child elements are passed as positional arguments, attributes as keyword arguments.

# <div class="card"><h1>Title</h1></div>div(h1("Title"),class: "card")# Nested structurehtml(head(meta(charset: "utf-8"),title("My App")),body(header("Welcome"),main(p("Hello world"),id: "content"),footer("© 2024")))

Why ARGS style?

  • Cleaner and more readable
  • Faster performance (no instance_eval overhead)
  • Natural functional composition
  • Children are auto-joined—no need for + operator

When is + needed? Only when you have multiple top-level siblings that cannot be nested:

# Rare: multiple top-level siblingshtmgdodiv("a") + div("b")# Must use + hereend# Preferred: nest them insteadhtmgdodiv(div("a"),div("b"))# Auto-joined, no + neededend

Attribute Syntax

# Attributes as keyword argumentsa("Home",href: "/",class: "nav-link")# Data attributesdiv("data-id": "123","data-type": "user")# Boolean attributesinput(type: "checkbox",checked: true)

Collections

Use standard Ruby .map and .join:

ul(items.map{ |i| li(h(i.name))}.join,class: "list")

Conditionals

Ruby's ternary or if expressions:

div(active ? "Active" : "Inactive",class: active ? "badge-success" : "badge-secondary")

Architectural Guide

HTMG is designed for unidirectional data flow. Data is fetched once in the route, then flows down through function arguments.

Route (fetch data) → Layout (document shell) → Page (compose) → Component (render)

Every layer follows the same pattern: a module with extend self and include HTMG. Pure functions — data in, HTML string out.

1. Components — Single UI Elements

Components are the smallest unit. They receive data as keyword arguments and return an HTML string. Never fetch data inside components.

# views/components/post_list.rbmoduleViewsmoduleComponentsmodulePostListextendselfincludeHTMGdefrender(posts:)htmgdoul(posts.map{ |post| li(h(post.title),class: "post")}.join,class: "posts")endendendendend

Components can be nested in subdirectories for grouping (e.g. views/components/headers/simple.rbComponents::Headers::Simple).

2. Pages — Compose Components for a Route

Pages compose components into a full page body. They receive explicit keyword arguments — no framework context needed.

# views/pages/home.rbmoduleViewsmodulePagesmoduleHomeextendselfdefrender(user:,posts:)Components::Headers::Simple.render(heading: "Welcome, #{CGI.escapeHTML(user.name)}") + Components::PostList.render(posts: posts)endendendend

Pages don't need include HTMG themselves — they just call component .render methods and concatenate results with +.

3. Layouts — Document Shell

Layouts wrap page content in the HTML document structure (<html>, <head>, <body>, header, footer). They receive ctx (the app context) because they need request-level information like the current path.

# views/layouts/application.rbmoduleViewsmoduleLayoutsmoduleApplicationextendselfincludeHTMGdefrender(ctx,title: "",flash_notices: [],flash_errors: [])current_path=ctx.request.path_infocontent=yield"<!DOCTYPE html>" + ctx.htmgdohtml(head(meta(charset: "utf-8"),meta(name: "viewport",content: "width=device-width, initial-scale=1"),'<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>',title(title.empty? ? "MyApp" : "MyApp - #{title}")),body(Components::Header.render(current_path: current_path),main(Components::FlashMessages.render(notices: flash_notices,errors: flash_errors),content,class: "pt-24"),Components::Footer.render),lang: "en")endendendendend

Multiple layouts are natural — e.g. an Error layout without header/footer for error pages.

4. The Route — Fetch Data, Wire Layers

The route is the only place data is fetched. A thin render_page helper wires flash messages into the layout.

# helpers/render.rbmoduleAppHelpersdefrender_page(title: "", &block)content=block.callViews::Layouts::Application.render(self,title: title,flash_notices: flash["notice"] ? [flash["notice"]] : [],flash_errors: flash["error"] ? [flash["error"]] : []){content}endend# app.rb (Roda)classApp < RodaincludeHTMGincludeAppHelpersroutedo |r|
r.rootdouser=current_userposts=Repo::Posts.recentrender_page(title: "Home")doViews::Pages::Home.render(user: user,posts: posts)endendendend

5. Summary

LayerReceivesReturnsNeeds ctx?
ComponentData as keyword argsHTML stringNo
PageData as keyword argsHTML string (composed components)No
Layoutctx, title, flash, &blockFull HTML documentYes
RouteRequestResponse (via render_page)Is ctx
# Components: data onlyComponents::PostList.render(posts: posts)# Pages: compose components, data onlyPages::Home.render(user: user,posts: posts)# Layout: wraps page content in document shellrender_page(title: "Home"){Pages::Home.render(user: user,posts: posts)}

Safety & Escaping

  • Attributes: Auto-escaped (except class/id for Tailwind compatibility).
  • Content: Raw by default. You must escape user input with h().
div(user_content)# Renders as-is (potentially unsafe)div(h(user_content))# HTML escaped (safe)

HTMX / Reactive Pattern

HTMG shines with "HTML over the Wire" (HTMX, Hotwire). Because components are pure functions, you can reuse them for both full-page renders and partial updates.

# Component — same function serves both contextsmoduleViewsmoduleComponentsmoduleAlertFeedextendselfincludeHTMGdefrender(alerts:)htmgdodiv(alerts.map{ |a| div(h(a.msg),class: "p-4 bg-yellow-50 rounded")}.join,id: "feed")endendendendend
# Full page render — component wrapped in layoutr.rootdoalerts=Repo::Alerts.recentrender_page(title: "Alert Feed")doComponents::AlertFeed.render(alerts: alerts)endend# HTMX partial — call the component directlyr.get"partials/feed"doComponents::AlertFeed.render(alerts: Repo::Alerts.recent)end

This works because:

  • Same component renders for both initial page load and HTMX updates
  • No duplicated logic between full-page and partial responses
  • Components are pure functions — call them anywhere with the same data

Best Practices

CategoryDoDon't
StyleUse ARGS style: div(h1("Title"), class: "card")Use + when you could nest instead
StatePass data as explicit keyword argumentsRely on scope or instance variables
LayersRoute → Layout → Page → ComponentSkip layers or fetch data in pages/components
ContextOnly layouts receive ctxPass ctx through pages and components
SafetyUse h() for dynamic contentInterpolate strings blindly
HTMXCall components directly for partialsWrap in unnecessary helpers

API Reference

htmg(context = nil, &block)

Main entry point. Returns HTML string.

ctx.htmgdodiv("Hello",class: "greeting")end# => '<div class="greeting">Hello</div>'

Tag Methods

All HTML5 tags are available as methods:

tag_name(*content, **attributes)
  • *content - Child elements or text (concatenated)
  • **attributes - HTML attributes (keyword arguments)
div("text"," more",class: "box",id: "main")# => '<div class="box" id="main">text more</div>'div(h1("Title"),p("Body"))# => '<div><h1>Title</h1><p>Body</p></div>'

h(text)

HTML escape helper. Always use for user input.

h("<script>")# => "&lt;script&gt;"

Performance

HTMG bypasses template parsing, running at Ruby method call speed. The ARGS style is faster than block style (no instance_eval overhead). Generally comparable to cached ERB.

License

MIT

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages