Skip to content

[Experiment] Herb as source of truth — engine-agnostic component export#357

Draft
djalmaaraujo wants to merge 4 commits intomainfrom
da/herb-experiment
Draft

[Experiment] Herb as source of truth — engine-agnostic component export#357
djalmaaraujo wants to merge 4 commits intomainfrom
da/herb-experiment

Conversation

@djalmaaraujo
Copy link
Copy Markdown
Contributor

What this does

Replaces hand-written Phlex as the authoring format for components with Herb (HTML+ERB templates) as the single source of truth. Components are now authored once and exported to any rendering engine on demand.

Why

RubyUI components are currently Phlex-only. To use them with plain ERB, ViewComponent, or future rendering targets you'd have to rewrite the whole library. That's the core limitation this PR removes.

Herb gives us:

  • A full HTML+ERB AST (49 node types) — parseable, transformable, walkable
  • A visitor system — write one visitor, emit any output format
  • Native tooling — LSP, linter (100+ rules), formatter, all work on .html.erb by default
  • An upgrade path to Level 4 (reactive ERB + JSX-style component syntax) without changing component source

Architecture

Every component now has two source files:

lib/ruby_ui/button/
  button.rb          # Plain Ruby class — logic, variants, TailwindMerge (no Phlex)
  button.html.erb    # Herb template — HTML structure (source of truth)

The --engine flag on the generator controls what consumers get:

Flag Consumer receives
--engine=phlex (default) A generated Phlex class — backwards compatible, no Herb needed
--engine=erb Plain Ruby class + .html.erb template
--engine=herb Same as erb, also runs bundle add herb
rails g ruby_ui:component Button               # Phlex, same as before
rails g ruby_ui:component Button --engine=erb  # Plain Ruby + ERB template
rails g ruby_ui:component Button --engine=herb # Same + installs herb gem

The Phlex output is generated, not hand-written. HerbToPhlexVisitor walks the Herb AST and emits Phlex DSL; PhlexTransformer wraps the result into a proper < Base class. The pre-generated _phlex.rb artifacts are committed so consumers don't need Herb installed.

What changed

  • 44 component directories migrated (accordion → typography, ~500 files)
  • ComponentBase module — engine-agnostic TailwindMerge + deep attr merge
  • HerbToPhlexVisitor + PhlexGenerator — ERB template → Phlex DSL
  • PhlexTransformer — plain Ruby class → Phlex class via text transformation
  • EngineUtils — engine-aware file selection in the generator
  • _docs.rb_docs.html.erb — docs written in ERB, Phlex tab auto-generated by visitor
  • Updated Phlex::Base to use ComponentBase-compatible class merging

Tests

649 tests, 1266 assertions, 0 failures, 0 errors. StandardRB clean.

All 44 component docs pages confirmed returning 200 on the rubyui/web app after exporting with the default --engine=phlex.

Djalma Araujo added 4 commits April 10, 2026 19:00
- HerbToPhlexVisitor: walks a Herb AST parsed from .html.erb templates
  and emits Phlex DSL code. Handles HTML elements, ERB output/control-flow
  tags, yield, tag_attributes() splat, and SVG block variable pattern.
- PhlexGenerator: convenience wrapper — template string in, Phlex class out.
- EngineUtils: engine-aware file selection (detects .html.erb presence).
- ComponentGenerator --engine flag:
    phlex (default) copies a pre-generated Phlex class, no herb needed
    erb/herb        copies the plain Ruby class + .html.erb template
- PhlexTransformer: converts a plain ComponentBase class to a Phlex class
  by text-transformation + inserting the visitor-generated view_template.
Design spec covers: Herb as sole source of truth, three-engine generator,
ComponentBase architecture, docs single-source-of-truth approach
(ERB tab / Phlex tab generated from one _docs.html.erb file), and the
incremental migration strategy.
ComponentBase (lib/ruby_ui/component_base.rb):
  Plain Ruby mixin for all component classes. Provides TailwindMerge
  class merging, deep data-attribute merging, and attrs accessor.
  No Phlex dependency — usable standalone or with any rendering engine.

Phlex Base (lib/ruby_ui/base.rb):
  Updated to use the same class-merging semantics as ComponentBase
  (array class values, deep merge). Generated Phlex classes that extend
  Base now behave identically to their plain Ruby source counterparts.
…+ Herb

Every component directory now has two source files:
  component.rb       — plain Ruby class (include ComponentBase, no Phlex)
  component.html.erb — Herb/ERB template (HTML source of truth)

The Phlex class is no longer hand-written. It is generated at gem build
time by PhlexTransformer + HerbToPhlexVisitor and stored as a pre-built
artifact (component_phlex.rb) that the generator copies for --engine=phlex.

Component changes:
  - class X < Base replaced by class X; include ComponentBase
  - default_attrs unchanged (same Tailwind classes and Stimulus data attrs)
  - view_template moved to .html.erb; templates use tag_attributes(attrs)
    which the visitor converts to **attrs in the generated Phlex output
  - _docs.rb replaced by _docs.html.erb (ERB usage examples)
  - All tests rewritten to test plain Ruby class attrs directly

Components: accordion, alert, alert_dialog, aspect_ratio, avatar, badge,
breadcrumb, button, calendar, card, carousel, chart, checkbox, clipboard,
codeblock, collapsible, combobox, command, context_menu, dialog,
dropdown_menu, form, hover_card, input, link, masked_input, native_select,
pagination, popover, progress, radio_button, select, separator, sheet,
shortcut_key, sidebar, skeleton, switch, table, tabs, textarea,
theme_toggle, tooltip, typography (44 directories, 500+ files)

649 tests, 1266 assertions, 0 failures, 0 errors, lint clean.
@djalmaaraujo djalmaaraujo requested a review from cirdes as a code owner April 10, 2026 19:06
@djalmaaraujo djalmaaraujo marked this pull request as draft April 10, 2026 19:20
@djalmaaraujo djalmaaraujo removed the request for review from cirdes April 10, 2026 19:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant