Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .standard.yml
Original file line number Diff line number Diff line change
@@ -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/*"
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
26 changes: 25 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
65 changes: 65 additions & 0 deletions lib/generators/vident/component/component_generator.rb
Original file line number Diff line number Diff line change
@@ -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
21 changes: 20 additions & 1 deletion lib/generators/vident/install/install_generator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# frozen_string_literal: true

class ApplicationPhlexComponent < Vident::Phlex::HTML
include Phlex::Rails::Helpers::Routes
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# frozen_string_literal: true

class ApplicationViewComponent < Vident::ViewComponent::Base
end
66 changes: 66 additions & 0 deletions lib/generators/vident/phlex/component/component_generator.rb
Original file line number Diff line number Diff line change
@@ -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
20 changes: 20 additions & 0 deletions lib/generators/vident/phlex/component/templates/component.rb.tt
Original file line number Diff line number Diff line change
@@ -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 -%>
Original file line number Diff line number Diff line change
@@ -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 -%>
11 changes: 11 additions & 0 deletions lib/generators/vident/phlex/component/templates/controller.js.tt
Original file line number Diff line number Diff line change
@@ -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 } })
}
}
13 changes: 13 additions & 0 deletions lib/generators/vident/phlex/component/templates/controller.ts.tt
Original file line number Diff line number Diff line change
@@ -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 } })
}
}
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<%%= root_element do %>
<h3 class="font-semibold"><%%= title %></h3>
<%% end %>
Original file line number Diff line number Diff line change
@@ -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 -%>
Loading
Loading