diff --git a/.standard.yml b/.standard.yml
index a84ccc3..14abe5a 100644
--- a/.standard.yml
+++ b/.standard.yml
@@ -1,3 +1,7 @@
# For available configuration options, see:
# https://github.com/testdouble/standard
ruby_version: 3.1
+ignore:
+ # Generated by `rake website:demos`. The ViewComponent source fragment
+ # concatenates a `.rb` and a `.erb` file for display, so it isn't valid Ruby.
+ - "website/_includes/demos/*"
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3dbd0bb..f4de8e8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,12 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
## [Unreleased]
+### Added
+
+- **Component scaffold generators.** `bin/rails g vident:phlex:component Dashboard::TaskCard` (shipped with `vident-phlex`) and `bin/rails g vident:view_component:component Dashboard::TaskCard` (shipped with `vident-view_component`) scaffold a component (`.rb`, plus `.html.erb` for ViewComponent), a Stimulus controller sidecar, and a unit test in one go. Flags: `--skip-stimulus`, `--skip-controller`, `--skip-test`, `--typescript` / `-t`, `--parent`. A trailing `Component` in the input name is stripped, matching ViewComponent's own generator behaviour.
+- **`vident:component` umbrella dispatcher.** Routes to the right engine generator when only one is loaded; requires `--engine=phlex` or `--engine=view_component` when both are present.
+- **`vident:install` generates `ApplicationPhlexComponent` / `ApplicationViewComponent`** in `app/components/` based on which engine gem is in the Gemfile, mirroring the `ApplicationRecord` / `ApplicationController` pattern. Existing files are preserved unless `--force` is passed.
+
### Changed
- `bin/rails generate vident:install --force` now overwrites an existing `.claude/skills/vident/SKILL.md` with the SKILL shipped in the installed gem, so upgrades can refresh it. Without `--force`, the existing file is preserved (unchanged behaviour).
diff --git a/README.md b/README.md
index 927e475..8c7569f 100644
--- a/README.md
+++ b/README.md
@@ -80,7 +80,31 @@ bundle install
bin/rails generate vident:install
```
-The `vident:install` generator writes `config/initializers/vident.rb`, wires per-request ID seeding into `ApplicationController`, and (if you use Claude Code) drops a Vident skill into `.claude/skills/vident/SKILL.md` so the model has first-party guidance on the gem's conventions. See [Element IDs and request-scoped seeding](#element-ids-and-request-scoped-seeding) for the initializer rationale, and [Claude Code skill](#claude-code-skill) for the skill.
+The `vident:install` generator writes `config/initializers/vident.rb`, wires per-request ID seeding into `ApplicationController`, generates `app/components/application_phlex_component.rb` and/or `application_view_component.rb` (one per engine gem in your Gemfile, mirroring `ApplicationRecord`), and (if you use Claude Code) drops a Vident skill into `.claude/skills/vident/SKILL.md` so the model has first-party guidance on the gem's conventions. See [Element IDs and request-scoped seeding](#element-ids-and-request-scoped-seeding) for the initializer rationale, and [Claude Code skill](#claude-code-skill) for the skill.
+
+### Scaffolding components
+
+Once `vident:install` has run, scaffold a component, its Stimulus controller sidecar, and a unit test in one go:
+
+```bash
+bin/rails generate vident:phlex:component Dashboard::TaskCard
+bin/rails generate vident:view_component:component Dashboard::TaskCard
+```
+
+There's also an umbrella `vident:component` dispatcher that picks the right engine when only one is in the Gemfile (pass `--engine=phlex` or `--engine=view_component` if both are):
+
+```bash
+bin/rails generate vident:component Dashboard::TaskCard
+```
+
+Useful flags:
+- `--skip-stimulus` — omit the `stimulus do` block and the JS controller sidecar.
+- `--skip-controller` — omit the JS sidecar but keep the `stimulus do` block (e.g. when sharing a controller).
+- `--skip-test` — skip the unit test.
+- `--typescript` / `-t` — emit a `.ts` controller instead of `.js`.
+- `--parent=ClassName` — override the default base class.
+
+A trailing `Component` in the input is stripped, so `g vident:component TaskCardComponent` and `g vident:component TaskCard` produce the same files.
## Quick Start
diff --git a/lib/generators/vident/component/component_generator.rb b/lib/generators/vident/component/component_generator.rb
new file mode 100644
index 0000000..d1a31e0
--- /dev/null
+++ b/lib/generators/vident/component/component_generator.rb
@@ -0,0 +1,65 @@
+# frozen_string_literal: true
+
+require "rails/generators/named_base"
+
+module Vident
+ module Generators
+ class ComponentGenerator < ::Rails::Generators::NamedBase
+ desc "Scaffold a Vident component. Dispatches to vident:phlex:component or vident:view_component:component based on which engine gem is loaded. Pass --engine to disambiguate when both are present."
+
+ class_option :engine, type: :string, default: nil,
+ desc: "Which engine to scaffold for: phlex or view_component"
+ class_option :skip_stimulus, type: :boolean, default: false
+ class_option :skip_controller, type: :boolean, default: false
+ class_option :skip_test, type: :boolean, default: false
+ class_option :typescript, type: :boolean, default: false, aliases: "-t"
+ class_option :parent, type: :string, default: nil
+
+ def dispatch
+ target = resolve_target_generator
+ invoke target, [name], forwarded_options
+ end
+
+ private
+
+ def resolve_target_generator
+ engine = options[:engine]
+ if engine.nil?
+ available = available_engines
+ if available.empty?
+ raise ::Thor::Error,
+ "No Vident engine gem detected. Add `vident-phlex` or `vident-view_component` to your Gemfile."
+ elsif available.size == 1
+ generator_for(available.first)
+ else
+ raise ::Thor::Error,
+ "Both vident-phlex and vident-view_component are loaded. Pass --engine=phlex or --engine=view_component."
+ end
+ else
+ unless %w[phlex view_component].include?(engine)
+ raise ::Thor::Error, "Unknown engine '#{engine}'. Use --engine=phlex or --engine=view_component."
+ end
+ generator_for(engine.to_sym)
+ end
+ end
+
+ def available_engines
+ engines = []
+ engines << :phlex if defined?(::Vident::Phlex::HTML)
+ engines << :view_component if defined?(::Vident::ViewComponent::Base)
+ engines
+ end
+
+ def generator_for(engine)
+ case engine
+ when :phlex then "vident:phlex:component"
+ when :view_component then "vident:view_component:component"
+ end
+ end
+
+ def forwarded_options
+ options.to_h.except("engine").transform_keys(&:to_s).reject { |_, v| v.nil? }
+ end
+ end
+ end
+end
diff --git a/lib/generators/vident/install/install_generator.rb b/lib/generators/vident/install/install_generator.rb
index 2932da1..cc29f42 100644
--- a/lib/generators/vident/install/install_generator.rb
+++ b/lib/generators/vident/install/install_generator.rb
@@ -9,13 +9,17 @@ class InstallGenerator < ::Rails::Generators::Base
desc "Install Vident: writes a StableId strategy initializer, wires a per-request seed into ApplicationController, and copies the Vident Claude Code skill to .claude/skills/vident/."
- # Path to the gem's ./skills directory, resolved relative to this file.
SKILL_SOURCE = File.expand_path("../../../../skills/vident/SKILL.md", __dir__)
def create_initializer
template "vident.rb", "config/initializers/vident.rb"
end
+ def create_application_components
+ write_application_component("application_phlex_component.rb") if defined?(::Vident::Phlex::HTML)
+ write_application_component("application_view_component.rb") if defined?(::Vident::ViewComponent::Base)
+ end
+
def install_claude_skill
return unless File.exist?(SKILL_SOURCE)
destination = ".claude/skills/vident/SKILL.md"
@@ -52,6 +56,21 @@ def patch_application_controller
inject_into_class controller_path, "ApplicationController", "\n#{hook}"
end
+
+ private
+
+ # Mirror the skill file's preserve-on-existing semantics: re-running
+ # the install generator should not clobber a base class the user has
+ # extended. `--force` opts back into overwriting.
+ def write_application_component(filename)
+ destination = "app/components/#{filename}"
+ absolute = File.expand_path(destination, destination_root)
+ if File.exist?(absolute) && !options[:force]
+ say_status :exist, destination, :blue
+ else
+ template "#{filename}.tt", destination
+ end
+ end
end
end
end
diff --git a/lib/generators/vident/install/templates/application_phlex_component.rb.tt b/lib/generators/vident/install/templates/application_phlex_component.rb.tt
new file mode 100644
index 0000000..014c264
--- /dev/null
+++ b/lib/generators/vident/install/templates/application_phlex_component.rb.tt
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+class ApplicationPhlexComponent < Vident::Phlex::HTML
+ include Phlex::Rails::Helpers::Routes
+end
diff --git a/lib/generators/vident/install/templates/application_view_component.rb.tt b/lib/generators/vident/install/templates/application_view_component.rb.tt
new file mode 100644
index 0000000..31628cb
--- /dev/null
+++ b/lib/generators/vident/install/templates/application_view_component.rb.tt
@@ -0,0 +1,4 @@
+# frozen_string_literal: true
+
+class ApplicationViewComponent < Vident::ViewComponent::Base
+end
diff --git a/lib/generators/vident/phlex/component/component_generator.rb b/lib/generators/vident/phlex/component/component_generator.rb
new file mode 100644
index 0000000..d352aeb
--- /dev/null
+++ b/lib/generators/vident/phlex/component/component_generator.rb
@@ -0,0 +1,66 @@
+# frozen_string_literal: true
+
+require "rails/generators/named_base"
+
+module Vident
+ module Phlex
+ module Generators
+ class ComponentGenerator < ::Rails::Generators::NamedBase
+ source_root File.expand_path("templates", __dir__)
+
+ desc "Scaffold a Vident Phlex component (.rb), its Stimulus controller sidecar, and a unit test."
+
+ class_option :skip_stimulus, type: :boolean, default: false,
+ desc: "Omit the stimulus DSL block and the JS controller sidecar"
+ class_option :skip_controller, type: :boolean, default: false,
+ desc: "Omit the JS controller sidecar (keeps the stimulus DSL block)"
+ class_option :skip_test, type: :boolean, default: false,
+ desc: "Skip generating a unit test"
+ class_option :typescript, type: :boolean, default: false, aliases: "-t",
+ desc: "Emit a TypeScript controller (.ts) instead of JavaScript (.js)"
+ class_option :parent, type: :string, default: "ApplicationPhlexComponent",
+ desc: "Parent class for the component"
+
+ def create_component_file
+ template "component.rb.tt", File.join("app/components", class_path, "#{file_name}_component.rb")
+ end
+
+ def create_controller_file
+ return if options[:skip_stimulus] || options[:skip_controller]
+ ext = options[:typescript] ? "ts" : "js"
+ template "controller.#{ext}.tt", File.join("app/components", class_path, "#{file_name}_component_controller.#{ext}")
+ end
+
+ def create_test_file
+ return if options[:skip_test]
+ template "component_test.rb.tt", File.join("test/components", class_path, "#{file_name}_component_test.rb")
+ end
+
+ private
+
+ # Allow `g vident:phlex:component TaskCardComponent` to produce the
+ # same files as `g ... TaskCard` rather than `TaskCardComponentComponent`.
+ # Matches ViewComponent's own generator behaviour.
+ def class_name
+ super.sub(/Component\z/, "")
+ end
+
+ def file_name
+ super.sub(/_component\z/, "")
+ end
+
+ def component_class_name
+ "#{class_name}Component"
+ end
+
+ def parent_class
+ options[:parent]
+ end
+
+ def stimulus_block?
+ !options[:skip_stimulus]
+ end
+ end
+ end
+ end
+end
diff --git a/lib/generators/vident/phlex/component/templates/component.rb.tt b/lib/generators/vident/phlex/component/templates/component.rb.tt
new file mode 100644
index 0000000..94db7bb
--- /dev/null
+++ b/lib/generators/vident/phlex/component/templates/component.rb.tt
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+<% module_namespacing do -%>
+class <%= component_class_name %> < <%= parent_class %>
+ prop :title, String
+
+<% if stimulus_block? -%>
+ stimulus do
+ values_from_props :title
+ action(:select).on(:click)
+ end
+
+<% end -%>
+ def view_template
+ root_element(class: "rounded border p-4") do
+ h3(class: "font-semibold") { @title }
+ end
+ end
+end
+<% end -%>
diff --git a/lib/generators/vident/phlex/component/templates/component_test.rb.tt b/lib/generators/vident/phlex/component/templates/component_test.rb.tt
new file mode 100644
index 0000000..076d053
--- /dev/null
+++ b/lib/generators/vident/phlex/component/templates/component_test.rb.tt
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+require "test_helper"
+
+<% module_namespacing do -%>
+class <%= component_class_name %>Test < ActiveSupport::TestCase
+ test "renders the title" do
+ html = <%= component_class_name %>.new(title: "Hello").call
+ assert_includes html, "Hello"
+ end
+end
+<% end -%>
diff --git a/lib/generators/vident/phlex/component/templates/controller.js.tt b/lib/generators/vident/phlex/component/templates/controller.js.tt
new file mode 100644
index 0000000..ffa4081
--- /dev/null
+++ b/lib/generators/vident/phlex/component/templates/controller.js.tt
@@ -0,0 +1,11 @@
+import { Controller } from "@hotwired/stimulus"
+
+export default class extends Controller {
+ static values = {
+ title: String,
+ }
+
+ select(event) {
+ this.dispatch("selected", { detail: { title: this.titleValue } })
+ }
+}
diff --git a/lib/generators/vident/phlex/component/templates/controller.ts.tt b/lib/generators/vident/phlex/component/templates/controller.ts.tt
new file mode 100644
index 0000000..28aeffa
--- /dev/null
+++ b/lib/generators/vident/phlex/component/templates/controller.ts.tt
@@ -0,0 +1,13 @@
+import { Controller } from "@hotwired/stimulus"
+
+export default class extends Controller {
+ static values = {
+ title: String,
+ }
+
+ declare readonly titleValue: string
+
+ select(event: Event): void {
+ this.dispatch("selected", { detail: { title: this.titleValue } })
+ }
+}
diff --git a/lib/generators/vident/view_component/component/component_generator.rb b/lib/generators/vident/view_component/component/component_generator.rb
new file mode 100644
index 0000000..3413727
--- /dev/null
+++ b/lib/generators/vident/view_component/component/component_generator.rb
@@ -0,0 +1,69 @@
+# frozen_string_literal: true
+
+require "rails/generators/named_base"
+
+module Vident
+ module ViewComponent
+ module Generators
+ class ComponentGenerator < ::Rails::Generators::NamedBase
+ source_root File.expand_path("templates", __dir__)
+
+ desc "Scaffold a Vident ViewComponent (.rb + .html.erb), its Stimulus controller sidecar, and a unit test."
+
+ class_option :skip_stimulus, type: :boolean, default: false,
+ desc: "Omit the stimulus DSL block and the JS controller sidecar"
+ class_option :skip_controller, type: :boolean, default: false,
+ desc: "Omit the JS controller sidecar (keeps the stimulus DSL block)"
+ class_option :skip_test, type: :boolean, default: false,
+ desc: "Skip generating a unit test"
+ class_option :typescript, type: :boolean, default: false, aliases: "-t",
+ desc: "Emit a TypeScript controller (.ts) instead of JavaScript (.js)"
+ class_option :parent, type: :string, default: "ApplicationViewComponent",
+ desc: "Parent class for the component"
+
+ def create_component_file
+ template "component.rb.tt", File.join("app/components", class_path, "#{file_name}_component.rb")
+ end
+
+ def create_template_file
+ template "component.html.erb.tt", File.join("app/components", class_path, "#{file_name}_component.html.erb")
+ end
+
+ def create_controller_file
+ return if options[:skip_stimulus] || options[:skip_controller]
+ ext = options[:typescript] ? "ts" : "js"
+ template "controller.#{ext}.tt", File.join("app/components", class_path, "#{file_name}_component_controller.#{ext}")
+ end
+
+ def create_test_file
+ return if options[:skip_test]
+ template "component_test.rb.tt", File.join("test/components", class_path, "#{file_name}_component_test.rb")
+ end
+
+ private
+
+ # Allow `g vident:view_component:component TaskCardComponent` to produce
+ # the same files as `g ... TaskCard` rather than `TaskCardComponentComponent`.
+ def class_name
+ super.sub(/Component\z/, "")
+ end
+
+ def file_name
+ super.sub(/_component\z/, "")
+ end
+
+ def component_class_name
+ "#{class_name}Component"
+ end
+
+ def parent_class
+ options[:parent]
+ end
+
+ def stimulus_block?
+ !options[:skip_stimulus]
+ end
+ end
+ end
+ end
+end
diff --git a/lib/generators/vident/view_component/component/templates/component.html.erb.tt b/lib/generators/vident/view_component/component/templates/component.html.erb.tt
new file mode 100644
index 0000000..f48b4e4
--- /dev/null
+++ b/lib/generators/vident/view_component/component/templates/component.html.erb.tt
@@ -0,0 +1,3 @@
+<%%= root_element do %>
+
<%%= title %>
+<%% end %>
diff --git a/lib/generators/vident/view_component/component/templates/component.rb.tt b/lib/generators/vident/view_component/component/templates/component.rb.tt
new file mode 100644
index 0000000..652fca6
--- /dev/null
+++ b/lib/generators/vident/view_component/component/templates/component.rb.tt
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+<% module_namespacing do -%>
+class <%= component_class_name %> < <%= parent_class %>
+ prop :title, String, reader: :public
+
+<% if stimulus_block? -%>
+ stimulus do
+ values_from_props :title
+ action(:select).on(:click)
+ end
+
+<% end -%>
+ def root_element_attributes
+ {html_options: {class: "rounded border p-4"}}
+ end
+end
+<% end -%>
diff --git a/lib/generators/vident/view_component/component/templates/component_test.rb.tt b/lib/generators/vident/view_component/component/templates/component_test.rb.tt
new file mode 100644
index 0000000..b4f1393
--- /dev/null
+++ b/lib/generators/vident/view_component/component/templates/component_test.rb.tt
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+require "test_helper"
+
+<% module_namespacing do -%>
+class <%= component_class_name %>Test < ViewComponent::TestCase
+ test "renders the title" do
+ render_inline(<%= component_class_name %>.new(title: "Hello"))
+ assert_text "Hello"
+ end
+end
+<% end -%>
diff --git a/lib/generators/vident/view_component/component/templates/controller.js.tt b/lib/generators/vident/view_component/component/templates/controller.js.tt
new file mode 100644
index 0000000..ffa4081
--- /dev/null
+++ b/lib/generators/vident/view_component/component/templates/controller.js.tt
@@ -0,0 +1,11 @@
+import { Controller } from "@hotwired/stimulus"
+
+export default class extends Controller {
+ static values = {
+ title: String,
+ }
+
+ select(event) {
+ this.dispatch("selected", { detail: { title: this.titleValue } })
+ }
+}
diff --git a/lib/generators/vident/view_component/component/templates/controller.ts.tt b/lib/generators/vident/view_component/component/templates/controller.ts.tt
new file mode 100644
index 0000000..28aeffa
--- /dev/null
+++ b/lib/generators/vident/view_component/component/templates/controller.ts.tt
@@ -0,0 +1,13 @@
+import { Controller } from "@hotwired/stimulus"
+
+export default class extends Controller {
+ static values = {
+ title: String,
+ }
+
+ declare readonly titleValue: string
+
+ select(event: Event): void {
+ this.dispatch("selected", { detail: { title: this.titleValue } })
+ }
+}
diff --git a/lib/tasks/website.rake b/lib/tasks/website.rake
index 61ed9ec..ca2c3f2 100644
--- a/lib/tasks/website.rake
+++ b/lib/tasks/website.rake
@@ -1,8 +1,8 @@
# frozen_string_literal: true
-namespace :website do
- WEBSITE_DIR = File.expand_path("../../website", __dir__)
+WEBSITE_DIR = File.expand_path("../../website", __dir__)
+namespace :website do
desc "Render demo components and write HTML/source fragments into the docs site"
task :demos do
require File.expand_path("../../test/dummy/config/environment", __dir__)
@@ -11,40 +11,69 @@ namespace :website do
out = File.join(WEBSITE_DIR, "_includes", "demos")
FileUtils.mkdir_p(out)
+ # Each demo declares twin components — one Phlex, one ViewComponent —
+ # implementing the same UI. The site shows the Phlex render as the
+ # canonical Live + Rendered HTML output (its source is cleaner than
+ # ERB's auto-encoded form), and the source tab toggles between the
+ # two engines' source files.
demos = [
{
- slug: "release_card",
- title: "Deploy dashboard release card",
- component: Dashboard::ReleaseCardComponent,
- args: {release_id: 1, name: "API Gateway", version: "2.4.1", environment: :production, status: :deployed},
- # The release card emits a sibling card and a third "pending" card so
- # the demo shows the dynamic `status:` class colours side by side.
- siblings: [
- {release_id: 2, name: "Auth Service", version: "1.9.0", environment: :staging, status: :pending},
- {release_id: 3, name: "Web Frontend", version: "3.0.0-rc1", environment: :preview, status: :failed}
+ slug: "task_card",
+ title: "Task card",
+ args: [
+ {task_id: 1, title: "Write the launch announcement", priority: :high, status: :todo, tags: ["docs", "marketing"]},
+ {task_id: 2, title: "Migrate the legacy importer", priority: :medium, status: :done, tags: ["backend"]},
+ {task_id: 3, title: "Add Stripe webhooks", priority: :low, status: :wont_do, tags: ["payments", "deferred"]}
],
- source_path: "test/dummy/app/components/dashboard/release_card_component.rb"
+ phlex: {
+ component: ::Phlex::TaskCardComponent,
+ source_path: "test/dummy/app/components/phlex/task_card_component.rb"
+ },
+ view_component: {
+ component: ::ViewComponent::TaskCardComponent,
+ source_path: "test/dummy/app/components/view_component/task_card_component.rb",
+ template_path: "test/dummy/app/components/view_component/task_card_component.html.erb"
+ }
}
]
+ view_context = ActionController::Base.new.view_context
+
demos.each do |demo|
- html = Vident::StableId.with_sequence_generator(seed: "vident-docs-#{demo[:slug]}") do
- rendered = demo[:component].new(**demo[:args]).call
- Array(demo[:siblings]).each do |sibling_args|
- rendered += demo[:component].new(**sibling_args).call
- end
- rendered
+ phlex_html = render_with_seed(demo[:slug]) do
+ demo[:args].map { |a| demo[:phlex][:component].new(**a).call }.join
+ end
+
+ vc_html = render_with_seed(demo[:slug]) do
+ demo[:args].map { |a| demo[:view_component][:component].new(**a).render_in(view_context) }.join
end
- # Strip the development-only "Before ..." HTML comment that the dummy
- # ApplicationComponent injects so the embedded fragment stays clean.
- html = html.gsub(//, "").strip
- source = File.read(File.expand_path("../../#{demo[:source_path]}", __dir__))
+ # The Phlex render is the visible Live + Rendered HTML output.
+ # ERB auto-encodes `>` in attribute values to `>`, which decodes
+ # identically in browsers but reads worse in the Raw HTML tab.
+ live_html = clean(phlex_html)
+
+ File.write(File.join(out, "#{demo[:slug]}_rendered.html"), live_html + "\n")
+ File.write(File.join(out, "#{demo[:slug]}_html.html"), pretty_html(live_html))
+
+ File.write(
+ File.join(out, "#{demo[:slug]}_phlex_source.rb"),
+ File.read(File.expand_path("../../#{demo[:phlex][:source_path]}", __dir__))
+ )
+
+ vc_source = File.read(File.expand_path("../../#{demo[:view_component][:source_path]}", __dir__))
+ vc_template = File.read(File.expand_path("../../#{demo[:view_component][:template_path]}", __dir__))
+ vc_combined = "# #{File.basename(demo[:view_component][:source_path])}\n#{vc_source}\n"
+ vc_combined += "# #{File.basename(demo[:view_component][:template_path])}\n#{vc_template}"
+ File.write(File.join(out, "#{demo[:slug]}_view_component_source.rb"), vc_combined)
- File.write(File.join(out, "#{demo[:slug]}_rendered.html"), html + "\n")
- File.write(File.join(out, "#{demo[:slug]}_source.rb"), source)
- File.write(File.join(out, "#{demo[:slug]}_html.html"), pretty_html(html))
- puts " rendered #{demo[:slug]} → #{html.bytesize} bytes"
+ # Sanity check: warn (don't fail) if the two engines diverge in their
+ # rendered HTML beyond the known ERB encoding cosmetics.
+ if normalised(phlex_html) != normalised(vc_html)
+ warn " ⚠ #{demo[:slug]}: Phlex and ViewComponent renders differ beyond ERB encoding"
+ end
+
+ puts " rendered #{demo[:slug]} → #{live_html.bytesize} bytes"
end
puts "Wrote demos to #{out}"
@@ -73,6 +102,34 @@ namespace :website do
end
end
+ def render_with_seed(slug, &block)
+ Vident::StableId.with_sequence_generator(seed: "vident-docs-#{slug}", &block)
+ end
+
+ # Strip the development-only "Before ..." HTML comment that the dummy
+ # ApplicationComponent injects, plus ViewComponent's per-template
+ # "" annotations, so the embedded fragment stays clean.
+ def clean(html)
+ html
+ .gsub(//, "")
+ .gsub(//, "")
+ .strip
+ end
+
+ # For the divergence sanity check: normalise away cosmetic encoding
+ # differences (ERB encodes `>` in attribute values to `>`) so the
+ # comparison reflects semantic equivalence.
+ def normalised(html)
+ clean(html)
+ .gsub(">", ">")
+ # Collapse whitespace between tags and inside attribute values so the
+ # comparison reflects semantic equivalence, not ERB's incidental
+ # indentation or trailing spaces from empty class interpolations.
+ .gsub(/>\s+, "><")
+ .gsub(/="([^"]*)"/) { %(="#{$1.strip.gsub(/\s+/, " ")}") }
+ .strip
+ end
+
# Pretty-prints the rendered fragment for the "Raw HTML" tab. Nokogiri
# escapes `>` inside attribute values to `>` (correct HTML5, but ugly to
# read), so we unescape that back — the result is still valid HTML and
diff --git a/skills/vident/SKILL.md b/skills/vident/SKILL.md
index d80f1f9..2fbfee3 100644
--- a/skills/vident/SKILL.md
+++ b/skills/vident/SKILL.md
@@ -362,7 +362,18 @@ Inline helper (ERB): `as_stimulus_param(:release_id, 42)` / `as_stimulus_params(
## 2. Component scaffolding
-Pick the right base class:
+The fastest path is the bundled generator, which writes the component, its Stimulus controller sidecar, and a unit test in one go:
+
+```bash
+bin/rails generate vident:component Dashboard::TaskCard
+# or, when you want to be explicit:
+bin/rails generate vident:phlex:component Dashboard::TaskCard
+bin/rails generate vident:view_component:component Dashboard::TaskCard
+```
+
+The umbrella `vident:component` dispatcher picks the engine when only one is in the Gemfile; pass `--engine=phlex` or `--engine=view_component` if both are. Useful flags: `--skip-stimulus`, `--skip-controller`, `--skip-test`, `--typescript` / `-t`, `--parent=ClassName`. A trailing `Component` in the input is stripped.
+
+Generated components inherit from `ApplicationPhlexComponent` or `ApplicationViewComponent` (created by `vident:install`). If you're writing a component by hand, pick the right base class directly:
- **ViewComponent:** `class Foo::BarComponent < Vident::ViewComponent::Base`
- **Phlex:** `class Foo::BarComponent < Vident::Phlex::HTML`
diff --git a/test/dummy/app/components/phlex/task_card_component.rb b/test/dummy/app/components/phlex/task_card_component.rb
new file mode 100644
index 0000000..229dae7
--- /dev/null
+++ b/test/dummy/app/components/phlex/task_card_component.rb
@@ -0,0 +1,74 @@
+# frozen_string_literal: true
+
+module Phlex
+ class TaskCardComponent < ApplicationComponent
+ # Locked so the Phlex and ViewComponent twins on the docs site share
+ # one Stimulus controller identifier and emit equivalent HTML.
+ class << self
+ def stimulus_identifier_path = "task_card_component"
+ end
+
+ prop :task_id, Integer
+ prop :title, String
+ prop :priority, _Union(:low, :medium, :high), default: :medium
+ prop :status, _Union(:todo, :done, :wont_do), default: :todo
+ prop :tags, _Array(String), default: -> { [] }
+
+ stimulus do
+ values_from_props :task_id, :title, :status
+
+ classes status: -> {
+ case @status
+ when :done then "border-green-500 bg-green-50"
+ when :wont_do then "border-gray-400 bg-gray-50"
+ else "border-yellow-400 bg-yellow-50"
+ end
+ }
+
+ action(:select).on(:click)
+ end
+
+ def view_template
+ root_element(
+ class: "block cursor-pointer rounded-lg border-2 p-4 shadow-sm transition hover:shadow-md #{class_list_for_stimulus_classes(:status)}",
+ role: "button",
+ tabindex: 0
+ ) do |card|
+ div(class: "flex items-center justify-between") do
+ h3(class: "font-semibold text-gray-900 #{"line-through text-gray-500" if @status == :wont_do}") { @title }
+ span(class: "rounded-full bg-white px-2 py-0.5 text-xs font-medium text-gray-700") { @priority.to_s }
+ end
+
+ if @tags.any?
+ div(class: "mt-2 flex flex-wrap gap-1") do
+ @tags.each do |tag|
+ span(class: "rounded bg-white px-2 py-0.5 text-xs text-gray-600 ring-1 ring-gray-200") { tag }
+ end
+ end
+ end
+
+ p(class: "mt-3 text-xs uppercase tracking-wide text-gray-500") { @status.to_s.tr("_", " ") }
+
+ div(class: "mt-3 flex gap-2") do
+ card.child_element(
+ :button,
+ stimulus_action: [:click, :apply],
+ stimulus_target: :done_button,
+ stimulus_params: {kind: "done"},
+ type: "button",
+ class: "flex-1 rounded bg-green-600 px-2 py-1 text-xs font-medium text-white hover:bg-green-700 disabled:opacity-50"
+ ) { "Mark done" }
+
+ card.child_element(
+ :button,
+ stimulus_action: [:click, :apply],
+ stimulus_target: :wont_do_button,
+ stimulus_params: {kind: "wont_do"},
+ type: "button",
+ class: "flex-1 rounded border border-gray-400 px-2 py-1 text-xs font-medium text-gray-600 hover:bg-gray-50 disabled:opacity-50"
+ ) { "Won't do" }
+ end
+ end
+ end
+ end
+end
diff --git a/test/dummy/app/components/view_component/task_card_component.html.erb b/test/dummy/app/components/view_component/task_card_component.html.erb
new file mode 100644
index 0000000..9dc8c32
--- /dev/null
+++ b/test/dummy/app/components/view_component/task_card_component.html.erb
@@ -0,0 +1,36 @@
+<%= root_element do |card| %>
+
+
<%= title %>
+ <%= priority %>
+
+
+ <% if tags.any? %>
+
+ <% tags.each do |tag| %>
+ <%= tag %>
+ <% end %>
+
+ <% end %>
+
+ <%= status_label %>
+
+
+ <%= card.child_element(
+ :button,
+ stimulus_action: [:click, :apply],
+ stimulus_target: :done_button,
+ stimulus_params: {kind: "done"},
+ type: "button",
+ class: "flex-1 rounded bg-green-600 px-2 py-1 text-xs font-medium text-white hover:bg-green-700 disabled:opacity-50"
+ ) { "Mark done" } %>
+
+ <%= card.child_element(
+ :button,
+ stimulus_action: [:click, :apply],
+ stimulus_target: :wont_do_button,
+ stimulus_params: {kind: "wont_do"},
+ type: "button",
+ class: "flex-1 rounded border border-gray-400 px-2 py-1 text-xs font-medium text-gray-600 hover:bg-gray-50 disabled:opacity-50"
+ ) { "Won't do" } %>
+
+<% end %>
diff --git a/test/dummy/app/components/view_component/task_card_component.rb b/test/dummy/app/components/view_component/task_card_component.rb
new file mode 100644
index 0000000..cdeaafd
--- /dev/null
+++ b/test/dummy/app/components/view_component/task_card_component.rb
@@ -0,0 +1,52 @@
+# frozen_string_literal: true
+
+module ViewComponent
+ class TaskCardComponent < ::Vident::ViewComponent::Base
+ # Locked so the Phlex and ViewComponent twins on the docs site share
+ # one Stimulus controller identifier and emit equivalent HTML.
+ class << self
+ def stimulus_identifier_path = "task_card_component"
+ end
+
+ prop :task_id, Integer
+ prop :title, String, reader: :public
+ prop :priority, _Union(:low, :medium, :high), default: :medium, reader: :public
+ prop :status, _Union(:todo, :done, :wont_do), default: :todo, reader: :public
+ prop :tags, _Array(String), default: -> { [] }, reader: :public
+
+ stimulus do
+ values_from_props :task_id, :title, :status
+
+ classes status: -> {
+ case @status
+ when :done then "border-green-500 bg-green-50"
+ when :wont_do then "border-gray-400 bg-gray-50"
+ else "border-yellow-400 bg-yellow-50"
+ end
+ }
+
+ action(:select).on(:click)
+ end
+
+ def title_class
+ base = "font-semibold text-gray-900"
+ (status == :wont_do) ? "#{base} line-through text-gray-500" : base
+ end
+
+ def status_label
+ status.to_s.tr("_", " ")
+ end
+
+ private
+
+ def root_element_attributes
+ {
+ html_options: {role: "button", tabindex: 0}
+ }
+ end
+
+ def root_element_classes
+ "block cursor-pointer rounded-lg border-2 p-4 shadow-sm transition hover:shadow-md #{class_list_for_stimulus_classes(:status)}"
+ end
+ end
+end
diff --git a/test/generators/vident/component_generator_test.rb b/test/generators/vident/component_generator_test.rb
new file mode 100644
index 0000000..6009490
--- /dev/null
+++ b/test/generators/vident/component_generator_test.rb
@@ -0,0 +1,48 @@
+# frozen_string_literal: true
+
+require "test_helper"
+require "rails/generators/test_case"
+require "generators/vident/component/component_generator"
+
+class Vident::Generators::ComponentGeneratorTest < Rails::Generators::TestCase
+ tests Vident::Generators::ComponentGenerator
+ destination File.expand_path("../../tmp/generators", __dir__)
+ setup :prepare_destination
+
+ def test_errors_when_engine_ambiguous_and_no_flag
+ skip "needs both engines loaded" unless defined?(::Vident::Phlex::HTML) && defined?(::Vident::ViewComponent::Base)
+
+ output = capture(:stderr) { run_generator ["Card"] }
+ assert_match(/Both vident-phlex and vident-view_component/, output)
+ end
+
+ def test_dispatches_to_phlex_with_engine_flag
+ run_generator ["Card", "--engine=phlex"]
+
+ assert_file "app/components/card_component.rb" do |contents|
+ assert_match(/class CardComponent < ApplicationPhlexComponent/, contents)
+ end
+ end
+
+ def test_dispatches_to_view_component_with_engine_flag
+ run_generator ["Card", "--engine=view_component"]
+
+ assert_file "app/components/card_component.rb" do |contents|
+ assert_match(/class CardComponent < ApplicationViewComponent/, contents)
+ end
+ assert_file "app/components/card_component.html.erb"
+ end
+
+ def test_unknown_engine_errors
+ output = capture(:stderr) { run_generator ["Card", "--engine=hanami"] }
+ assert_match(/Unknown engine/, output)
+ end
+
+ def test_forwards_skip_stimulus_flag
+ run_generator ["Card", "--engine=phlex", "--skip-stimulus"]
+
+ assert_file "app/components/card_component.rb" do |contents|
+ refute_match(/stimulus do/, contents)
+ end
+ end
+end
diff --git a/test/generators/vident/install_generator_test.rb b/test/generators/vident/install_generator_test.rb
index 3271d56..c0c7ea1 100644
--- a/test/generators/vident/install_generator_test.rb
+++ b/test/generators/vident/install_generator_test.rb
@@ -10,8 +10,6 @@ class Vident::Generators::InstallGeneratorTest < Rails::Generators::TestCase
setup :prepare_destination
def test_creates_initializer
- # Without an existing ApplicationController the generator should still
- # write the initializer (the controller patch is best-effort).
run_generator
assert_file "config/initializers/vident.rb" do |contents|
assert_match(/Vident::StableId\.strategy = if Rails\.env\.test\?/, contents)
@@ -68,6 +66,46 @@ def test_force_overwrites_existing_skill
assert_match(/^name: Vident$/, File.read(existing))
end
+ def test_creates_application_phlex_component_when_phlex_loaded
+ skip "vident-phlex not loaded" unless defined?(::Vident::Phlex::HTML)
+ run_generator
+ assert_file "app/components/application_phlex_component.rb" do |contents|
+ assert_match(/class ApplicationPhlexComponent < Vident::Phlex::HTML/, contents)
+ assert_match(/include Phlex::Rails::Helpers::Routes/, contents)
+ end
+ end
+
+ def test_creates_application_view_component_when_view_component_loaded
+ skip "vident-view_component not loaded" unless defined?(::Vident::ViewComponent::Base)
+ run_generator
+ assert_file "app/components/application_view_component.rb" do |contents|
+ assert_match(/class ApplicationViewComponent < Vident::ViewComponent::Base/, contents)
+ end
+ end
+
+ def test_does_not_overwrite_existing_application_phlex_component
+ skip "vident-phlex not loaded" unless defined?(::Vident::Phlex::HTML)
+ FileUtils.mkdir_p(File.join(destination_root, "app/components"))
+ existing = File.join(destination_root, "app/components/application_phlex_component.rb")
+ File.write(existing, "user-edited content\n")
+
+ run_generator
+
+ assert_equal "user-edited content\n", File.read(existing)
+ end
+
+ def test_force_overwrites_existing_application_phlex_component
+ skip "vident-phlex not loaded" unless defined?(::Vident::Phlex::HTML)
+ FileUtils.mkdir_p(File.join(destination_root, "app/components"))
+ existing = File.join(destination_root, "app/components/application_phlex_component.rb")
+ File.write(existing, "stale content\n")
+
+ run_generator ["--force"]
+
+ refute_equal "stale content\n", File.read(existing)
+ assert_match(/class ApplicationPhlexComponent < Vident::Phlex::HTML/, File.read(existing))
+ end
+
def test_running_generator_twice_does_not_duplicate_controller_hook
controller_path = File.join(destination_root, "app/controllers/application_controller.rb")
FileUtils.mkdir_p(File.dirname(controller_path))
@@ -77,8 +115,6 @@ class ApplicationController < ActionController::Base
RUBY
run_generator
- # Force-overwrite the initializer on the second pass; we only care about
- # the controller patch behavior here.
run_generator([destination_root, "--force"])
contents = File.read(controller_path)
diff --git a/test/generators/vident/phlex/component_generator_test.rb b/test/generators/vident/phlex/component_generator_test.rb
new file mode 100644
index 0000000..c7bd396
--- /dev/null
+++ b/test/generators/vident/phlex/component_generator_test.rb
@@ -0,0 +1,86 @@
+# frozen_string_literal: true
+
+require "test_helper"
+require "rails/generators/test_case"
+require "generators/vident/phlex/component/component_generator"
+
+class Vident::Phlex::Generators::ComponentGeneratorTest < Rails::Generators::TestCase
+ tests Vident::Phlex::Generators::ComponentGenerator
+ destination File.expand_path("../../../tmp/generators", __dir__)
+ setup :prepare_destination
+
+ def test_generates_component_controller_and_test
+ run_generator ["Dashboard::TaskCard"]
+
+ assert_file "app/components/dashboard/task_card_component.rb" do |contents|
+ assert_match(/class (?:Dashboard::TaskCardComponent|TaskCardComponent) < ApplicationPhlexComponent/, contents)
+ assert_match(/prop :title, String/, contents)
+ assert_match(/stimulus do/, contents)
+ assert_match(/values_from_props :title/, contents)
+ assert_match(/action\(:select\)\.on\(:click\)/, contents)
+ assert_match(/root_element/, contents)
+ end
+
+ assert_file "app/components/dashboard/task_card_component_controller.js" do |contents|
+ assert_match(/import \{ Controller \} from "@hotwired\/stimulus"/, contents)
+ assert_match(/static values = \{/, contents)
+ assert_match(/title: String,/, contents)
+ end
+
+ assert_file "test/components/dashboard/task_card_component_test.rb" do |contents|
+ assert_match(/class Dashboard::TaskCardComponentTest/, contents)
+ assert_match(/Dashboard::TaskCardComponent\.new\(title: "Hello"\)\.call/, contents)
+ end
+ end
+
+ def test_skip_stimulus_omits_dsl_and_controller
+ run_generator ["Card", "--skip-stimulus"]
+
+ assert_file "app/components/card_component.rb" do |contents|
+ refute_match(/stimulus do/, contents)
+ end
+ assert_no_file "app/components/card_component_controller.js"
+ end
+
+ def test_skip_controller_keeps_stimulus_dsl
+ run_generator ["Card", "--skip-controller"]
+
+ assert_file "app/components/card_component.rb" do |contents|
+ assert_match(/stimulus do/, contents)
+ end
+ assert_no_file "app/components/card_component_controller.js"
+ end
+
+ def test_skip_test_omits_test_file
+ run_generator ["Card", "--skip-test"]
+ assert_no_file "test/components/card_component_test.rb"
+ end
+
+ def test_typescript_emits_ts_controller
+ run_generator ["Card", "--typescript"]
+
+ assert_no_file "app/components/card_component_controller.js"
+ assert_file "app/components/card_component_controller.ts" do |contents|
+ assert_match(/declare readonly titleValue: string/, contents)
+ assert_match(/select\(event: Event\): void/, contents)
+ end
+ end
+
+ def test_parent_class_override
+ run_generator ["Card", "--parent=AdminComponent"]
+
+ assert_file "app/components/card_component.rb" do |contents|
+ assert_match(/class CardComponent < AdminComponent/, contents)
+ end
+ end
+
+ def test_strips_trailing_component_from_input_name
+ run_generator ["Dashboard::TaskCardComponent"]
+
+ assert_file "app/components/dashboard/task_card_component.rb" do |contents|
+ assert_match(/class (?:Dashboard::TaskCardComponent|TaskCardComponent) < ApplicationPhlexComponent/, contents)
+ refute_match(/TaskCardComponentComponent/, contents)
+ end
+ assert_no_file "app/components/dashboard/task_card_component_component.rb"
+ end
+end
diff --git a/test/generators/vident/view_component/component_generator_test.rb b/test/generators/vident/view_component/component_generator_test.rb
new file mode 100644
index 0000000..2809b43
--- /dev/null
+++ b/test/generators/vident/view_component/component_generator_test.rb
@@ -0,0 +1,64 @@
+# frozen_string_literal: true
+
+require "test_helper"
+require "rails/generators/test_case"
+require "generators/vident/view_component/component/component_generator"
+
+class Vident::ViewComponent::Generators::ComponentGeneratorTest < Rails::Generators::TestCase
+ tests Vident::ViewComponent::Generators::ComponentGenerator
+ destination File.expand_path("../../../tmp/generators", __dir__)
+ setup :prepare_destination
+
+ def test_generates_component_template_controller_and_test
+ run_generator ["Dashboard::TaskCard"]
+
+ assert_file "app/components/dashboard/task_card_component.rb" do |contents|
+ assert_match(/class (?:Dashboard::TaskCardComponent|TaskCardComponent) < ApplicationViewComponent/, contents)
+ assert_match(/prop :title, String, reader: :public/, contents)
+ assert_match(/stimulus do/, contents)
+ assert_match(/root_element_attributes/, contents)
+ end
+
+ assert_file "app/components/dashboard/task_card_component.html.erb" do |contents|
+ assert_match(/<%= root_element do %>/, contents)
+ assert_match(/<%= title %>/, contents)
+ end
+
+ assert_file "app/components/dashboard/task_card_component_controller.js" do |contents|
+ assert_match(/import \{ Controller \} from "@hotwired\/stimulus"/, contents)
+ assert_match(/title: String,/, contents)
+ end
+
+ assert_file "test/components/dashboard/task_card_component_test.rb" do |contents|
+ assert_match(/class Dashboard::TaskCardComponentTest < ViewComponent::TestCase/, contents)
+ assert_match(/render_inline\(Dashboard::TaskCardComponent\.new\(title: "Hello"\)\)/, contents)
+ end
+ end
+
+ def test_skip_stimulus_omits_dsl_and_controller
+ run_generator ["Card", "--skip-stimulus"]
+
+ assert_file "app/components/card_component.rb" do |contents|
+ refute_match(/stimulus do/, contents)
+ end
+ assert_no_file "app/components/card_component_controller.js"
+ end
+
+ def test_typescript_emits_ts_controller
+ run_generator ["Card", "--typescript"]
+
+ assert_no_file "app/components/card_component_controller.js"
+ assert_file "app/components/card_component_controller.ts" do |contents|
+ assert_match(/declare readonly titleValue: string/, contents)
+ end
+ end
+
+ def test_strips_trailing_component_from_input_name
+ run_generator ["Dashboard::TaskCardComponent"]
+
+ assert_file "app/components/dashboard/task_card_component.rb" do |contents|
+ refute_match(/TaskCardComponentComponent/, contents)
+ end
+ assert_no_file "app/components/dashboard/task_card_component_component.rb"
+ end
+end
diff --git a/vident-phlex.gemspec b/vident-phlex.gemspec
index 27e8d96..811ede2 100644
--- a/vident-phlex.gemspec
+++ b/vident-phlex.gemspec
@@ -22,6 +22,7 @@ Gem::Specification.new do |spec|
files.select do |f|
f == "lib/vident-phlex.rb" ||
f.match?(%r{^lib/vident/phlex(\.rb|/)}) ||
+ f.match?(%r{^lib/generators/vident/phlex/}) ||
f == "README.md" ||
f == "LICENSE.txt" ||
f == "CHANGELOG.md"
diff --git a/vident-view_component.gemspec b/vident-view_component.gemspec
index 4141170..c1875c0 100644
--- a/vident-view_component.gemspec
+++ b/vident-view_component.gemspec
@@ -22,6 +22,7 @@ Gem::Specification.new do |spec|
files.select do |f|
f == "lib/vident-view_component.rb" ||
f.match?(%r{^lib/vident/view_component(\.rb|/)(?!caching)}) ||
+ f.match?(%r{^lib/generators/vident/view_component/}) ||
f == "README.md" ||
f == "LICENSE.txt" ||
f == "CHANGELOG.md"
diff --git a/website/_includes/demo.html b/website/_includes/demo.html
index ed31320..e258367 100644
--- a/website/_includes/demo.html
+++ b/website/_includes/demo.html
@@ -1,28 +1,39 @@
{%- comment -%}
-Embeds a live demo with Live / Vident source / Raw HTML tabs.
+Embeds a live demo with Live / Source / Rendered HTML tabs. The Source tab
+hosts an inline Phlex / ViewComponent toggle so visitors can compare how
+the same UI is built in either engine. Both engine sources are rendered
+into the DOM; CSS hides the inactive one based on `data-engine` on the
+demo wrapper. The toggle script (in layout_end.html) syncs the choice
+across every demo on the page and persists it in localStorage.
-Usage:
- {% include demo.html slug="greeter_vident" title="Phlex + Vident greeter" %}
-
-Reads three fragments from `_includes/demos/`, all produced by
+Reads four fragments from `_includes/demos/`, all produced by
`rake website:demos`:
- _rendered.html — the literal output of the component (live panel)
- _source.rb — the component's Ruby source (Vident tab)
- _html.html — the pretty-printed copy of the rendered HTML (Raw tab)
+ _rendered.html — the literal output (live panel)
+ _html.html — the pretty-printed Rendered HTML tab
+ _phlex_source.rb — Phlex Ruby source
+ _view_component_source.rb — ViewComponent .rb + .erb concatenated
{%- endcomment -%}
{%- assign slug = include.slug -%}
{%- assign title = include.title | default: slug -%}
-{%- capture src -%}{% include demos/{{ slug }}_source.rb %}{%- endcapture -%}
+{%- capture phlex_src -%}{% include demos/{{ slug }}_phlex_source.rb %}{%- endcapture -%}
+{%- capture vc_src -%}{% include demos/{{ slug }}_view_component_source.rb %}{%- endcapture -%}
{%- capture html -%}{% include demos/{{ slug }}_html.html %}{%- endcapture -%}
-
+
Live
- Vident source
+ Source
Rendered HTML
{% include demos/{{ slug }}_rendered.html %}
-
+
+
+ Phlex
+ ViewComponent
+
+
{{ phlex_src | escape }}
+
{{ vc_src | escape }}
+
diff --git a/website/_includes/demos/release_card_html.html b/website/_includes/demos/release_card_html.html
deleted file mode 100644
index b9801a7..0000000
--- a/website/_includes/demos/release_card_html.html
+++ /dev/null
@@ -1,40 +0,0 @@
-
-
-
deployed
-
- Promote
- Cancel
-
-
-
-
-
Auth Service
-
v1.9.0
-
-
staging
-
-
pending
-
- Promote
- Cancel
-
-
-
-
-
Web Frontend
-
v3.0.0-rc1
-
-
preview
-
-
failed
-
- Promote
- Cancel
-
-
\ No newline at end of file
diff --git a/website/_includes/demos/release_card_rendered.html b/website/_includes/demos/release_card_rendered.html
deleted file mode 100644
index c8998b1..0000000
--- a/website/_includes/demos/release_card_rendered.html
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/website/_includes/demos/release_card_source.rb b/website/_includes/demos/release_card_source.rb
deleted file mode 100644
index a65f990..0000000
--- a/website/_includes/demos/release_card_source.rb
+++ /dev/null
@@ -1,69 +0,0 @@
-# frozen_string_literal: true
-
-module Dashboard
- class ReleaseCardComponent < ApplicationComponent
- prop :release_id, Integer
- prop :name, String
- prop :version, String
- prop :environment, _Union(:production, :staging, :preview), default: :staging
- prop :status, _Union(:pending, :deployed, :failed), default: :pending
-
- stimulus do
- values_from_props :release_id, :name, :status
-
- # Procs run in the component instance at render time, so they see
- # `@status`. `class_list_for_stimulus_classes(:status)` inlines the
- # same value into `class=` for the first paint.
- classes status: -> {
- case @status
- when :deployed then "border-green-500 bg-green-50"
- when :failed then "border-red-500 bg-red-50"
- else "border-yellow-400 bg-yellow-50"
- end
- }
-
- action(:select).on(:click)
- end
-
- def view_template
- root_element(
- class: "block cursor-pointer rounded-lg border-2 p-4 shadow-sm transition hover:shadow-md #{class_list_for_stimulus_classes(:status)}",
- role: "button",
- tabindex: 0
- ) do |card|
- div(class: "flex items-center justify-between") do
- div do
- h3(class: "font-semibold text-gray-900") { @name }
- p(class: "text-sm text-gray-500") { "v#{@version}" }
- end
- span(class: "rounded-full bg-white px-2 py-0.5 text-xs font-medium text-gray-700") { @environment.to_s }
- end
-
- p(class: "mt-3 text-xs uppercase tracking-wide text-gray-500") { @status.to_s }
-
- div(class: "mt-3 flex gap-2") do
- # Both buttons share an `apply` handler; the controller reads
- # `event.params.kind` to tell them apart, matching each button's
- # `stimulus_params:` declaration.
- card.child_element(
- :button,
- stimulus_action: [:click, :apply],
- stimulus_target: :promote_button,
- stimulus_params: {kind: "promote"},
- type: "button",
- class: "flex-1 rounded bg-blue-600 px-2 py-1 text-xs font-medium text-white hover:bg-blue-700 disabled:opacity-50"
- ) { "Promote" }
-
- card.child_element(
- :button,
- stimulus_action: [:click, :apply],
- stimulus_target: :cancel_button,
- stimulus_params: {kind: "cancel"},
- type: "button",
- class: "flex-1 rounded border border-red-500 px-2 py-1 text-xs font-medium text-red-600 hover:bg-red-50 disabled:opacity-50"
- ) { "Cancel" }
- end
- end
- end
- end
-end
diff --git a/website/_includes/demos/task_card_html.html b/website/_includes/demos/task_card_html.html
new file mode 100644
index 0000000..36510f6
--- /dev/null
+++ b/website/_includes/demos/task_card_html.html
@@ -0,0 +1,42 @@
+
+
+
Write the launch announcement
+ high
+
+
+ docs
+ marketing
+
+
todo
+
+ Mark done
+ Won't do
+
+
+
+
Migrate the legacy importer
+ medium
+
+
+ backend
+
+
done
+
+ Mark done
+ Won't do
+
+
+
+
Add Stripe webhooks
+ low
+
+
+ payments
+ deferred
+
+
wont do
+
+ Mark done
+ Won't do
+
+
\ No newline at end of file
diff --git a/website/_includes/demos/task_card_phlex_source.rb b/website/_includes/demos/task_card_phlex_source.rb
new file mode 100644
index 0000000..229dae7
--- /dev/null
+++ b/website/_includes/demos/task_card_phlex_source.rb
@@ -0,0 +1,74 @@
+# frozen_string_literal: true
+
+module Phlex
+ class TaskCardComponent < ApplicationComponent
+ # Locked so the Phlex and ViewComponent twins on the docs site share
+ # one Stimulus controller identifier and emit equivalent HTML.
+ class << self
+ def stimulus_identifier_path = "task_card_component"
+ end
+
+ prop :task_id, Integer
+ prop :title, String
+ prop :priority, _Union(:low, :medium, :high), default: :medium
+ prop :status, _Union(:todo, :done, :wont_do), default: :todo
+ prop :tags, _Array(String), default: -> { [] }
+
+ stimulus do
+ values_from_props :task_id, :title, :status
+
+ classes status: -> {
+ case @status
+ when :done then "border-green-500 bg-green-50"
+ when :wont_do then "border-gray-400 bg-gray-50"
+ else "border-yellow-400 bg-yellow-50"
+ end
+ }
+
+ action(:select).on(:click)
+ end
+
+ def view_template
+ root_element(
+ class: "block cursor-pointer rounded-lg border-2 p-4 shadow-sm transition hover:shadow-md #{class_list_for_stimulus_classes(:status)}",
+ role: "button",
+ tabindex: 0
+ ) do |card|
+ div(class: "flex items-center justify-between") do
+ h3(class: "font-semibold text-gray-900 #{"line-through text-gray-500" if @status == :wont_do}") { @title }
+ span(class: "rounded-full bg-white px-2 py-0.5 text-xs font-medium text-gray-700") { @priority.to_s }
+ end
+
+ if @tags.any?
+ div(class: "mt-2 flex flex-wrap gap-1") do
+ @tags.each do |tag|
+ span(class: "rounded bg-white px-2 py-0.5 text-xs text-gray-600 ring-1 ring-gray-200") { tag }
+ end
+ end
+ end
+
+ p(class: "mt-3 text-xs uppercase tracking-wide text-gray-500") { @status.to_s.tr("_", " ") }
+
+ div(class: "mt-3 flex gap-2") do
+ card.child_element(
+ :button,
+ stimulus_action: [:click, :apply],
+ stimulus_target: :done_button,
+ stimulus_params: {kind: "done"},
+ type: "button",
+ class: "flex-1 rounded bg-green-600 px-2 py-1 text-xs font-medium text-white hover:bg-green-700 disabled:opacity-50"
+ ) { "Mark done" }
+
+ card.child_element(
+ :button,
+ stimulus_action: [:click, :apply],
+ stimulus_target: :wont_do_button,
+ stimulus_params: {kind: "wont_do"},
+ type: "button",
+ class: "flex-1 rounded border border-gray-400 px-2 py-1 text-xs font-medium text-gray-600 hover:bg-gray-50 disabled:opacity-50"
+ ) { "Won't do" }
+ end
+ end
+ end
+ end
+end
diff --git a/website/_includes/demos/task_card_rendered.html b/website/_includes/demos/task_card_rendered.html
new file mode 100644
index 0000000..e21dc4e
--- /dev/null
+++ b/website/_includes/demos/task_card_rendered.html
@@ -0,0 +1 @@
+
Write the launch announcement high docs marketing
todo
Mark done Won't do
Migrate the legacy importer medium backend
done
Mark done Won't do
Add Stripe webhooks low payments deferred
wont do
Mark done Won't do
diff --git a/website/_includes/demos/task_card_view_component_source.rb b/website/_includes/demos/task_card_view_component_source.rb
new file mode 100644
index 0000000..069d194
--- /dev/null
+++ b/website/_includes/demos/task_card_view_component_source.rb
@@ -0,0 +1,91 @@
+# task_card_component.rb
+# frozen_string_literal: true
+
+module ViewComponent
+ class TaskCardComponent < ::Vident::ViewComponent::Base
+ # Locked so the Phlex and ViewComponent twins on the docs site share
+ # one Stimulus controller identifier and emit equivalent HTML.
+ class << self
+ def stimulus_identifier_path = "task_card_component"
+ end
+
+ prop :task_id, Integer
+ prop :title, String, reader: :public
+ prop :priority, _Union(:low, :medium, :high), default: :medium, reader: :public
+ prop :status, _Union(:todo, :done, :wont_do), default: :todo, reader: :public
+ prop :tags, _Array(String), default: -> { [] }, reader: :public
+
+ stimulus do
+ values_from_props :task_id, :title, :status
+
+ classes status: -> {
+ case @status
+ when :done then "border-green-500 bg-green-50"
+ when :wont_do then "border-gray-400 bg-gray-50"
+ else "border-yellow-400 bg-yellow-50"
+ end
+ }
+
+ action(:select).on(:click)
+ end
+
+ def title_class
+ base = "font-semibold text-gray-900"
+ (status == :wont_do) ? "#{base} line-through text-gray-500" : base
+ end
+
+ def status_label
+ status.to_s.tr("_", " ")
+ end
+
+ private
+
+ def root_element_attributes
+ {
+ html_options: {role: "button", tabindex: 0}
+ }
+ end
+
+ def root_element_classes
+ "block cursor-pointer rounded-lg border-2 p-4 shadow-sm transition hover:shadow-md #{class_list_for_stimulus_classes(:status)}"
+ end
+ end
+end
+
+# task_card_component.html.erb
+<%= root_element do |card| %>
+
+
<%= title %>
+ <%= priority %>
+
+
+ <% if tags.any? %>
+
+ <% tags.each do |tag| %>
+ <%= tag %>
+ <% end %>
+
+ <% end %>
+
+
<%= status_label %>
+
+
+ <%= card.child_element(
+ :button,
+ stimulus_action: [:click, :apply],
+ stimulus_target: :done_button,
+ stimulus_params: {kind: "done"},
+ type: "button",
+ class: "flex-1 rounded bg-green-600 px-2 py-1 text-xs font-medium text-white hover:bg-green-700 disabled:opacity-50"
+ ) { "Mark done" } %>
+
+ <%= card.child_element(
+ :button,
+ stimulus_action: [:click, :apply],
+ stimulus_target: :wont_do_button,
+ stimulus_params: {kind: "wont_do"},
+ type: "button",
+ class: "flex-1 rounded border border-gray-400 px-2 py-1 text-xs font-medium text-gray-600 hover:bg-gray-50 disabled:opacity-50"
+ ) { "Won't do" } %>
+
+<% end %>
diff --git a/website/_includes/jekyll_vitepress/layout_end.html b/website/_includes/jekyll_vitepress/layout_end.html
index 46f0897..63eb4f6 100644
--- a/website/_includes/jekyll_vitepress/layout_end.html
+++ b/website/_includes/jekyll_vitepress/layout_end.html
@@ -18,4 +18,34 @@
}
document.querySelectorAll('.vident-demo').forEach(bind);
})();
+
+// Engine toggle. Each demo has Phlex/VC source panels in the DOM; clicking a
+// `[data-vident-engine]` button updates `data-engine` on every `.vident-demo`
+// on the page and persists the choice in localStorage so it sticks across
+// pages. Default (set in the include) is `view_component`.
+(function () {
+ var STORAGE_KEY = 'vident-engine';
+ var VALID = { phlex: true, view_component: true };
+
+ function applyEngine(engine) {
+ if (!VALID[engine]) return;
+ document.querySelectorAll('.vident-demo').forEach(function (demo) {
+ demo.setAttribute('data-engine', engine);
+ demo.querySelectorAll('[data-vident-engine]').forEach(function (btn) {
+ btn.setAttribute('aria-pressed', btn.getAttribute('data-vident-engine') === engine ? 'true' : 'false');
+ });
+ });
+ try { localStorage.setItem(STORAGE_KEY, engine); } catch (_) {}
+ }
+
+ var stored;
+ try { stored = localStorage.getItem(STORAGE_KEY); } catch (_) {}
+ applyEngine(stored && VALID[stored] ? stored : 'view_component');
+
+ document.querySelectorAll('[data-vident-engine]').forEach(function (btn) {
+ btn.addEventListener('click', function () {
+ applyEngine(btn.getAttribute('data-vident-engine'));
+ });
+ });
+})();
diff --git a/website/assets/css/demo.css b/website/assets/css/demo.css
index b9d6468..c42cbea 100644
--- a/website/assets/css/demo.css
+++ b/website/assets/css/demo.css
@@ -64,6 +64,48 @@
overflow: auto;
}
+/* Engine toggle inside the Source tab. Sits flush above the source code
+ so the choice (Phlex / ViewComponent) is right next to the thing it
+ controls. */
+.vident-demo__engine-toggle {
+ display: inline-flex;
+ margin: 0 0 0.75rem;
+ border: 1px solid var(--vp-c-divider);
+ border-radius: 6px;
+ overflow: hidden;
+ background: var(--vp-c-bg);
+}
+
+.vident-demo__engine {
+ appearance: none;
+ border: none;
+ background: transparent;
+ font: inherit;
+ font-size: 0.8rem;
+ font-weight: 500;
+ color: var(--vp-c-text-2);
+ padding: 0.4rem 0.85rem;
+ cursor: pointer;
+}
+
+.vident-demo__engine + .vident-demo__engine {
+ border-left: 1px solid var(--vp-c-divider);
+}
+
+.vident-demo__engine[aria-pressed="true"] {
+ background: var(--vp-c-brand-1);
+ color: #fff;
+}
+
+/* Both engine sources are rendered into the DOM so they're crawlable as
+ plain code blocks. The wrapper's data-engine attribute picks which one
+ is visible. */
+.vident-demo__source { display: none; }
+.vident-demo[data-engine="phlex"] .vident-demo__source[data-engine-source="phlex"],
+.vident-demo[data-engine="view_component"] .vident-demo__source[data-engine-source="view_component"] {
+ display: block;
+}
+
/* The "Live" panel renders the actual component HTML. Cards from the dummy
dashboard are 240–280px wide; the responsive grid below stacks them on
narrow viewports and lays them out in a row when there's space. */
diff --git a/website/assets/js/demo.js b/website/assets/js/demo.js
index c8a3548..e4153d6 100644
--- a/website/assets/js/demo.js
+++ b/website/assets/js/demo.js
@@ -1,27 +1,28 @@
// Bootstraps the live demos on the docs site. We load Stimulus from a CDN so
// the static site needs no build pipeline, then register a controller under
-// the exact identifier the rendered component uses. The HTML fragment
-// embedded on the page is byte-for-byte what the dummy app produces, so the
-// same wiring reaches the same elements here.
+// the exact identifier the rendered component uses. Phlex and ViewComponent
+// twins on the site share one stimulus_identifier_path so a single
+// controller wires them both.
import { Application, Controller } from "https://cdn.jsdelivr.net/npm/@hotwired/stimulus@3.2.2/dist/stimulus.js"
-class ReleaseCardController extends Controller {
- static targets = ["promoteButton", "cancelButton"]
- static values = { releaseId: Number, name: String, status: String }
+class TaskCardController extends Controller {
+ static targets = ["doneButton", "wontDoButton"]
+ static values = { taskId: Number, title: String, status: String }
select(event) {
if (event.target.closest("button")) return
- flash(this.element, `Selected ${this.nameValue}`, "info")
+ flash(this.element, `Selected: ${this.titleValue}`, "info")
}
// event.params.kind comes from the button's `data-…-kind-param` attribute,
- // which the Vident `stimulus_params: { kind: "promote" }` declaration
- // emits. Stimulus auto-camelCases these into `event.params.
`.
+ // which Vident's `stimulus_params: { kind: "done" }` declaration emits.
+ // Stimulus auto-camelCases these into `event.params.`.
apply(event) {
const kind = event.params.kind
- this.promoteButtonTarget.disabled = true
- this.cancelButtonTarget.disabled = true
- flash(this.element, `${this.nameValue} ${kind === "promote" ? "promoted" : "cancelled"}`, kind)
+ this.doneButtonTarget.disabled = true
+ this.wontDoButtonTarget.disabled = true
+ const verb = kind === "done" ? "marked done" : "won't do"
+ flash(this.element, `${this.titleValue} — ${verb}`, kind)
}
}
@@ -38,7 +39,7 @@ function flash(card, message, kind) {
"padding:0.4rem 0.7rem", "border-radius:0.375rem",
"font-size:0.8rem", "font-weight:600",
"color:#fff",
- "background:" + ({promote: "#16a34a", cancel: "#dc2626", info: "#2563eb"}[kind] || "#374151"),
+ "background:" + ({done: "#16a34a", wont_do: "#6b7280", info: "#2563eb"}[kind] || "#374151"),
"box-shadow:0 6px 16px -8px rgba(0,0,0,0.4)",
"transition:opacity 200ms ease, transform 200ms ease",
"transform:translateY(-4px)", "opacity:0", "z-index:10"
@@ -54,5 +55,5 @@ function flash(card, message, kind) {
const application = Application.start()
application.debug = false
-application.register("dashboard--release-card-component", ReleaseCardController)
+application.register("task-card-component", TaskCardController)
window.Stimulus = application
diff --git a/website/index.md b/website/index.md
index 77d2f65..7074968 100644
--- a/website/index.md
+++ b/website/index.md
@@ -4,7 +4,7 @@ title: Vident
permalink: /
hero:
name: Vident
- text: Type-safe Rails components with first-class Stimulus
+ text: Build type-safe Rails components with first-class Stimulus
tagline: One declarative DSL for Phlex or ViewComponent — no more hand-crafted data attributes, no more refactor anxiety.
image:
src: /assets/img/vident-logo.svg
@@ -18,7 +18,7 @@ hero:
link: https://github.com/stevegeek/vident
features:
- title: Two engines, one API
- details: "Drop into a Phlex or ViewComponent codebase without changing how you build views. Pick the engine that fits the file."
+ details: "Drop into a Phlex or ViewComponent codebase without changing how you build views. Pick the engine that fits your app or preferred framework."
- title: Stimulus without the boilerplate
details: "Declare actions, targets, values, and classes in Ruby. The data attributes are generated for you, and renames stay safe."
- title: Typed props
@@ -28,29 +28,30 @@ features:
- title: Component caching
details: "A cache_component helper scopes Rails fragment caching to the component, so expensive renders only happen once."
- title: First-class generators
- details: "bin/rails g vident:install wires the per-request ID seeding and (optionally) drops a Claude Code skill in your repo."
+ details: "bin/rails g vident:install sets up your app and base components. bin/rails g vident:component scaffolds a component, its Stimulus controller, and a unit test in one go."
---
## See it in action
-Three release cards from a small deploy dashboard. Each card carries typed
-props (`environment` is `_Union(:production, :staging, :preview)`, `status`
-is `_Union(:pending, :deployed, :failed)`), a `stimulus do` block that maps
-those props straight to Stimulus values, and dynamic `classes` that pick
-the border colour from `@status` at render time.
+Three task cards. Each carries typed props (`priority` is
+`_Union(:low, :medium, :high)`, `status` is `_Union(:todo, :done, :wont_do)`,
+`tags` is `_Array(String)`), a `stimulus do` block that maps those props
+straight to Stimulus values, and dynamic `classes` that pick the border
+colour from `@status` at render time.
-Click a card or its **Promote** / **Cancel** buttons to see the same
+Click a card or its **Mark done** / **Won't do** buttons to see the same
controller code that runs in the dummy Rails app fire here too. The
-**Vident source** tab shows the entire component — under 70 lines, no
-hand-typed `data-*` attributes. The **Rendered HTML** tab shows what the
-browser actually receives, with every attribute the DSL generated.
+**Source** tab shows the whole component — toggle between Phlex and
+ViewComponent to see the same UI built with either engine. The
+**Rendered HTML** tab shows what the browser actually receives, with
+every attribute the DSL generated.
-{% include demo.html slug="release_card" title="Deploy dashboard release card" %}
+{% include demo.html slug="task_card" title="Task card" %}
The Ruby file is the only source of truth for the controller identifier
-(`dashboard--release-card-component`). Rename the class, and every
-`data-action`, `data-target`, and `data-value` attribute moves with it —
-no string-chasing across `.erb`/`.js`/`.rb` files.
+(`task-card-component`). Rename the class, and every `data-action`,
+`data-target`, and `data-value` attribute moves with it — no
+string-chasing across `.erb`/`.js`/`.rb` files.
## Installation