vident 2.0.1 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 174865fed5f1a8edc71ef641af56b0656c97ab66dfe3dcc1c7b4eea996a7e31d
4
- data.tar.gz: b581f504c154b4a732702f8cddbe21d020dff12450a11b3880f0f11b13236c4e
3
+ metadata.gz: ad2b06ce20d99e816db5362f70a9ef4e068c9f6452d629253eb2283efc7e4b7e
4
+ data.tar.gz: 5d91a89eef5349d1ba7922875cef900c696684d2a627a5c958a7f5ed5d237ba9
5
5
  SHA512:
6
- metadata.gz: d3602b080034a184bcbb663602ff6d8fff95ef1cdbe855393b2d2d875f0d3485415237008818f4fa336da33c4587a7f57e52bcef6542d09a2f153f468bb1a7c9
7
- data.tar.gz: f133d2034fbcbdf31429696469f7127a8d38f765e174f75857abd9c18f3f1e8da6156534514b84bdddbe436c590d2c927509bd29a2023fe6a53c6d034c81db96
6
+ metadata.gz: 19e30e85766415738689f4a746ade4c21fe4d649b83212f2f8cfeb829e93a4b7109d625ad46fb3391446afb9e7b6865a9542a78bac1be4f2cfca3644769b7902
7
+ data.tar.gz: f4f93452b54d1971ea135a39eb079f4a3e559b5057400154dbaf5998a2e22c072c70f6dd7cf786c14648de891ebeca2b959a5a9be670b7c8ea70a5249e91e207
data/CHANGELOG.md CHANGED
@@ -6,6 +6,29 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/)
6
6
  and this project adheres to [Semantic Versioning](http://semver.org/).
7
7
 
8
8
 
9
+ ## [3.0.0] - 2026-05-04
10
+
11
+ A bare `String` now means the same thing across every Stimulus primitive — a controller path. The 2.x ambiguity where a `String` could variously mean controller path, outlet name, CSS selector, or fully-qualified action descriptor (depending on primitive and position) is gone, closing a class of silent-failure footguns. See `UPGRADING.md` "Upgrading to Vident 3.0" for symptom → fix on each breaking change.
12
+
13
+ ### Breaking
14
+
15
+ - **Outlet: bare String is no longer a CSS selector** (#32). Wrap verbatim selectors in `Vident::Selector(...)`, or pass `nil` for auto-selector. Class-level `stimulus_outlet(name, selector)` and the `[Symbol, String]` / `[String, String]` array forms follow the same rule.
16
+ - **Action: bare String is no longer a qualified descriptor.** Use structured args `:event, "ctrl", :method`, the Hash descriptor, or `Vident::Stimulus::Action.parse_descriptor(s)` for genuine wire strings.
17
+ - **Target: bare String is no longer a target name.** Names must be Symbols. The cross-controller `[String, Symbol]` form is unchanged.
18
+
19
+ ### Added
20
+
21
+ - `Vident::Stimulus::Selector` value class + `Vident::Selector(css)` (and `Vident::Stimulus.Selector(css)`) constructors for tagging verbatim CSS selectors at outlet sites (#32).
22
+ - `Vident::Stimulus::Action.parse_descriptor(string)` — public escape hatch for parsing wire-format Stimulus action descriptors verbatim (formerly the private `parse_qualified_string`).
23
+ - Component scaffold generators: `bin/rails g vident:phlex:component …` and `bin/rails g vident:view_component:component …` scaffold a component, sidecar Stimulus controller, and unit test, with `--skip-stimulus` / `--skip-controller` / `--skip-test` / `--typescript` / `--parent` flags.
24
+ - `vident:component` umbrella generator dispatches to the right engine; requires `--engine=phlex` or `--engine=view_component` when both adapters are loaded.
25
+ - `vident:install` now scaffolds `ApplicationPhlexComponent` / `ApplicationViewComponent` based on the installed adapter gem, mirroring `ApplicationRecord` / `ApplicationController`.
26
+
27
+ ### Changed
28
+
29
+ - `bin/rails generate vident:install --force` overwrites an existing `.claude/skills/vident/SKILL.md` so upgrades can refresh it; without `--force` the existing file is preserved.
30
+
31
+
9
32
  ## [2.0.1] - 2026-04-24
10
33
 
11
34
  ### Fixed
data/README.md CHANGED
@@ -1,10 +1,28 @@
1
- # Vident
1
+ <p align="center">
2
+ <a href="https://vident-gem.diaconou.com">
3
+ <img src="https://vident-gem.diaconou.com/assets/img/vident-logo.png" alt="Vident" width="180">
4
+ </a>
5
+ </p>
6
+
7
+ <h1 align="center">Vident</h1>
8
+
9
+ <p align="center">
10
+ <strong>Type-safe Rails components with first-class Stimulus, on Phlex or ViewComponent.</strong>
11
+ <br>
12
+ <a href="https://vident-gem.diaconou.com">Documentation</a>
13
+ &nbsp;·&nbsp;
14
+ <a href="https://vident-gem.diaconou.com/introduction/getting-started/">Getting started</a>
15
+ &nbsp;·&nbsp;
16
+ <a href="https://rubygems.org/gems/vident">RubyGems</a>
17
+ </p>
2
18
 
3
19
  A powerful Ruby gem for building interactive, type-safe components in Rails applications with seamless [Stimulus.js](https://stimulus.hotwired.dev/) integration.
4
20
 
5
21
  Vident supports both [ViewComponent](https://viewcomponent.org/) and [Phlex](https://www.phlex.fun/) rendering engines while providing a consistent API for creating
6
22
  reusable UI components powered by [Stimulus.js](https://stimulus.hotwired.dev/).
7
23
 
24
+ > **Full documentation lives at [vident-gem.diaconou.com](https://vident-gem.diaconou.com)** — including a live, clickable component demo on the home page.
25
+
8
26
  ## Table of Contents
9
27
 
10
28
  - [Introduction](#introduction)
@@ -62,7 +80,31 @@ bundle install
62
80
  bin/rails generate vident:install
63
81
  ```
64
82
 
65
- 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.
83
+ 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.
84
+
85
+ ### Scaffolding components
86
+
87
+ Once `vident:install` has run, scaffold a component, its Stimulus controller sidecar, and a unit test in one go:
88
+
89
+ ```bash
90
+ bin/rails generate vident:phlex:component Dashboard::TaskCard
91
+ bin/rails generate vident:view_component:component Dashboard::TaskCard
92
+ ```
93
+
94
+ 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):
95
+
96
+ ```bash
97
+ bin/rails generate vident:component Dashboard::TaskCard
98
+ ```
99
+
100
+ Useful flags:
101
+ - `--skip-stimulus` — omit the `stimulus do` block and the JS controller sidecar.
102
+ - `--skip-controller` — omit the JS sidecar but keep the `stimulus do` block (e.g. when sharing a controller).
103
+ - `--skip-test` — skip the unit test.
104
+ - `--typescript` / `-t` — emit a `.ts` controller instead of `.js`.
105
+ - `--parent=ClassName` — override the default base class.
106
+
107
+ A trailing `Component` in the input is stripped, so `g vident:component TaskCardComponent` and `g vident:component TaskCard` produce the same files.
66
108
 
67
109
  ## Quick Start
68
110
 
@@ -640,12 +682,16 @@ Parallel helpers exist for every attribute kind: `as_stimulus_controller(s)`, `a
640
682
  ```ruby
641
683
  class DashboardComponent < Vident::ViewComponent::Base
642
684
  stimulus do
643
- # kwarg form: outlet name is the implied controller's identifier
644
- outlets modal: ".modal", user_status: ".online-user"
645
-
646
- # positional-hash form: required when the outlet identifier contains "--"
647
- # (e.g. cross-namespace controllers) because Ruby kwarg keys can't have dashes
648
- outlets({"admin--users" => ".admin-users"})
685
+ # kwarg form: key is the *child controller identifier*. nil = auto-selector
686
+ # (`#<host-id> [data-controller~=modal]`); wrap a verbatim CSS selector in
687
+ # Vident::Selector(...). Bare String values are rejected.
688
+ outlets modal: nil
689
+ outlets user_status: Vident::Selector(".online-user")
690
+
691
+ # positional-hash form: required when the child controller identifier contains "--"
692
+ # (e.g. cross-namespace) because Ruby kwarg keys can't carry dashes
693
+ outlets({"admin--users" => nil})
694
+ outlets({"admin--users" => Vident::Selector(".admin-users")})
649
695
  end
650
696
  end
651
697
  ```
@@ -654,14 +700,15 @@ Or via the `stimulus_outlets:` prop / `root_element_attributes`:
654
700
 
655
701
  ```ruby
656
702
  stimulus_outlets: [
657
- [:modal, ".modal"], # [name, selector] on implied controller
658
- ["admin/users", :row, ".user-row"], # [controller_path, name, selector] for cross-controller
659
- :user_status, # single symbol → auto-selector (see below)
660
- other_component # component instance reuses its stimulus_identifier + id
703
+ :user_status, # symbol → auto-selector
704
+ [:modal, Vident::Selector(".modal")], # [name, Selector] verbatim
705
+ ["admin/users", :row], # cross-controller, auto-selector
706
+ ["admin/users", :row, Vident::Selector(".x")], # cross-controller, verbatim
707
+ other_component # component instance → reuses its stimulus_identifier + id
661
708
  ]
662
709
  ```
663
710
 
664
- **Auto-generated selectors.** Pass just a name (symbol or string) and the selector becomes `[data-controller~=<name>]`. Pass a component instance and the selector additionally scopes to the component's id (`#<id> [data-controller~=...]`), which is what lets you target a specific instance rather than every matching controller on the page.
711
+ **Auto-generated selectors.** Pass just a name (Symbol or String) and the selector becomes `#<host-id> [data-controller~=<name>]` scoped to this component's element id so you target a specific instance rather than every matching controller on the page. Wrap a verbatim CSS selector in `Vident::Selector(...)` to opt out of auto-scoping. A bare String inside an outlet position is treated as a controller identifier, never as a CSS selector — that ambiguity used to silently produce broken outlets in v2 and is now rejected.
665
712
 
666
713
  **Self-registration via `stimulus_outlet_host`.** A built-in prop on every Vident component. When set to another component, the child registers itself as an outlet on that host at initialization — the host doesn't need to know about the child in its DSL:
667
714
 
@@ -671,7 +718,7 @@ render DashboardComponent.new do |dashboard|
671
718
  end
672
719
  ```
673
720
 
674
- The modal now appears on the dashboard's root element as `data-dashboard-component-modal-component-outlet="#<modal-id>"` without the dashboard declaring it upfront.
721
+ The modal now appears on the dashboard's root element as `data-dashboard-component-modal-component-outlet="#<dashboard-id> [data-controller~=modal-component]"` without the dashboard declaring it upfront.
675
722
 
676
723
  **On child elements** — `child_element` accepts `stimulus_outlet:` (singular) and `stimulus_outlets:` (plural / Enumerable) exactly like the target/action kwargs, so a nested `<div>` can carry its own outlet declarations.
677
724
 
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/named_base"
4
+
5
+ module Vident
6
+ module Generators
7
+ class ComponentGenerator < ::Rails::Generators::NamedBase
8
+ 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."
9
+
10
+ class_option :engine, type: :string, default: nil,
11
+ desc: "Which engine to scaffold for: phlex or view_component"
12
+ class_option :skip_stimulus, type: :boolean, default: false
13
+ class_option :skip_controller, type: :boolean, default: false
14
+ class_option :skip_test, type: :boolean, default: false
15
+ class_option :typescript, type: :boolean, default: false, aliases: "-t"
16
+ class_option :parent, type: :string, default: nil
17
+
18
+ def dispatch
19
+ target = resolve_target_generator
20
+ invoke target, [name], forwarded_options
21
+ end
22
+
23
+ private
24
+
25
+ def resolve_target_generator
26
+ engine = options[:engine]
27
+ if engine.nil?
28
+ available = available_engines
29
+ if available.empty?
30
+ raise ::Thor::Error,
31
+ "No Vident engine gem detected. Add `vident-phlex` or `vident-view_component` to your Gemfile."
32
+ elsif available.size == 1
33
+ generator_for(available.first)
34
+ else
35
+ raise ::Thor::Error,
36
+ "Both vident-phlex and vident-view_component are loaded. Pass --engine=phlex or --engine=view_component."
37
+ end
38
+ else
39
+ unless %w[phlex view_component].include?(engine)
40
+ raise ::Thor::Error, "Unknown engine '#{engine}'. Use --engine=phlex or --engine=view_component."
41
+ end
42
+ generator_for(engine.to_sym)
43
+ end
44
+ end
45
+
46
+ def available_engines
47
+ engines = []
48
+ engines << :phlex if defined?(::Vident::Phlex::HTML)
49
+ engines << :view_component if defined?(::Vident::ViewComponent::Base)
50
+ engines
51
+ end
52
+
53
+ def generator_for(engine)
54
+ case engine
55
+ when :phlex then "vident:phlex:component"
56
+ when :view_component then "vident:view_component:component"
57
+ end
58
+ end
59
+
60
+ def forwarded_options
61
+ options.to_h.except("engine").transform_keys(&:to_s).reject { |_, v| v.nil? }
62
+ end
63
+ end
64
+ end
65
+ end
@@ -9,21 +9,29 @@ module Vident
9
9
 
10
10
  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/."
11
11
 
12
- # Path to the gem's ./skills directory, resolved relative to this file.
13
12
  SKILL_SOURCE = File.expand_path("../../../../skills/vident/SKILL.md", __dir__)
14
13
 
15
14
  def create_initializer
16
15
  template "vident.rb", "config/initializers/vident.rb"
17
16
  end
18
17
 
18
+ def create_application_components
19
+ write_application_component("application_phlex_component.rb") if defined?(::Vident::Phlex::HTML)
20
+ write_application_component("application_view_component.rb") if defined?(::Vident::ViewComponent::Base)
21
+ end
22
+
19
23
  def install_claude_skill
24
+ return unless File.exist?(SKILL_SOURCE)
20
25
  destination = ".claude/skills/vident/SKILL.md"
21
26
  absolute = File.expand_path(destination, destination_root)
22
- if File.exist?(absolute)
27
+ # Preserve an existing skill file (user may have edited it);
28
+ # `--force` pulls the current SKILL from the installed gem over
29
+ # the top so upgrades can refresh it.
30
+ if File.exist?(absolute) && !options[:force]
23
31
  say_status :exist, destination, :blue
24
- elsif File.exist?(SKILL_SOURCE)
32
+ else
25
33
  empty_directory(File.dirname(destination))
26
- copy_file(SKILL_SOURCE, destination)
34
+ copy_file(SKILL_SOURCE, destination, force: true)
27
35
  end
28
36
  end
29
37
 
@@ -48,6 +56,21 @@ module Vident
48
56
 
49
57
  inject_into_class controller_path, "ApplicationController", "\n#{hook}"
50
58
  end
59
+
60
+ private
61
+
62
+ # Mirror the skill file's preserve-on-existing semantics: re-running
63
+ # the install generator should not clobber a base class the user has
64
+ # extended. `--force` opts back into overwriting.
65
+ def write_application_component(filename)
66
+ destination = "app/components/#{filename}"
67
+ absolute = File.expand_path(destination, destination_root)
68
+ if File.exist?(absolute) && !options[:force]
69
+ say_status :exist, destination, :blue
70
+ else
71
+ template "#{filename}.tt", destination
72
+ end
73
+ end
51
74
  end
52
75
  end
53
76
  end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ApplicationPhlexComponent < Vident::Phlex::HTML
4
+ include Phlex::Rails::Helpers::Routes
5
+ end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ApplicationViewComponent < Vident::ViewComponent::Base
4
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ WEBSITE_DIR = File.expand_path("../../website", __dir__)
4
+
5
+ namespace :website do
6
+ desc "Render demo components and write HTML/source fragments into the docs site"
7
+ task :demos do
8
+ require File.expand_path("../../test/dummy/config/environment", __dir__)
9
+ require "fileutils"
10
+
11
+ out = File.join(WEBSITE_DIR, "_includes", "demos")
12
+ FileUtils.mkdir_p(out)
13
+
14
+ # The homepage demo is the same Phlex component the dummy app renders at
15
+ # /components/tasks. We pre-render it here so the static site can ship
16
+ # byte-identical HTML, paired with the same Stimulus controller registered
17
+ # in `website/assets/js/demo.js`.
18
+ demos = [
19
+ {
20
+ slug: "task_card",
21
+ component: ::Tasks::TaskCardComponent,
22
+ source_path: "test/dummy/app/components/tasks/task_card_component.rb",
23
+ args: [
24
+ {task_id: 1, title: "Write the launch announcement", due: "Today", list: :today, status: :todo},
25
+ {task_id: 2, title: "Migrate the legacy importer", due: "Wed", list: :this_week, status: :done},
26
+ {task_id: 3, title: "Add Stripe webhooks", due: "—", list: :backlog, status: :wont_do}
27
+ ]
28
+ }
29
+ ]
30
+
31
+ demos.each do |demo|
32
+ html = Vident::StableId.with_sequence_generator(seed: "vident-docs-#{demo[:slug]}") do
33
+ demo[:args].map { |a| demo[:component].new(**a).call }.join
34
+ end
35
+ html = clean(html)
36
+
37
+ File.write(File.join(out, "#{demo[:slug]}_rendered.html"), html + "\n")
38
+ File.write(File.join(out, "#{demo[:slug]}_html.html"), pretty_html(html))
39
+ File.write(
40
+ File.join(out, "#{demo[:slug]}_source.rb"),
41
+ File.read(File.expand_path("../../#{demo[:source_path]}", __dir__))
42
+ )
43
+
44
+ puts " rendered #{demo[:slug]} → #{html.bytesize} bytes"
45
+ end
46
+
47
+ puts "Wrote demos to #{out}"
48
+ end
49
+
50
+ desc "Build the documentation website"
51
+ task build: :demos do
52
+ Dir.chdir(WEBSITE_DIR) do
53
+ sh "bundle install"
54
+ sh "bundle exec jekyll build"
55
+ end
56
+ end
57
+
58
+ desc "Serve the documentation website locally with live reload"
59
+ task serve: :demos do
60
+ Dir.chdir(WEBSITE_DIR) do
61
+ sh "bundle install"
62
+ sh "bundle exec jekyll serve --livereload"
63
+ end
64
+ end
65
+
66
+ desc "Clean the documentation website build"
67
+ task :clean do
68
+ Dir.chdir(WEBSITE_DIR) do
69
+ sh "bundle exec jekyll clean"
70
+ end
71
+ end
72
+
73
+ # Strip the development-only "Before ..." HTML comment that the dummy
74
+ # ApplicationComponent injects so the embedded fragment stays clean.
75
+ def clean(html)
76
+ html.gsub(/<!--\s*Before [^>]*?-->/, "").strip
77
+ end
78
+
79
+ # Pretty-prints the rendered fragment for the "Rendered HTML" tab. Nokogiri
80
+ # escapes `>` inside attribute values to `&gt;` (correct HTML5, but ugly to
81
+ # read), so we unescape that back — the result is still valid HTML and
82
+ # matches what a developer would expect to see in their source.
83
+ def pretty_html(html)
84
+ require "nokogiri"
85
+ Nokogiri::HTML5.fragment(html).to_xhtml(indent: 2).gsub("&gt;", ">")
86
+ end
87
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../internals/registry"
4
+
5
+ module Vident
6
+ module Capabilities
7
+ # `as_stimulus_*` returns a ready-to-splat HTML data-attribute string for
8
+ # the given stimulus declaration — usable from any rendering context
9
+ # (ERB partials, Phlex templates, helpers, controller renderers).
10
+ #
11
+ # Unlike `child_element`, these helpers don't write to a buffer, so they
12
+ # work even when called on a Phlex Vident component from outside its own
13
+ # `view_template` lifecycle.
14
+ module StimulusAttributeStrings
15
+ ::Vident::Internals::Registry.each do |kind|
16
+ define_method(:"as_stimulus_#{kind.singular_name}") do |*args|
17
+ to_data_attribute_string(**send(:"stimulus_#{kind.singular_name}", *args).to_h)
18
+ end
19
+
20
+ define_method(:"as_stimulus_#{kind.plural_name}") do |*args|
21
+ to_data_attribute_string(**send(:"stimulus_#{kind.plural_name}", *args).to_h)
22
+ end
23
+
24
+ alias_method :"as_#{kind.singular_name}", :"as_stimulus_#{kind.singular_name}"
25
+ end
26
+
27
+ private
28
+
29
+ def to_data_attribute_string(**attributes)
30
+ attributes.map { |key, value| "data-#{escape_attribute_name_for_html(key)}=\"#{escape_attribute_value_for_html(value)}\"" }
31
+ .join(" ")
32
+ .html_safe
33
+ end
34
+
35
+ def escape_attribute_name_for_html(name)
36
+ name.to_s.gsub(/[^a-zA-Z0-9\-_]/, "").tr("_", "-")
37
+ end
38
+
39
+ def escape_attribute_value_for_html(value)
40
+ value.to_s.gsub('"', "&quot;").gsub("'", "&#39;")
41
+ end
42
+ end
43
+ end
44
+ end
@@ -118,13 +118,13 @@ module Vident
118
118
 
119
119
  def stimulus_outlet(*args)
120
120
  case args
121
- in [Symbol => _name, String => _selector] | [String => _name, String => _selector]
121
+ in [Symbol => _name, ::Vident::Stimulus::Selector] | [String => _name, ::Vident::Stimulus::Selector]
122
122
  ::Vident::Stimulus::Outlet.parse(*args, implied: implied_controller_for_class)
123
123
  else
124
124
  raise ::Vident::ParseError,
125
- "#{name}.stimulus_outlet requires (name, selector) — no component_id at class level. " \
125
+ "#{name}.stimulus_outlet requires (name, Vident::Selector(...)) — no component_id at class level. " \
126
126
  "Use instance-level `component.stimulus_outlet(:name)` for auto-selector, " \
127
- "or `#{name}.stimulus_outlet(:name, '.selector')` with an explicit selector."
127
+ "or `#{name}.stimulus_outlet(:name, Vident::Selector('.css'))` for a verbatim selector."
128
128
  end
129
129
  end
130
130
 
@@ -14,6 +14,7 @@ module Vident
14
14
  include ::Vident::Capabilities::StimulusMutation
15
15
  include ::Vident::Capabilities::StimulusDraft
16
16
  include ::Vident::Capabilities::StimulusDataEmitting
17
+ include ::Vident::Capabilities::StimulusAttributeStrings
17
18
  include ::Vident::Capabilities::ClassListBuilding
18
19
  include ::Vident::Capabilities::RootElementRendering
19
20
  include ::Vident::Capabilities::ChildElementRendering
@@ -80,11 +80,11 @@ module Vident
80
80
  # Positional Hash arg supports keys like `"admin--users"` that can't be Ruby kwargs.
81
81
  def outlets(positional = nil, **hash)
82
82
  if positional.is_a?(Hash)
83
- positional.each { |k, v| record_keyed(@outlets, k, v) }
83
+ positional.each { |k, v| record_outlet(k, v) }
84
84
  elsif !positional.nil?
85
85
  raise ArgumentError, "outlets: positional arg must be a Hash, got #{positional.class}"
86
86
  end
87
- hash.each { |k, v| record_keyed(@outlets, k, v) }
87
+ hash.each { |k, v| record_outlet(k, v) }
88
88
  self
89
89
  end
90
90
 
@@ -158,6 +158,25 @@ module Vident
158
158
  replace_or_append(bucket, entry)
159
159
  end
160
160
 
161
+ def record_outlet(key, value)
162
+ validate_outlet_value!(key, value)
163
+ entry = [key, value.nil? ? Declaration.of : Declaration.of(value)]
164
+ replace_or_append(@outlets, entry)
165
+ end
166
+
167
+ def validate_outlet_value!(key, value)
168
+ return if value.nil?
169
+ return if value.is_a?(Proc)
170
+ return if value.is_a?(::Vident::Stimulus::Selector)
171
+ return if value.is_a?(::Vident::Stimulus::Outlet)
172
+ return if value.respond_to?(:stimulus_identifier)
173
+ raise ::Vident::ParseError,
174
+ "outlets: value for #{key.inspect} must be nil (auto-selector), " \
175
+ "a `Vident::Selector(…)`, a Proc returning one, an Outlet, or a child component. " \
176
+ "Got #{value.class}: #{value.inspect}. " \
177
+ "If you meant a verbatim CSS selector, wrap it: `Vident::Selector(#{value.inspect})`."
178
+ end
179
+
161
180
  def replace_or_append(bucket, entry)
162
181
  key = entry.first
163
182
  idx = bucket.index { |(k, _)| k == key }
@@ -34,8 +34,6 @@ module Vident
34
34
  method_name: Naming.js_name(method_sym),
35
35
  event: nil
36
36
  )
37
- in [String => s]
38
- parse_qualified_string(s)
39
37
  in [Symbol => event, Symbol => method_sym]
40
38
  new(
41
39
  controller: implied,
@@ -54,6 +52,12 @@ module Vident
54
52
  method_name: Naming.js_name(method_sym),
55
53
  event: event.to_s
56
54
  )
55
+ in [String => s]
56
+ raise ::Vident::ParseError,
57
+ "Action.parse: a bare String is a controller path, not a fully-qualified action descriptor. " \
58
+ "For event/controller/method use structured args like `:click, \"path/ctrl\", :method` " \
59
+ "or the Hash descriptor form. To parse an existing wire-format string like " \
60
+ "\"click->ctrl#m\", call `Vident::Stimulus::Action.parse_descriptor(#{s.inspect})`."
57
61
  else
58
62
  raise ::Vident::ParseError, "Action.parse: invalid arguments #{args.inspect}"
59
63
  end
@@ -103,8 +107,9 @@ module Vident
103
107
  )
104
108
  end
105
109
 
106
- # Pass-through: the controller segment is NOT re-stimulized.
107
- def self.parse_qualified_string(s)
110
+ # Parses a wire-format Stimulus action descriptor (`"event->ctrl#method"` or
111
+ # `"ctrl#method"`). The controller segment is taken verbatim — not re-stimulized.
112
+ def self.parse_descriptor(s)
108
113
  if s.include?("->")
109
114
  event_part, ctrl_method = s.split("->", 2)
110
115
  ctrl, method = ctrl_method.split("#", 2)
@@ -4,63 +4,61 @@ require "literal"
4
4
  require_relative "naming"
5
5
  require_relative "base"
6
6
  require_relative "controller"
7
+ require_relative "selector"
7
8
 
8
9
  module Vident
9
10
  module Stimulus
10
- # `data-<ctrl>-<name>-outlet` fragment. `selector` is the CSS selector
11
- # the Stimulus runtime uses to resolve the outlet on the page.
11
+ # `data-<parent-ctrl>-<child-ctrl>-outlet` fragment.
12
12
  class Outlet < Base
13
13
  prop :controller, Controller
14
14
  prop :name, String
15
15
  prop :selector, String
16
16
 
17
+ # Characters that only make sense in a CSS selector, never in a
18
+ # Stimulus controller path/identifier.
19
+ SELECTOR_CHARS = /[.#\[>,*+:]/
20
+
17
21
  def self.parse(*args, implied:, component_id: nil)
18
22
  case args
23
+ in [Outlet => o]
24
+ o
19
25
  in [Symbol => sym]
20
26
  name = sym.to_s.dasherize
21
- new(
22
- controller: implied,
23
- name: name,
24
- selector: auto_selector(name, component_id: component_id)
25
- )
27
+ new(controller: implied, name: name, selector: auto_selector(name, component_id: component_id))
28
+ in [String => str] if SELECTOR_CHARS.match?(str)
29
+ raise ::Vident::ParseError, raw_string_selector_message(str)
26
30
  in [String => str]
27
- name = str.dasherize
28
- new(
29
- controller: implied,
30
- name: name,
31
- selector: auto_selector(name, component_id: component_id)
32
- )
33
- in [[identifier, selector]]
34
- new(
35
- controller: implied,
36
- name: identifier.to_s.dasherize,
37
- selector: selector
38
- )
39
- in [Symbol => sym, String => sel]
31
+ name = Naming.stimulize_path(str)
32
+ new(controller: implied, name: name, selector: auto_selector(name, component_id: component_id))
33
+ in [Symbol => sym, Selector => sel]
34
+ new(controller: implied, name: sym.to_s.dasherize, selector: sel.css)
35
+ in [String => str, Selector => sel]
36
+ new(controller: implied, name: Naming.stimulize_path(str), selector: sel.css)
37
+ in [String => parent_path, Symbol => child_sym]
38
+ child_name = child_sym.to_s.dasherize
40
39
  new(
41
- controller: implied,
42
- name: sym.to_s.dasherize,
43
- selector: sel
40
+ controller: Controller.parse(parent_path, implied: implied),
41
+ name: child_name,
42
+ selector: auto_selector(child_name, component_id: component_id)
44
43
  )
45
- in [String => id_or_name, String => sel]
44
+ in [String => parent_path, Symbol => child_sym, Selector => sel]
46
45
  new(
47
- controller: implied,
48
- name: id_or_name.dasherize,
49
- selector: sel
50
- )
51
- in [String => ctrl_path, Symbol => sym, String => sel]
52
- new(
53
- controller: Controller.parse(ctrl_path, implied: implied),
54
- name: sym.to_s.dasherize,
55
- selector: sel
46
+ controller: Controller.parse(parent_path, implied: implied),
47
+ name: child_sym.to_s.dasherize,
48
+ selector: sel.css
56
49
  )
57
50
  in [obj] if obj.respond_to?(:stimulus_identifier)
58
51
  ident = obj.stimulus_identifier
59
- new(
60
- controller: implied,
61
- name: ident,
62
- selector: auto_selector(ident, component_id: component_id)
63
- )
52
+ new(controller: implied, name: ident, selector: auto_selector(ident, component_id: component_id))
53
+ in [Selector => sel]
54
+ raise ::Vident::ParseError,
55
+ "Outlet.parse: a Selector must be paired with a child controller name. " \
56
+ "Use `(:name, Vident::Selector(#{sel.css.inspect}))` or " \
57
+ "`(\"some/parent\", :name, Vident::Selector(...))` for the cross-controller form."
58
+ in [_, String => sel]
59
+ raise ::Vident::ParseError, raw_string_selector_message(sel)
60
+ in [_, _, String => sel]
61
+ raise ::Vident::ParseError, raw_string_selector_message(sel)
64
62
  else
65
63
  raise ::Vident::ParseError, "Outlet.parse: invalid arguments #{args.inspect}"
66
64
  end
@@ -80,6 +78,13 @@ module Vident
80
78
  id.to_s.gsub(/[^A-Za-z0-9_-]/) { |c| "\\#{c.ord.to_s(16)} " }
81
79
  end
82
80
 
81
+ def self.raw_string_selector_message(value)
82
+ "Outlet.parse: a bare String is a controller path, never a CSS selector. " \
83
+ "Wrap verbatim selectors in `Vident::Selector(...)` (got #{value.inspect}). " \
84
+ "For auto-selectors based on a child controller identifier, pass a Symbol or " \
85
+ "an unwrapped String — Vident builds `[data-controller~=…]` for you."
86
+ end
87
+
83
88
  def to_s = selector
84
89
 
85
90
  def data_attribute_key = :"#{controller.name}-#{name}-outlet"
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vident
4
+ module Stimulus
5
+ Selector = Data.define(:css) do
6
+ def to_s = css
7
+ end
8
+
9
+ def self.Selector(css) = Selector.new(css: css)
10
+ end
11
+
12
+ def self.Selector(css) = Stimulus::Selector.new(css: css)
13
+ end
@@ -16,13 +16,16 @@ module Vident
16
16
  case args
17
17
  in [Symbol => sym]
18
18
  new(controller: implied, name: Naming.js_name(sym))
19
- in [String => str]
20
- new(controller: implied, name: str)
21
19
  in [String => ctrl_path, Symbol => sym]
22
20
  new(
23
21
  controller: Controller.parse(ctrl_path, implied: implied),
24
22
  name: Naming.js_name(sym)
25
23
  )
24
+ in [String => s]
25
+ raise ::Vident::ParseError,
26
+ "Target.parse: a bare String is a controller path; target names must be Symbols " \
27
+ "(got #{s.inspect}). Use `target :name` for a local target, or " \
28
+ "`target \"path/to/ctrl\", :name` for cross-controller."
26
29
  else
27
30
  raise ::Vident::ParseError, "Target.parse: invalid arguments #{args.inspect}"
28
31
  end
data/lib/vident/types.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "literal"
4
+ require_relative "stimulus/selector"
4
5
  require_relative "stimulus/controller"
5
6
  require_relative "stimulus/action"
6
7
  require_relative "stimulus/target"
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Vident
4
- VERSION = "2.0.1"
4
+ VERSION = "3.0.0"
5
5
  end
data/lib/vident.rb CHANGED
@@ -16,6 +16,7 @@ require "vident/stimulus_null"
16
16
 
17
17
  require "vident/stimulus/naming"
18
18
  require "vident/stimulus/null"
19
+ require "vident/stimulus/selector"
19
20
  require "vident/stimulus/controller"
20
21
  require "vident/stimulus/action"
21
22
  require "vident/stimulus/target"
@@ -44,6 +45,7 @@ require "vident/capabilities/stimulus_parsing"
44
45
  require "vident/capabilities/stimulus_mutation"
45
46
  require "vident/capabilities/stimulus_draft"
46
47
  require "vident/capabilities/stimulus_data_emitting"
48
+ require "vident/capabilities/stimulus_attribute_strings"
47
49
  require "vident/capabilities/class_list_building"
48
50
  require "vident/capabilities/root_element_rendering"
49
51
  require "vident/capabilities/child_element_rendering"
@@ -273,7 +273,9 @@ These only tell the JS controller *which* classes to toggle; they do **not** app
273
273
 
274
274
  ### 1.7 Outlets
275
275
 
276
- Stimulus: `data-<identifier>-<outlet-name>-outlet="<css-selector>"` attaches matching controller instances as `this.nameOutlet` / `this.nameOutlets` / `this.nameOutletElement(s)` / `this.hasNameOutlet`. Outlet names are the kebab-case *identifier* of the outlet controller.
276
+ Stimulus: `data-<identifier>-<outlet-name>-outlet="<css-selector>"` attaches matching controller instances as `this.nameOutlet` / `this.nameOutlets` / `this.nameOutletElement(s)` / `this.hasNameOutlet`. The `<outlet-name>` is the kebab-case identifier of the *child* controller — Stimulus enforces this; it is not just a free label.
277
+
278
+ Argument vocabulary (same as Action/Target/Value/etc.): `Symbol` and bare `String` always denote a controller (path or identifier); a verbatim CSS selector must be wrapped with `Vident::Selector(...)`. A bare `".modal"` is rejected.
277
279
 
278
280
  Vident has three forms.
279
281
 
@@ -281,19 +283,28 @@ Vident has three forms.
281
283
 
282
284
  ```ruby
283
285
  stimulus do
284
- outlets modal: ".modal", user_status: ".online-user"
285
- outlets({"admin--users" => ".admin-user"}) # positional-hash for names containing `--`
286
+ outlets modal: nil # auto-selector: [data-controller~=modal]
287
+ outlets user_status: Vident::Selector(".online-user")
288
+ outlets({"admin--users" => nil}) # positional-hash, namespaced child id
289
+ outlets({"admin--users" => Vident::Selector(".admin-user")})
286
290
  end
287
291
  ```
288
292
 
293
+ The kwarg/Hash key is the **child controller identifier**. Use the singular `outlet` form for cross-controller cases:
294
+
295
+ ```ruby
296
+ outlet "some/parent-ctrl", :child # auto-selector
297
+ outlet "some/parent-ctrl", :child, Vident::Selector(".x") # verbatim
298
+ ```
299
+
289
300
  **(b) `stimulus_outlets:` prop / `root_element_attributes` / `child_element(stimulus_outlet(s): …)`:**
290
301
 
291
302
  ```ruby
292
303
  stimulus_outlets: [
293
- [:modal, ".modal"], # implied controller
294
- ["admin/users", :row, ".user-row"], # cross-controller
295
- :user_status, # auto-selector: [data-controller~=user-status]
296
- other_component_instance, # #<id> [data-controller~=<other identifier>]
304
+ :user_status, # auto-selector
305
+ [:modal, Vident::Selector(".modal")], # explicit selector
306
+ ["admin/users", :row, Vident::Selector(".user-row")], # cross-controller, explicit
307
+ other_component_instance, # #<id> [data-controller~=<other identifier>]
297
308
  ]
298
309
  ```
299
310
 
@@ -362,7 +373,18 @@ Inline helper (ERB): `as_stimulus_param(:release_id, 42)` / `as_stimulus_params(
362
373
 
363
374
  ## 2. Component scaffolding
364
375
 
365
- Pick the right base class:
376
+ The fastest path is the bundled generator, which writes the component, its Stimulus controller sidecar, and a unit test in one go:
377
+
378
+ ```bash
379
+ bin/rails generate vident:component Dashboard::TaskCard
380
+ # or, when you want to be explicit:
381
+ bin/rails generate vident:phlex:component Dashboard::TaskCard
382
+ bin/rails generate vident:view_component:component Dashboard::TaskCard
383
+ ```
384
+
385
+ 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.
386
+
387
+ 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:
366
388
 
367
389
  - **ViewComponent:** `class Foo::BarComponent < Vident::ViewComponent::Base`
368
390
  - **Phlex:** `class Foo::BarComponent < Vident::Phlex::HTML`
@@ -22,13 +22,6 @@ Adds:
22
22
  class precedence rules from SKILL.md §4). Self-closing tags (`:area`, `:br`, `:col`,
23
23
  `:embed`, `:hr`, `:img`, `:input`, `:link`, `:meta`, `:param`, `:source`, `:track`,
24
24
  `:wbr`) are emitted without children.
25
- - 14 `as_stimulus_*` helpers — return an HTML-safe `String` of raw `data-*` attributes
26
- suitable for embedding inside an HTML tag in ERB. Signatures match the corresponding
27
- `stimulus_*` method (see section 4):
28
- - Plural: `as_stimulus_controllers`, `as_stimulus_actions`, `as_stimulus_targets`,
29
- `as_stimulus_outlets`, `as_stimulus_values`, `as_stimulus_params`, `as_stimulus_classes`.
30
- - Singular: `as_stimulus_controller`, `as_stimulus_action`, `as_stimulus_target`,
31
- `as_stimulus_outlet`, `as_stimulus_value`, `as_stimulus_param`, `as_stimulus_class`.
32
25
  - Class-level cache support: `template_path`, `component_path`, `components_base_path`,
33
26
  `cache_component_modified_time`, `cache_sidecar_view_modified_time`,
34
27
  `cache_rb_component_modified_time` — used by `Vident::Caching` to chain
@@ -49,8 +42,13 @@ Adds:
49
42
  `child_element` raises `ArgumentError`.
50
43
  - Source-file tracking — the class-level `inherited` hook records each subclass's source
51
44
  file in `component_source_file_path` so `Vident::Caching` can pick up an mtime.
52
- - No `as_stimulus_*` helpersPhlex has its own tag DSL; use `child_element` or spread
53
- `data: { **component.stimulus_target(:name) }` inline.
45
+ - `child_element` lifecycle constraint — `child_element` writes to Phlex's render
46
+ buffer, so it is **only valid during the component's own `view_template`**. From
47
+ outside the render lifecycle (an external ERB partial holding a Phlex component
48
+ reference, a helper, `ApplicationController.renderer.render(...)`, etc.) it raises
49
+ `undefined method 'buffer' for nil`. Use the `as_stimulus_*` helpers (see
50
+ `Vident::Component`) or spread `data: { **component.stimulus_target(:name) }` inline
51
+ instead — those don't touch the buffer.
54
52
 
55
53
  ### `Vident::Component` (module)
56
54
 
@@ -77,6 +75,17 @@ Public instance methods:
77
75
  - `id` — `String`, auto-generated from `StableId` if `@id` was nil. The generated form
78
76
  is `"#{component_name}-#{StableId.next_id_in_sequence}"`.
79
77
  - `prop_names` — instance-method alias for the class method.
78
+ - 14 `as_stimulus_*` helpers + 7 short aliases — return an HTML-safe `String` of raw
79
+ `data-*` attributes, suitable for embedding inside an HTML tag (ERB or Phlex `raw(...)`).
80
+ Signatures match the corresponding `stimulus_*` method (see section 4). Pure transforms
81
+ over `stimulus_*` outputs — they don't write to a render buffer, so they're safe to call
82
+ on a Phlex Vident component from outside its `view_template` (unlike `child_element`).
83
+ - Plural: `as_stimulus_controllers`, `as_stimulus_actions`, `as_stimulus_targets`,
84
+ `as_stimulus_outlets`, `as_stimulus_values`, `as_stimulus_params`, `as_stimulus_classes`.
85
+ - Singular: `as_stimulus_controller`, `as_stimulus_action`, `as_stimulus_target`,
86
+ `as_stimulus_outlet`, `as_stimulus_value`, `as_stimulus_param`, `as_stimulus_class`.
87
+ - Aliases (singular only): `as_controller`, `as_action`, `as_target`, `as_outlet`,
88
+ `as_value`, `as_param`, `as_class`.
80
89
 
81
90
  Not public (override at your own risk, used internally):
82
91
 
@@ -468,7 +477,12 @@ def child_element(tag_name,
468
477
  - `**options` passes through as HTML options.
469
478
  - For ViewComponent's renderer, self-closing tags are emitted without the block.
470
479
  - For Phlex's renderer, the tag name is validated against
471
- `Vident::Phlex::HTML::VALID_TAGS`; unknown tags raise `ArgumentError`.
480
+ `Vident::Phlex::HTML::VALID_TAGS`; unknown tags raise `ArgumentError`. Phlex's
481
+ `child_element` writes to the component's render buffer, so it is **only valid
482
+ during the component's own `view_template`**. Calling it from outside the render
483
+ lifecycle (an external ERB partial, a helper, `ApplicationController.renderer.render`)
484
+ raises `undefined method 'buffer' for nil`. Use `as_stimulus_*` helpers there
485
+ instead — those have no buffer dependency.
472
486
 
473
487
  ---
474
488
 
@@ -398,8 +398,12 @@ a hand-authored HTML tag. All three are equivalent; pick one per file for consis
398
398
  <% end %>
399
399
  ```
400
400
 
401
- Phlex users have two choices — `child_element` (identical) and the native Phlex tag
402
- methods with `data: { **component.stimulus_target(:name) }`.
401
+ Phlex users have three choices — `child_element` (identical to ERB, but only valid
402
+ inside `view_template`), the native Phlex tag methods with
403
+ `data: { **component.stimulus_target(:name) }`, and the `as_stimulus_*` helpers
404
+ embedded via `raw(...)`. From an external ERB partial holding a Phlex component
405
+ reference, `child_element` won't work (no render buffer) — use `as_stimulus_*` or
406
+ the `data: { **stimulus_target(...) }` spread instead.
403
407
 
404
408
  ---
405
409
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: vident
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.1
4
+ version: 3.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stephen Ierodiaconou
@@ -82,8 +82,12 @@ files:
82
82
  - CHANGELOG.md
83
83
  - LICENSE.txt
84
84
  - README.md
85
+ - lib/generators/vident/component/component_generator.rb
85
86
  - lib/generators/vident/install/install_generator.rb
87
+ - lib/generators/vident/install/templates/application_phlex_component.rb.tt
88
+ - lib/generators/vident/install/templates/application_view_component.rb.tt
86
89
  - lib/generators/vident/install/templates/vident.rb
90
+ - lib/tasks/website.rake
87
91
  - lib/vident.rb
88
92
  - lib/vident/caching.rb
89
93
  - lib/vident/capabilities/caching.rb
@@ -93,6 +97,7 @@ files:
93
97
  - lib/vident/capabilities/identifiable.rb
94
98
  - lib/vident/capabilities/inspectable.rb
95
99
  - lib/vident/capabilities/root_element_rendering.rb
100
+ - lib/vident/capabilities/stimulus_attribute_strings.rb
96
101
  - lib/vident/capabilities/stimulus_data_emitting.rb
97
102
  - lib/vident/capabilities/stimulus_declaring.rb
98
103
  - lib/vident/capabilities/stimulus_draft.rb
@@ -124,6 +129,7 @@ files:
124
129
  - lib/vident/stimulus/null.rb
125
130
  - lib/vident/stimulus/outlet.rb
126
131
  - lib/vident/stimulus/param.rb
132
+ - lib/vident/stimulus/selector.rb
127
133
  - lib/vident/stimulus/target.rb
128
134
  - lib/vident/stimulus/value.rb
129
135
  - lib/vident/stimulus_null.rb