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 -%} -
+
- +
{% include demos/{{ slug }}_rendered.html %}
-
{{ src | escape }}
+
+
+ + +
+
{{ phlex_src | escape }}
+
{{ vc_src | escape }}
+
{{ html | 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 @@ -
-
-
-

API Gateway

-

v2.4.1

-
- production -
-

deployed

-
- - -
-
-
-
-

Auth Service

-

v1.9.0

-
- staging -
-

pending

-
- - -
-
-
-
-

Web Frontend

-

v3.0.0-rc1

-
- preview -
-

failed

-
- - -
-
\ 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 @@ -

API Gateway

v2.4.1

production

deployed

Auth Service

v1.9.0

staging

pending

Web Frontend

v3.0.0-rc1

preview

failed

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

+
+ + +
+
+
+

Migrate the legacy importer

+ medium +
+
+ backend +
+

done

+
+ + +
+
+
+

Add Stripe webhooks

+ low +
+
+ payments + deferred +
+

wont 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
docsmarketing

todo

Migrate the legacy importer

medium
backend

done

Add Stripe webhooks

low
paymentsdeferred

wont 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